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:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"languageMode": "nonstrict",
|
||||
"lint": {
|
||||
"FunctionUnused": false
|
||||
},
|
||||
"lintErrors": false
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
Executable
+35
@@ -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
@@ -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
@@ -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
|
||||
Executable
+118
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Executable
+47
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 |
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user