Add Drive Health (gustav0ar/drive-health) (#58)

* add Drive Health

* declare optional package manager commands

* use live Drive Health screenshot

* hide redundant dependency subtitle

* fix drive error counter alerts

* stabilize transient SMART availability

* address collector packaging review

* refactor: use generic collector service name

* test: harden collector packaging checks
This commit is contained in:
Gustavo de Andrade Rosa
2026-07-21 11:14:38 -04:00
committed by GitHub
parent a55b9e6b8a
commit ddb054ae3c
26 changed files with 6222 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"languageMode": "nonstrict",
"lint": {
"FunctionUnused": false
},
"lintErrors": false
}
+39
View File
@@ -0,0 +1,39 @@
SHELL := /bin/sh
.PHONY: test unit shell translations lint
test: unit shell translations lint
unit:
lua tests/collector_harness.lua ready
lua tests/collector_harness.lua missing-lsblk
lua tests/collector_harness.lua missing-smartctl
lua tests/collector_harness.lua incompatible-lsblk
lua tests/collector_harness.lua incompatible-smartctl
lua tests/collector_harness.lua async-incompatible-lsblk
lua tests/collector_harness.lua probe-timeout
lua tests/collector_harness.lua probe-completes-during-collection
lua tests/collector_harness.lua raw-cache
lua tests/collector_harness.lua outdated-raw-cache
lua tests/collector_harness.lua collector-disabled
lua tests/alert_harness.lua
lua tests/history_harness.lua
lua tests/panel_harness.lua
lua tests/widget_harness.lua
shell:
sh tests/test_collect_raw.sh
sh tests/test_packaging.sh
sh -n scripts/collect_raw.sh packaging/smart-action.sh tests/test_collect_raw.sh tests/test_packaging.sh
bash -n packaging/install-system-collector.sh packaging/uninstall-system-collector.sh
@if command -v shellcheck >/dev/null 2>&1; then \
shellcheck scripts/collect_raw.sh packaging/*.sh tests/test_collect_raw.sh tests/test_packaging.sh tests/fixtures/bin/*; \
fi
translations:
jq empty translations/en.json
lint:
@if command -v noctalia >/dev/null 2>&1; then noctalia plugins lint .; \
else echo "noctalia CLI unavailable; skipped plugin manifest lint"; fi
git diff --check
+136
View File
@@ -0,0 +1,136 @@
# Drive Health
Drive Health is a storage-health monitor for Noctalia Shell. It discovers SSDs
and HDDs, shows temperature and mounted-space usage, and can optionally expose
full SMART health, endurance, error counters, trends, alerts, and background
self-tests through a read-only system collector.
## Plugin
| Field | Value |
| --- | --- |
| ID | `gustav0ar/drive-health` |
| Entries | Bar widget: `summary`; panel: `drives`; services: `collector`, `alerts`, `history` |
## Requirements
Drive Health runs on Linux with Noctalia Shell v5 and uses the following
commands declared in `plugin.toml`:
`lsblk`, `smartctl`, `sh`, `date`, `dirname`, `mkdir`, `mktemp`, `rm`, `sed`,
`cat`, `chmod`, `mv`, `sudo`, `env`, `bash`, `install`, `systemctl`, `pkexec`,
`id`, `tr`, `pacman`, `apt-get`, `dnf`, `zypper`, `apk`, `xbps-install`, and
`emerge`.
Most are standard system utilities. Install `smartctl` from the
`smartmontools` package and `lsblk` from `util-linux`. `systemctl`, `sudo`, and
`pkexec` are needed only for the optional collector and SMART self-tests.
The dependency card can open a terminal with a package-manager command for
`pacman`, `apt-get`, `dnf`, `zypper`, `apk`, `xbps-install`, or `emerge`. The
command is shown for review before any privileged prompt.
## Usage
Enable Drive Health from Noctalia's community source, then add the `summary`
widget to a bar. Select the widget to open the drives panel. The same panel can
be toggled with:
```sh
noctalia msg panel-toggle gustav0ar/drive-health:drives
```
Basic mode discovers drives, mounted folders, storage use, and temperatures
available to the user session. Open the collector controls from the gear in
the panel header to compare basic mode with optional Full SMART mode.
Full SMART installation always opens a terminal with the exact command. After
the user reviews and approves `sudo`, the installer adds a hardened systemd
oneshot and timer. Disabling Full SMART in settings makes Drive Health ignore
the collector cache; use **Stop background service** to stop an installed
timer as well.
Expand a drive for detailed counters, trend history, per-drive preferences,
and SMART self-tests. A self-test requires explicit confirmation and a Polkit
authorization prompt, then runs in the background while progress and its final
firmware result appear in the panel. Sleeping HDDs are not spun up merely to
refresh their SMART data.
Transient SMART read failures are stabilized across three distinct successful
collector snapshots. The first failure establishes a pending state; an alert is
created only if unavailability persists, so device passthrough and reattachment
do not produce one-scan notification noise.
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `system_collector_enabled` | `bool` | `false` | Read the optional root collector cache for complete SMART data. |
| `refresh_seconds` | `int` | `30` | Seconds between user-session refreshes (15–300). The root timer independently refreshes every 30 seconds. |
| `warning_temperature` | `int` | `65` | Global warning temperature in °C. |
| `critical_temperature` | `int` | `80` | Global critical temperature in °C. |
| `life_warning_percent` | `int` | `20` | Remaining SSD-life percentage that triggers a warning. |
| `alerts_enabled` | `bool` | `true` | Show notifications for new or worsening issues. |
| `notify_recovery` | `bool` | `true` | Notify when an active issue clears. |
| `show_hdd` | `bool` | `true` | Include rotational drives in the panel. |
| `alert_hdd` | `bool` | `true` | Evaluate rotational drives for health alerts. |
| `drive_missing_alerts` | `bool` | `true` | Alert when an established internal drive disappears. |
| `missing_grace_scans` | `int` | `3` | Successful scans a drive may be absent before alerting (1–20). |
| `use_hotspot_temperature` | `bool` | `true` | Use the hottest valid NVMe sensor for summaries and alerts. |
| `history_interval_minutes` | `int` | `60` | Minutes between saved trend samples (15–1440). |
| `history_retention_days` | `int` | `30` | Days of bounded trend history to retain (1–365). |
Per-drive controls can set an alias and alert thresholds, reorder or hide a
drive, and enable missing-drive alerts. Dismissed alerts are dropped and only
return when the condition clears and later recurs or escalates.
## IPC
The normal public entry is the panel command above. The plugin's internal
services communicate through Noctalia state and do not require manual IPC.
## Notes
Drive Health makes no network requests and does not download or execute code.
It spawns only the commands documented under Requirements. Conditional
package-manager commands are generated locally and opened in a terminal for
review.
The plugin stores bounded local state in its Noctalia data directory:
- `alert-state.json` for current and dismissed alert state;
- `history.json` for temperature and endurance samples;
- `drive-preferences.json` for per-drive display and alert preferences;
- `last-collector-snapshot.json` for monotonic-counter comparisons.
Full SMART mode installs these system files only after explicit approval:
- `/usr/local/libexec/noctalia-drive-health/collect_raw.sh`;
- `/usr/local/libexec/noctalia-drive-health/smart-action.sh`;
- `/etc/systemd/system/noctalia-drive-health.service`;
- `/etc/systemd/system/noctalia-drive-health.timer`;
- `/run/noctalia-drive-health/raw.json`.
The runtime directory is mode `0750`, the cache is mode `0640`, and access is
limited to root plus the desktop user's primary group. SMART serials and mount
paths stay inside the local cache and panel; they are never transmitted.
The system collector performs read-only `smartctl --all` queries. SMART
self-tests are separate, explicitly authorized firmware operations. They can
take minutes or hours, may increase drive activity, and should not be confused
with filesystem repair or data recovery.
To remove the optional collector, use **Remove collector** in its controls and
approve the terminal command. Removing the Noctalia entry alone does not
silently remove system files.
## Development
Run the unit, shell, translation, lint, privacy, and packaging checks from this
directory:
```sh
make test
```
This source is licensed under the MIT License.
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
--!nonstrict
-- Bounded, low-write-rate drive history. Alert evaluation remains in
-- service.luau; this service only persists trend samples and publishes them.
local HISTORY_FILE = "history.json"
local history = { schema = 1, drives = {} }
local historyDirty = false
local function number(value, fallback)
local parsed = tonumber(value)
return parsed ~= nil and parsed or fallback
end
local function historyPath()
local directory = noctalia.pluginDataDir()
return directory ~= nil and directory .. "/" .. HISTORY_FILE or nil
end
local function loadHistory()
local path = historyPath()
local encoded = path ~= nil and noctalia.readFile(path) or nil
local decoded = encoded ~= nil and noctalia.json.decode(encoded) or nil
if type(decoded) == "table" and tonumber(decoded.schema) == 1 then
history = decoded
history.drives = type(decoded.drives) == "table" and decoded.drives or {}
end
end
local function saveHistory()
local path = historyPath()
if path == nil then
return false
end
local encoded, encodeError = noctalia.json.encode(history, true)
if encoded == nil then
noctalia.log("Unable to encode SMART history: " .. tostring(encodeError))
return false
end
local temporary = path .. ".tmp"
local written, writeError = noctalia.writeFile(temporary, encoded)
if not written then
noctalia.log("Unable to write SMART history: " .. tostring(writeError))
return false
end
local renamed, renameError = noctalia.renameFile(temporary, path)
if not renamed then
noctalia.log("Unable to commit SMART history: " .. tostring(renameError))
return false
end
return true
end
local function publishHistory()
noctalia.state.set("drive_history", history)
end
local function recordSnapshot(snapshot)
if type(snapshot) ~= "table" or type(snapshot.disks) ~= "table" or snapshot.collector_error ~= nil then
return
end
local epoch = math.floor(number(snapshot.generated_at_epoch, os.time()))
local interval = math.max(15, math.min(1440,
number(noctalia.getConfig("history_interval_minutes"), 60))) * 60
local retention = math.max(1, math.min(365,
number(noctalia.getConfig("history_retention_days"), 30))) * 86400
local cutoff = epoch - retention
local changed = false
for _, drive in ipairs(snapshot.disks) do
local id = tostring(drive.id or drive.serial or drive.device or "")
if id ~= "" then
local entry = history.drives[id]
if type(entry) ~= "table" then
entry = { name = drive.display_name or drive.model or drive.device, samples = {} }
history.drives[id] = entry
end
entry.name = drive.display_name or drive.model or drive.device
entry.kind = drive.kind
entry.samples = type(entry.samples) == "table" and entry.samples or {}
local retained = {}
for _, sample in ipairs(entry.samples) do
if type(sample) == "table" and number(sample.epoch, 0) >= cutoff then
table.insert(retained, sample)
else
changed = true
end
end
entry.samples = retained
local latest = retained[#retained]
if latest == nil or epoch - number(latest.epoch, 0) >= interval then
table.insert(retained, {
epoch = epoch,
temperature_c = tonumber(drive.temperature_c),
hotspot_temperature_c = tonumber(drive.hotspot_temperature_c),
remaining_life_percent = tonumber(drive.remaining_life_percent),
storage_usage_percent = tonumber(drive.storage_usage_percent),
data_written_bytes = tonumber(drive.data_written_bytes),
})
changed = true
end
end
end
for id, entry in pairs(history.drives) do
local samples = type(entry) == "table" and entry.samples or nil
if type(samples) ~= "table" or (#samples > 0 and number(samples[#samples].epoch, 0) < cutoff) then
history.drives[id] = nil
changed = true
end
end
history.updated_at_epoch = epoch
if changed then
historyDirty = true
end
if historyDirty and saveHistory() then
historyDirty = false
end
publishHistory()
end
loadHistory()
publishHistory()
noctalia.state.watch("snapshot", function(snapshot)
recordSnapshot(snapshot)
end)
local initial = noctalia.state.get("snapshot")
if initial ~= nil then
recordSnapshot(initial)
end
function onConfigChanged()
local snapshot = noctalia.state.get("snapshot")
if snapshot ~= nil then
recordSnapshot(snapshot)
end
end
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
if (( EUID != 0 )); then
echo "Run this installer with sudo." >&2
exit 1
fi
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
service_name="noctalia-drive-health"
target_user="${SUDO_USER:-${1:-}}"
if [[ -z "$target_user" || "$target_user" == root ]] || ! id "$target_user" >/dev/null 2>&1; then
echo "Unable to determine the desktop user. Run with sudo, or pass the username explicitly." >&2
exit 1
fi
target_gid="$(id -g "$target_user")"
for dependency in sh smartctl lsblk systemctl install sed mktemp id; do
if ! command -v "$dependency" >/dev/null 2>&1; then
echo "Missing required command: $dependency" >&2
exit 1
fi
done
rendered_service="$(mktemp)"
trap 'rm -f -- "$rendered_service"' EXIT
sed "s/@TARGET_GID@/$target_gid/g" \
"$project_dir/packaging/$service_name.service.in" >"$rendered_service"
install -Dm0755 \
"$project_dir/scripts/collect_raw.sh" \
"/usr/local/libexec/$service_name/collect_raw.sh"
install -Dm0755 \
"$project_dir/packaging/smart-action.sh" \
"/usr/local/libexec/$service_name/smart-action.sh"
install -Dm0644 \
"$rendered_service" \
"/etc/systemd/system/$service_name.service"
install -Dm0644 \
"$project_dir/packaging/$service_name.timer" \
"/etc/systemd/system/$service_name.timer"
systemctl daemon-reload
systemctl enable --now "$service_name.timer"
systemctl start "$service_name.service"
echo "Installed the read-only SMART collector."
echo "Cache: /run/$service_name/raw.json"
@@ -0,0 +1,36 @@
[Unit]
Description=Collect read-only SMART data for Noctalia Drive Health
Documentation=man:smartctl(8)
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/bin/sh /usr/local/libexec/noctalia-drive-health/collect_raw.sh --output /run/noctalia-drive-health/raw.json
Group=@TARGET_GID@
RuntimeDirectory=noctalia-drive-health
RuntimeDirectoryMode=0750
RuntimeDirectoryPreserve=yes
UMask=0027
StandardOutput=null
StandardError=journal
TimeoutStartSec=60s
NoNewPrivileges=true
PrivateTmp=true
PrivateNetwork=true
ProtectSystem=strict
ProtectHome=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
RestrictAddressFamilies=AF_UNIX
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
SystemCallArchitectures=native
LockPersonality=true
MemoryDenyWriteExecute=true
CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_SYS_ADMIN CAP_SYS_RAWIO
ReadWritePaths=/run/noctalia-drive-health
@@ -0,0 +1,11 @@
[Unit]
Description=Refresh SMART data for Noctalia
[Timer]
OnBootSec=20s
OnUnitActiveSec=30s
AccuracySec=5s
Unit=noctalia-drive-health.service
[Install]
WantedBy=timers.target
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
set -eu
if [ "$(id -u)" -ne 0 ]; then
echo "Run this SMART action as root." >&2
exit 1
fi
action=${1:-}
device=${2:-}
case "$action" in
short|long) ;;
*)
echo "usage: $0 {short|long} DEVICE" >&2
exit 2
;;
esac
case "$device" in
/dev/nvme[0-9]* )
[ -c "$device" ] || { echo "Not an NVMe controller: $device" >&2; exit 2; }
;;
/dev/* )
[ -b "$device" ] || { echo "Not a block device: $device" >&2; exit 2; }
[ "$(lsblk --nodeps --noheadings --output TYPE "$device" 2>/dev/null | tr -d ' ')" = "disk" ] \
|| { echo "SMART tests require a whole disk: $device" >&2; exit 2; }
;;
*)
echo "Device must be an absolute /dev path." >&2
exit 2
;;
esac
echo "Starting the $action SMART self-test on $device"
exec smartctl --test="$action" "$device"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
if (( EUID != 0 )); then
echo "Run this uninstaller with sudo." >&2
exit 1
fi
service_name="noctalia-drive-health"
systemctl disable --now "$service_name.timer" 2>/dev/null || true
systemctl stop "$service_name.service" 2>/dev/null || true
rm -f \
"/etc/systemd/system/$service_name.service" \
"/etc/systemd/system/$service_name.timer"
rm -rf \
"/usr/local/libexec/$service_name" \
"/run/$service_name"
systemctl daemon-reload
systemctl reset-failed "$service_name.service" 2>/dev/null || true
echo "Removed the Noctalia Drive Health system collector."
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
id = "gustav0ar/drive-health"
name = "Drive Health"
version = "1.2.0"
plugin_api = 3
author = "Drive Health contributors"
license = "MIT"
deprecated = false
dependencies = ["lsblk", "smartctl", "sh", "date", "dirname", "mkdir", "mktemp", "rm", "sed", "cat", "chmod", "mv", "sudo", "env", "bash", "install", "systemctl", "pkexec", "id", "tr", "pacman", "apt-get", "dnf", "zypper", "apk", "xbps-install", "emerge"]
tags = ["bar", "panel", "service", "system", "hardware", "utility"]
icon = "server-2"
description = "SMART health, temperature, integrity, endurance, and storage monitoring for SSDs and hard drives."
[[setting]]
key = "system_collector_enabled"
type = "bool"
label_key = "settings.system_collector_enabled.label"
description_key = "settings.system_collector_enabled.description"
default = false
[[setting]]
key = "refresh_seconds"
type = "int"
label_key = "settings.refresh_seconds.label"
description_key = "settings.refresh_seconds.description"
default = 30
min = 15
max = 300
[[setting]]
key = "warning_temperature"
type = "int"
label_key = "settings.warning_temperature.label"
description_key = "settings.warning_temperature.description"
default = 65
min = 40
max = 90
[[setting]]
key = "critical_temperature"
type = "int"
label_key = "settings.critical_temperature.label"
description_key = "settings.critical_temperature.description"
default = 80
min = 50
max = 100
[[setting]]
key = "life_warning_percent"
type = "int"
label_key = "settings.life_warning_percent.label"
description_key = "settings.life_warning_percent.description"
default = 20
min = 5
max = 50
[[setting]]
key = "alerts_enabled"
type = "bool"
label_key = "settings.alerts_enabled.label"
description_key = "settings.alerts_enabled.description"
default = true
[[setting]]
key = "notify_recovery"
type = "bool"
label_key = "settings.notify_recovery.label"
description_key = "settings.notify_recovery.description"
default = true
[[setting]]
key = "show_hdd"
type = "bool"
label_key = "settings.show_hdd.label"
description_key = "settings.show_hdd.description"
default = true
[[setting]]
key = "alert_hdd"
type = "bool"
label_key = "settings.alert_hdd.label"
description_key = "settings.alert_hdd.description"
default = true
[[setting]]
key = "drive_missing_alerts"
type = "bool"
label_key = "settings.drive_missing_alerts.label"
description_key = "settings.drive_missing_alerts.description"
default = true
[[setting]]
key = "missing_grace_scans"
type = "int"
label_key = "settings.missing_grace_scans.label"
description_key = "settings.missing_grace_scans.description"
default = 3
min = 1
max = 20
[[setting]]
key = "use_hotspot_temperature"
type = "bool"
label_key = "settings.use_hotspot_temperature.label"
description_key = "settings.use_hotspot_temperature.description"
default = true
[[setting]]
key = "history_interval_minutes"
type = "int"
label_key = "settings.history_interval_minutes.label"
description_key = "settings.history_interval_minutes.description"
default = 60
min = 15
max = 1440
[[setting]]
key = "history_retention_days"
type = "int"
label_key = "settings.history_retention_days.label"
description_key = "settings.history_retention_days.description"
default = 30
min = 1
max = 365
[[service]]
id = "collector"
entry = "collector.luau"
[[service]]
id = "alerts"
entry = "service.luau"
[[service]]
id = "history"
entry = "history.luau"
[[widget]]
id = "summary"
entry = "widget.luau"
[[panel]]
id = "drives"
entry = "panel.luau"
width = 590
height = 720
placement = "floating"
position = "center"
open_near_click = true
+118
View File
@@ -0,0 +1,118 @@
#!/bin/sh
set -eu
# Capture raw lsblk and smartctl JSON without interpreting device health. The
# same script is used by the unprivileged plugin fallback and the hardened root
# systemd service, keeping collection behavior identical in both paths.
LC_ALL=C
export LC_ALL
collector_version="2.0.0"
generated_at_epoch=$(date +%s)
collection_id=""
if [ -r /proc/sys/kernel/random/uuid ]; then
IFS= read -r collection_id </proc/sys/kernel/random/uuid || collection_id=""
fi
if [ -z "$collection_id" ]; then
uptime_field=""
if [ -r /proc/uptime ]; then
IFS=' ' read -r uptime_field _ </proc/uptime || uptime_field=""
fi
collection_id="${generated_at_epoch}-$$-${uptime_field:-unknown}"
fi
output=""
if [ "${1:-}" = "--output" ]; then
if [ "$#" -ne 2 ] || [ -z "$2" ]; then
echo "usage: $0 [--output PATH]" >&2
exit 2
fi
output=$2
elif [ "$#" -ne 0 ]; then
echo "usage: $0 [--output PATH]" >&2
exit 2
fi
if ! command -v lsblk >/dev/null 2>&1; then
echo "collect_raw: lsblk is required" >&2
exit 1
fi
if [ -n "$output" ]; then
output_dir=$(dirname -- "$output")
mkdir -p -- "$output_dir"
payload_tmp=$(mktemp "$output_dir/.raw.json.XXXXXX")
else
payload_tmp=$(mktemp "${TMPDIR:-/tmp}/noctalia-smart-raw.XXXXXX")
fi
devices_tmp=$(mktemp "${TMPDIR:-/tmp}/noctalia-smart-devices.XXXXXX")
smart_tmp=$(mktemp "${TMPDIR:-/tmp}/noctalia-smart-device.XXXXXX")
cleanup() {
rm -f -- "$payload_tmp" "$devices_tmp" "$smart_tmp"
}
trap cleanup EXIT HUP INT TERM
lsblk --nodeps --noheadings --paths --output PATH,TYPE,ROTA >"$devices_tmp"
{
printf '{"schema":2,"collector_version":"%s","collection_id":"%s","generated_at_epoch":%s,"lsblk":' \
"$collector_version" "$collection_id" "$generated_at_epoch"
lsblk --json --bytes --output \
NAME,KNAME,PATH,PKNAME,TYPE,TRAN,ROTA,RM,HOTPLUG,SIZE,LOG-SEC,PHY-SEC,MODEL,SERIAL,FSTYPE,FSSIZE,FSUSED,FSAVAIL,MOUNTPOINTS
printf ',"smart":['
first=true
if command -v smartctl >/dev/null 2>&1; then
while read -r device device_type rotational; do
[ "$device_type" = "disk" ] || continue
case "$device" in
/dev/loop*|/dev/ram*|/dev/sr*|/dev/zram*) continue ;;
esac
smart_device=$device
nvme_controller=$(printf '%s\n' "$device" | sed -n 's#^\(/dev/nvme[0-9][0-9]*\)n[0-9][0-9]*$#\1#p')
if [ -n "$nvme_controller" ]; then
smart_device=$nvme_controller
fi
: >"$smart_tmp"
if [ "$rotational" = "1" ]; then
if smartctl --json=c --all --nocheck=standby,0 "$smart_device" >"$smart_tmp" 2>/dev/null; then
smart_exit=0
else
smart_exit=$?
fi
else
if smartctl --json=c --all "$smart_device" >"$smart_tmp" 2>/dev/null; then
smart_exit=0
else
smart_exit=$?
fi
fi
if [ "$first" = true ]; then
first=false
else
printf ','
fi
printf '{"requested_device":"%s","exit_code":%s,"payload":' "$smart_device" "$smart_exit"
if [ -s "$smart_tmp" ]; then
cat "$smart_tmp"
else
printf '{"smartctl":{"exit_status":%s,"messages":[{"severity":"error","string":"smartctl produced no JSON output"}]}}' "$smart_exit"
fi
printf '}'
done <"$devices_tmp"
fi
printf ']}\n'
} >"$payload_tmp"
chmod 0640 "$payload_tmp"
if [ -n "$output" ]; then
mv -f -- "$payload_tmp" "$output"
else
cat "$payload_tmp"
fi
+582
View File
@@ -0,0 +1,582 @@
--!nonstrict
-- Alert service. Collection and SMART normalization are intentionally isolated
-- in collector.luau; this entry only evaluates normalized snapshots.
local SMART_UNAVAILABLE_GRACE_SCANS = 3
local alertState = { active = {}, counters = {}, inventory = {}, availability = {}, dismissed = {} }
local alertStateDirty = false
local legacyMissingSnapshots = {}
local legacyAvailabilitySnapshots = {}
local function stateValuesEqual(left, right)
if left == right then
return true
end
local valueType = type(left)
if valueType ~= type(right) or valueType ~= "table" then
return false
end
for key, value in pairs(left) do
if not stateValuesEqual(value, right[key]) then
return false
end
end
for key in pairs(right) do
if left[key] == nil then
return false
end
end
return true
end
local function copyStateValue(value)
if type(value) ~= "table" then
return value
end
local copied = {}
for key, child in pairs(value) do
copied[key] = copyStateValue(child)
end
return copied
end
local function replaceAlertStateSection(section, value)
if stateValuesEqual(alertState[section], value) then
return false
end
alertState[section] = value
alertStateDirty = true
return true
end
local function number(value, fallback)
local parsed = tonumber(value)
return parsed ~= nil and parsed or fallback
end
local function boolConfig(key, fallback)
local value = noctalia.getConfig(key)
return value == nil and fallback or value == true
end
local function snapshotCollectionId(snapshot)
local value = snapshot.collection_id
if type(value) ~= "string" or value:match("^%s*$") ~= nil
or #value > 128 or value:find("[%c]") ~= nil then
return nil
end
return value
end
local function alertStatePath()
local directory = noctalia.pluginDataDir()
return directory ~= nil and directory .. "/alert-state.json" or nil
end
local function loadAlertState()
local path = alertStatePath()
local raw = path ~= nil and noctalia.readFile(path) or nil
local decoded = raw ~= nil and noctalia.json.decode(raw) or nil
if type(decoded) == "table" then
alertState.active = type(decoded.active) == "table" and decoded.active or {}
alertState.counters = type(decoded.counters) == "table" and decoded.counters or {}
alertState.inventory = type(decoded.inventory) == "table" and decoded.inventory or {}
alertState.availability = type(decoded.availability) == "table" and decoded.availability or {}
alertState.dismissed = type(decoded.dismissed) == "table" and decoded.dismissed or {}
end
alertStateDirty = false
end
local function saveAlertState()
local path = alertStatePath()
if path == nil then
return false
end
local encoded, encodeError = noctalia.json.encode(alertState, true)
if encoded == nil then
noctalia.log("Unable to encode SMART alert state: " .. tostring(encodeError))
return false
end
local temporary = path .. ".tmp"
local written, writeError = noctalia.writeFile(temporary, encoded)
if not written then
noctalia.log("Unable to persist SMART alert state: " .. tostring(writeError))
return false
end
local renamed, renameError = noctalia.renameFile(temporary, path)
if not renamed then
noctalia.log("Unable to commit SMART alert state: " .. tostring(renameError))
return false
end
return true
end
local function persistAlertState()
if alertStateDirty and saveAlertState() then
alertStateDirty = false
end
end
local function severityRank(severity)
return severity == "critical" and 2 or 1
end
local function sendIssueNotification(issue)
if not boolConfig("alerts_enabled", true) then
return
end
if issue.severity == "critical" then
noctalia.notifyError(issue.title, issue.message)
else
noctalia.notify(issue.title, issue.message)
end
end
local function driveIdentity(drive)
return tostring(drive.id or drive.serial or drive.device or drive.model or "unknown-drive")
end
local function driveName(drive)
return tostring(drive.display_name or drive.model or drive.device or noctalia.tr("alerts.unknown_drive"))
end
local function driveIssue(drive, kind, severity, message, value, monotonic)
return {
id = driveIdentity(drive) .. ":" .. kind,
drive_id = driveIdentity(drive),
kind = kind,
drive = driveName(drive),
severity = severity,
title = noctalia.tr("alerts.drive_title", { drive = driveName(drive) }),
message = message,
value = value,
monotonic = monotonic == true,
}
end
local function evaluateDrive(drive, previousActive, smartUnavailableConfirmed)
local issues = {}
local name = driveName(drive)
local warningTemperature = number(drive.warning_temperature, number(noctalia.getConfig("warning_temperature"), 65))
local criticalTemperature = math.max(warningTemperature + 1,
number(drive.critical_temperature, number(noctalia.getConfig("critical_temperature"), 80)))
local lifeWarning = number(drive.life_warning_percent, number(noctalia.getConfig("life_warning_percent"), 20))
if smartUnavailableConfirmed then
table.insert(issues, driveIssue(
drive, "smart-unavailable", "warning",
noctalia.tr("alerts.smart_unavailable", { drive = name }), 1, false
))
end
if drive.health == "failed" then
table.insert(issues, driveIssue(
drive, "health", "critical",
noctalia.tr("alerts.health_failed", { drive = name }), 1, false
))
end
local temperature = tonumber(noctalia.getConfig("use_hotspot_temperature") == false
and drive.temperature_c or drive.hotspot_temperature_c or drive.temperature_c)
local temperatureId = driveIdentity(drive) .. ":temperature"
local wasTemperatureAlert = previousActive[temperatureId] ~= nil
local temperatureActive = temperature ~= nil and (
temperature >= warningTemperature or (wasTemperatureAlert and temperature >= warningTemperature - 3)
)
if temperatureActive then
local critical = temperature >= criticalTemperature
local cooling = temperature < warningTemperature
table.insert(issues, driveIssue(
drive, "temperature", critical and "critical" or "warning",
noctalia.tr(critical and "alerts.temperature_critical"
or (cooling and "alerts.temperature_cooling" or "alerts.temperature_warning"), {
drive = name,
temperature = string.format("%.0f", temperature),
threshold = string.format("%.0f", critical and criticalTemperature
or (cooling and warningTemperature - 3 or warningTemperature)),
}), temperature, false
))
end
local life = tonumber(drive.remaining_life_percent)
if life ~= nil and life <= lifeWarning then
local critical = life <= 10
table.insert(issues, driveIssue(
drive, "endurance", critical and "critical" or "warning",
noctalia.tr(critical and "alerts.life_critical" or "alerts.life_warning", {
drive = name, remaining = string.format("%.0f", life),
}), life, false
))
end
local spare = tonumber(drive.available_spare_percent)
local spareThreshold = number(drive.available_spare_threshold_percent, 10)
if spare ~= nil and spare <= spareThreshold then
table.insert(issues, driveIssue(
drive, "available-spare", spare <= math.max(5, spareThreshold / 2) and "critical" or "warning",
noctalia.tr("alerts.spare_low", { drive = name, spare = string.format("%.0f", spare) }), spare, false
))
end
if drive.self_test_state == "failed" then
table.insert(issues, driveIssue(
drive, "self-test", "critical",
noctalia.tr("alerts.self_test_failed", { drive = name, status = tostring(drive.self_test_status or "failed") }),
1, false
))
end
local criticalWarning = number(drive.critical_warning, 0)
if criticalWarning > 0 then
table.insert(issues, driveIssue(
drive, "critical-warning", "critical",
noctalia.tr("alerts.nvme_critical", {
drive = name, value = string.format("0x%02X", math.floor(criticalWarning)),
}), criticalWarning, true
))
end
local storage = tonumber(drive.storage_usage_percent)
if storage ~= nil and storage >= 90 then
local critical = storage >= 95
table.insert(issues, driveIssue(
drive, "storage", critical and "critical" or "warning",
noctalia.tr(critical and "alerts.storage_critical" or "alerts.storage_warning", {
drive = name, used = string.format("%.0f", storage),
}), storage, false
))
end
return issues
end
local function dependencyIssue(dependencies)
if type(dependencies) ~= "table" or dependencies.ready then
return nil
end
return {
id = "dependencies:missing",
kind = "missing-dependencies",
drive = noctalia.tr("panel.title"),
severity = dependencies.blocking and "critical" or "warning",
title = noctalia.tr("dependencies.alert_title"),
message = noctalia.tr("dependencies.alert_body", { missing = dependencies.missing_text }),
value = #(dependencies.missing or {}),
revision = dependencies.signature,
}
end
local function collectorIssue(snapshot)
if snapshot.collector_error == nil or snapshot.collector_error == "" then
return nil
end
return {
id = "collector:error",
kind = "collector-error",
drive = noctalia.tr("panel.title"),
severity = "critical",
title = noctalia.tr("alerts.collector_title"),
message = noctalia.tr("alerts.collector_error", { error = snapshot.collector_error }),
value = 1,
revision = tostring(snapshot.collector_error),
}
end
local MONOTONIC_COUNTERS = {
{ key = "media-errors", field = "media_errors", translation = "alerts.media_errors", critical = true },
{ key = "reallocated", field = "reallocated_sectors", translation = "alerts.reallocated", critical = false },
{ key = "pending", field = "pending_sectors", translation = "alerts.pending", critical = true },
{ key = "uncorrectable", field = "uncorrectable_errors", translation = "alerts.uncorrectable", critical = true },
{ key = "spin-retry", field = "spin_retry_count", translation = "alerts.spin_retry", critical = true },
{ key = "command-timeout", field = "command_timeout_count", translation = "alerts.command_timeout", critical = false },
{ key = "interface-crc", field = "interface_crc_errors", translation = "alerts.interface_crc", critical = false },
{ key = "unsafe-shutdowns", field = "unsafe_shutdowns", translation = "alerts.unsafe_shutdown_increase", critical = false },
{ key = "error-log-entries", field = "error_log_entries", translation = "alerts.error_log_increase", critical = false },
{ key = "warning-temperature-time", field = "warning_temperature_time_minutes", translation = "alerts.warning_temperature_time_increase", critical = false },
{ key = "critical-temperature-time", field = "critical_temperature_time_minutes", translation = "alerts.critical_temperature_time_increase", critical = true },
}
local MONOTONIC_COUNTER_KINDS = {}
for _, counter in ipairs(MONOTONIC_COUNTERS) do
MONOTONIC_COUNTER_KINDS[counter.key] = true
end
local function checkCounterIncrease(drive, counter)
local key = driveIdentity(drive) .. ":" .. counter.key
local current = tonumber(drive[counter.field])
local previous = tonumber(alertState.counters[key])
if current ~= nil and previous ~= nil and current > previous and boolConfig("alerts_enabled", true) then
local title = noctalia.tr("alerts.drive_title", { drive = driveName(drive) })
local message = noctalia.tr(counter.translation, {
drive = driveName(drive), count = math.floor(current - previous),
})
if counter.critical then
noctalia.notifyError(title, message)
else
noctalia.notify(title, message)
end
end
if current ~= nil and current ~= previous then
alertState.counters[key] = current
alertStateDirty = true
end
end
local function checkMonotonicCounters(drive)
for _, counter in ipairs(MONOTONIC_COUNTERS) do
checkCounterIncrease(drive, counter)
end
end
local function updateSmartAvailability(availability, drive, snapshot, collectionHealthy, collectionId,
fullSmartExpected)
local id = driveIdentity(drive)
local unavailable = fullSmartExpected and drive.smart_available == false and drive.smart_sleeping ~= true
if not unavailable then
availability[id] = nil
legacyAvailabilitySnapshots[id] = nil
return false
end
local known = availability[id]
if type(known) ~= "table" then
known = { unavailable_scans = 0 }
end
if number(known.unavailable_scans, 0) >= SMART_UNAVAILABLE_GRACE_SCANS then
availability[id] = known
return true
end
local countScan = false
if collectionHealthy and collectionId ~= nil then
countScan = known.last_collection_id ~= collectionId
known.last_collection_id = collectionId
legacyAvailabilitySnapshots[id] = nil
elseif collectionHealthy and legacyAvailabilitySnapshots[id] ~= snapshot then
countScan = true
legacyAvailabilitySnapshots[id] = snapshot
end
if countScan then
known.unavailable_scans = number(known.unavailable_scans, 0) + 1
end
availability[id] = known
return known.unavailable_scans >= SMART_UNAVAILABLE_GRACE_SCANS
end
local function processSnapshot(snapshot)
if type(snapshot) ~= "table" then
return
end
local previousActive = alertState.active or {}
local dismissed = copyStateValue(alertState.dismissed or {})
local active = {}
local issues = {}
local alertHdd = boolConfig("alert_hdd", true)
local missingAlerts = boolConfig("drive_missing_alerts", true)
local missingGrace = math.max(1, math.floor(number(noctalia.getConfig("missing_grace_scans"), 3)))
local fullSmartExpected = noctalia.getConfig("system_collector_enabled") == true
local inventory = copyStateValue(alertState.inventory or {})
local availability = copyStateValue(alertState.availability or {})
local seen = {}
local collectionHealthy = snapshot.collecting ~= true and snapshot.collector_error == nil
and not (type(snapshot.dependencies) == "table" and snapshot.dependencies.blocking)
local collectionId = snapshotCollectionId(snapshot)
local globals = {}
local missingDependency = dependencyIssue(snapshot.dependencies)
local collectionFailure = collectorIssue(snapshot)
if missingDependency ~= nil then
table.insert(globals, missingDependency)
end
if collectionFailure ~= nil then
table.insert(globals, collectionFailure)
end
for _, issue in ipairs(globals) do
if issue ~= nil then
active[issue.id] = issue
table.insert(issues, issue)
end
end
if type(snapshot.disks) == "table" then
for _, drive in ipairs(snapshot.disks) do
local id = driveIdentity(drive)
local eligibleKind = drive.kind == "ssd" or alertHdd
seen[id] = true
if missingAlerts and eligibleKind and drive.presence_alert_enabled == true and drive.alerts_enabled ~= false then
inventory[id] = {
id = id,
drive = driveName(drive),
device = drive.device,
kind = drive.kind,
missing_scans = 0,
}
legacyMissingSnapshots[id] = nil
else
inventory[id] = nil
legacyMissingSnapshots[id] = nil
end
if eligibleKind and drive.alerts_enabled ~= false then
local smartUnavailableConfirmed = updateSmartAvailability(
availability, drive, snapshot, collectionHealthy, collectionId, fullSmartExpected)
checkMonotonicCounters(drive)
for _, issue in ipairs(evaluateDrive(drive, previousActive, smartUnavailableConfirmed)) do
active[issue.id] = issue
table.insert(issues, issue)
end
else
availability[id] = nil
legacyAvailabilitySnapshots[id] = nil
end
end
end
for id in pairs(availability) do
if not seen[id] then
availability[id] = nil
legacyAvailabilitySnapshots[id] = nil
end
end
if missingAlerts then
for id, known in pairs(inventory) do
local eligibleKind = known.kind == "ssd" or alertHdd
if not seen[id] and eligibleKind then
local countScan = false
if collectionHealthy and collectionId ~= nil then
countScan = known.last_missing_collection_id ~= collectionId
known.last_missing_collection_id = collectionId
legacyMissingSnapshots[id] = nil
elseif collectionHealthy and legacyMissingSnapshots[id] ~= snapshot then
countScan = true
legacyMissingSnapshots[id] = snapshot
end
if countScan then
known.missing_scans = number(known.missing_scans, 0) + 1
end
if known.missing_scans >= missingGrace then
local issue = {
id = id .. ":missing",
drive_id = id,
kind = "drive-missing",
drive = tostring(known.drive or known.device or id),
severity = "warning",
title = noctalia.tr("alerts.drive_title", { drive = tostring(known.drive or id) }),
message = noctalia.tr("alerts.drive_missing", {
drive = tostring(known.drive or known.device or id), count = known.missing_scans,
}),
value = known.missing_scans,
}
active[issue.id] = issue
table.insert(issues, issue)
end
end
end
end
for _, issue in ipairs(issues) do
local previous = previousActive[issue.id]
local isNew = previous == nil
local escalated = previous ~= nil and severityRank(issue.severity) > severityRank(previous.severity)
local worsened = previous ~= nil and issue.monotonic == true
and tonumber(issue.value) ~= nil and tonumber(previous.value) ~= nil
and tonumber(issue.value) > tonumber(previous.value)
local changed = previous ~= nil and issue.revision ~= nil and issue.revision ~= previous.revision
if dismissed[issue.id] == nil and (isNew or escalated or worsened or changed) then
sendIssueNotification(issue)
end
end
if boolConfig("alerts_enabled", true) and boolConfig("notify_recovery", true) then
for id, previous in pairs(previousActive) do
if active[id] == nil and dismissed[id] == nil
and MONOTONIC_COUNTER_KINDS[previous.kind] ~= true then
noctalia.notify(
noctalia.tr("alerts.recovered_title", { drive = tostring(previous.drive or noctalia.tr("panel.title")) }),
noctalia.tr("alerts.recovered_body", { issue = tostring(previous.message or id) })
)
end
end
end
table.sort(issues, function(left, right)
local difference = severityRank(left.severity) - severityRank(right.severity)
if difference ~= 0 then
return difference > 0
end
return tostring(left.title) < tostring(right.title)
end)
local visibleIssues = {}
local criticalCount = 0
for _, issue in ipairs(issues) do
if dismissed[issue.id] == nil then
table.insert(visibleIssues, issue)
if issue.severity == "critical" then
criticalCount += 1
end
end
issue.monotonic = nil
end
replaceAlertStateSection("active", active)
replaceAlertStateSection("inventory", inventory)
replaceAlertStateSection("availability", availability)
replaceAlertStateSection("dismissed", dismissed)
snapshot.issues = visibleIssues
snapshot.summary = snapshot.summary or {}
snapshot.summary.active_alert_count = #visibleIssues
snapshot.summary.critical_alert_count = criticalCount
snapshot.summary.dismissed_alert_count = nil
persistAlertState()
noctalia.state.set("snapshot", snapshot)
end
loadAlertState()
noctalia.state.watch("collector_snapshot", function(snapshot)
processSnapshot(snapshot)
end)
noctalia.state.watch("dismiss_alert_request", function(request)
if type(request) ~= "table" then
return
end
local dismissed = copyStateValue(alertState.dismissed or {})
if request.all == true then
for id in pairs(alertState.active or {}) do
dismissed[id] = {
dismissed_at = os.time(),
}
end
elseif request.id ~= nil then
local id = tostring(request.id)
local issue = (alertState.active or {})[id]
if issue ~= nil then
dismissed[id] = {
dismissed_at = os.time(),
}
end
else
return
end
replaceAlertStateSection("dismissed", dismissed)
local currentSnapshot = noctalia.state.get("collector_snapshot")
if type(currentSnapshot) == "table" then
processSnapshot(currentSnapshot)
else
persistAlertState()
end
end)
local initialSnapshot = noctalia.state.get("collector_snapshot")
if initialSnapshot ~= nil then
processSnapshot(initialSnapshot)
end
function onConfigChanged()
processSnapshot(noctalia.state.get("collector_snapshot"))
end
function onIpc(event, _payload)
if event == "test-alert" then
noctalia.notify(noctalia.tr("alerts.test_title"), noctalia.tr("alerts.test_body"))
end
end
+504
View File
@@ -0,0 +1,504 @@
-- Behavioral tests for the isolated alert service.
local state = {}
local watchers = {}
local notifications = {}
local stateWrites = {}
local stateRenames = {}
local successfulStateCommits = 0
local failNextStateWrite = false
local failNextStateRename = false
local fullSmartEnabled = false
local function translate(key, substitutions)
local value = key
for name, replacement in pairs(substitutions or {}) do
value = value:gsub("{" .. name .. "}", tostring(replacement))
end
if substitutions ~= nil and substitutions.count ~= nil then
value = value .. ":count=" .. tostring(substitutions.count)
end
return value
end
noctalia = {
getConfig = function(key)
local values = {
alerts_enabled = true,
notify_recovery = true,
warning_temperature = 65,
critical_temperature = 80,
life_warning_percent = 20,
show_hdd = false,
alert_hdd = true,
drive_missing_alerts = true,
missing_grace_scans = 3,
use_hotspot_temperature = true,
system_collector_enabled = fullSmartEnabled,
}
return values[key]
end,
pluginDataDir = function() return "/mock/plugin-data" end,
readFile = function(path)
return path:match("alert%-state%.json$") and "{}" or nil
end,
writeFile = function(path, _contents)
table.insert(stateWrites, path)
if failNextStateWrite then
failNextStateWrite = false
return false, "fixture write failure"
end
return true
end,
renameFile = function(from, to)
table.insert(stateRenames, { from = from, to = to })
if failNextStateRename then
failNextStateRename = false
return false, "fixture rename failure"
end
successfulStateCommits = successfulStateCommits + 1
return true
end,
log = function(_message) end,
notify = function(title, body)
table.insert(notifications, { severity = "warning", title = title, body = body })
end,
notifyError = function(title, body)
table.insert(notifications, { severity = "critical", title = title, body = body })
end,
tr = translate,
state = {
get = function(key) return state[key] end,
set = function(key, value) state[key] = value end,
watch = function(key, callback) watchers[key] = callback end,
},
json = {
decode = function(_raw)
return {
active = {
["SERIAL1:interface-crc"] = {
id = "SERIAL1:interface-crc", kind = "interface-crc",
drive = "Fixture SSD", message = "historical CRC total", severity = "warning",
},
},
counters = {}, inventory = {}, availability = {}, dismissed = {},
}
end,
encode = function(_value, _pretty) return "{}" end,
},
}
local handle = assert(io.open("service.luau", "rb"))
local source = handle:read("*a")
handle:close()
source = source:gsub("([%a_][%w_]*) %+%= ([^\n]+)", "%1 = %1 + %2")
assert(load(source, "@service.luau"))()
local publishSnapshot = assert(watchers.collector_snapshot, "alert service did not watch collector snapshots")
local function publish(value)
state.collector_snapshot = value
publishSnapshot(value)
end
local dismiss = assert(watchers.dismiss_alert_request, "alert service did not watch dismissal requests")
local function drive(temperature)
return {
id = "SERIAL1", device = "/dev/nvme0n1", model = "Fixture SSD",
kind = "ssd", health = "passed", smart_available = true,
temperature_c = temperature, hotspot_temperature_c = temperature, remaining_life_percent = 95,
available_spare_percent = 100, critical_warning = 0,
media_errors = 0, reallocated_sectors = 0, pending_sectors = 0,
uncorrectable_errors = 0, spin_retry_count = 0, command_timeout_count = 0,
interface_crc_errors = 0, unsafe_shutdowns = 12, error_log_entries = 5893,
smart_completeness = "full", alerts_enabled = true, presence_alert_enabled = true,
self_test_state = "passed",
}
end
local function snapshot(disk, collectionId)
return {
collection_id = collectionId,
disks = disk ~= nil and { disk } or {},
dependencies = { ready = true, missing = {}, missing_text = "", signature = "" },
summary = { ssd_count = disk ~= nil and 1 or 0 },
}
end
local function findIssue(kind)
for _, issue in ipairs(state.snapshot.issues or {}) do
if issue.kind == kind then
return issue
end
end
return nil
end
publish(snapshot(drive(45)))
assert(#state.snapshot.issues == 0, "healthy drive produced an alert")
assert(#notifications == 0, "healthy baseline or legacy counter cleanup produced a notification")
assert(#stateWrites == 1 and #stateRenames == 1 and successfulStateCommits == 1,
"first healthy baseline was not persisted as one atomic commit")
publish(snapshot(drive(45)))
onConfigChanged()
assert(#stateWrites == 1 and #stateRenames == 1,
"identical or config-only processing rewrote alert state")
local writesBeforeNewAlert = #stateWrites
publish(snapshot(drive(70)))
assert(#state.snapshot.issues == 1 and state.snapshot.issues[1].kind == "temperature", "warning temperature was missed")
assert(#notifications == 1 and notifications[1].severity == "warning", "warning notification was not sent")
assert(#stateWrites == writesBeforeNewAlert + 1 and #stateRenames == writesBeforeNewAlert + 1,
"new alert was not persisted as exactly one atomic commit")
publish(snapshot(drive(70)))
assert(#notifications == 1, "unchanged warning notification was duplicated")
assert(#stateWrites == writesBeforeNewAlert + 1,
"unchanged active alert rewrote alert state")
publish(snapshot(drive(63)))
assert(#state.snapshot.issues == 1 and state.snapshot.issues[1].message == "alerts.temperature_cooling",
"temperature hysteresis displayed a contradictory threshold message")
assert(#notifications == 1, "cooling hysteresis duplicated its warning notification")
publish(snapshot(drive(85)))
assert(state.snapshot.issues[1].severity == "critical", "critical escalation was missed")
assert(#notifications == 2 and notifications[2].severity == "critical", "critical escalation did not notify")
local writesBeforeRecovery = #stateWrites
publish(snapshot(drive(60)))
assert(#state.snapshot.issues == 0, "temperature recovery did not clear")
assert(#notifications == 3 and notifications[3].severity == "warning", "recovery did not notify")
assert(#stateWrites == writesBeforeRecovery + 1 and #stateRenames == writesBeforeRecovery + 1,
"alert recovery was not persisted as exactly one atomic commit")
publish(snapshot(drive(70)))
assert(#notifications == 4, "recurring temperature issue did not notify")
publish(snapshot(drive(45)))
assert(#notifications == 5, "temperature recovery did not notify")
local retiredFixture = drive(45)
retiredFixture.presence_alert_enabled = false
publish(snapshot(retiredFixture))
local hddIssue = drive(35)
hddIssue.id = "HDD-SERIAL"
hddIssue.kind = "hdd"
hddIssue.remaining_life_percent = nil
hddIssue.presence_alert_enabled = false
hddIssue.interface_crc_errors = 2
local notificationsBeforeHddBaseline = #notifications
publish(snapshot(hddIssue))
assert(#state.snapshot.issues == 0 and #notifications == notificationsBeforeHddBaseline,
"historical HDD interface CRC errors produced a false alert")
hddIssue.interface_crc_errors = 3
publish(snapshot(hddIssue))
assert(#state.snapshot.issues == 0, "new HDD interface CRC errors produced a persistent issue")
assert(#notifications == notificationsBeforeHddBaseline + 1
and notifications[#notifications].severity == "warning"
and notifications[#notifications].body:match("count=1"),
"new HDD interface CRC error did not produce one delta notification")
publish(snapshot(hddIssue))
assert(#notifications == notificationsBeforeHddBaseline + 1,
"unchanged HDD interface CRC total duplicated its notification")
hddIssue.interface_crc_errors = 0
publish(snapshot(hddIssue))
assert(#notifications == notificationsBeforeHddBaseline + 1,
"decreased HDD interface CRC total produced a notification")
local historicalErrors = drive(45)
historicalErrors.id = "HISTORICAL-SERIAL"
historicalErrors.presence_alert_enabled = false
historicalErrors.media_errors = 8
historicalErrors.reallocated_sectors = 4
historicalErrors.pending_sectors = 2
historicalErrors.uncorrectable_errors = 3
historicalErrors.spin_retry_count = 1
historicalErrors.command_timeout_count = 6
historicalErrors.interface_crc_errors = 9
historicalErrors.error_log_entries = 20
local notificationsBeforeHistoricalBaseline = #notifications
publish(snapshot(historicalErrors))
assert(#state.snapshot.issues == 0 and #notifications == notificationsBeforeHistoricalBaseline,
"first observation of historical SMART counters produced false alerts")
historicalErrors.uncorrectable_errors = 5
historicalErrors.error_log_entries = 21
historicalErrors.interface_crc_errors = 11
publish(snapshot(historicalErrors))
assert(#state.snapshot.issues == 0, "counter deltas produced persistent drive issues")
assert(#notifications == notificationsBeforeHistoricalBaseline + 3,
"new SMART counter values did not produce exactly one notification per increase")
assert(notifications[notificationsBeforeHistoricalBaseline + 1].severity == "critical"
and notifications[notificationsBeforeHistoricalBaseline + 1].body:match("count=2"),
"uncorrectable-error delta notification was incorrect")
assert(notifications[notificationsBeforeHistoricalBaseline + 2].severity == "warning"
and notifications[notificationsBeforeHistoricalBaseline + 2].body:match("count=2"),
"interface CRC delta notification was incorrect")
assert(notifications[notificationsBeforeHistoricalBaseline + 3].severity == "warning"
and notifications[notificationsBeforeHistoricalBaseline + 3].body:match("count=1"),
"error-log delta notification was incorrect")
publish(snapshot(historicalErrors))
local unchangedCounterNotifications = {}
for index = notificationsBeforeHistoricalBaseline + 1, #notifications do
table.insert(unchangedCounterNotifications, notifications[index].body)
end
assert(#notifications == notificationsBeforeHistoricalBaseline + 3,
"unchanged SMART counter values duplicated notifications: " .. table.concat(unchangedCounterNotifications, ", "))
publish(snapshot(drive(45)))
local hotspot = drive(45)
hotspot.hotspot_temperature_c = 70
publish(snapshot(hotspot))
assert(state.snapshot.issues[1].kind == "temperature", "NVMe hotspot warning was missed")
publish(snapshot(drive(45)))
local customThreshold = drive(55)
customThreshold.warning_temperature = 50
customThreshold.critical_temperature = 70
publish(snapshot(customThreshold))
assert(state.snapshot.issues[1].kind == "temperature" and state.snapshot.issues[1].severity == "warning",
"per-drive temperature threshold was ignored")
publish(snapshot(drive(45)))
local partial = drive(45)
partial.smart_completeness = "partial"
publish(snapshot(partial))
assert(#state.snapshot.issues == 0, "healthy partial SMART data produced an alert")
publish(snapshot(drive(45)))
local unavailable = drive(45)
unavailable.smart_available = false
publish(snapshot(unavailable))
assert(#state.snapshot.issues == 0,
"Basic mode produced a false SMART-unavailable warning")
fullSmartEnabled = true
local notificationsBeforeUnavailable = #notifications
local writesBeforeUnavailable = #stateWrites
publish(snapshot(unavailable, "smart-unavailable-transient"))
assert(#state.snapshot.issues == 0 and #notifications == notificationsBeforeUnavailable,
"a single transient SMART read failure produced an alert")
assert(#stateWrites == writesBeforeUnavailable + 1,
"the first unavailable SMART scan did not persist its pending state")
publish(snapshot(unavailable, "smart-unavailable-transient"))
onConfigChanged()
assert(#state.snapshot.issues == 0 and #notifications == notificationsBeforeUnavailable,
"reprocessing one unavailable SMART snapshot advanced its grace period")
assert(#stateWrites == writesBeforeUnavailable + 1,
"reprocessing one unavailable SMART snapshot rewrote pending state")
publish(snapshot(drive(45), "smart-available-reset"))
assert(#state.snapshot.issues == 0 and #notifications == notificationsBeforeUnavailable,
"transient SMART availability recovery produced a notification")
publish(snapshot(unavailable, "smart-unavailable-1"))
publish(snapshot(unavailable, "smart-unavailable-2"))
assert(#state.snapshot.issues == 0 and #notifications == notificationsBeforeUnavailable,
"SMART unavailability alerted before three completed scans")
publish(snapshot(unavailable, "smart-unavailable-3"))
assert(#state.snapshot.issues == 1 and state.snapshot.issues[1].kind == "smart-unavailable",
"sustained SMART unavailability did not alert after three completed scans")
assert(#notifications == notificationsBeforeUnavailable + 1,
"sustained SMART unavailability did not produce exactly one notification")
local writesAfterConfirmedUnavailable = #stateWrites
publish(snapshot(unavailable, "smart-unavailable-4"))
assert(#notifications == notificationsBeforeUnavailable + 1,
"continued SMART unavailability duplicated its notification")
assert(#stateWrites == writesAfterConfirmedUnavailable,
"confirmed SMART unavailability rewrote stable alert state")
local sleeping = drive(45)
sleeping.smart_available = false
sleeping.smart_sleeping = true
publish(snapshot(sleeping, "smart-sleeping"))
assert(#state.snapshot.issues == 0, "sleeping drive produced a SMART-unavailable warning")
assert(#notifications == notificationsBeforeUnavailable + 2,
"confirmed SMART-unavailable recovery did not notify exactly once")
fullSmartEnabled = false
publish(snapshot(drive(45)))
local selfTestFailure = drive(45)
selfTestFailure.self_test_state = "failed"
selfTestFailure.self_test_status = "Completed with read failure"
publish(snapshot(selfTestFailure))
assert(state.snapshot.issues[1].kind == "self-test" and state.snapshot.issues[1].severity == "critical",
"self-test failure was missed")
publish(snapshot(drive(45)))
local counterIncrease = drive(45)
counterIncrease.unsafe_shutdowns = 13
local writesBeforeCounterIncrease = #stateWrites
local notificationsBeforeCounterIncrease = #notifications
publish(snapshot(counterIncrease))
assert(#stateWrites == writesBeforeCounterIncrease + 1
and #stateRenames == writesBeforeCounterIncrease + 1,
"counter increase was not persisted as exactly one atomic commit")
assert(#notifications == notificationsBeforeCounterIncrease + 1,
"counter increase notification behavior changed")
publish(snapshot(counterIncrease))
assert(#stateWrites == writesBeforeCounterIncrease + 1,
"unchanged diagnostic counter rewrote alert state")
publish(snapshot(drive(45)))
publish(snapshot(drive(45), "scan-100"))
local firstMissingScan = snapshot(nil, "scan-101")
local writesBeforeMissingScan = #stateWrites
publish(firstMissingScan)
assert(findIssue("drive-missing") == nil, "missing drive alerted before the grace period")
assert(#stateWrites == writesBeforeMissingScan + 1
and #stateRenames == writesBeforeMissingScan + 1,
"missing-drive inventory change was not persisted as exactly one atomic commit")
publish(firstMissingScan)
assert(findIssue("drive-missing") == nil, "reprocessing one snapshot advanced the grace period")
assert(#stateWrites == writesBeforeMissingScan + 1,
"reprocessing one collection rewrote unchanged inventory state")
publish(snapshot(nil, "scan-101"))
assert(findIssue("drive-missing") == nil, "a repeated collection ID advanced the grace period")
assert(#stateWrites == writesBeforeMissingScan + 1,
"a repeated collection ID rewrote unchanged inventory state")
onConfigChanged()
assert(findIssue("drive-missing") == nil, "a config refresh advanced the grace period")
assert(#stateWrites == writesBeforeMissingScan + 1,
"config refresh rewrote unchanged missing-drive state")
local collectingSnapshot = snapshot(nil, "scan-collecting")
collectingSnapshot.collecting = true
publish(collectingSnapshot)
assert(findIssue("drive-missing") == nil, "an in-progress collection advanced the grace period")
local failedMissingScan = snapshot(nil, "scan-failed")
failedMissingScan.collector_error = "fixture failure"
publish(failedMissingScan)
assert(findIssue("drive-missing") == nil, "a failed collection advanced the grace period")
local blockedMissingScan = snapshot(nil, "scan-blocked")
blockedMissingScan.dependencies = {
ready = false, blocking = true,
missing = { "lsblk (lsblk)" }, missing_text = "lsblk (lsblk)", signature = "lsblk",
}
publish(blockedMissingScan)
assert(findIssue("drive-missing") == nil, "a blocked collection advanced the grace period")
publish(snapshot(nil, "scan-102"))
assert(findIssue("drive-missing") == nil, "missing drive alerted after only two completed scans")
publish(snapshot(nil, "scan-103"))
assert(findIssue("drive-missing") ~= nil, "three unique completed scans did not trigger a missing-drive alert")
publish(snapshot(nil, "scan-103"))
assert(findIssue("drive-missing") ~= nil, "reprocessing a snapshot removed an active missing-drive alert")
local writesBeforeReappearance = #stateWrites
publish(snapshot(drive(45), "scan-104"))
assert(findIssue("drive-missing") == nil, "drive reappearance did not clear its missing alert")
assert(#stateWrites == writesBeforeReappearance + 1
and #stateRenames == writesBeforeReappearance + 1,
"drive reappearance was not persisted as exactly one atomic commit")
publish(snapshot(nil, "scan-105"))
publish(snapshot(nil, "scan-106"))
assert(findIssue("drive-missing") == nil, "reappearance did not reset the missing-drive grace period")
publish(snapshot(nil, "scan-107"))
assert(findIssue("drive-missing") ~= nil, "three new scans after reappearance did not trigger an alert")
publish(snapshot(drive(45), "scan-108"))
local legacyMissingScan = snapshot(nil)
publish(legacyMissingScan)
publish(legacyMissingScan)
assert(findIssue("drive-missing") == nil, "a repeated legacy snapshot advanced the grace period")
publish(snapshot(nil))
assert(findIssue("drive-missing") == nil, "two legacy snapshot objects triggered an early alert")
publish(snapshot(nil))
assert(findIssue("drive-missing") ~= nil, "distinct legacy snapshot objects did not advance the grace period")
local missing = snapshot(nil)
missing.dependencies = {
ready = false, blocking = true,
missing = { "lsblk (lsblk)" }, missing_text = "lsblk (lsblk)", signature = "lsblk",
}
local notificationCountBeforeDependency = #notifications
publish(missing)
assert(findIssue("missing-dependencies") ~= nil, "dependency issue was missed")
assert(findIssue("drive-missing") ~= nil, "a blocking dependency removed an active missing-drive alert")
local dependencyCritical = false
for index = notificationCountBeforeDependency + 1, #notifications do
if notifications[index].severity == "critical" then dependencyCritical = true end
end
assert(dependencyCritical, "blocking dependency was not critical")
local failed = snapshot(nil)
failed.collector_error = "fixture failure"
local notificationCountBeforeFailure = #notifications
publish(failed)
assert(findIssue("collector-error") ~= nil, "collector issue was missed")
assert(findIssue("drive-missing") ~= nil, "a collector failure removed an active missing-drive alert")
local collectorCritical = false
for index = notificationCountBeforeFailure + 1, #notifications do
if notifications[index].severity == "critical" then
collectorCritical = true
end
end
assert(collectorCritical, "collector failure did not notify critically")
local writesBeforeWriteFailure = #stateWrites
local renamesBeforeWriteFailure = #stateRenames
local commitsBeforeWriteFailure = successfulStateCommits
failNextStateWrite = true
dismiss({ id = "collector:error", nonce = 8 })
assert(#stateWrites == writesBeforeWriteFailure + 1
and #stateRenames == renamesBeforeWriteFailure
and successfulStateCommits == commitsBeforeWriteFailure,
"failed temporary write attempted a rename or lost the pending state")
publish(failed)
assert(#stateWrites == writesBeforeWriteFailure + 2
and #stateRenames == renamesBeforeWriteFailure + 1
and successfulStateCommits == commitsBeforeWriteFailure + 1,
"unchanged snapshot did not retry a failed alert-state write")
publish(failed)
assert(#stateWrites == writesBeforeWriteFailure + 2,
"successful write retry did not clear dirty alert state")
local writesBeforeRenameFailure = #stateWrites
local renamesBeforeRenameFailure = #stateRenames
local commitsBeforeRenameFailure = successfulStateCommits
failNextStateRename = true
dismiss({ all = true, nonce = 9 })
assert(#state.snapshot.issues == 0, "dismiss all did not hide every active issue")
assert(#stateWrites == writesBeforeRenameFailure + 1
and #stateRenames == renamesBeforeRenameFailure + 1
and successfulStateCommits == commitsBeforeRenameFailure,
"failed atomic rename was treated as a successful commit")
onConfigChanged()
assert(#stateWrites == writesBeforeRenameFailure + 2
and #stateRenames == renamesBeforeRenameFailure + 2
and successfulStateCommits == commitsBeforeRenameFailure + 1,
"unchanged processing did not retry a failed atomic rename")
onConfigChanged()
assert(#stateWrites == writesBeforeRenameFailure + 2,
"successful rename retry did not clear dirty alert state")
publish(snapshot(drive(45), "scan-dismissal-baseline"))
publish(snapshot(drive(70), "scan-dismissal-warning"))
assert(findIssue("temperature") ~= nil, "permanent-dismissal fixture did not create an alert")
local notificationsBeforeDismissal = #notifications
local writesBeforeDismissal = #stateWrites
local renamesBeforeDismissal = #stateRenames
dismiss({ id = "SERIAL1:temperature", nonce = 10 })
assert(findIssue("temperature") == nil, "individual dismissal did not hide the active issue")
assert(#notifications == notificationsBeforeDismissal, "dismissing an issue emitted a notification")
assert(#stateWrites == writesBeforeDismissal + 1 and #stateRenames == renamesBeforeDismissal + 1,
"dismissal was not persisted as exactly one atomic commit")
publish(snapshot(drive(85), "scan-dismissal-critical"))
assert(findIssue("temperature") == nil and #notifications == notificationsBeforeDismissal,
"a dismissed alert returned or notified after escalating")
publish(snapshot(drive(45), "scan-dismissal-recovery"))
publish(snapshot(drive(70), "scan-dismissal-recurrence"))
assert(findIssue("temperature") == nil and #notifications == notificationsBeforeDismissal,
"a dismissed alert returned or notified after recurring")
for _, path in ipairs(stateWrites) do
assert(path:match("alert%-state%.json%.tmp$"),
"alert state bypassed its temporary file")
end
for _, rename in ipairs(stateRenames) do
assert(rename.from:match("alert%-state%.json%.tmp$")
and rename.to:match("alert%-state%.json$"),
"alert state was not committed with an atomic rename")
end
print("alert behavior tests passed")
+590
View File
@@ -0,0 +1,590 @@
-- Unit and initialization tests for collector.luau using a minimal Noctalia
-- host mock. This runs with stock Lua after lowering Luau compound assignment.
local mode = arg[1] or "ready"
local state = {}
local launchedCommand = nil
local launchedCommands = {}
local logs = {}
local notifications = {}
local files = {}
local directories = {}
local watchers = {}
local pendingProbeCallback = nil
local probeCalls = 0
local probeAction = nil
local collectorEnabled = mode == "raw-cache" or mode == "outdated-raw-cache"
local available = {
lsblk = mode ~= "missing-lsblk",
smartctl = mode ~= "missing-smartctl",
pacman = true,
sudo = true,
pkexec = true,
systemctl = true,
}
local rawFixture = {
schema = 2,
collector_version = mode == "outdated-raw-cache" and "0.6.0" or "2.0.0",
collection_id = "fixture-collection-id",
generated_at_epoch = 1700000000,
lsblk = { blockdevices = {} },
smart = {},
}
local function translate(key, substitutions)
local value = key
for name, replacement in pairs(substitutions or {}) do
value = value:gsub("{" .. name .. "}", tostring(replacement))
end
return value
end
noctalia = {
commandExists = function(command) return available[command] == true end,
getConfig = function(key)
if key == "system_collector_enabled" then
return collectorEnabled
end
return nil
end,
pluginDir = function() return "/mock/plugin" end,
pluginDataDir = function() return "/mock/plugin-data" end,
fileInfo = function(path)
if (mode == "raw-cache" or mode == "outdated-raw-cache" or mode == "collector-disabled")
and path:match("raw%.json$") then
return { isDir = false, mtime = os.time() }
end
return nil
end,
fileExists = function(path) return files[path] ~= nil end,
listDir = function(path) return directories[path] or {} end,
readFile = function(path)
if (mode == "raw-cache" or mode == "outdated-raw-cache" or mode == "collector-disabled")
and path:match("raw%.json$") then
return "raw-cache"
end
return files[path]
end,
writeFile = function(path, contents) files[path] = contents return true end,
log = function(message) table.insert(logs, message) end,
notify = function(title, body) table.insert(notifications, { title = title, body = body }) end,
tr = translate,
formatTime = function(_pattern, _epoch) return "22:13:20" end,
setUpdateInterval = function(_milliseconds) end,
state = {
get = function(key) return state[key] end,
set = function(key, value)
state[key] = value
if watchers[key] ~= nil then watchers[key](value) end
end,
watch = function(key, callback) watchers[key] = callback end,
},
json = {
decode = function(_raw) return rawFixture end,
encode = function(_value, _pretty) return "{}" end,
},
string = {
trim = function(value) return tostring(value):match("^%s*(.-)%s*$") end,
},
runAsync = function(command, callback, _timeout)
launchedCommand = command
table.insert(launchedCommands, command)
if command:match("lsblk=ok") then
probeCalls = probeCalls + 1
if probeAction == "pending" then
assert(pendingProbeCallback == nil, "dependency probes overlapped")
pendingProbeCallback = callback
return true
end
if probeAction == "launch-failure" then
probeAction = nil
return false
end
if type(probeAction) == "table" then
local response = probeAction
probeAction = nil
callback(response)
return true
end
if mode == "async-incompatible-lsblk" or mode == "probe-completes-during-collection" then
pendingProbeCallback = callback
return true
end
if mode == "probe-timeout" then
callback({ exitCode = 124, stdout = "", stderr = "", timedOut = true })
return true
end
local lsblk = mode == "incompatible-lsblk" and "bad" or "ok"
local smartctl = mode == "incompatible-smartctl" and "bad" or "ok"
callback({ exitCode = 0, stdout = "lsblk=" .. lsblk .. "\nsmartctl=" .. smartctl .. "\n",
stderr = "", timedOut = false })
else
if mode == "probe-completes-during-collection" and pendingProbeCallback ~= nil then
local probe = pendingProbeCallback
pendingProbeCallback = nil
probe({ exitCode = 0, stdout = "lsblk=bad\nsmartctl=ok\n", stderr = "", timedOut = false })
end
callback({ exitCode = 0, stdout = "{}", stderr = "", timedOut = false })
end
return true
end,
}
local handle = assert(io.open("collector.luau", "rb"))
local source = handle:read("*a")
handle:close()
source = source:gsub("local function mountedUsage", "function mountedUsage")
source = source:gsub("local function normalizeSmart", "function normalizeSmart")
source = source:gsub("local function normalizeRaw", "function normalizeRaw")
source = source:gsub("local function publishError", "function publishError")
source = source:gsub("([%a_][%w_]*) %+%= ([^\n]+)", "%1 = %1 + %2")
assert(load(source, "@collector.luau"))()
local snapshot = assert(state.collector_snapshot, "collector did not publish an initialization snapshot")
local dependencies = assert(snapshot.dependencies, "snapshot has no dependency state")
if mode == "probe-completes-during-collection" then
assert(dependencies.ready == false and dependencies.blocking == true,
"collector published stale dependency state when the probe completed during collection")
print("collector initialization test passed: " .. mode)
return
elseif mode == "async-incompatible-lsblk" then
assert(dependencies.ready == true and pendingProbeCallback ~= nil,
"asynchronous capability probe was not pending")
pendingProbeCallback({ exitCode = 0, stdout = "lsblk=bad\nsmartctl=ok\n", stderr = "", timedOut = false })
dependencies = assert(state.collector_snapshot.dependencies)
assert(dependencies.ready == false and dependencies.blocking == true,
"completed capability probe did not immediately refresh dependency state")
print("collector initialization test passed: " .. mode)
return
elseif mode == "probe-timeout" then
assert(dependencies.ready == false and dependencies.blocking == true,
"failed capability probe incorrectly reported dependencies ready")
print("collector initialization test passed: " .. mode)
return
elseif mode == "missing-lsblk" then
assert(dependencies.ready == false and dependencies.blocking == true, "missing lsblk was not blocking")
assert(dependencies.install_command == "sudo pacman -S --needed util-linux", "wrong lsblk install command")
assert(launchedCommand == nil, "collector launched without lsblk")
assert(snapshot.collector_error ~= nil, "blocking dependency did not publish an error")
print("collector initialization test passed: " .. mode)
return
elseif mode == "missing-smartctl" then
assert(dependencies.ready == false and dependencies.blocking == false, "missing smartctl blocked inventory")
assert(dependencies.install_command == "sudo pacman -S --needed smartmontools", "wrong smartctl install command")
assert(launchedCommand and launchedCommand:match("collect_raw%.sh"), "fallback collector did not launch")
print("collector initialization test passed: " .. mode)
return
elseif mode == "raw-cache" then
assert(snapshot.source == "system-cache", "raw cache was not normalized")
assert(snapshot.collection_id == "fixture-collection-id", "raw cache lost its collection ID")
assert(snapshot.system_collector.status == "healthy"
and snapshot.system_collector.version == "2.0.0"
and snapshot.system_collector.expected_version == "2.0.0",
"current system collector was not reported healthy")
assert(not (launchedCommand or ""):match("collect_raw%.sh"), "collector launched despite a fresh raw cache")
print("collector initialization test passed: " .. mode)
return
elseif mode == "outdated-raw-cache" then
assert(snapshot.source == "system-cache", "outdated raw cache was not normalized")
assert(snapshot.system_collector.status == "upgrade-required"
and snapshot.system_collector.version == "0.6.0"
and snapshot.system_collector.expected_version == "2.0.0",
"older system collector did not request an upgrade")
assert(not (launchedCommand or ""):match("collect_raw%.sh"), "collector launched despite a fresh raw cache")
assert(#notifications == 1 and notifications[1].title == "collector.update_title"
and notifications[1].body == "collector.update_body",
"enabled outdated collector did not produce one coordinated update notice")
print("collector initialization test passed: " .. mode)
return
elseif mode == "collector-disabled" then
assert(snapshot.source == "direct", "disabled collector still consumed the privileged cache")
assert(snapshot.system_collector.enabled == false and snapshot.system_collector.status == "disabled",
"disabled collector did not publish Basic-mode state")
assert((launchedCommand or ""):match("collect_raw%.sh"),
"disabled collector did not fall back to direct Basic collection")
assert(#notifications == 0, "disabled collector produced an update or installation notification")
print("collector initialization test passed: " .. mode)
return
elseif mode == "incompatible-lsblk" then
assert(dependencies.ready == false and dependencies.blocking == true, "incompatible lsblk was not blocking")
assert(dependencies.missing_text:match("incompatible"), "incompatible lsblk was not explained")
assert(not (launchedCommand or ""):match("collect_raw%.sh"), "collector launched with incompatible lsblk")
print("collector initialization test passed: " .. mode)
return
elseif mode == "incompatible-smartctl" then
assert(dependencies.ready == false and dependencies.blocking == false, "incompatible smartctl blocked inventory")
assert(dependencies.missing_text:match("incompatible"), "incompatible smartctl was not explained")
assert((launchedCommand or ""):match("collect_raw%.sh"), "fallback inventory did not launch")
print("collector initialization test passed: " .. mode)
return
end
assert(dependencies.ready == true, "available dependencies were reported missing")
assert(launchedCommand and launchedCommand:match("collect_raw%.sh"), "raw collector was not launched")
assert(snapshot.system_collector.authorization_available == true,
"available Polkit authorization was not exposed to the panel")
local nvme = normalizeSmart({
smart_status = { passed = true },
temperature = { current = 55 },
nvme_smart_health_information_log = {
available_spare = 100, available_spare_threshold = 25, percentage_used = 3,
temperature_sensors = { 74, 59, 55 },
data_units_read = 10, data_units_written = 20,
power_cycles = 7, power_on_hours = 100, unsafe_shutdowns = 2,
media_errors = 0, num_err_log_entries = 0, critical_warning = 0,
warning_temp_time = 4, critical_comp_time = 1,
},
nvme_self_test_log = {
current_self_test_operation = { value = 0, string = "No self-test in progress" },
table = { { self_test_code = { value = 1, string = "Short" },
self_test_result = { value = 0, string = "Completed without error" }, power_on_hours = 99 } },
},
})
assert(nvme.health == "passed" and nvme.temperature_c == 55, "NVMe health normalization failed")
assert(nvme.hotspot_temperature_c == 74 and #nvme.temperature_sensors_c == 3, "NVMe hotspot normalization failed")
assert(nvme.remaining_life_percent == 97, "NVMe endurance normalization failed")
assert(nvme.data_written_bytes == 20 * 512000, "NVMe data-unit conversion failed")
assert(nvme.available_spare_threshold_percent == 25, "NVMe spare threshold normalization failed")
assert(nvme.self_test_state == "passed" and nvme.self_test_supported, "NVMe self-test normalization failed")
local runningNvme = normalizeSmart({
smart_status = { passed = true },
nvme_smart_health_information_log = { percentage_used = 1 },
nvme_self_test_log = {
current_self_test_operation = { value = 1, string = "Short self-test in progress" },
current_self_test_completion_percent = 37,
},
})
assert(runningNvme.self_test_state == "running" and runningNvme.self_test_completion_percent == 37,
"NVMe self-test completion was not normalized")
local runningAta = normalizeSmart({
smart_status = { passed = true },
ata_smart_self_test_log = { standard = { table = { {
status = { value = 249, string = "Self-test routine in progress", remaining_percent = 80 },
} } } },
})
assert(runningAta.self_test_state == "running" and runningAta.self_test_completion_percent == 20,
"ATA remaining self-test percentage was not converted to completion")
local samsung = normalizeSmart({
smart_status = { passed = true },
power_on_time = { hours = 83062 },
ata_smart_error_log = { summary = { count = 5 } },
ata_smart_attributes = { table = {
{ name = "Wear_Leveling_Count", value = 90, raw = { value = 194 } },
{ name = "Total_LBAs_Written", value = 99, raw = { value = 210090409618 } },
{ name = "Airflow_Temperature_Cel", value = 61, raw = { value = 39 } },
{ name = "Reallocated_Sector_Ct", value = 100, raw = { value = 0 } },
} },
})
assert(samsung.remaining_life_percent == 90, "Samsung wear normalization failed")
assert(samsung.remaining_life_estimated == true, "vendor ATA life was not marked estimated")
assert(samsung.temperature_c == 39, "Samsung temperature normalization failed")
assert(samsung.data_written_bytes == 210090409618 * 512, "Samsung LBA conversion failed")
assert(samsung.reallocated_sectors == 0, "Samsung integrity counter normalization failed")
assert(samsung.media_errors == nil and samsung.error_log_entries == 5, "ATA error log was misclassified as media errors")
local hdd = normalizeSmart({
smart_status = { passed = true },
power_on_time = { hours = 113397 },
ata_smart_attributes = { table = {
{ name = "Start_Stop_Count", value = 98, raw = { value = 1423 } },
{ name = "Load_Cycle_Count", value = 92, raw = { value = 18421 } },
{ name = "Spin_Retry_Count", value = 100, raw = { value = 0 } },
{ name = "Command_Timeout", value = 100, raw = { value = 3 } },
{ name = "UDMA_CRC_Error_Count", value = 200, raw = { value = 2 } },
{ name = "Offline_Uncorrectable", value = 100, raw = { value = 0 } },
{ name = "Reported_Uncorrect", value = 100, raw = { value = 4 } },
} },
})
assert(hdd.start_stop_count == 1423 and hdd.load_cycle_count == 18421,
"HDD mechanical cycle counters were not normalized")
assert(hdd.spin_retry_count == 0 and hdd.command_timeout_count == 3 and hdd.interface_crc_errors == 2,
"HDD transport and spindle counters were not normalized")
assert(hdd.uncorrectable_errors == 4,
"HDD uncorrectable normalization ignored a nonzero counter after a zero counter")
local partial = normalizeSmart({
smart_status = { passed = true },
smartctl = { exit_status = 4, messages = { { severity = "error", string = "Optional log unavailable" } } },
nvme_smart_health_information_log = { percentage_used = 1 },
})
assert(partial.health == "passed" and partial.smart_completeness == "partial", "partial SMART result was not preserved")
assert(partial.smart_messages[1].message == "Optional log unavailable", "SMART diagnostic message was lost")
local currentPrefail = normalizeSmart({
smart_status = { passed = true }, smartctl = { exit_status = 16 },
})
assert(currentPrefail.health == "failed" and currentPrefail.smart_prefail_attribute_now == true,
"current pre-failure threshold bit did not fail health")
local historicalThreshold = normalizeSmart({
smart_status = { passed = true }, smartctl = { exit_status = 32 },
})
assert(historicalThreshold.health == "passed" and historicalThreshold.smart_past_threshold == true,
"historical threshold bit incorrectly failed current health")
local failedSelfTestLog = normalizeSmart({
smart_status = { passed = true }, smartctl = { exit_status = 128 },
})
assert(failedSelfTestLog.self_test_state == "failed" and failedSelfTestLog.smart_self_test_log_error == true,
"failed self-test log bit was ignored")
local sandisk = normalizeSmart({
smart_status = { passed = true },
ata_smart_attributes = { table = {
{ name = "Lifetime_Remaining%", value = 99, raw = { value = 99 } },
{ name = "Total_Writes_GiB", value = 253, raw = { value = 43210 } },
{ name = "Total_Reads_GiB", value = 253, raw = { value = 8765 } },
{ name = "Unexpect_Power_Loss_Ct", value = 100, raw = { value = 12 } },
} },
})
assert(sandisk.remaining_life_percent == 99, "SanDisk endurance normalization failed")
assert(sandisk.data_written_bytes == 43210 * 1024 ^ 3, "SanDisk write conversion failed")
assert(sandisk.data_read_bytes == 8765 * 1024 ^ 3, "SanDisk read conversion failed")
assert(sandisk.unsafe_shutdowns == 12, "SanDisk unsafe shutdown normalization failed")
local ambiguousHostWrites = normalizeSmart({
smart_status = { passed = true },
ata_smart_attributes = { table = {
{ name = "Host_Writes", value = 99, raw = { value = 123456 } },
{ name = "Host_Reads", value = 99, raw = { value = 654321 } },
} },
})
assert(ambiguousHostWrites.data_written_bytes == nil and ambiguousHostWrites.data_read_bytes == nil,
"unitless ATA host counters were incorrectly treated as LBAs")
local estimated = normalizeSmart({
smart_status = { passed = true },
ata_smart_attributes = { table = {
{ name = "Perc_Write/Erase_Count", value = 83, raw = { value = 590 } },
} },
})
assert(estimated.remaining_life_percent == 83 and estimated.remaining_life_estimated, "vendor life fallback failed")
assert(estimated.percentage_used == 17, "vendor life usage calculation failed")
local reserveOnly = normalizeSmart({
smart_status = { passed = true },
ata_smart_attributes = { table = {
{ name = "Perc_Avail_Resrvd_Space", value = 97, raw = { value = 97 } },
} },
})
assert(reserveOnly.remaining_life_percent == nil, "reserve space must not be treated as remaining life")
assert(reserveOnly.percentage_used == nil, "reserve space must not produce a used-life percentage")
assert(not reserveOnly.remaining_life_estimated, "reserve space must not be marked as estimated life")
assert(reserveOnly.available_spare_percent == 97, "vendor spare normalization failed")
local used, availableBytes, usage, mountPoints = mountedUsage({ children = {
{ kname = "nvme0n1p1", mountpoints = { "/home", "/", "/home" }, fsused = 25, fsavail = 75 },
} })
assert(used == 25 and availableBytes == 75 and usage == 25, "mounted usage aggregation failed")
assert(#mountPoints == 2 and mountPoints[1] == "/" and mountPoints[2] == "/home",
"mount points were not deduplicated and normalized")
files["/sys/class/nvme/nvme9/device/hwmon/hwmon9/temp1_input"] = "47000\n"
directories["/sys/class/nvme/nvme9/device/hwmon"] = { "hwmon9" }
local normalized, normalizeError = normalizeRaw({
schema = 2,
collection_id = "fixture-normalized-id",
generated_at_epoch = 1700000000,
lsblk = { blockdevices = {
{
name = "nvme9n1", kname = "nvme9n1", path = "/dev/nvme9n1", type = "disk",
tran = "nvme", rota = false, size = 2000000000, model = "Fixture NVMe", serial = "FIXTURE1",
mountpoints = {}, children = {
{ name = "nvme9n1p1", kname = "nvme9n1p1", path = "/dev/nvme9n1p1", type = "part",
mountpoints = { "/mnt/work" }, fsused = 250, fsavail = 750 },
},
},
} },
smart = {
{
requested_device = "/dev/nvme9",
payload = { smartctl = { messages = { { string = "Permission denied" } } } },
},
},
}, "test")
assert(normalized ~= nil and normalizeError == nil, "raw normalization failed")
assert(normalized.collection_id == "fixture-normalized-id", "raw normalization lost its collection ID")
assert(normalized.summary.ssd_count == 1 and normalized.disks[1].id == "FIXTURE1:n1", "drive discovery failed")
assert(normalized.disks[1].temperature_c == 47, "sysfs temperature fallback failed")
assert(normalized.disks[1].mount_points[1] == "/mnt/work",
"normalized drive omitted its mounted folder")
assert(normalized.disks[1].smart_available == false, "permission failure incorrectly marked SMART available")
assert(normalized.disks[1].smart_error:match("Permission denied"), "permission error was not preserved")
local mixed = assert(normalizeRaw({
schema = 2,
generated_at_epoch = 1700000000,
lsblk = { blockdevices = {
{ name = "sda", kname = "sda", path = "/dev/sda", type = "disk", tran = "sata",
rota = false, size = 1000000000, model = "Fixture SSD", serial = "SSD1", children = {} },
{ name = "sdb", kname = "sdb", path = "/dev/sdb", type = "disk", tran = "sata",
rota = true, size = 2000000000, model = "Fixture HDD", serial = "HDD1", children = {} },
} },
smart = {
{ requested_device = "/dev/sda", payload = {
smart_status = { passed = true }, temperature = { current = 42 },
nvme_smart_health_information_log = { percentage_used = 12 },
ata_smart_attributes = { table = {} },
} },
{ requested_device = "/dev/sdb", payload = {
smart_status = { passed = true }, temperature = { current = 36 },
ata_smart_attributes = { table = {} },
} },
},
}, "test"))
assert(mixed.summary.disk_count == 2 and mixed.summary.ssd_count == 1 and mixed.summary.hdd_count == 1,
"mixed SSD/HDD summary counts were incorrect")
assert(mixed.summary.smart_available_count == 2 and mixed.summary.hottest_drive_temperature_c == 42,
"mixed-drive SMART or temperature summary was incorrect")
assert(mixed.summary.hottest_drive_id == "SSD1" and mixed.summary.hottest_drive_name == "Fixture SSD"
and mixed.summary.hottest_ssd_drive_id == "SSD1",
"temperature summary omitted the responsible drive")
assert(mixed.summary.worst_ssd_remaining_life_percent == 88
and mixed.summary.worst_ssd_life_drive_id == "SSD1"
and mixed.summary.worst_ssd_life_drive_name == "Fixture SSD",
"SSD-life summary omitted the responsible drive")
local healthyRaw = assert(normalizeRaw({
schema = 2,
generated_at_epoch = 1700000000,
lsblk = { blockdevices = { {
name = "sda", kname = "sda", path = "/dev/sda", type = "disk", tran = "sata",
rota = false, size = 1000000000, model = "Healthy SSD", serial = "HEALTHY1",
mountpoints = {}, children = {},
} } },
smart = { {
requested_device = "/dev/sda",
payload = { smart_status = { passed = true }, ata_smart_attributes = { table = {} } },
} },
}, "test"))
assert(healthyRaw.disks[1].smart_available == true and healthyRaw.disks[1].smart_error == nil,
"healthy SMART data retained a contradictory error message")
local sleeping = assert(normalizeRaw({
schema = 2, generated_at_epoch = 1700000000,
lsblk = { blockdevices = { {
name = "sdb", kname = "sdb", path = "/dev/sdb", type = "disk", tran = "sata",
rota = true, size = 1000000000, model = "Sleeping HDD", serial = "SLEEP1",
mountpoints = {}, children = {},
} } },
smart = { { requested_device = "/dev/sdb", payload = {
power_mode = { value = 128, string = "STANDBY" },
smartctl = { exit_status = 2 },
} } },
}, "test"))
assert(sleeping.disks[1].smart_sleeping == true and sleeping.disks[1].smart_error == nil,
"sleeping HDD was reported as a SMART access failure")
assert(sleeping.summary.sleeping_count == 1 and sleeping.summary.smart_unavailable_count == 0,
"sleeping HDD was counted as unavailable")
local namespaces = assert(normalizeRaw({
schema = 2, generated_at_epoch = 1700000000,
lsblk = { blockdevices = {
{ name = "nvme0n1", kname = "nvme0n1", path = "/dev/nvme0n1", type = "disk",
tran = "nvme", rota = false, serial = "SHARED", children = {} },
{ name = "nvme0n2", kname = "nvme0n2", path = "/dev/nvme0n2", type = "disk",
tran = "nvme", rota = false, serial = "SHARED", children = {} },
} }, smart = {},
}, "test"))
assert(namespaces.disks[1].id ~= namespaces.disks[2].id
and namespaces.disks[1].id:match(":n%d+$") and namespaces.disks[2].id:match(":n%d+$"),
"NVMe namespaces sharing a controller serial did not receive unique IDs")
local empty = assert(normalizeRaw({
schema = 2, collection_id = " ", generated_at_epoch = 1700000000,
lsblk = { blockdevices = {} }, smart = {},
}, "test"))
assert(empty.access == "unavailable", "an empty drive inventory incorrectly reported full SMART access")
assert(empty.collection_id == nil, "invalid collection ID was preserved")
local oversizedId = assert(normalizeRaw({
schema = 2, collection_id = string.rep("x", 129), generated_at_epoch = 1700000000,
lsblk = { blockdevices = {} }, smart = {},
}, "test"))
assert(oversizedId.collection_id == nil, "oversized collection ID was preserved")
files["/usr/local/libexec/noctalia-drive-health/collect_raw.sh"] = "installed"
collectorEnabled = true
state.collector_snapshot = { summary = {}, system_collector = { status = "healthy" } }
publishError("fixture failure", { ready = true, blocking = false })
assert(state.collector_snapshot.system_collector.status == "stale",
"collector failure retained a stale healthy lifecycle status")
collectorEnabled = false
local compatibleProbe = {
exitCode = 0, stdout = "lsblk=ok\nsmartctl=ok\n", stderr = "", timedOut = false,
}
local incompatibleProbe = {
exitCode = 0, stdout = "lsblk=bad\nsmartctl=ok\n", stderr = "", timedOut = false,
}
probeAction = incompatibleProbe
watchers.refresh_nonce(1)
assert(state.collector_snapshot.dependencies.ready == false,
"manual dependency probe did not cache an incompatible result")
probeAction = "pending"
watchers.refresh_nonce(2)
assert(pendingProbeCallback ~= nil, "manual recheck did not launch a fresh dependency probe")
local manualRecheck = pendingProbeCallback
pendingProbeCallback = nil
manualRecheck(compatibleProbe)
assert(state.collector_snapshot.dependencies.ready == true,
"manual recheck did not recover a cached incompatible dependency")
local completedProbeCalls = probeCalls
update()
onIpc("refresh")
assert(probeCalls == completedProbeCalls,
"routine collection reran a completed dependency probe")
probeAction = incompatibleProbe
watchers.refresh_nonce(3)
assert(state.collector_snapshot.dependencies.ready == false,
"IPC recheck fixture did not cache an incompatible result")
probeAction = "pending"
onIpc("check-dependencies")
assert(pendingProbeCallback ~= nil, "dependency-check IPC did not launch a fresh probe")
local ipcRecheck = pendingProbeCallback
pendingProbeCallback = nil
ipcRecheck(compatibleProbe)
assert(state.collector_snapshot.dependencies.ready == true,
"dependency-check IPC did not recover a cached incompatible dependency")
probeAction = "pending"
local callsBeforeQueuedRecheck = probeCalls
watchers.refresh_nonce(4)
onIpc("check-dependencies")
assert(probeCalls == callsBeforeQueuedRecheck + 1 and pendingProbeCallback ~= nil,
"recheck during a running probe launched an overlapping probe")
local runningRecheck = pendingProbeCallback
pendingProbeCallback = nil
runningRecheck(compatibleProbe)
assert(probeCalls == callsBeforeQueuedRecheck + 2 and pendingProbeCallback ~= nil,
"pending rechecks were not coalesced into one follow-up probe")
local queuedRecheck = pendingProbeCallback
pendingProbeCallback = nil
probeAction = nil
queuedRecheck(compatibleProbe)
assert(probeCalls == callsBeforeQueuedRecheck + 2,
"queued recheck launched more than one follow-up probe")
probeAction = "launch-failure"
local callsBeforeLaunchFailure = probeCalls
watchers.refresh_nonce(5)
assert(probeCalls == callsBeforeLaunchFailure + 1
and state.collector_snapshot.dependencies.ready == false,
"probe launch failure did not publish an incompatible state")
update()
assert(probeCalls == callsBeforeLaunchFailure + 1,
"routine collection retried a failed probe launch")
probeAction = compatibleProbe
watchers.refresh_nonce(6)
assert(probeCalls == callsBeforeLaunchFailure + 2
and state.collector_snapshot.dependencies.ready == true,
"manual recheck did not recover after a probe launch failure")
print("collector normalization tests passed")
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -eu
case " $* " in
*" --nodeps "*)
printf '%s\n' "/dev/sda disk 1" "/dev/nvme0n1 disk 0"
;;
*" --json "*)
printf '%s\n' '{"blockdevices":[' \
'{"name":"sda","kname":"sda","path":"/dev/sda","type":"disk","tran":"sata","rota":true,"size":1000000000,"model":"Fixture SATA","serial":"SATA1","mountpoints":[]},' \
'{"name":"nvme0n1","kname":"nvme0n1","path":"/dev/nvme0n1","type":"disk","tran":"nvme","rota":false,"size":2000000000,"model":"Fixture NVMe","serial":"NVME1","mountpoints":[]}' \
']}'
;;
*)
echo "unexpected lsblk arguments: $*" >&2
exit 2
;;
esac
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
set -eu
device=""
standby=false
for argument in "$@"; do
device=$argument
if [ "$argument" = "--nocheck=standby,0" ]; then
standby=true
fi
done
case "$device" in
/dev/sda)
if [ "${SMARTCTL_EMPTY:-0}" = "1" ]; then
exit 2
fi
printf '%s\n' "{\"json_format_version\":[1,0],\"device\":{\"name\":\"/dev/sda\"},\"smart_status\":{\"passed\":true},\"temperature\":{\"current\":35},\"test_standby\":$standby}"
;;
/dev/nvme0)
printf '%s\n' "{\"json_format_version\":[1,0],\"device\":{\"name\":\"/dev/nvme0\"},\"smart_status\":{\"passed\":true},\"temperature\":{\"current\":42},\"nvme_smart_health_information_log\":{\"percentage_used\":2,\"available_spare\":100,\"critical_warning\":0},\"test_standby\":$standby}"
;;
*)
echo "unexpected smartctl device: $device" >&2
exit 2
;;
esac
+69
View File
@@ -0,0 +1,69 @@
-- Behavioral tests for bounded trend persistence.
local state = {}
local watchers = {}
local writes = 0
local renames = 0
local writesSucceed = true
noctalia = {
getConfig = function(key)
if key == "history_interval_minutes" then return 15 end
if key == "history_retention_days" then return 1 end
return nil
end,
pluginDataDir = function() return "/mock/plugin-data" end,
readFile = function(_path) return nil end,
writeFile = function(_path, _contents) writes = writes + 1 return writesSucceed end,
renameFile = function(_from, _to) renames = renames + 1 return true end,
log = function(_message) end,
state = {
get = function(key) return state[key] end,
set = function(key, value) state[key] = value end,
watch = function(key, callback) watchers[key] = callback end,
},
json = {
decode = function(_raw) return nil end,
encode = function(_value, _pretty) return "{}" end,
},
}
local handle = assert(io.open("history.luau", "rb"))
local source = handle:read("*a")
handle:close()
assert(load(source, "@history.luau"))()
local publish = assert(watchers.snapshot, "history service did not watch snapshots")
local function snapshot(epoch, hotspot, life)
return {
generated_at_epoch = epoch,
disks = { {
id = "SERIAL1", model = "Fixture SSD", display_name = "Fixture SSD", kind = "ssd",
temperature_c = hotspot - 5, hotspot_temperature_c = hotspot,
remaining_life_percent = life, storage_usage_percent = 25, data_written_bytes = 1000,
} },
}
end
publish(snapshot(100000, 60, 99))
local samples = state.drive_history.drives.SERIAL1.samples
assert(#samples == 1 and samples[1].hotspot_temperature_c == 60, "first history sample was not recorded")
assert(writes == 1 and renames == 1, "history was not committed atomically")
publish(snapshot(100100, 61, 99))
assert(#state.drive_history.drives.SERIAL1.samples == 1, "history ignored its sample interval")
assert(writes == 1, "history rewrote the file without a new sample")
publish(snapshot(100901, 62, 98))
samples = state.drive_history.drives.SERIAL1.samples
assert(#samples == 2 and samples[2].remaining_life_percent == 98, "scheduled history sample was missed")
assert(writes == 2 and renames == 2, "second history sample was not committed")
writesSucceed = false
publish(snapshot(101802, 63, 97))
assert(writes == 3 and renames == 2, "failed history write was not exercised")
writesSucceed = true
publish(snapshot(101900, 63, 97))
assert(writes == 4 and renames == 3, "dirty history was not retried after a transient write failure")
print("history behavior tests passed")
+404
View File
@@ -0,0 +1,404 @@
-- Declarative panel smoke tests with a minimal Noctalia/UI host.
local state = {}
local rendered = nil
local terminalCommand = nil
local asyncCommand = nil
local asyncCallback = nil
local notifications = {}
local errors = {}
local writesSucceed = true
local configValues = {
warning_temperature = 65,
critical_temperature = 80,
show_hdd = true,
use_hotspot_temperature = true,
system_collector_enabled = true,
}
local function translate(key, substitutions)
if key == "metrics.mounted_at" then
return key .. " " .. tostring(substitutions and substitutions.paths or "")
elseif key == "metrics.serial" then
return key .. " " .. tostring(substitutions and substitutions.value or "")
end
local value = key
for name, replacement in pairs(substitutions or {}) do
value = value:gsub("{" .. name .. "}", tostring(replacement))
end
return value
end
local function node(kind, props, children)
return { kind = kind, props = props or {}, children = children or {} }
end
ui = setmetatable({}, {
__index = function(_table, kind)
return function(props, children) return node(kind, props, children) end
end,
})
panel = {
render = function(tree) rendered = tree end,
close = function() end,
}
local watchers = {}
noctalia = {
getConfig = function(key)
return configValues[key]
end,
tr = translate,
pluginDataDir = function() return "/mock/plugin-data" end,
writeFile = function(_path, _contents) return writesSucceed end,
renameFile = function(_from, _to) return true end,
notify = function(title, body) table.insert(notifications, { title = title, body = body }) end,
notifyError = function(title, body) table.insert(errors, { title = title, body = body }) end,
runInTerminal = function(command) terminalCommand = command return true end,
runAsync = function(command, callback, _timeout)
asyncCommand = command
asyncCallback = callback
return true
end,
copyToClipboard = function(_text, _mime) return true end,
string = { trim = function(value) return tostring(value):match("^%s*(.-)%s*$") end },
json = { encode = function(_value, _pretty) return "{}" end },
state = {
get = function(key) return state[key] end,
set = function(key, value) state[key] = value end,
watch = function(key, callback) watchers[key] = callback end,
},
}
state.snapshot = {
generated_at_local = "12:00:00",
collector_error = nil,
dependencies = { ready = true },
system_collector = { enabled = true, installed = true, status = "healthy", version = "1.0.0",
expected_version = "1.0.0", helper_available = true, authorization_available = true,
enable_command = "sudo systemctl enable --now noctalia-drive-health.timer",
disable_command = "sudo systemctl disable --now noctalia-drive-health.timer",
install_command = "sudo '/mock/plugin/packaging/install-system-collector.sh'",
uninstall_command = "sudo '/mock/plugin/packaging/uninstall-system-collector.sh'" },
summary = { disk_count = 2, ssd_count = 1, hdd_count = 1, smart_available_count = 2,
ssd_smart_available_count = 1, hottest_drive_temperature_c = 70,
hottest_drive_name = "Fixture SSD", hottest_ssd_temperature_c = 70,
hottest_ssd_drive_name = "Fixture SSD", worst_ssd_remaining_life_percent = 95,
worst_ssd_life_drive_name = "Fixture SSD" },
issues = {},
disks = { {
id = "SERIAL1", serial = "SERIAL1", model = "Fixture SSD", display_name = "Fixture SSD", device = "/dev/nvme0n1",
smart_device = "/dev/nvme0", kind = "ssd", transport = "nvme", capacity_bytes = 2000000000,
health = "passed", smart_available = true, smart_completeness = "full",
temperature_c = 45, hotspot_temperature_c = 70, temperature_sensors_c = { 70, 45 },
remaining_life_percent = 95, percentage_used = 5, available_spare_percent = 100,
power_on_hours = 100, data_written_bytes = 1000, self_test_supported = true,
self_test_state = "running", self_test_status = "Short self-test in progress",
self_test_completion_percent = 37,
mount_points = { "/", "/home/example" },
alerts_enabled = true, presence_alert_enabled = true,
}, {
id = "HDD1", model = "Fixture HDD", display_name = "Fixture HDD", device = "/dev/sdb",
smart_device = "/dev/sdb", kind = "hdd", transport = "sata", capacity_bytes = 2000000000000,
health = "passed", smart_available = true, smart_completeness = "full",
temperature_c = 36, hotspot_temperature_c = 36, power_on_hours = 113397, power_cycles = 2200,
start_stop_count = 1423, load_cycle_count = 18421, reallocated_sectors = 0,
pending_sectors = 0, uncorrectable_errors = 0, spin_retry_count = 0,
command_timeout_count = 0, interface_crc_errors = 0, self_test_supported = true,
self_test_state = "passed", self_test_status = "Completed without error",
alerts_enabled = true, presence_alert_enabled = true,
} },
}
state.drive_history = { schema = 1, drives = { SERIAL1 = { samples = {
{ epoch = 1, hotspot_temperature_c = 65, remaining_life_percent = 96 },
{ epoch = 2, hotspot_temperature_c = 67, remaining_life_percent = 96 },
{ epoch = 3, hotspot_temperature_c = 69, remaining_life_percent = 95 },
{ epoch = 4, hotspot_temperature_c = 70, remaining_life_percent = 95 },
} } } }
state.drive_preferences = { schema = 1, order = {}, drives = {} }
local handle = assert(io.open("panel.luau", "rb"))
local source = handle:read("*a")
handle:close()
source = source:gsub("([%a_][%w_]*) %+%= ([^\n]+)", "%1 = %1 + %2")
source = source:gsub("([%a_][%w_]*) /%= ([^\n]+)", "%1 = %1 / %2")
assert(load(source, "@panel.luau"))()
local function containsText(value, target)
if type(value) ~= "table" then return false end
if type(value.props) == "table" and value.props.text == target then return true end
for _, child in pairs(value.children or {}) do
if containsText(child, target) then return true end
end
return false
end
local function countText(value, target)
if type(value) ~= "table" then return 0 end
local count = type(value.props) == "table" and value.props.text == target and 1 or 0
for _, child in pairs(value.children or {}) do
count = count + countText(child, target)
end
return count
end
local function findNode(value, kind)
if type(value) ~= "table" then return nil end
if value.kind == kind then return value end
for _, child in pairs(value.children or {}) do
local found = findNode(child, kind)
if found ~= nil then return found end
end
return nil
end
local function findNodeWithProp(value, kind, property, expected)
if type(value) ~= "table" then return nil end
if value.kind == kind and type(value.props) == "table" and value.props[property] == expected then
return value
end
for _, child in pairs(value.children or {}) do
local found = findNodeWithProp(child, kind, property, expected)
if found ~= nil then return found end
end
return nil
end
onOpen({})
assert(rendered ~= nil and not containsText(rendered, "collector.title"),
"healthy collector consumed panel space")
assert(findNodeWithProp(rendered, "glyph", "name", "server-2") ~= nil,
"panel header did not use the physical-storage icon")
assert(countText(rendered, "Fixture SSD") >= 3,
"summary cards did not identify the hottest and lowest-life drives")
assert(not containsText(rendered, "metrics.mounted_at / · /home/example"),
"collapsed drive card exposed mount paths")
onToggleCollectorSettingsClicked()
assert(containsText(rendered, "collector.settings_title")
and containsText(rendered, "collector.basic_features")
and containsText(rendered, "collector.full_features"),
"collector settings did not explain Basic and Full SMART capabilities")
onPauseCollectorClicked()
assert(terminalCommand == "sudo systemctl disable --now noctalia-drive-health.timer",
"collector settings did not expose the explicit service pause command")
terminalCommand = nil
onOpenPluginSettingsClicked()
assert(asyncCommand == "noctalia msg settings-open plugins",
"collector settings did not open Noctalia's Plugins section")
onToggleCollectorSettingsClicked()
state.snapshot.system_collector.status = "upgrade-required"
state.snapshot.system_collector.version = "0.6.0"
watchers.snapshot(state.snapshot)
assert(containsText(rendered, "collector.title"), "actionable collector state did not render")
onToggleCollectorSettingsClicked()
assert(not containsText(rendered, "collector.title") and containsText(rendered, "collector.settings_title"),
"collector settings duplicated the actionable lifecycle card")
onToggleCollectorSettingsClicked()
state.snapshot.system_collector.status = "healthy"
state.snapshot.system_collector.version = "1.0.0"
watchers.snapshot(state.snapshot)
configValues.system_collector_enabled = false
state.snapshot.system_collector.enabled = false
state.snapshot.system_collector.status = "disabled"
state.snapshot.system_collector.helper_available = false
watchers.snapshot(state.snapshot)
assert(not containsText(rendered, "collector.title"),
"disabled optional collector created a persistent main-panel warning")
onToggleCollectorSettingsClicked()
assert(containsText(rendered, "collector.status_disabled")
and containsText(rendered, "collector.open_settings"),
"disabled collector status or re-enable route was missing from collector settings")
onToggleCollectorSettingsClicked()
configValues.system_collector_enabled = true
state.snapshot.system_collector.enabled = true
state.snapshot.system_collector.status = "healthy"
state.snapshot.system_collector.helper_available = true
watchers.snapshot(state.snapshot)
onDrive1Clicked()
assert(containsText(rendered, "self_test.title"), "expanded self-test card did not render")
assert(containsText(rendered, "metrics.mounted_at / · /home/example"),
"expanded drive card omitted its mounted folders")
assert(containsText(rendered, "metrics.serial SERIAL1"),
"expanded drive card omitted its serial")
assert(containsText(rendered, "self_test.progress"), "running self-test progress did not render")
state.snapshot.disks[1].smart_completeness = "partial"
watchers.snapshot(state.snapshot)
assert(containsText(rendered, "smart.partial_details"), "partial SMART status lost its inline explanation")
state.snapshot.disks[1].smart_completeness = "full"
watchers.snapshot(state.snapshot)
local testProgress = assert(findNodeWithProp(rendered, "progress", "progress", 0.37),
"running self-test progress bar did not render")
assert(testProgress.props.progress == 0.37 and testProgress.props.value == nil,
"running self-test used an invalid progress property")
assert(containsText(rendered, "history.title"), "expanded history graph did not render")
assert(containsText(rendered, "preferences.edit"), "drive preference action did not render")
onDrive1Clicked()
assert(not containsText(rendered, "self_test.title"), "drive details did not collapse")
assert(not containsText(rendered, "history.title"), "drive history remained visible after collapse")
onDrive1Clicked()
state.snapshot.disks[1].self_test_state = "passed"
state.snapshot.disks[1].self_test_status = "Previous test passed"
state.snapshot.disks[1].self_test_completion_percent = nil
state.snapshot.generated_at_epoch = 100
watchers.snapshot(state.snapshot)
onStartShortSelfTestClicked()
assert(containsText(rendered, "self_test.confirm_action"), "self-test confirmation did not render")
assert(terminalCommand == nil, "self-test started before confirmation")
onConfirmSelfTestClicked()
assert(asyncCommand:match("^pkexec /usr/local/libexec/noctalia%-drive%-health/smart%-action%.sh 'short' '/dev/nvme0'$"),
"self-test did not use Polkit, the fixed helper, and normalized controller")
assert(terminalCommand == nil, "background self-test opened a terminal")
assert(containsText(rendered, "self_test.authorizing"), "authorization state did not render")
assert(asyncCallback ~= nil, "background self-test callback was not registered")
-- Bit 3 is an existing SMART health finding; it must not hide an accepted test request.
asyncCallback({ exitCode = 8, stdout = "accepted", stderr = "", timedOut = false })
assert(containsText(rendered, "self_test.starting"), "accepted self-test did not render startup state")
assert(state.refresh_nonce == 1, "accepted self-test did not request an immediate SMART refresh")
assert(notifications[#notifications].body == "self_test.started_background",
"accepted self-test did not notify that it is running in the background")
state.snapshot.generated_at_epoch = 101
state.snapshot.disks[1].self_test_state = "running"
state.snapshot.disks[1].self_test_status = "Short self-test in progress"
state.snapshot.disks[1].self_test_completion_percent = 52
watchers.snapshot(state.snapshot)
assert(containsText(rendered, "Short self-test in progress"), "firmware self-test state did not replace startup state")
assert(findNodeWithProp(rendered, "progress", "progress", 0.52) ~= nil,
"background self-test progress did not update")
state.snapshot.generated_at_epoch = 102
state.snapshot.disks[1].self_test_state = "passed"
state.snapshot.disks[1].self_test_status = "Completed without error"
state.snapshot.disks[1].self_test_completion_percent = nil
watchers.snapshot(state.snapshot)
assert(containsText(rendered, "Completed without error"), "completed background self-test result did not render")
asyncCommand = nil
asyncCallback = nil
onStartLongSelfTestClicked()
onConfirmSelfTestClicked()
assert(asyncCommand:match("smart%-action%.sh 'long' '/dev/nvme0'"),
"extended self-test did not use the long action")
asyncCallback({ exitCode = 126, stdout = "", stderr = "", timedOut = false })
assert(containsText(rendered, "self_test.authorization_cancelled"),
"cancelled authorization did not render a useful inline result")
assert(errors[#errors].body == "self_test.authorization_cancelled",
"cancelled authorization did not produce an error notification")
state.snapshot.system_collector.authorization_available = false
watchers.snapshot(state.snapshot)
assert(containsText(rendered, "self_test.authorization_required"),
"missing Polkit dependency was not explained")
state.snapshot.system_collector.authorization_available = true
watchers.snapshot(state.snapshot)
onEditExpandedDriveClicked()
assert(containsText(rendered, "preferences.title"), "drive preference editor did not render")
onDriveAlertsChanged(false)
onPresenceAlertsChanged(false)
onCancelDrivePreferencesClicked()
local cancelled = state.drive_preferences.drives.SERIAL1
assert(cancelled.alerts_enabled == nil and cancelled.presence_alert_enabled == nil,
"cancelled alert preference changes leaked into shared state")
onEditExpandedDriveClicked()
onAliasChanged("Workspace")
onWarningThresholdChanged("68")
onCriticalThresholdChanged("82")
onLifeThresholdChanged("15")
onSaveDrivePreferencesClicked()
local saved = state.drive_preferences.drives.SERIAL1
assert(saved.alias == "Workspace" and saved.warning_temperature == 68
and saved.critical_temperature == 82 and saved.life_warning_percent == 15,
"drive preferences were not persisted to shared state")
onEditExpandedDriveClicked()
onAliasChanged("Should not persist")
writesSucceed = false
onSaveDrivePreferencesClicked()
writesSucceed = true
assert(saved.alias == "Workspace", "failed preference write leaked changes into shared state")
onCancelDrivePreferencesClicked()
onEditExpandedDriveClicked()
writesSucceed = false
onHideDriveClicked()
writesSucceed = true
assert(saved.hidden == nil and containsText(rendered, "preferences.title"),
"failed hide write changed visibility or closed the editor")
onCancelDrivePreferencesClicked()
state.drive_history.drives.SERIAL1.samples = {
{ epoch = 1, hotspot_temperature_c = 65 },
{ epoch = 2, hotspot_temperature_c = 67 },
{ epoch = 3, hotspot_temperature_c = 69 },
}
watchers.drive_history(state.drive_history)
assert(findNode(rendered, "graph") == nil and not containsText(rendered, "history.title"),
"trend section rendered before a graph-compatible series had four samples")
state.drive_history.drives.SERIAL1.samples = {
{ epoch = 1, hotspot_temperature_c = 65 },
{ epoch = 2, hotspot_temperature_c = 67 },
{ epoch = 3, hotspot_temperature_c = 69 },
{ epoch = 4, hotspot_temperature_c = 70 },
}
watchers.drive_history(state.drive_history)
local graph = assert(findNode(rendered, "graph"), "temperature-only history graph did not render")
assert(graph.props.values2 == nil, "missing endurance history was rendered as a zero-percent series")
assert(graph.props.height == 44, "rendered trend graph did not use the compact height")
assert(not containsText(rendered, "● history.life"), "missing endurance history kept a misleading legend")
state.snapshot.issues = {
{ id = "SERIAL1:temperature", severity = "warning", message = "Fixture temperature warning" },
{ id = "SERIAL1:interface-crc", severity = "warning", message = "Fixture interface CRC warning" },
}
state.snapshot.summary.active_alert_count = 2
watchers.snapshot(state.snapshot)
assert(containsText(rendered, "alerts.dismiss_all"), "dismiss-all alert action did not render")
assert(findNodeWithProp(rendered, "button", "tooltip", "alerts.dismiss") ~= nil,
"per-alert dismiss action did not render")
onDismissAlert1Clicked()
assert(state.dismiss_alert_request.id == "SERIAL1:temperature",
"per-alert dismiss action targeted the wrong issue")
onDismissAllAlertsClicked()
assert(state.dismiss_alert_request.all == true, "dismiss-all action did not request all active issues")
state.snapshot.issues = {}
state.snapshot.summary.active_alert_count = 0
watchers.snapshot(state.snapshot)
assert(not containsText(rendered, "alerts.active_title")
and findNodeWithProp(rendered, "button", "tooltip", "alerts.dismiss") == nil,
"empty alert state kept an alert card or dismiss controls")
onDrive2Clicked()
assert(containsText(rendered, "metrics.start_stop_count")
and containsText(rendered, "metrics.load_cycle_count")
and containsText(rendered, "metrics.interface_crc_errors"),
"expanded HDD card omitted mechanical or interface health details")
assert(countText(rendered, "metrics.life_remaining") == 1,
"HDD card rendered a meaningless SSD endurance metric")
onEditExpandedDriveClicked()
assert(not containsText(rendered, "preferences.life_warning"),
"HDD preference editor exposed an SSD-only endurance threshold")
onCancelDrivePreferencesClicked()
state.snapshot.dependencies = {
ready = false, blocking = true, missing_text = "lsblk (util-linux)",
install_command = "sudo pacman -S --needed util-linux", package_manager = "pacman", can_install = true,
}
watchers.snapshot(state.snapshot)
assert(containsText(rendered, "dependencies.title"), "missing dependency card did not render")
state.snapshot.dependencies = { ready = true }
state.snapshot.disks = { state.snapshot.disks[2] }
state.snapshot.summary = {
disk_count = 1, ssd_count = 0, hdd_count = 1, smart_available_count = 1,
hdd_smart_available_count = 1, hottest_drive_temperature_c = 36,
hottest_drive_name = "Fixture HDD",
}
watchers.snapshot(state.snapshot)
assert(not containsText(rendered, "summary.lowest_ssd_life"),
"HDD-only system rendered the SSD-life summary card")
assert(countText(rendered, "Fixture HDD") >= 2,
"HDD-only temperature summary did not identify its drive")
print("panel rendering tests passed")
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
set -eu
project_dir=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
fixture_bin="$project_dir/tests/fixtures/bin"
payload=$(PATH="$fixture_bin:$PATH" sh "$project_dir/scripts/collect_raw.sh")
printf '%s\n' "$payload" | jq -e '
.schema == 2
and .collector_version == "2.0.0"
and (.collection_id | type == "string" and length > 0)
and (.lsblk.blockdevices | length) == 2
and (.smart | length) == 2
and ([.smart[].requested_device] | sort) == ["/dev/nvme0", "/dev/sda"]
and (.smart[] | select(.requested_device == "/dev/sda") | .payload.test_standby) == true
and (.smart[] | select(.requested_device == "/dev/nvme0") | .payload.test_standby) == false
and ([.smart[].exit_code] | all(. == 0))
' >/dev/null
first_collection_id=$(printf '%s\n' "$payload" | jq -er '.collection_id')
second_payload=$(PATH="$fixture_bin:$PATH" sh "$project_dir/scripts/collect_raw.sh")
second_collection_id=$(printf '%s\n' "$second_payload" | jq -er '.collection_id')
if [ "$first_collection_id" = "$second_collection_id" ]; then
echo "raw collector reused a collection ID" >&2
exit 1
fi
empty_payload=$(SMARTCTL_EMPTY=1 PATH="$fixture_bin:$PATH" sh "$project_dir/scripts/collect_raw.sh")
printf '%s\n' "$empty_payload" | jq -e '
(.smart | length) == 2
and (.smart[] | select(.requested_device == "/dev/sda") | .exit_code) == 2
and (.smart[] | select(.requested_device == "/dev/sda")
| .payload.smartctl.messages[0].string) == "smartctl produced no JSON output"
' >/dev/null
output=$(mktemp "${TMPDIR:-/tmp}/noctalia-smart-raw-test.XXXXXX")
PATH="$fixture_bin:$PATH" sh "$project_dir/scripts/collect_raw.sh" --output "$output"
jq -e '.schema == 2 and (.collection_id | type == "string" and length > 0)
and (.smart | length) == 2' "$output" >/dev/null
mode=$(stat -c '%a' "$output")
if [ "$mode" != "640" ]; then
echo "raw collector output mode is $mode, expected 640" >&2
exit 1
fi
rm -f -- "$output"
echo "raw collector tests passed"
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
set -eu
project_dir=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
service_template="$project_dir/packaging/noctalia-drive-health.service.in"
timer="$project_dir/packaging/noctalia-drive-health.timer"
fixture=$(mktemp -d "${TMPDIR:-/tmp}/drive-health-packaging.XXXXXX")
trap 'rm -rf -- "$fixture"' EXIT HUP INT TERM
sed 's/@TARGET_GID@/1000/g' "$service_template" >"$fixture/noctalia-drive-health.service"
cp "$timer" "$fixture/noctalia-drive-health.timer"
grep -q '^Group=1000$' "$fixture/noctalia-drive-health.service"
grep -q '^RuntimeDirectoryMode=0750$' "$fixture/noctalia-drive-health.service"
grep -q '^UMask=0027$' "$fixture/noctalia-drive-health.service"
grep -q '^Unit=noctalia-drive-health.service$' "$fixture/noctalia-drive-health.timer"
if grep -R -q 'noctalia-smart-monito[r]' "$project_dir"; then
echo "generic legacy collector namespace must not be read, modified, or removed" >&2
exit 1
fi
if grep -R -q 'noctalia-gustav0ar-drive-healt[h]' "$project_dir"; then
echo "publisher-specific collector namespace must not be packaged" >&2
exit 1
fi
declared_dependencies=$(sed -n 's/^dependencies = \[\(.*\)\]$/\1/p' "$project_dir/plugin.toml")
for dependency in \
lsblk smartctl sh date dirname mkdir mktemp rm sed cat chmod mv sudo env bash \
install systemctl pkexec id tr pacman apt-get dnf zypper apk xbps-install emerge; do
case "$declared_dependencies" in
*\"$dependency\"*) ;;
*)
echo "runtime command is missing from plugin.toml dependencies: $dependency" >&2
exit 1
;;
esac
grep -q "\`$dependency\`" "$project_dir/README.md" || {
echo "runtime command is missing from README requirements: $dependency" >&2
exit 1
}
done
if command -v systemd-analyze >/dev/null 2>&1; then
if ! systemd-analyze verify \
"$fixture/noctalia-drive-health.service" \
"$fixture/noctalia-drive-health.timer" >"$fixture/verify.log" 2>&1; then
if grep -q 'Operation not permitted' "$fixture/verify.log"; then
echo "systemd unit verification unavailable in this sandbox; structural checks passed"
else
cat "$fixture/verify.log" >&2
exit 1
fi
fi
fi
echo "collector packaging tests passed"
+109
View File
@@ -0,0 +1,109 @@
-- Declarative bar-widget smoke tests with a minimal Noctalia host.
local state = {}
local watchers = {}
local rendered = nil
local tooltip = nil
local toggledPanel = nil
local function node(kind, props, children)
return { kind = kind, props = props or {}, children = children or {} }
end
ui = setmetatable({}, {
__index = function(_table, kind)
return function(props, children) return node(kind, props, children) end
end,
})
barWidget = {
render = function(tree) rendered = tree end,
setTooltip = function(value) tooltip = value end,
isVertical = function() return false end,
}
noctalia = {
getConfig = function(key)
local values = { warning_temperature = 65, critical_temperature = 50 }
return values[key]
end,
tr = function(key, substitutions)
local value = key
for name, replacement in pairs(substitutions or {}) do
value = value:gsub("{" .. name .. "}", tostring(replacement))
end
return value
end,
togglePanel = function(id) toggledPanel = id end,
state = {
get = function(key) return state[key] end,
watch = function(key, callback) watchers[key] = callback end,
},
}
state.snapshot = {
summary = {
disk_count = 3,
ssd_count = 2,
hdd_count = 1,
hottest_drive_temperature_c = 60,
hottest_ssd_temperature_c = 60,
worst_ssd_remaining_life_percent = 90,
ssd_smart_unavailable_count = 0,
ssd_unhealthy_count = 0,
smart_unavailable_count = 0,
unhealthy_count = 0,
active_alert_count = 0,
critical_alert_count = 0,
},
issues = {},
}
local handle = assert(io.open("widget.luau", "rb"))
local source = handle:read("*a")
handle:close()
source = source:gsub("([%a_][%w_]*) %.%.= ([^\n]+)", "%1 = %1 .. %2")
assert(load(source, "@widget.luau"))()
local function findNodeWithProp(value, kind, property, expected)
if type(value) ~= "table" then return nil end
if value.kind == kind and type(value.props) == "table" and value.props[property] == expected then
return value
end
for _, child in pairs(value.children or {}) do
local found = findNodeWithProp(child, kind, property, expected)
if found ~= nil then return found end
end
return nil
end
update()
assert(rendered ~= nil and tooltip:find("widget.no_alerts", 1, true), "healthy widget did not render")
assert(findNodeWithProp(rendered, "glyph", "name", "server-2") ~= nil,
"healthy widget did not use the physical-storage icon")
assert(findNodeWithProp(rendered, "glyph", "color", "primary") ~= nil,
"invalid cross-setting temperature thresholds produced a false critical state")
state.snapshot.summary.hottest_drive_temperature_c = 70
watchers.snapshot(state.snapshot)
assert(findNodeWithProp(rendered, "glyph", "color", "error") ~= nil,
"HDD temperature was excluded from the mixed-drive widget state")
state.snapshot.summary.hottest_drive_temperature_c = 60
state.snapshot.summary.unhealthy_count = 1
watchers.snapshot(state.snapshot)
assert(findNodeWithProp(rendered, "glyph", "color", "error") ~= nil,
"unhealthy HDD was excluded from the mixed-drive widget state")
state.snapshot.summary.unhealthy_count = 0
state.snapshot.summary.active_alert_count = 1
state.snapshot.summary.critical_alert_count = 1
state.snapshot.issues = { { message = "Fixture failure", severity = "critical" } }
watchers.snapshot(state.snapshot)
assert(findNodeWithProp(rendered, "glyph", "color", "error") ~= nil,
"critical widget alert did not render")
assert(tooltip:find("Fixture failure", 1, true), "widget tooltip omitted active alert details")
onClick()
assert(toggledPanel == "gustav0ar/drive-health:drives", "widget click did not toggle its panel")
print("widget rendering tests passed")
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+266
View File
@@ -0,0 +1,266 @@
{
"common": {
"duration_days": "{value} d",
"duration_hours": "{value} h",
"duration_years": "{value} y",
"never": "never",
"not_available": "N/A",
"unknown": "unknown"
},
"widget": {
"loading": "Loading drive health…",
"tooltip": "{count} drives ({ssds} SSD, {hdds} HDD) • hottest {temperature} • lowest SSD life {remaining}",
"smart_unavailable": "Full SMART access unavailable for {count} drive(s)",
"active_alerts": "{count} active SMART alert(s)",
"no_alerts": "No active alerts"
},
"panel": {
"title": "Drive Health",
"refreshing": "Refreshing SMART data…",
"updated": "Updated {time}",
"waiting": "Waiting for the first drive scan…",
"no_drives": "No supported drives were discovered.",
"expand": "Show drive details",
"collapse": "Hide drive details"
},
"summary": {
"drives": "Drives",
"drive_mix": "{ssds} SSD · {hdds} HDD",
"hottest": "Hottest",
"lowest_ssd_life": "Lowest SSD life"
},
"health": {
"passed": "Healthy",
"failed": "Failed",
"unknown": "Unknown"
},
"storage": {
"not_mounted": "Not mounted"
},
"dependencies": {
"title": "Required dependencies are missing",
"missing": "Missing commands: {missing}",
"alert_title": "Drive Health setup required",
"alert_body": "Install the missing dependencies to enable complete monitoring: {missing}. Open the Drive Health panel to review the command.",
"collection_blocked": "Drive collection is paused because required commands are missing: {missing}",
"command": "Suggested {manager} command",
"package_manager": "package manager",
"manual_install": "No supported package manager with sudo was detected. Install the listed commands manually, then recheck.",
"install": "Open installer",
"copy_command": "Copy command",
"recheck": "Recheck",
"install_title": "Review the dependency installation",
"install_body": "A terminal is opening with the suggested command. Review it and approve the sudo and package-manager prompts to continue.",
"copied": "Installation command copied."
},
"metrics": {
"temperature": "Temperature",
"hotspot_temperature": "Hotspot",
"hotspot_and_composite": "{hotspot} · {composite} composite",
"temperature_sensors": "Temperature sensors",
"device_temperature_limits": "Firmware limits (warning / critical)",
"smart_health": "SMART health",
"life_remaining": "Life remaining",
"life_remaining_estimated": "Life remaining (estimated)",
"storage_used": "Storage used",
"data_written": "Data written",
"data_read": "Data read",
"endurance_used": "Endurance used",
"available_spare": "Available spare",
"power_on": "Power-on time",
"power_cycles": "Power cycles",
"start_stop_count": "Start/stop cycles",
"load_cycle_count": "Head load cycles",
"unsafe_shutdowns": "Unsafe shutdowns",
"media_errors": "Media errors",
"error_log_entries": "Error-log entries",
"critical_warning": "Critical warning",
"reallocated": "Reallocated sectors",
"pending": "Pending sectors",
"uncorrectable": "Uncorrectable errors",
"spin_retry_count": "Spin retry count",
"command_timeout_count": "Command timeouts",
"interface_crc_errors": "Interface CRC errors",
"mounted_at": "Mounted at: {paths}",
"serial": "Serial: {value}"
},
"smart": {
"access_limited": "Temperature and storage are available; health and full SMART details require the read-only system collector.",
"sleeping": "Drive is sleeping; SMART checks were skipped to avoid spinning it up.",
"partial_title": "Limited SMART access",
"partial_body": "Full SMART details available for {available} of {total} drives.",
"partial_details": "Core health is available, but at least one optional SMART section could not be read."
},
"collector": {
"title": "System collector",
"settings_title": "Collector settings",
"status_disabled": "Basic mode is active; the optional system collector is not being used.",
"status_healthy": "Collector {version} is installed and refreshing full SMART data.",
"status_not_installed": "Install the read-only collector to unlock full SMART data.",
"status_stale": "The collector is installed, but its cache is stale or unavailable.",
"status_upgrade_required": "Collector {version} is outdated; upgrade to the bundled version.",
"install": "Install collector",
"upgrade": "Upgrade collector",
"start": "Start collector",
"pause": "Pause service",
"stop_service": "Stop background service",
"open_settings": "Open Plugins page",
"copy_install": "Copy command",
"remove": "Remove collector",
"remove_confirm": "Click Remove collector again to open the explicit privileged uninstall command.",
"terminal_opened": "A terminal opened. Review the command and approve sudo to install or upgrade.",
"start_terminal_opened": "A terminal opened. Review the command and approve sudo to enable and refresh the collector.",
"pause_terminal_opened": "A terminal opened. Review the command and approve sudo to stop the collector timer.",
"uninstall_terminal_opened": "A terminal opened. Review the removal command and approve sudo to continue.",
"basic_title": "Basic monitoring — no elevated service",
"basic_features": "Drive discovery, mounted folders, storage use, and temperatures exposed by Linux.",
"full_title": "Full SMART — optional",
"full_features": "Reliable health, endurance, error counters, sensor details, and background self-test progress.",
"settings_hint": "Enable or disable Full SMART from Settings → Plugins → Drive Health. Privileged actions always open a terminal for review.",
"settings_opened": "Opened the Plugins page. Select the gear on Drive Health to change its settings.",
"update_title": "Drive Health collector update available",
"update_body": "Full SMART is enabled. Update collector {current} to {expected} from the Drive Health panel to keep all features working."
},
"history": {
"title": "Drive health trends",
"samples": "{count} samples",
"hotspot": "Hotspot temperature",
"life": "Life remaining"
},
"self_test": {
"title": "SMART self-test",
"unavailable": "Self-test information unavailable",
"none_recorded": "No self-test recorded",
"in_progress": "Self-test in progress",
"log_unavailable": "Self-test log unavailable",
"short": "Short test",
"long": "Extended test",
"progress": "Test progress",
"log_failure": "The SMART self-test log contains a relevant failed test.",
"confirm": "Start a {type} SMART self-test in the background? Your desktop will request administrator approval.",
"confirm_action": "Authorize and start",
"cancel": "Cancel",
"helper_required": "Install or upgrade the system collector to enable explicitly approved self-tests.",
"authorization_required": "Polkit (pkexec) is required for background self-tests. Install your distribution's polkit package.",
"authorizing": "Waiting for administrator approval…",
"starting": "Request accepted; waiting for the drive to report progress…",
"started_background": "The self-test is running in the background. Progress and the final result will update here.",
"authorization_cancelled": "Administrator approval was cancelled.",
"authorization_timeout": "Administrator approval timed out. Try again when you are ready to approve it.",
"launch_failed": "The background self-test could not be started."
},
"preferences": {
"title": "Drive preferences",
"edit": "Customize drive",
"alias": "Display name",
"warning_temperature": "Warning °C",
"critical_temperature": "Critical °C",
"life_warning": "Life warning %",
"alerts": "Health alerts",
"presence": "Missing-drive alerts",
"move_up": "Move drive up",
"move_down": "Move drive down",
"hide": "Hide drive",
"save": "Save",
"saved": "Drive preferences saved.",
"save_failed": "Drive preferences could not be saved.",
"invalid_thresholds": "Enter numeric thresholds and keep critical temperature above warning temperature.",
"hidden_drives": "Hidden drives",
"restore": "Restore"
},
"alerts": {
"unknown_drive": "Unknown drive",
"drive_title": "SMART alert: {drive}",
"collector_title": "Drive Health collector problem",
"collector_error": "Drive monitoring could not refresh: {error}",
"active_title": "{count} active drive alert(s)",
"dismiss": "Dismiss alert",
"dismiss_all": "Dismiss all",
"smart_unavailable": "Full SMART data is unavailable for {drive}.",
"health_failed": "{drive} reports a failed SMART health check.",
"temperature_warning": "{drive} is at {temperature} °C (warning threshold: {threshold} °C).",
"temperature_cooling": "{drive} is cooling at {temperature} °C; the alert clears below {threshold} °C.",
"temperature_critical": "{drive} is at {temperature} °C (critical threshold: {threshold} °C).",
"life_warning": "{drive} has only {remaining}% estimated endurance remaining.",
"life_critical": "{drive} has critically low endurance: {remaining}% remaining.",
"spare_low": "{drive} has only {spare}% spare capacity remaining.",
"nvme_critical": "{drive} reports NVMe critical-warning flags {value}.",
"media_errors": "{drive} recorded {count} new media/data integrity error(s) since the previous scan.",
"reallocated": "{drive} recorded {count} newly reallocated sector(s) since the previous scan.",
"pending": "{drive} recorded {count} new pending sector(s) since the previous scan.",
"uncorrectable": "{drive} recorded {count} new uncorrectable error(s) since the previous scan.",
"spin_retry": "{drive} recorded {count} new spindle spin retry event(s) since the previous scan.",
"command_timeout": "{drive} recorded {count} new command timeout event(s) since the previous scan.",
"interface_crc": "{drive} recorded {count} new interface CRC error(s) since the previous scan; check its data cable and connectors.",
"storage_warning": "{drive} mounted storage is {used}% full.",
"storage_critical": "{drive} mounted storage is critically full at {used}%.",
"unsafe_shutdown_increase": "{drive} recorded {count} new unsafe shutdown(s) since the previous scan.",
"error_log_increase": "{drive} recorded {count} new SMART error-log entry or entries.",
"warning_temperature_time_increase": "{drive} accumulated {count} new minute(s) above its warning temperature.",
"critical_temperature_time_increase": "{drive} accumulated {count} new minute(s) above its critical temperature.",
"self_test_failed": "{drive} reports a failed SMART self-test: {status}.",
"drive_missing": "{drive} has been missing for {count} consecutive scans.",
"recovered_title": "SMART issue recovered: {drive}",
"recovered_body": "No longer active: {issue}",
"test_title": "Drive Health alert test",
"test_body": "Notifications are working. This is only a test; no drive issue was created."
},
"settings": {
"system_collector_enabled": {
"label": "Full SMART collector (optional)",
"description": "Use the separately authorized read-only system collector for reliable health, endurance, error counters, sensor details, and self-test progress. Turning this off ignores its cache but does not stop an installed timer; stop it from the Drive Health collector controls."
},
"refresh_seconds": {
"label": "Refresh interval",
"description": "Seconds between plugin refreshes. The optional root collector refreshes its cache every 30 seconds independently."
},
"warning_temperature": {
"label": "Warning temperature",
"description": "Temperature in °C that changes a drive to warning color."
},
"critical_temperature": {
"label": "Critical temperature",
"description": "Temperature in °C that changes a drive to critical color."
},
"life_warning_percent": {
"label": "Endurance warning",
"description": "Remaining SSD life percentage that triggers a warning."
},
"alerts_enabled": {
"label": "Desktop alerts",
"description": "Notify when a new drive issue appears or an existing issue worsens."
},
"notify_recovery": {
"label": "Recovery notifications",
"description": "Notify when a previously active drive issue clears."
},
"show_hdd": {
"label": "Show hard drives",
"description": "Display rotational disks with HDD-specific health and integrity information."
},
"alert_hdd": {
"label": "Alert on hard drives",
"description": "Evaluate rotational disks for temperature, SMART health, sector, spindle, timeout, and interface alerts."
},
"drive_missing_alerts": {
"label": "Missing-drive alerts",
"description": "Warn when an established internal drive disappears for several scans."
},
"missing_grace_scans": {
"label": "Missing-drive grace scans",
"description": "Consecutive successful scans a drive may be absent before an alert appears."
},
"use_hotspot_temperature": {
"label": "Use hottest sensor",
"description": "Use the hottest valid NVMe sensor for summary colors and temperature alerts."
},
"history_interval_minutes": {
"label": "History sample interval",
"description": "Minutes between persisted temperature and endurance samples."
},
"history_retention_days": {
"label": "History retention",
"description": "Days of bounded drive trend history to retain."
}
}
}
+106
View File
@@ -0,0 +1,106 @@
--!nonstrict
local snapshot = noctalia.state.get("snapshot")
local function number(value, fallback)
local parsed = tonumber(value)
return parsed ~= nil and parsed or fallback
end
local function temperatureColor(value)
local warning = number(noctalia.getConfig("warning_temperature"), 65)
local critical = math.max(warning + 1, number(noctalia.getConfig("critical_temperature"), 80))
if value == nil then
return "on_surface_variant"
elseif value >= critical then
return "error"
elseif value >= warning then
return "secondary"
end
return "primary"
end
local function render()
local summary = snapshot and snapshot.summary or nil
if summary == nil then
barWidget.render(ui.row({ gap = 6, align = "center" }, {
ui.glyph({ name = "server-2", size = 15, color = "on_surface_variant" }),
ui.label({ text = "…", color = "on_surface_variant" }),
}))
barWidget.setTooltip(noctalia.tr("widget.loading"))
return
end
local count = number(summary.disk_count, number(summary.ssd_count, 0))
local ssdCount = number(summary.ssd_count, 0)
local hddCount = number(summary.hdd_count, 0)
local hottest = tonumber(summary.hottest_drive_temperature_c or summary.hottest_ssd_temperature_c)
local remaining = tonumber(summary.worst_ssd_remaining_life_percent)
local unavailable = number(summary.smart_unavailable_count, number(summary.ssd_smart_unavailable_count, 0))
local unhealthy = number(summary.unhealthy_count, number(summary.ssd_unhealthy_count, 0))
local alerts = number(summary.active_alert_count, 0)
local criticalAlerts = number(summary.critical_alert_count, 0)
local tempText = hottest ~= nil and string.format("%.0f°", hottest) or "--°"
local lifeText = remaining ~= nil and string.format("%.0f%%", remaining) or "--%"
local stateColor = (criticalAlerts > 0 or unhealthy > 0) and "error" or (alerts > 0 and "secondary" or temperatureColor(hottest))
local container = barWidget.isVertical() and ui.column or ui.row
local children = {
ui.glyph({ name = alerts > 0 and "alert-triangle" or "server-2", size = 15, color = stateColor }),
ui.row({ fill = stateColor .. "/0.18", radius = 8, paddingH = 6, align = "center" }, {
ui.label({ text = tempText, color = stateColor, fontWeight = "bold" }),
}),
}
if not barWidget.isVertical() then
if remaining ~= nil then
table.insert(children, ui.label({ text = lifeText, color = "on_surface" }))
end
if alerts > 0 then
table.insert(children, ui.row({ fill = stateColor .. "/0.18", radius = 8, paddingH = 5, align = "center" }, {
ui.label({ text = tostring(math.floor(alerts)), fontSize = 10, fontWeight = "bold", color = stateColor }),
}))
end
if unavailable > 0 then
table.insert(children, ui.glyph({ name = "alert-circle", size = 12, color = "secondary" }))
end
end
barWidget.render(container({ gap = 6, align = "center" }, children))
local tooltip = noctalia.tr("widget.tooltip", {
count = count,
ssds = ssdCount,
hdds = hddCount,
temperature = tempText,
remaining = lifeText,
})
if unavailable > 0 then
tooltip ..= "\n" .. noctalia.tr("widget.smart_unavailable", { count = unavailable })
end
if alerts > 0 then
tooltip ..= "\n" .. noctalia.tr("widget.active_alerts", { count = math.floor(alerts) })
local issues = snapshot and snapshot.issues or {}
for index, issue in ipairs(issues) do
if index > 3 then
break
end
tooltip ..= "\n• " .. tostring(issue.message or issue.title)
end
else
tooltip ..= "\n" .. noctalia.tr("widget.no_alerts")
end
barWidget.setTooltip(tooltip)
end
noctalia.state.watch("snapshot", function(value)
snapshot = value
render()
end)
function update()
render()
end
function onClick()
noctalia.togglePanel("gustav0ar/drive-health:drives")
end