From ddb054ae3c62475a9629dad62295dbb88c364e22 Mon Sep 17 00:00:00 2001 From: Gustavo de Andrade Rosa Date: Tue, 21 Jul 2026 12:14:38 -0300 Subject: [PATCH] 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 --- drive-health/.luaurc | 7 + drive-health/Makefile | 39 + drive-health/README.md | 136 ++ drive-health/collector.luau | 1146 ++++++++++++ drive-health/history.luau | 141 ++ .../packaging/install-system-collector.sh | 49 + .../noctalia-drive-health.service.in | 36 + .../packaging/noctalia-drive-health.timer | 11 + drive-health/packaging/smart-action.sh | 35 + .../packaging/uninstall-system-collector.sh | 24 + drive-health/panel.luau | 1552 +++++++++++++++++ drive-health/plugin.toml | 148 ++ drive-health/scripts/collect_raw.sh | 118 ++ drive-health/service.luau | 582 +++++++ drive-health/tests/alert_harness.lua | 504 ++++++ drive-health/tests/collector_harness.lua | 590 +++++++ drive-health/tests/fixtures/bin/lsblk | 18 + drive-health/tests/fixtures/bin/smartctl | 27 + drive-health/tests/history_harness.lua | 69 + drive-health/tests/panel_harness.lua | 404 +++++ drive-health/tests/test_collect_raw.sh | 47 + drive-health/tests/test_packaging.sh | 58 + drive-health/tests/widget_harness.lua | 109 ++ drive-health/thumbnail.webp | Bin 0 -> 63032 bytes drive-health/translations/en.json | 266 +++ drive-health/widget.luau | 106 ++ 26 files changed, 6222 insertions(+) create mode 100644 drive-health/.luaurc create mode 100644 drive-health/Makefile create mode 100644 drive-health/README.md create mode 100644 drive-health/collector.luau create mode 100644 drive-health/history.luau create mode 100755 drive-health/packaging/install-system-collector.sh create mode 100644 drive-health/packaging/noctalia-drive-health.service.in create mode 100644 drive-health/packaging/noctalia-drive-health.timer create mode 100755 drive-health/packaging/smart-action.sh create mode 100755 drive-health/packaging/uninstall-system-collector.sh create mode 100644 drive-health/panel.luau create mode 100644 drive-health/plugin.toml create mode 100755 drive-health/scripts/collect_raw.sh create mode 100644 drive-health/service.luau create mode 100644 drive-health/tests/alert_harness.lua create mode 100644 drive-health/tests/collector_harness.lua create mode 100755 drive-health/tests/fixtures/bin/lsblk create mode 100755 drive-health/tests/fixtures/bin/smartctl create mode 100644 drive-health/tests/history_harness.lua create mode 100644 drive-health/tests/panel_harness.lua create mode 100755 drive-health/tests/test_collect_raw.sh create mode 100644 drive-health/tests/test_packaging.sh create mode 100644 drive-health/tests/widget_harness.lua create mode 100644 drive-health/thumbnail.webp create mode 100644 drive-health/translations/en.json create mode 100644 drive-health/widget.luau diff --git a/drive-health/.luaurc b/drive-health/.luaurc new file mode 100644 index 0000000..5b75c42 --- /dev/null +++ b/drive-health/.luaurc @@ -0,0 +1,7 @@ +{ + "languageMode": "nonstrict", + "lint": { + "FunctionUnused": false + }, + "lintErrors": false +} diff --git a/drive-health/Makefile b/drive-health/Makefile new file mode 100644 index 0000000..025b4b9 --- /dev/null +++ b/drive-health/Makefile @@ -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 diff --git a/drive-health/README.md b/drive-health/README.md new file mode 100644 index 0000000..66e668b --- /dev/null +++ b/drive-health/README.md @@ -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. diff --git a/drive-health/collector.luau b/drive-health/collector.luau new file mode 100644 index 0000000..c4d133f --- /dev/null +++ b/drive-health/collector.luau @@ -0,0 +1,1146 @@ +--!nonstrict + +-- Collection and normalization service. Raw device access lives in the small +-- POSIX script; this entry owns all platform-neutral SMART interpretation. + +local SYSTEM_NAMESPACE = "noctalia-drive-health" +local SYSTEM_LIBEXEC = "/usr/local/libexec/" .. SYSTEM_NAMESPACE +local RAW_CACHE = "/run/" .. SYSTEM_NAMESPACE .. "/raw.json" +local CACHE_MAX_AGE_SECONDS = 180 +local NVME_DATA_UNIT_BYTES = 512000 +local EXPECTED_COLLECTOR_VERSION = "2.0.0" +local PREFERENCES_FILE = "drive-preferences.json" + +local collecting = false +local refreshNonce = 0 +local loggedDependencySignature = nil +local notifiedCollectorUpgradeSignature = nil +local dependencyProbe = { + checked = false, + running = false, + forcedPending = false, + incompatible = {}, +} +local dependencyProbeGeneration = tonumber(noctalia.state.get("dependency_probe_generation")) or 0 +local drivePreferences = { schema = 1, order = {}, drives = {} } + +local DEPENDENCIES = { + { + command = "lsblk", + label = "lsblk", + blocking = true, + packages = { + pacman = "util-linux", apt = "util-linux", dnf = "util-linux", + zypper = "util-linux", apk = "util-linux", xbps = "util-linux", + emerge = "sys-apps/util-linux", + }, + }, + { + command = "smartctl", + label = "smartctl", + blocking = false, + packages = { + pacman = "smartmontools", apt = "smartmontools", dnf = "smartmontools", + zypper = "smartmontools", apk = "smartmontools", xbps = "smartmontools", + emerge = "sys-apps/smartmontools", + }, + }, +} + +local PACKAGE_MANAGERS = { + { command = "pacman", id = "pacman", label = "pacman", prefix = "sudo pacman -S --needed " }, + { command = "apt-get", id = "apt", label = "APT", prefix = "sudo apt-get install " }, + { command = "dnf", id = "dnf", label = "DNF", prefix = "sudo dnf install " }, + { command = "zypper", id = "zypper", label = "Zypper", prefix = "sudo zypper install " }, + { command = "apk", id = "apk", label = "APK", prefix = "sudo apk add " }, + { command = "xbps-install", id = "xbps", label = "XBPS", prefix = "sudo xbps-install -S " }, + { command = "emerge", id = "emerge", label = "Portage", prefix = "sudo emerge --ask " }, +} + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +local function systemCollectorEnabled() + return noctalia.getConfig("system_collector_enabled") == true +end + +local function numeric(value) + if value == nil or type(value) == "boolean" then + return nil + end + if type(value) == "number" then + if value == value and value ~= math.huge and value ~= -math.huge then + return value + end + return nil + end + if type(value) ~= "string" then + return nil + end + local cleaned = value:gsub(",", "") + local token = cleaned:match("%-?%d+%.?%d*") + return token ~= nil and tonumber(token) or nil +end + +local function integer(value) + local parsed = numeric(value) + return parsed ~= nil and math.floor(parsed) or nil +end + +local function validCollectionId(value) + if type(value) ~= "string" then + return nil + end + local trimmed = noctalia.string.trim(value) + if trimmed == "" or #trimmed > 128 or trimmed:find("[%c]") ~= nil then + return nil + end + return trimmed +end + +local function clamp(value, minimum, maximum) + return math.max(minimum, math.min(maximum, value)) +end + +local function bitSet(value, bit) + local parsed = integer(value) + return parsed ~= nil and math.floor(parsed / (2 ^ bit)) % 2 == 1 +end + +local function validTemperature(value) + local parsed = numeric(value) + return parsed ~= nil and parsed >= -20 and parsed <= 150 and parsed or nil +end + +local function smartDeviceFor(path) + local controller = tostring(path):match("^/dev/(nvme%d+)n%d+$") + return controller ~= nil and "/dev/" .. controller or tostring(path) +end + +local function listDirectory(path) + local entries = noctalia.listDir(path) + return type(entries) == "table" and entries or {} +end + +local function readTemperatureFile(path) + local raw = noctalia.readFile(path) + local value = raw ~= nil and tonumber(noctalia.string.trim(raw)) or nil + if value == nil then + return nil + end + local celsius = math.abs(value) >= 1000 and value / 1000 or value + if celsius < -20 or celsius > 150 then + return nil + end + return math.floor(celsius * 10 + 0.5) / 10 +end + +local function sysfsTemperature(devicePath) + local name = tostring(devicePath):match("([^/]+)$") or tostring(devicePath) + local candidates = {} + local controller = name:match("^(nvme%d+)n%d+$") + if controller ~= nil then + local controllerPath = "/sys/class/nvme/" .. controller + for _, entry in ipairs(listDirectory(controllerPath)) do + if entry:match("^hwmon") then + table.insert(candidates, controllerPath .. "/" .. entry .. "/temp1_input") + end + end + local hwmonPath = controllerPath .. "/device/hwmon" + for _, entry in ipairs(listDirectory(hwmonPath)) do + if entry:match("^hwmon") then + table.insert(candidates, hwmonPath .. "/" .. entry .. "/temp1_input") + end + end + end + + for _, entry in ipairs(listDirectory("/sys/class/hwmon")) do + if entry:match("^hwmon") then + local root = "/sys/class/hwmon/" .. entry + local sensorName = noctalia.readFile(root .. "/name") + if sensorName ~= nil and noctalia.string.trim(sensorName) == "drivetemp" + and noctalia.fileExists(root .. "/device/block/" .. name) then + table.insert(candidates, root .. "/temp1_input") + end + end + end + + for _, path in ipairs(candidates) do + local temperature = readTemperatureFile(path) + if temperature ~= nil then + return temperature + end + end + return nil +end + +local function walkBlockDevices(nodes, callback) + for _, node in ipairs(nodes or {}) do + callback(node) + walkBlockDevices(node.children, callback) + end +end + +local function mountedUsage(root) + local used = 0 + local available = 0 + local found = false + local seen = {} + local mountPoints = {} + local seenMountPoints = {} + walkBlockDevices({ root }, function(node) + local hasMount = false + for _, mountpoint in ipairs(node.mountpoints or {}) do + if type(mountpoint) == "string" and mountpoint ~= "" and mountpoint ~= "[SWAP]" then + hasMount = true + if not seenMountPoints[mountpoint] then + seenMountPoints[mountpoint] = true + table.insert(mountPoints, mountpoint) + end + end + end + local identity = tostring(node.kname or node.path or "") + local fsUsed = integer(node.fsused) + local fsAvailable = integer(node.fsavail) + if hasMount and identity ~= "" and not seen[identity] and fsUsed ~= nil and fsAvailable ~= nil then + seen[identity] = true + used += fsUsed + available += fsAvailable + found = true + end + end) + table.sort(mountPoints, function(left, right) + if left == right then return false end + if left == "/" then return true end + if right == "/" then return false end + return left < right + end) + if not found then + return nil, nil, nil, mountPoints + end + local total = used + available + local percent = total > 0 and math.floor((used / total * 100) * 10 + 0.5) / 10 or 0 + return used, available, percent, mountPoints +end + +local function attributeMap(smart) + local result = {} + local ata = type(smart.ata_smart_attributes) == "table" and smart.ata_smart_attributes or {} + for _, item in ipairs(ata.table or {}) do + if type(item) == "table" and item.name ~= nil then + result[tostring(item.name):lower()] = item + end + end + return result +end + +local function rawAttribute(attribute) + if type(attribute) ~= "table" then + return nil + end + local raw = attribute.raw + return type(raw) == "table" and integer(raw.value) or integer(raw) +end + +local function firstAttribute(attributes, names) + for _, name in ipairs(names) do + local attribute = attributes[tostring(name):lower()] + if attribute ~= nil then + return attribute + end + end + return nil +end + +local function maximumRawAttribute(attributes, names) + local maximum = nil + for _, name in ipairs(names) do + local value = rawAttribute(attributes[tostring(name):lower()]) + if value ~= nil then + maximum = maximum == nil and value or math.max(maximum, value) + end + end + return maximum +end + +local function remainingLife(attributes) + local remaining = firstAttribute(attributes, { + "Percent_Lifetime_Remain", "SSD_Life_Left", "Media_Wearout_Indicator", + "Wear_Leveling_Count", "Remaining_Lifetime_Perc", "Remaining_Life", + "Lifetime_Remaining%", "Drive_Life_Remaining%", + }) + if remaining ~= nil then + local value = numeric(remaining.value) + if value ~= nil then + return math.floor(clamp(value, 0, 100) * 10 + 0.5) / 10, true + end + end + local used = rawAttribute(firstAttribute(attributes, { + "Percentage_Used_Endurance_Indicator", "Percent_Lifetime_Used", "SSD_Life_Used", + })) + return used ~= nil and math.floor(clamp(100 - used, 0, 100) * 10 + 0.5) / 10 or nil, used ~= nil +end + +local function lbaBytes(attributes, names, blockSize) + local count = rawAttribute(firstAttribute(attributes, names)) + return count ~= nil and count * blockSize or nil +end + +local function ataDataBytes(attributes, direction, blockSize) + local lbaNames + local gibNames + local chunkNames + if direction == "written" then + lbaNames = { "Total_LBAs_Written" } + gibNames = { "Lifetime_Writes_GiB", "Host_Writes_GiB", "Total_Writes_GiB", "Total_NAND_Writes_GiB", "TLC_NAND_GB_Writes" } + chunkNames = { "Host_Writes_32MiB" } + else + lbaNames = { "Total_LBAs_Read" } + gibNames = { "Lifetime_Reads_GiB", "Host_Reads_GiB", "Total_Reads_GiB" } + chunkNames = { "Host_Reads_32MiB" } + end + local bytes = lbaBytes(attributes, lbaNames, blockSize) + if bytes ~= nil then + return bytes + end + local gib = rawAttribute(firstAttribute(attributes, gibNames)) + if gib ~= nil then + return gib * 1024 * 1024 * 1024 + end + local chunks = rawAttribute(firstAttribute(attributes, chunkNames)) + return chunks ~= nil and chunks * 32 * 1024 * 1024 or nil +end + +local function classifySelfTest(text, passed, value) + local normalized = tostring(text or ""):lower() + if passed == true or tonumber(value) == 0 then + return "passed" + elseif normalized:find("progress", 1, true) or normalized:find("in progress", 1, true) then + return "running" + elseif normalized:find("interrupt", 1, true) or normalized:find("abort", 1, true) then + return "interrupted" + elseif normalized ~= "" then + return "failed" + end + return "unknown" +end + +local function normalizeSelfTest(smart) + local ata = type(smart.ata_smart_self_test_log) == "table" and smart.ata_smart_self_test_log or {} + local standard = type(ata.standard) == "table" and ata.standard or {} + local ataLatest = type(standard.table) == "table" and standard.table[1] or nil + if type(ataLatest) == "table" then + local status = type(ataLatest.status) == "table" and ataLatest.status or {} + local remaining = numeric(status.remaining_percent) + return { + supported = true, + state = classifySelfTest(status.string, status.passed, status.value), + status = tostring(status.string or "Unknown"), + test_type = type(ataLatest.type) == "table" and ataLatest.type.string or nil, + lifetime_hours = integer(ataLatest.lifetime_hours), + completion_percent = remaining ~= nil and clamp(100 - remaining, 0, 100) or nil, + error_count = integer(standard.error_count_total), + } + elseif next(standard) ~= nil then + return { supported = true, state = "never", status = noctalia.tr("self_test.none_recorded") } + end + + local nvme = type(smart.nvme_self_test_log) == "table" and smart.nvme_self_test_log or {} + if next(nvme) ~= nil then + local operation = type(nvme.current_self_test_operation) == "table" and nvme.current_self_test_operation or {} + if integer(operation.value) ~= nil and integer(operation.value) ~= 0 then + return { + supported = true, + state = "running", + status = tostring(operation.string or noctalia.tr("self_test.in_progress")), + completion_percent = numeric(nvme.current_self_test_completion_percent), + } + end + local latest = type(nvme.table) == "table" and nvme.table[1] or nil + if type(latest) == "table" then + local result = type(latest.self_test_result) == "table" and latest.self_test_result or {} + return { + supported = true, + state = classifySelfTest(result.string, result.passed, result.value), + status = tostring(result.string or "Unknown"), + test_type = type(latest.self_test_code) == "table" and latest.self_test_code.string or nil, + lifetime_hours = integer(latest.power_on_hours), + } + end + return { supported = true, state = "never", status = noctalia.tr("self_test.none_recorded") } + end + return { supported = false, state = "unsupported", status = noctalia.tr("self_test.log_unavailable") } +end + +local function normalizeSmart(smart, blockSize) + smart = type(smart) == "table" and smart or {} + blockSize = blockSize or 512 + local nvme = type(smart.nvme_smart_health_information_log) == "table" + and smart.nvme_smart_health_information_log or {} + local attributes = attributeMap(smart) + local isNvme = next(nvme) ~= nil + local smartctl = type(smart.smartctl) == "table" and smart.smartctl or {} + local powerMode = type(smart.power_mode) == "table" and smart.power_mode or {} + local powerModeText = tostring(powerMode.string or ""):lower() + local smartSleeping = powerModeText:find("standby", 1, true) ~= nil + or powerModeText:find("sleep", 1, true) ~= nil + local smartExitStatus = integer(smartctl.exit_status) or integer(smart._collector_exit_code) + local status = type(smart.smart_status) == "table" and smart.smart_status.passed or nil + if bitSet(smartExitStatus, 3) or bitSet(smartExitStatus, 4) then + status = false + end + local health = status == true and "passed" or (status == false and "failed" or "unknown") + local temperatureTable = type(smart.temperature) == "table" and smart.temperature or {} + local temperature = validTemperature(temperatureTable.current) + if temperature == nil and isNvme then + temperature = validTemperature(nvme.temperature) + end + if temperature == nil then + temperature = rawAttribute(firstAttribute(attributes, { + "Temperature_Celsius", "Airflow_Temperature_Cel", "Temperature_Internal", + })) + end + temperature = validTemperature(temperature) + local temperatureSensors = {} + if type(nvme.temperature_sensors) == "table" then + for _, sensor in ipairs(nvme.temperature_sensors) do + local parsed = validTemperature(sensor) + if parsed ~= nil then + table.insert(temperatureSensors, parsed) + end + end + end + local hotspotTemperature = temperature + for _, sensor in ipairs(temperatureSensors) do + hotspotTemperature = hotspotTemperature == nil and sensor or math.max(hotspotTemperature, sensor) + end + local smartMessages = {} + local hasCollectionError = bitSet(smartExitStatus, 0) or bitSet(smartExitStatus, 1) or bitSet(smartExitStatus, 2) + for _, message in ipairs(smartctl.messages or {}) do + if type(message) == "table" then + local text = tostring(message.string or "") + if text ~= "" then + table.insert(smartMessages, { severity = tostring(message.severity or "info"), message = text }) + if tostring(message.severity or ""):lower() == "error" then + hasCollectionError = true + end + end + end + end + + local percentageUsed + local life + local lifeEstimated = false + local dataRead + local dataWritten + local powerOnHours + local powerCycles + local unsafeShutdowns + local mediaErrors + local errorEntries + local spare + local criticalWarning + local spareThreshold + local warningTemperatureTime + local criticalTemperatureTime + + if isNvme then + percentageUsed = numeric(nvme.percentage_used) + life = percentageUsed ~= nil and math.floor(clamp(100 - percentageUsed, 0, 100) * 10 + 0.5) / 10 or nil + local readUnits = integer(nvme.data_units_read) + local writtenUnits = integer(nvme.data_units_written) + dataRead = readUnits ~= nil and readUnits * NVME_DATA_UNIT_BYTES or nil + dataWritten = writtenUnits ~= nil and writtenUnits * NVME_DATA_UNIT_BYTES or nil + powerOnHours = integer(nvme.power_on_hours) + powerCycles = integer(nvme.power_cycles) + unsafeShutdowns = integer(nvme.unsafe_shutdowns) + mediaErrors = integer(nvme.media_errors) + errorEntries = integer(nvme.num_err_log_entries) + spare = numeric(nvme.available_spare) + spareThreshold = numeric(nvme.available_spare_threshold) + criticalWarning = integer(nvme.critical_warning) + warningTemperatureTime = integer(nvme.warning_temp_time) + criticalTemperatureTime = integer(nvme.critical_comp_time) + else + life, lifeEstimated = remainingLife(attributes) + if life == nil then + local vendorHealth = firstAttribute(attributes, { "Perc_Write/Erase_Count" }) + local vendorValue = type(vendorHealth) == "table" and numeric(vendorHealth.value) or nil + if vendorValue ~= nil then + life = math.floor(clamp(vendorValue, 0, 100) * 10 + 0.5) / 10 + lifeEstimated = true + end + end + percentageUsed = life ~= nil and math.floor((100 - life) * 10 + 0.5) / 10 or nil + dataRead = ataDataBytes(attributes, "read", blockSize) + dataWritten = ataDataBytes(attributes, "written", blockSize) + local powerTime = type(smart.power_on_time) == "table" and smart.power_on_time or {} + powerOnHours = integer(powerTime.hours) + powerCycles = rawAttribute(firstAttribute(attributes, { "Power_Cycle_Count" })) + unsafeShutdowns = rawAttribute(firstAttribute(attributes, { + "Unsafe_Shutdown_Count", "POR_Recovery_Count", "Unexpect_Power_Loss_Ct", + })) + local ataLog = type(smart.ata_smart_error_log) == "table" and smart.ata_smart_error_log or {} + local summary = type(ataLog.summary) == "table" and ataLog.summary or {} + mediaErrors = nil + errorEntries = integer(summary.count) + local spareAttribute = firstAttribute(attributes, { "Perc_Avail_Resrvd_Space" }) + spare = type(spareAttribute) == "table" and numeric(spareAttribute.value) or nil + end + + local selfTest = normalizeSelfTest(smart) + if bitSet(smartExitStatus, 7) and selfTest.state ~= "failed" then + selfTest.state = "failed" + selfTest.status = noctalia.tr("self_test.log_failure") + end + + return { + health = health, + temperature_c = temperature, + hotspot_temperature_c = hotspotTemperature, + temperature_sensors_c = #temperatureSensors > 0 and temperatureSensors or nil, + device_warning_temperature_c = validTemperature(temperatureTable.op_limit_max), + device_critical_temperature_c = validTemperature(temperatureTable.critical_limit_max), + percentage_used = percentageUsed, + remaining_life_percent = life, + remaining_life_estimated = lifeEstimated, + available_spare_percent = spare, + available_spare_threshold_percent = spareThreshold, + data_read_bytes = dataRead, + data_written_bytes = dataWritten, + power_on_hours = powerOnHours, + power_on_hours_saturated = not isNvme and powerOnHours == 65535, + power_cycles = powerCycles, + unsafe_shutdowns = unsafeShutdowns, + media_errors = mediaErrors, + error_log_entries = errorEntries, + critical_warning = criticalWarning, + warning_temperature_time_minutes = warningTemperatureTime, + critical_temperature_time_minutes = criticalTemperatureTime, + smartctl_exit_status = smartExitStatus, + smart_sleeping = smartSleeping, + smart_power_mode = powerMode.string, + smart_messages = #smartMessages > 0 and smartMessages or nil, + smart_completeness = hasCollectionError and "partial" or "full", + smart_error_log_present = bitSet(smartExitStatus, 6), + smart_prefail_attribute_now = bitSet(smartExitStatus, 4), + smart_past_threshold = bitSet(smartExitStatus, 5), + smart_self_test_log_error = bitSet(smartExitStatus, 7), + self_test_supported = selfTest.supported, + self_test_state = selfTest.state, + self_test_status = selfTest.status, + self_test_type = selfTest.test_type, + self_test_lifetime_hours = selfTest.lifetime_hours, + self_test_completion_percent = selfTest.completion_percent, + self_test_error_count = selfTest.error_count, + reallocated_sectors = rawAttribute(firstAttribute(attributes, { "Reallocated_Sector_Ct", "Reallocated_Event_Count" })), + pending_sectors = rawAttribute(firstAttribute(attributes, { "Current_Pending_Sector" })), + uncorrectable_errors = maximumRawAttribute(attributes, { + "Offline_Uncorrectable", "Reported_Uncorrect", "Uncorrectable_Error_Cnt", + }), + start_stop_count = rawAttribute(firstAttribute(attributes, { "Start_Stop_Count" })), + load_cycle_count = rawAttribute(firstAttribute(attributes, { "Load_Cycle_Count" })), + spin_retry_count = rawAttribute(firstAttribute(attributes, { "Spin_Retry_Count" })), + command_timeout_count = rawAttribute(firstAttribute(attributes, { "Command_Timeout" })), + interface_crc_errors = rawAttribute(firstAttribute(attributes, { "UDMA_CRC_Error_Count", "CRC_Error_Count" })), + } +end + +local function conciseSmartError(smart) + local smartctl = type(smart.smartctl) == "table" and smart.smartctl or {} + for _, message in ipairs(smartctl.messages or {}) do + local text = type(message) == "table" and tostring(message.string or "") or tostring(message) + if text:lower():find("permission denied", 1, true) ~= nil then + return "Permission denied; install the read-only system collector for full SMART data." + elseif text ~= "" then + return text + end + end + return "SMART data unavailable." +end + +local function copySmartAttributes(smart) + local result = {} + local ata = type(smart.ata_smart_attributes) == "table" and smart.ata_smart_attributes or {} + for _, item in ipairs(ata.table or {}) do + if type(item) == "table" then + local raw = type(item.raw) == "table" and item.raw or {} + table.insert(result, { + id = integer(item.id), name = item.name, value = integer(item.value), + worst = integer(item.worst), threshold = integer(item.thresh), + raw_value = rawAttribute(item), raw_string = raw.string, + }) + end + end + return result +end + +local function hasSmartData(smart) + return type(smart) == "table" and ( + smart.smart_status ~= nil or smart.nvme_smart_health_information_log ~= nil + or smart.ata_smart_attributes ~= nil + ) +end + +local function summarize(disks) + local ssdCount = 0 + local hddCount = 0 + local smartAvailable = 0 + local ssdSmartAvailable = 0 + local unhealthy = 0 + local ssdUnhealthy = 0 + local partial = 0 + local ssdPartial = 0 + local selfTestFailures = 0 + local ssdSelfTestFailures = 0 + local sleeping = 0 + local hottest = nil + local hottestDrive = nil + local hottestSsd = nil + local hottestSsdDrive = nil + local lowestLife = nil + local lowestLifeDrive = nil + for _, disk in ipairs(disks) do + local isSsd = disk.kind == "ssd" + if isSsd then + ssdCount += 1 + else + hddCount += 1 + end + if disk.smart_available then + smartAvailable += 1 + if isSsd then ssdSmartAvailable += 1 end + end + if disk.smart_sleeping then + sleeping += 1 + end + if disk.smart_completeness == "partial" then + partial += 1 + if isSsd then ssdPartial += 1 end + end + if disk.health == "failed" then + unhealthy += 1 + if isSsd then ssdUnhealthy += 1 end + end + if disk.self_test_state == "failed" then + selfTestFailures += 1 + if isSsd then ssdSelfTestFailures += 1 end + end + local temperature = tonumber(isSsd and noctalia.getConfig("use_hotspot_temperature") ~= false + and disk.hotspot_temperature_c or disk.temperature_c) + if temperature ~= nil and (hottest == nil or temperature > hottest) then + hottest = temperature + hottestDrive = disk + end + if isSsd then + if temperature ~= nil and (hottestSsd == nil or temperature > hottestSsd) then + hottestSsd = temperature + hottestSsdDrive = disk + end + local life = tonumber(disk.remaining_life_percent) + if life ~= nil and (lowestLife == nil or life < lowestLife) then + lowestLife = life + lowestLifeDrive = disk + end + end + end + return { + disk_count = #disks, + ssd_count = ssdCount, + hdd_count = hddCount, + smart_available_count = smartAvailable, + smart_unavailable_count = #disks - smartAvailable - sleeping, + sleeping_count = sleeping, + unhealthy_count = unhealthy, + partial_smart_count = partial, + self_test_failure_count = selfTestFailures, + hottest_drive_temperature_c = hottest, + hottest_drive_id = hottestDrive and hottestDrive.id or nil, + hottest_drive_name = hottestDrive and hottestDrive.display_name or nil, + ssd_smart_available_count = ssdSmartAvailable, + ssd_smart_unavailable_count = ssdCount - ssdSmartAvailable, + hdd_smart_available_count = smartAvailable - ssdSmartAvailable, + hdd_smart_unavailable_count = hddCount - (smartAvailable - ssdSmartAvailable), + ssd_unhealthy_count = ssdUnhealthy, + ssd_partial_smart_count = ssdPartial, + ssd_self_test_failure_count = ssdSelfTestFailures, + hottest_ssd_temperature_c = hottestSsd, + hottest_ssd_drive_id = hottestSsdDrive and hottestSsdDrive.id or nil, + hottest_ssd_drive_name = hottestSsdDrive and hottestSsdDrive.display_name or nil, + worst_ssd_remaining_life_percent = lowestLife, + worst_ssd_life_drive_id = lowestLifeDrive and lowestLifeDrive.id or nil, + worst_ssd_life_drive_name = lowestLifeDrive and lowestLifeDrive.display_name or nil, + } +end + +local function loadDrivePreferences() + local directory = noctalia.pluginDataDir() + local path = directory ~= nil and directory .. "/" .. PREFERENCES_FILE or nil + 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" then + drivePreferences = decoded + drivePreferences.schema = 1 + drivePreferences.order = type(decoded.order) == "table" and decoded.order or {} + drivePreferences.drives = type(decoded.drives) == "table" and decoded.drives or {} + end + noctalia.state.set("drive_preferences", drivePreferences) +end + +local function preferenceFor(id) + local preferences = type(drivePreferences.drives) == "table" and drivePreferences.drives or {} + return type(preferences[id]) == "table" and preferences[id] or {} +end + +local function orderFor(id) + for index, value in ipairs(drivePreferences.order or {}) do + if tostring(value) == tostring(id) then + return index + end + end + return 100000 +end + +local function systemCollectorState(raw, source) + local pluginDirectory = noctalia.pluginDir() + local enabled = systemCollectorEnabled() + local installed = noctalia.fileExists(SYSTEM_LIBEXEC .. "/collect_raw.sh") + local reportedVersion = type(raw) == "table" and tostring(raw.collector_version or "") or "" + local version = source == "system-cache" and reportedVersion or "" + local status + if not enabled then + status = "disabled" + elseif source == "system-cache" and version == EXPECTED_COLLECTOR_VERSION then + status = "healthy" + elseif source == "system-cache" and version ~= EXPECTED_COLLECTOR_VERSION then + status = "upgrade-required" + elseif installed then + status = "stale" + else + status = "not-installed" + end + return { + enabled = enabled, + installed = installed, + status = status, + version = version ~= "" and version or nil, + expected_version = EXPECTED_COLLECTOR_VERSION, + helper_installed = noctalia.fileExists(SYSTEM_LIBEXEC .. "/smart-action.sh"), + helper_available = enabled + and noctalia.fileExists(SYSTEM_LIBEXEC .. "/smart-action.sh"), + authorization_available = noctalia.commandExists("pkexec"), + install_command = pluginDirectory ~= nil and "sudo " + .. shellQuote(pluginDirectory .. "/packaging/install-system-collector.sh") or nil, + uninstall_command = pluginDirectory ~= nil and "sudo " + .. shellQuote(pluginDirectory .. "/packaging/uninstall-system-collector.sh") or nil, + enable_command = installed and noctalia.commandExists("systemctl") + and "sudo systemctl enable --now " .. SYSTEM_NAMESPACE .. ".timer" + .. " && sudo systemctl start " .. SYSTEM_NAMESPACE .. ".service" or nil, + disable_command = installed and noctalia.commandExists("systemctl") + and "sudo systemctl disable --now " .. SYSTEM_NAMESPACE .. ".timer" + .. " && sudo systemctl stop " .. SYSTEM_NAMESPACE .. ".service" or nil, + } +end + +local function normalizeRaw(raw, source) + if type(raw) ~= "table" or tonumber(raw.schema) ~= 2 then + return nil, "Unsupported raw SMART cache schema." + end + local lsblk = type(raw.lsblk) == "table" and raw.lsblk or {} + local smartByDevice = {} + for _, entry in ipairs(raw.smart or {}) do + if type(entry) == "table" then + local smart = type(entry.payload) == "table" and entry.payload or entry + smart._collector_exit_code = entry.exit_code + local device = type(smart.device) == "table" and smart.device or {} + if entry.requested_device ~= nil then + smartByDevice[tostring(entry.requested_device)] = smart + end + if device.name ~= nil then + smartByDevice[tostring(device.name)] = smart + end + end + end + + local disks = {} + for _, block in ipairs(lsblk.blockdevices or {}) do + local device = tostring(block.path or ("/dev/" .. tostring(block.name or ""))) + local name = tostring(block.name or "") + local virtual = name:match("^zram") or name:match("^loop") or name:match("^ram") or name:match("^sr") + if block.type == "disk" and device:match("^/dev/") and not virtual then + local smartDevice = smartDeviceFor(device) + local smart = smartByDevice[smartDevice] or smartByDevice[device] or {} + local smartAvailable = hasSmartData(smart) + local blockSize = integer(smart.logical_block_size) or integer(block["log-sec"]) or 512 + local values = normalizeSmart(smart, blockSize) + if not smartAvailable then + values.smart_completeness = "unavailable" + end + if values.temperature_c == nil then + values.temperature_c = sysfsTemperature(device) + end + if values.hotspot_temperature_c == nil then + values.hotspot_temperature_c = values.temperature_c + end + local used, available, usagePercent, mountPoints = mountedUsage(block) + local rotational = block.rota == true or tonumber(block.rota) == 1 + local serial = noctalia.string.trim(tostring(block.serial or "")) + local model = noctalia.string.trim(tostring(block.model or "")) + local namespace = name:match("^nvme%d+(n%d+)$") + local id = serial ~= "" and serial .. (namespace ~= nil and ":" .. namespace or "") + or tostring(block.kname or device) + local preferences = preferenceFor(id) + local removable = block.rm == true or tonumber(block.rm) == 1 or block.hotplug == true or tonumber(block.hotplug) == 1 + local transport = tostring(block.tran or "unknown") + local defaultPresenceAlert = not removable and transport ~= "usb" + local drive = { + id = id, + device = device, + smart_device = smartDevice, + model = model ~= "" and model or name, + display_name = tostring(preferences.alias or "") ~= "" and tostring(preferences.alias) or (model ~= "" and model or name), + serial = serial ~= "" and serial or nil, + transport = transport, + kind = rotational and "hdd" or "ssd", + removable = removable, + hidden = preferences.hidden == true, + alerts_enabled = preferences.alerts_enabled ~= false, + presence_alert_enabled = preferences.presence_alert_enabled == nil and defaultPresenceAlert + or preferences.presence_alert_enabled == true, + warning_temperature = numeric(preferences.warning_temperature), + critical_temperature = numeric(preferences.critical_temperature), + life_warning_percent = numeric(preferences.life_warning_percent), + display_order = orderFor(id), + capacity_bytes = integer(block.size), + storage_used_bytes = used, + storage_available_bytes = available, + storage_usage_percent = usagePercent, + mount_points = #mountPoints > 0 and mountPoints or nil, + smart_available = smartAvailable, + smart_error = not smartAvailable and not values.smart_sleeping and conciseSmartError(smart) or nil, + } + for key, value in pairs(values) do + drive[key] = value + end + local attributes = copySmartAttributes(smart) + if #attributes > 0 then + drive.smart_attributes = attributes + end + table.insert(disks, drive) + end + end + + table.sort(disks, function(left, right) + if left.display_order ~= right.display_order then + return left.display_order < right.display_order + elseif left.kind ~= right.kind then + return left.kind == "ssd" + elseif left.transport ~= right.transport then + return left.transport < right.transport + end + return left.device < right.device + end) + + local epoch = integer(raw.generated_at_epoch) or os.time() + local smartCount = 0 + for _, disk in ipairs(disks) do + if disk.smart_available then + smartCount += 1 + end + end + return { + schema = 1, + collection_id = validCollectionId(raw.collection_id), + generated_at = tostring(epoch), + generated_at_epoch = epoch, + generated_at_local = noctalia.formatTime("%H:%M:%S", epoch), + collector_version = raw.collector_version, + source = source or "direct", + access = #disks > 0 and smartCount == #disks and "full" + or (smartCount > 0 and "partial" or "unavailable"), + disks = disks, + summary = summarize(disks), + system_collector = systemCollectorState(raw, source or "direct"), + }, nil +end + +local function detectedPackageManager() + for _, manager in ipairs(PACKAGE_MANAGERS) do + if noctalia.commandExists(manager.command) then + return manager + end + end + return nil +end + +local startDependencyProbe + +local function forceDependencyProbe() + if dependencyProbe.running then + dependencyProbe.forcedPending = true + return + end + dependencyProbe.checked = false + dependencyProbe.incompatible = {} + startDependencyProbe() +end + +startDependencyProbe = function() + if dependencyProbe.checked or dependencyProbe.running + or not noctalia.commandExists("lsblk") or not noctalia.commandExists("smartctl") then + return + end + dependencyProbe.running = true + local command = "if lsblk --json --bytes --output NAME,TYPE >/dev/null 2>&1; then " + .. "printf 'lsblk=ok\\n'; else printf 'lsblk=bad\\n'; fi; " + .. "if smartctl --json=c --version >/dev/null 2>&1; then " + .. "printf 'smartctl=ok\\n'; else printf 'smartctl=bad\\n'; fi" + local launched = noctalia.runAsync(command, function(result) + dependencyProbe.running = false + dependencyProbe.checked = true + local output = tostring(result.stdout or "") + local probeFailed = result.timedOut == true or tonumber(result.exitCode) ~= 0 + dependencyProbe.incompatible = { + lsblk = probeFailed or output:find("lsblk=ok", 1, true) == nil, + smartctl = probeFailed or output:find("smartctl=ok", 1, true) == nil, + } + dependencyProbeGeneration += 1 + noctalia.state.set("dependency_probe_generation", dependencyProbeGeneration) + if dependencyProbe.forcedPending then + dependencyProbe.forcedPending = false + forceDependencyProbe() + end + end, 5000) + if not launched then + dependencyProbe.running = false + dependencyProbe.checked = true + dependencyProbe.incompatible = { lsblk = true, smartctl = true } + if dependencyProbe.forcedPending then + dependencyProbe.forcedPending = false + forceDependencyProbe() + end + end +end + +local function checkDependencies() + startDependencyProbe() + local missing = {} + local blocking = false + for _, dependency in ipairs(DEPENDENCIES) do + local exists = noctalia.commandExists(dependency.command) + if not exists or dependencyProbe.incompatible[dependency.command] == true then + dependency.incompatible = exists + table.insert(missing, dependency) + blocking = blocking or dependency.blocking + end + end + local manager = detectedPackageManager() + local packages = {} + local seen = {} + local labels = {} + local signatureParts = {} + for _, dependency in ipairs(missing) do + table.insert(labels, dependency.incompatible + and dependency.label .. " (installed but incompatible)" + or dependency.label .. " (" .. dependency.command .. ")") + table.insert(signatureParts, dependency.command) + local packageName = manager ~= nil and dependency.packages[manager.id] or nil + if packageName ~= nil and not seen[packageName] then + seen[packageName] = true + table.insert(packages, packageName) + end + end + table.sort(signatureParts) + local installCommand = nil + if manager ~= nil and #packages > 0 and noctalia.commandExists("sudo") then + installCommand = manager.prefix .. table.concat(packages, " ") + end + local signature = table.concat(signatureParts, ",") + local logSignature = signature == "" and "ready" or signature + if logSignature ~= loggedDependencySignature then + loggedDependencySignature = logSignature + noctalia.log(signature == "" and "Dependency check passed: lsblk and smartctl are available." + or "Dependency check found missing commands: " .. table.concat(labels, ", ")) + end + return { + ready = #missing == 0, + blocking = blocking, + missing = labels, + missing_text = table.concat(labels, ", "), + signature = signature, + package_manager = manager ~= nil and manager.label or nil, + install_command = installCommand, + can_install = installCommand ~= nil, + checking = dependencyProbe.running, + } +end + +local function freshJson(path) + local info = noctalia.fileInfo(path) + if type(info) ~= "table" or info.isDir or tonumber(info.mtime) == nil then + return nil + end + local age = os.time() - tonumber(info.mtime) + if age < 0 or age > CACHE_MAX_AGE_SECONDS then + return nil + end + local encoded = noctalia.readFile(path) + if encoded == nil then + return nil + end + return noctalia.json.decode(encoded) +end + +local function publishError(message, dependencies) + local snapshot = noctalia.state.get("collector_snapshot") or {} + snapshot.collecting = false + snapshot.collector_error = message + snapshot.dependencies = dependencies + snapshot.summary = snapshot.summary or {} + snapshot.system_collector = systemCollectorState(nil, "error") + noctalia.state.set("collector_snapshot", snapshot) +end + +local function publish(snapshot, dependencies) + snapshot.collecting = false + snapshot.collector_error = nil + snapshot.dependencies = dependencies + local collector = snapshot.system_collector + if type(collector) == "table" and collector.enabled == true + and collector.status == "upgrade-required" then + local signature = tostring(collector.version or "unknown") + .. "->" .. tostring(collector.expected_version or "unknown") + if signature ~= notifiedCollectorUpgradeSignature then + notifiedCollectorUpgradeSignature = signature + noctalia.notify(noctalia.tr("collector.update_title"), noctalia.tr("collector.update_body", { + current = tostring(collector.version or noctalia.tr("common.unknown")), + expected = tostring(collector.expected_version or noctalia.tr("common.unknown")), + })) + end + end + noctalia.state.set("collector_snapshot", snapshot) +end + +local function collect() + if collecting then + return + end + local dependencies = checkDependencies() + if dependencies.blocking then + publishError(noctalia.tr("dependencies.collection_blocked", { missing = dependencies.missing_text }), dependencies) + return + end + + if systemCollectorEnabled() then + local rawCache = freshJson(RAW_CACHE) + if type(rawCache) == "table" and tonumber(rawCache.schema) == 2 then + local snapshot, normalizeError = normalizeRaw(rawCache, "system-cache") + if snapshot ~= nil then + publish(snapshot, dependencies) + return + end + noctalia.log("Raw SMART cache rejected: " .. tostring(normalizeError)) + end + end + + local pluginDir = noctalia.pluginDir() + if pluginDir == nil or pluginDir == "" then + publishError("Cannot resolve the plugin directory.", dependencies) + return + end + + collecting = true + local current = noctalia.state.get("collector_snapshot") or {} + current.collecting = true + current.dependencies = dependencies + noctalia.state.set("collector_snapshot", current) + local command = "sh " .. shellQuote(pluginDir .. "/scripts/collect_raw.sh") + local launched = noctalia.runAsync(command, function(result) + collecting = false + local resolvedDependencies = checkDependencies() + if result.timedOut then + publishError("SMART collection timed out.", resolvedDependencies) + return + elseif result.exitCode ~= 0 then + local reason = noctalia.string.trim(result.stderr or "") + publishError(reason ~= "" and reason or "SMART collection failed.", resolvedDependencies) + return + end + local raw, decodeError = noctalia.json.decode(result.stdout or "") + if raw == nil then + publishError("Invalid raw collector response: " .. tostring(decodeError or "unknown JSON error"), resolvedDependencies) + return + end + local snapshot, normalizeError = normalizeRaw(raw, "direct") + if snapshot == nil then + publishError(tostring(normalizeError or "SMART normalization failed."), resolvedDependencies) + return + end + publish(snapshot, resolvedDependencies) + end, 60000) + if not launched then + collecting = false + publishError("Noctalia could not start the SMART collector.", dependencies) + end +end + +loadDrivePreferences() + +noctalia.state.watch("drive_preferences", function(value) + if type(value) == "table" then + drivePreferences = value + drivePreferences.order = type(value.order) == "table" and value.order or {} + drivePreferences.drives = type(value.drives) == "table" and value.drives or {} + collect() + end +end) + +noctalia.state.watch("refresh_nonce", function(value) + local nextNonce = tonumber(value) or 0 + if nextNonce ~= refreshNonce then + refreshNonce = nextNonce + forceDependencyProbe() + collect() + end +end) + +noctalia.state.watch("dependency_probe_generation", function(value) + local generation = tonumber(value) or 0 + if generation > dependencyProbeGeneration then + dependencyProbeGeneration = generation + end + collect() +end) + +noctalia.setUpdateInterval(30000) + +function update() + collect() +end + +function onConfigChanged() + local seconds = tonumber(noctalia.getConfig("refresh_seconds")) or 30 + noctalia.setUpdateInterval(math.max(15, math.min(300, seconds)) * 1000) + collect() +end + +function onIpc(event, _payload) + if event == "check-dependencies" then + forceDependencyProbe() + collect() + elseif event == "refresh" then + collect() + elseif event == "test-alert" then + noctalia.notify(noctalia.tr("alerts.test_title"), noctalia.tr("alerts.test_body")) + elseif event == "export-snapshot" then + local snapshot = noctalia.state.get("collector_snapshot") + local directory = noctalia.pluginDataDir() + local encoded = snapshot ~= nil and noctalia.json.encode(snapshot, true) or nil + if directory ~= nil and encoded ~= nil then + noctalia.writeFile(directory .. "/last-collector-snapshot.json", encoded) + end + end +end + +onConfigChanged() diff --git a/drive-health/history.luau b/drive-health/history.luau new file mode 100644 index 0000000..dd5182b --- /dev/null +++ b/drive-health/history.luau @@ -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 diff --git a/drive-health/packaging/install-system-collector.sh b/drive-health/packaging/install-system-collector.sh new file mode 100755 index 0000000..d60981e --- /dev/null +++ b/drive-health/packaging/install-system-collector.sh @@ -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" diff --git a/drive-health/packaging/noctalia-drive-health.service.in b/drive-health/packaging/noctalia-drive-health.service.in new file mode 100644 index 0000000..870dfd5 --- /dev/null +++ b/drive-health/packaging/noctalia-drive-health.service.in @@ -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 diff --git a/drive-health/packaging/noctalia-drive-health.timer b/drive-health/packaging/noctalia-drive-health.timer new file mode 100644 index 0000000..eaa27a1 --- /dev/null +++ b/drive-health/packaging/noctalia-drive-health.timer @@ -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 diff --git a/drive-health/packaging/smart-action.sh b/drive-health/packaging/smart-action.sh new file mode 100755 index 0000000..6d4ce8e --- /dev/null +++ b/drive-health/packaging/smart-action.sh @@ -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" diff --git a/drive-health/packaging/uninstall-system-collector.sh b/drive-health/packaging/uninstall-system-collector.sh new file mode 100755 index 0000000..0843949 --- /dev/null +++ b/drive-health/packaging/uninstall-system-collector.sh @@ -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." diff --git a/drive-health/panel.luau b/drive-health/panel.luau new file mode 100644 index 0000000..7ed80d9 --- /dev/null +++ b/drive-health/panel.luau @@ -0,0 +1,1552 @@ +--!nonstrict + +local snapshot = noctalia.state.get("snapshot") +local history = noctalia.state.get("drive_history") or { drives = {} } +local preferences = noctalia.state.get("drive_preferences") or { schema = 1, order = {}, drives = {} } +local opened = false +local expandedDriveId = nil +local editingDriveId = nil +local currentDrives = {} +local currentIssues = {} +local dismissRequestNonce = 0 +local pendingSelfTest = nil +local selfTestLaunch = nil +local confirmUninstall = false +local showCollectorSettings = false +local hiddenSelection = 1 +local aliasDraft = "" +local warningDraft = "" +local criticalDraft = "" +local lifeDraft = "" +local alertsDraft = true +local presenceDraft = false +local EDITABLE_PREFERENCE_FIELDS = { + "alias", "warning_temperature", "critical_temperature", "life_warning_percent", + "alerts_enabled", "presence_alert_enabled", +} +local MIN_TREND_SAMPLES = 4 + +local function number(value, fallback) + local parsed = tonumber(value) + return parsed ~= nil and parsed or fallback +end + +local function clamp(value, minimum, maximum) + return math.max(minimum, math.min(maximum, value)) +end + +local function formatBytes(value) + local bytes = tonumber(value) + if bytes == nil then + return noctalia.tr("common.not_available") + end + local units = { "B", "KiB", "MiB", "GiB", "TiB", "PiB" } + local index = 1 + while bytes >= 1024 and index < #units do + bytes /= 1024 + index += 1 + end + if index <= 3 then + return string.format("%.0f %s", bytes, units[index]) + end + return string.format("%.1f %s", bytes, units[index]) +end + +local function formatHours(value) + local hours = tonumber(value) + if hours == nil then + return noctalia.tr("common.not_available") + end + if hours >= 8760 then + return noctalia.tr("common.duration_years", { value = string.format("%.1f", hours / 8760) }) + elseif hours >= 24 then + return noctalia.tr("common.duration_days", { value = string.format("%.0f", hours / 24) }) + end + return noctalia.tr("common.duration_hours", { value = string.format("%.0f", hours) }) +end + +local function formatPowerOn(drive) + local formatted = formatHours(drive.power_on_hours) + return drive.power_on_hours_saturated and "≥ " .. formatted or formatted +end + +local function formatPercent(value) + local percent = tonumber(value) + return percent ~= nil and string.format("%.0f%%", percent) or noctalia.tr("common.not_available") +end + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +local function driveId(drive) + return tostring(drive.id or drive.serial or drive.device or "unknown") +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 preferenceFor(id) + preferences.drives = type(preferences.drives) == "table" and preferences.drives or {} + local entry = preferences.drives[id] + if type(entry) ~= "table" then + entry = {} + preferences.drives[id] = entry + end + return entry +end + +local function savePreferences() + local directory = noctalia.pluginDataDir() + if directory == nil then + noctalia.notifyError(noctalia.tr("preferences.title"), noctalia.tr("preferences.save_failed")) + return false + end + preferences.schema = 1 + preferences.order = type(preferences.order) == "table" and preferences.order or {} + preferences.drives = type(preferences.drives) == "table" and preferences.drives or {} + local encoded = noctalia.json.encode(preferences, true) + if encoded == nil then + noctalia.notifyError(noctalia.tr("preferences.title"), noctalia.tr("preferences.save_failed")) + return false + end + local path = directory .. "/drive-preferences.json" + local temporary = path .. ".tmp" + if not noctalia.writeFile(temporary, encoded) or not noctalia.renameFile(temporary, path) then + noctalia.notifyError(noctalia.tr("preferences.title"), noctalia.tr("preferences.save_failed")) + return false + end + noctalia.state.set("drive_preferences", preferences) + return true +end + +local function temperatureColor(value, drive) + local warning = number(drive and drive.warning_temperature, + number(noctalia.getConfig("warning_temperature"), 65)) + local critical = math.max(warning + 1, number(drive and drive.critical_temperature, + 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 metric(icon, label, value, color) + return ui.row({ gap = 8, align = "center", flexGrow = 1 }, { + ui.glyph({ name = icon, size = 14, color = color or "on_surface_variant" }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = label, fontSize = 11, color = "on_surface_variant" }), + ui.label({ text = value, fontWeight = "medium", color = color or "on_surface" }), + }), + }) +end + +local function progressMetric(label, value, display, fill) + local progress = value ~= nil and clamp(number(value, 0) / 100, 0, 1) or 0 + return ui.column({ gap = 5, flexGrow = 1, align = "stretch" }, { + ui.row({ justify = "space_between", align = "center" }, { + ui.label({ text = label, fontSize = 11, color = "on_surface_variant" }), + ui.label({ text = display, fontSize = 11, fontWeight = "bold", color = fill }), + }), + ui.progress({ progress = progress, fill = fill, height = 6, radius = 3 }), + }) +end + +local function summaryCard(value, label, detail, color) + local children = { + ui.label({ text = value, fontSize = 20, fontWeight = "bold", color = color }), + ui.label({ text = label, fontSize = 11, color = "on_surface_variant" }), + } + if detail ~= nil and tostring(detail) ~= "" then + table.insert(children, ui.label({ text = tostring(detail), fontSize = 9, + color = "on_surface_variant/0.78", maxLines = 1 })) + end + return ui.column({ gap = 2, padding = 10, radius = 10, + fill = color .. "/0.12", flexGrow = 1 }, children) +end + +local function healthInfo(drive) + if drive.health == "passed" then + return noctalia.tr("health.passed"), "primary", "check" + elseif drive.health == "failed" then + return noctalia.tr("health.failed"), "error", "alert-triangle" + end + return noctalia.tr("health.unknown"), "on_surface_variant", "help-circle" +end + +local function appendMetricRows(target, metrics) + local index = 1 + while index <= #metrics do + local row = { metrics[index] } + if metrics[index + 1] ~= nil then + table.insert(row, metrics[index + 1]) + end + table.insert(target, ui.row({ gap = 14, align = "center" }, row)) + index += 2 + end +end + +local function alertsCard(issues) + if type(issues) ~= "table" or #issues == 0 then + return nil + end + + local header = { + ui.glyph({ name = "alert-triangle", size = 17, color = "error" }), + ui.label({ + text = noctalia.tr("alerts.active_title", { count = #issues }), + fontWeight = "bold", + color = "error", + flexGrow = 1, + }), + } + table.insert(header, ui.button({ text = noctalia.tr("alerts.dismiss_all"), variant = "ghost", controlSize = "sm", + onClick = "onDismissAllAlertsClicked" })) + local rows = { ui.row({ gap = 7, align = "center" }, header) } + for index, issue in ipairs(issues) do + local color = issue.severity == "critical" and "error" or "secondary" + table.insert(rows, ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = issue.severity == "critical" and "alert-octagon" or "alert-circle", size = 13, color = color }), + ui.label({ text = tostring(issue.message or issue.title), fontSize = 11, color = "on_surface", flexGrow = 1 }), + ui.button({ glyph = "x", tooltip = noctalia.tr("alerts.dismiss"), variant = "ghost", controlSize = "sm", + enabled = index <= 32, onClick = "onDismissAlert" .. tostring(index) .. "Clicked" }), + })) + end + + return ui.column({ gap = 7, padding = 11, radius = 10, fill = "error/0.10", border = "error/0.30", borderWidth = 1 }, rows) +end + +local function dependencyCard(dependencies) + if type(dependencies) ~= "table" or dependencies.ready then + return nil + end + + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "package", size = 18, color = "error" }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("dependencies.title"), fontWeight = "bold", color = "error" }), + ui.label({ + text = noctalia.tr("dependencies.missing", { missing = tostring(dependencies.missing_text or "") }), + fontSize = 11, + color = "on_surface_variant", + flexGrow = 1, + }), + }), + }), + } + + if dependencies.install_command ~= nil and dependencies.install_command ~= "" then + table.insert(children, ui.column({ gap = 4, fill = "surface/0.48", radius = 8, padding = 8 }, { + ui.label({ + text = noctalia.tr("dependencies.command", { + manager = tostring(dependencies.package_manager or noctalia.tr("dependencies.package_manager")), + }), + fontSize = 10, + color = "on_surface_variant", + }), + ui.label({ text = tostring(dependencies.install_command), fontSize = 10, color = "on_surface", maxLines = 2 }), + })) + else + table.insert(children, ui.label({ + text = noctalia.tr("dependencies.manual_install"), + fontSize = 11, + color = "on_surface_variant", + })) + end + + local actions = {} + if dependencies.can_install and dependencies.install_command ~= nil then + table.insert(actions, ui.button({ + text = noctalia.tr("dependencies.install"), + variant = "primary", + onClick = "onInstallDependenciesClicked", + })) + table.insert(actions, ui.button({ + text = noctalia.tr("dependencies.copy_command"), + variant = "outline", + onClick = "onCopyInstallCommandClicked", + })) + end + table.insert(actions, ui.button({ + text = noctalia.tr("dependencies.recheck"), + variant = "ghost", + onClick = "onRefreshClicked", + })) + table.insert(children, ui.row({ gap = 8, align = "center" }, actions)) + + return ui.column({ + gap = 9, + padding = 11, + radius = 10, + fill = "error/0.10", + border = "error/0.32", + borderWidth = 1, + }, children) +end + +local function collectorCard(collector) + if showCollectorSettings or type(collector) ~= "table" then + return nil + end + local status = tostring(collector.status or "not-installed") + if status == "healthy" or status == "disabled" then + return nil + end + local color = status == "stale" and "error" or "secondary" + local actions = {} + local actionKey = status == "stale" and "collector.start" + or (collector.installed and "collector.upgrade" or "collector.install") + local actionHandler = status == "stale" and "onStartCollectorClicked" + or "onInstallCollectorClicked" + table.insert(actions, ui.button({ + text = noctalia.tr(actionKey), variant = "primary", controlSize = "sm", onClick = actionHandler, + })) + table.insert(actions, ui.button({ + text = noctalia.tr("collector.copy_install"), variant = "outline", controlSize = "sm", + onClick = "onCopyCollectorCommandClicked", + })) + if collector.installed then + table.insert(actions, ui.button({ + text = noctalia.tr("collector.remove"), variant = confirmUninstall and "destructive" or "ghost", + controlSize = "sm", onClick = "onUninstallCollectorClicked", + })) + end + table.insert(actions, ui.button({ + text = noctalia.tr("dependencies.recheck"), variant = "ghost", controlSize = "sm", + onClick = "onRefreshClicked", + })) + + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "shield-cog", size = 17, color = color }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("collector.title"), fontWeight = "bold", color = color }), + ui.label({ + text = noctalia.tr("collector.status_" .. status:gsub("%-", "_"), { + version = tostring(collector.version or collector.expected_version or ""), + }), + fontSize = 11, color = "on_surface_variant", + }), + }), + }), + } + if confirmUninstall then + table.insert(children, ui.label({ + text = noctalia.tr("collector.remove_confirm"), fontSize = 11, color = "error", + })) + end + table.insert(children, ui.row({ gap = 7, align = "center" }, actions)) + return ui.column({ + gap = 8, padding = 10, radius = 10, fill = color .. "/0.08", + border = color .. "/0.24", borderWidth = 1, + }, children) +end + +local function collectorSettingsCard(collector) + if not showCollectorSettings or type(collector) ~= "table" then + return nil + end + local status = tostring(collector.status or "not-installed") + local enabled = collector.enabled == true + local color = status == "healthy" and "primary" + or (status == "disabled" and "on_surface_variant" + or (status == "stale" and "error" or "secondary")) + local actions = { + ui.button({ text = noctalia.tr("collector.open_settings"), glyph = "settings", variant = "outline", + controlSize = "sm", onClick = "onOpenPluginSettingsClicked" }), + } + + if enabled and status == "not-installed" then + table.insert(actions, 1, ui.button({ text = noctalia.tr("collector.install"), variant = "primary", + controlSize = "sm", onClick = "onInstallCollectorClicked" })) + elseif enabled and status == "upgrade-required" then + table.insert(actions, 1, ui.button({ text = noctalia.tr("collector.upgrade"), variant = "primary", + controlSize = "sm", onClick = "onInstallCollectorClicked" })) + elseif enabled and status == "stale" and collector.enable_command ~= nil then + table.insert(actions, 1, ui.button({ text = noctalia.tr("collector.start"), variant = "primary", + controlSize = "sm", onClick = "onStartCollectorClicked" })) + elseif status == "healthy" and collector.disable_command ~= nil then + table.insert(actions, 1, ui.button({ text = noctalia.tr("collector.pause"), variant = "ghost", + controlSize = "sm", onClick = "onPauseCollectorClicked" })) + elseif not enabled and collector.installed and collector.disable_command ~= nil then + table.insert(actions, 1, ui.button({ text = noctalia.tr("collector.stop_service"), variant = "ghost", + controlSize = "sm", onClick = "onPauseCollectorClicked" })) + end + + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "shield-cog", size = 17, color = color }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("collector.settings_title"), fontWeight = "bold", color = color }), + ui.label({ + text = noctalia.tr("collector.status_" .. status:gsub("%-", "_"), { + version = tostring(collector.version or collector.expected_version or ""), + }), + fontSize = 11, color = "on_surface_variant", maxLines = 2, + }), + }), + }), + ui.row({ gap = 8, padding = 8, radius = 8, fill = "surface/0.42", align = "center" }, { + ui.glyph({ name = "server", size = 14, color = "on_surface_variant" }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("collector.basic_title"), fontWeight = "bold", fontSize = 11 }), + ui.label({ text = noctalia.tr("collector.basic_features"), fontSize = 10, + color = "on_surface_variant", maxLines = 2 }), + }), + }), + ui.row({ gap = 8, padding = 8, radius = 8, fill = "primary/0.10", align = "center" }, { + ui.glyph({ name = "shield-check", size = 14, color = "primary" }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("collector.full_title"), fontWeight = "bold", fontSize = 11, + color = "primary" }), + ui.label({ text = noctalia.tr("collector.full_features"), fontSize = 10, + color = "on_surface_variant", maxLines = 3 }), + }), + }), + ui.label({ text = noctalia.tr("collector.settings_hint"), fontSize = 10, + color = "on_surface_variant", maxLines = 2 }), + ui.row({ gap = 7, align = "center" }, actions), + } + if confirmUninstall then + table.insert(children, ui.label({ text = noctalia.tr("collector.remove_confirm"), + fontSize = 10, color = "error" })) + end + if collector.installed then + table.insert(children, ui.button({ text = noctalia.tr("collector.remove"), + variant = confirmUninstall and "destructive" or "ghost", controlSize = "sm", + onClick = "onUninstallCollectorClicked" })) + end + return ui.column({ + gap = 8, padding = 10, radius = 10, fill = "surface_variant/0.24", + border = "outline/0.38", borderWidth = 1, + }, children) +end + +local function trendCard(drive) + local historyDrives = type(history.drives) == "table" and history.drives or {} + local entry = historyDrives[driveId(drive)] + local samples = type(entry) == "table" and entry.samples or {} + if #samples < MIN_TREND_SAMPLES then + return nil + end + local temperatures = {} + local life = {} + local hasTemperatures = true + local hasLife = true + for _, sample in ipairs(samples) do + local temperature = tonumber(sample.hotspot_temperature_c or sample.temperature_c) + local remaining = tonumber(sample.remaining_life_percent) + if temperature == nil then + hasTemperatures = false + else + table.insert(temperatures, clamp(temperature / 100, 0, 1)) + end + if remaining == nil then + hasLife = false + else + table.insert(life, clamp(remaining / 100, 0, 1)) + end + end + if not hasTemperatures then temperatures = {} end + if not hasLife then life = {} end + if #temperatures < MIN_TREND_SAMPLES and #life < MIN_TREND_SAMPLES then + return nil + end + local primaryValues = #temperatures >= MIN_TREND_SAMPLES and temperatures or life + local secondaryValues = #temperatures >= MIN_TREND_SAMPLES and #life >= MIN_TREND_SAMPLES and life or nil + local primaryColor = #temperatures >= MIN_TREND_SAMPLES and "secondary" or "primary" + local legend = {} + if #temperatures >= MIN_TREND_SAMPLES then + table.insert(legend, ui.label({ text = "● " .. noctalia.tr("history.hotspot"), fontSize = 10, color = "secondary" })) + end + if #life >= MIN_TREND_SAMPLES then + table.insert(legend, ui.label({ text = "● " .. noctalia.tr("history.life"), fontSize = 10, color = "primary" })) + end + return ui.column({ gap = 5, padding = 8, radius = 9, fill = "surface/0.38" }, { + ui.row({ justify = "space_between", align = "center" }, { + ui.label({ text = noctalia.tr("history.title"), fontSize = 11, fontWeight = "bold" }), + ui.label({ text = noctalia.tr("history.samples", { count = #samples }), fontSize = 10, color = "on_surface_variant" }), + }), + ui.graph({ values = primaryValues, values2 = secondaryValues, color = primaryColor, color2 = "primary", + lineWidth = 2, fillOpacity = 0.10, height = 44 }), + ui.row({ gap = 12, align = "center" }, legend), + }) +end + +local function selfTestCard(drive) + local firmwareState = tostring(drive.self_test_state or "unsupported") + local launch = selfTestLaunch ~= nil and selfTestLaunch.drive_id == driveId(drive) and selfTestLaunch or nil + local state = launch ~= nil and launch.state ~= "failed" and firmwareState ~= "running" + and launch.state or firmwareState + local color = state == "failed" and "error" + or ((state == "running" or state == "authorizing" or state == "starting") and "secondary" or "primary") + local collector = snapshot and snapshot.system_collector or {} + local helper = collector.helper_available == true + local authorization = collector.authorization_available == true + local canStart = helper and authorization + local pending = pendingSelfTest ~= nil and pendingSelfTest.drive_id == driveId(drive) + local completion = tonumber(drive.self_test_completion_percent) + local status = drive.self_test_status or noctalia.tr("self_test.unavailable") + if launch ~= nil and firmwareState ~= "running" then + if launch.state == "authorizing" then + status = noctalia.tr("self_test.authorizing") + elseif launch.state == "starting" then + status = noctalia.tr("self_test.starting") + elseif launch.state == "failed" then + status = launch.error or noctalia.tr("self_test.launch_failed") + end + end + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "stethoscope", size = 15, color = color }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("self_test.title"), fontSize = 11, fontWeight = "bold" }), + ui.label({ text = tostring(status), + fontSize = 10, color = "on_surface_variant" }), + }), + }), + } + if firmwareState == "running" and completion ~= nil then + table.insert(children, ui.column({ gap = 4 }, { + ui.row({ justify = "space_between", align = "center" }, { + ui.label({ text = noctalia.tr("self_test.progress"), fontSize = 10, color = "on_surface_variant" }), + ui.label({ text = formatPercent(completion), fontSize = 10, fontWeight = "bold", color = color }), + }), + ui.progress({ progress = clamp(completion / 100, 0, 1), fill = color, height = 5 }), + })) + end + if drive.self_test_supported then + if pending then + table.insert(children, ui.label({ + text = noctalia.tr("self_test.confirm", { type = noctalia.tr("self_test." .. pendingSelfTest.kind) }), + fontSize = 11, color = "secondary", + })) + table.insert(children, ui.row({ gap = 7 }, { + ui.button({ text = noctalia.tr("self_test.confirm_action"), variant = "primary", controlSize = "sm", + onClick = "onConfirmSelfTestClicked" }), + ui.button({ text = noctalia.tr("self_test.cancel"), variant = "ghost", controlSize = "sm", + onClick = "onCancelSelfTestClicked" }), + })) + else + table.insert(children, ui.row({ gap = 7 }, { + ui.button({ text = noctalia.tr("self_test.short"), variant = "outline", controlSize = "sm", + enabled = canStart and firmwareState ~= "running" and state ~= "authorizing" and state ~= "starting", + onClick = "onStartShortSelfTestClicked" }), + ui.button({ text = noctalia.tr("self_test.long"), variant = "outline", controlSize = "sm", + enabled = canStart and firmwareState ~= "running" and state ~= "authorizing" and state ~= "starting", + onClick = "onStartLongSelfTestClicked" }), + })) + if not helper then + table.insert(children, ui.label({ text = noctalia.tr("self_test.helper_required"), + fontSize = 10, color = "on_surface_variant" })) + elseif not authorization then + table.insert(children, ui.label({ text = noctalia.tr("self_test.authorization_required"), + fontSize = 10, color = "error" })) + end + end + end + return ui.column({ gap = 7, padding = 9, radius = 9, fill = color .. "/0.08", + border = color .. "/0.20", borderWidth = 1 }, children) +end + +local function preferenceEditor(drive) + local thresholdFields = { + ui.column({ gap = 3, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("preferences.warning_temperature"), fontSize = 10, color = "on_surface_variant" }), + ui.input({ key = driveId(drive) .. "-warning", value = warningDraft, placeholder = "65", + controlSize = "sm", onChange = "onWarningThresholdChanged" }), + }), + ui.column({ gap = 3, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("preferences.critical_temperature"), fontSize = 10, color = "on_surface_variant" }), + ui.input({ key = driveId(drive) .. "-critical", value = criticalDraft, placeholder = "80", + controlSize = "sm", onChange = "onCriticalThresholdChanged" }), + }), + } + if drive.kind ~= "hdd" then + table.insert(thresholdFields, ui.column({ gap = 3, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("preferences.life_warning"), fontSize = 10, color = "on_surface_variant" }), + ui.input({ key = driveId(drive) .. "-life", value = lifeDraft, placeholder = "20", + controlSize = "sm", onChange = "onLifeThresholdChanged" }), + })) + end + + return ui.column({ gap = 8, padding = 10, radius = 9, fill = "primary/0.07", + border = "primary/0.24", borderWidth = 1 }, { + ui.row({ justify = "space_between", align = "center" }, { + ui.label({ text = noctalia.tr("preferences.title"), fontWeight = "bold", color = "primary" }), + ui.row({ gap = 5 }, { + ui.button({ glyph = "arrow-up", tooltip = noctalia.tr("preferences.move_up"), variant = "ghost", + controlSize = "sm", onClick = "onMoveDriveUpClicked" }), + ui.button({ glyph = "arrow-down", tooltip = noctalia.tr("preferences.move_down"), variant = "ghost", + controlSize = "sm", onClick = "onMoveDriveDownClicked" }), + }), + }), + ui.label({ text = noctalia.tr("preferences.alias"), fontSize = 10, color = "on_surface_variant" }), + ui.input({ key = driveId(drive) .. "-alias", value = aliasDraft, + placeholder = drive.model or drive.device, controlSize = "sm", onChange = "onAliasChanged" }), + ui.row({ gap = 8, align = "center" }, thresholdFields), + ui.row({ gap = 12, align = "center" }, { + ui.toggle({ checked = alertsDraft, onChange = "onDriveAlertsChanged" }), + ui.label({ text = noctalia.tr("preferences.alerts"), fontSize = 11, flexGrow = 1 }), + ui.toggle({ checked = presenceDraft, onChange = "onPresenceAlertsChanged" }), + ui.label({ text = noctalia.tr("preferences.presence"), fontSize = 11, flexGrow = 1 }), + }), + ui.row({ gap = 7, justify = "end" }, { + ui.button({ text = noctalia.tr("preferences.hide"), variant = "ghost", controlSize = "sm", + onClick = "onHideDriveClicked" }), + ui.button({ text = noctalia.tr("self_test.cancel"), variant = "ghost", controlSize = "sm", + onClick = "onCancelDrivePreferencesClicked" }), + ui.button({ text = noctalia.tr("preferences.save"), variant = "primary", controlSize = "sm", + onClick = "onSaveDrivePreferencesClicked" }), + }), + }) +end + +local function driveCard(drive, index) + local id = driveId(drive) + local expanded = expandedDriveId == id + local isHdd = drive.kind == "hdd" + local healthText, healthColor, healthIcon = healthInfo(drive) + local temperature = tonumber(drive.temperature_c) + local hotspot = tonumber(drive.hotspot_temperature_c) or temperature + local alertTemperature = noctalia.getConfig("use_hotspot_temperature") == false and temperature or hotspot + local tempColor = temperatureColor(alertTemperature, drive) + local tempText = hotspot ~= nil and string.format("%.0f °C", hotspot) or noctalia.tr("common.not_available") + if temperature ~= nil and hotspot ~= nil and hotspot > temperature then + tempText = noctalia.tr("metrics.hotspot_and_composite", { + hotspot = string.format("%.0f °C", hotspot), + composite = string.format("%.0f °C", temperature), + }) + end + local life = tonumber(drive.remaining_life_percent) + local lifeText = formatPercent(life) + if drive.remaining_life_estimated then + lifeText = "~" .. lifeText + end + local lifeWarning = number(drive.life_warning_percent, + number(noctalia.getConfig("life_warning_percent"), 20)) + local lifeColor = life ~= nil and (life <= 10 and "error" + or (life <= lifeWarning and "secondary" or "primary")) or "on_surface_variant" + local storage = tonumber(drive.storage_usage_percent) + local storageColor = storage ~= nil and (storage >= 95 and "error" or (storage >= 85 and "secondary" or "primary")) or "on_surface_variant" + local storageText = storage ~= nil and formatPercent(storage) or noctalia.tr("storage.not_mounted") + local transport = string.upper(tostring(drive.transport or "unknown")) + local device = tostring(drive.device or "") + + local details = { + metric("temperature", noctalia.tr("metrics.temperature"), tempText, tempColor), + metric(healthIcon, noctalia.tr("metrics.smart_health"), healthText, healthColor), + } + local progressRows = {} + if not isHdd then + table.insert(progressRows, progressMetric( + drive.remaining_life_estimated and noctalia.tr("metrics.life_remaining_estimated") or noctalia.tr("metrics.life_remaining"), + life, + lifeText, + lifeColor + )) + end + table.insert(progressRows, progressMetric(noctalia.tr("metrics.storage_used"), storage, storageText, storageColor)) + + local ioDetails = {} + if drive.data_written_bytes ~= nil then + table.insert(ioDetails, metric("database-export", noctalia.tr("metrics.data_written"), formatBytes(drive.data_written_bytes))) + end + if drive.data_read_bytes ~= nil then + table.insert(ioDetails, metric("database-import", noctalia.tr("metrics.data_read"), formatBytes(drive.data_read_bytes))) + end + + local wearDetails = {} + if drive.percentage_used ~= nil then + table.insert(wearDetails, metric( + "gauge", + noctalia.tr("metrics.endurance_used"), + formatPercent(drive.percentage_used), + number(drive.percentage_used, 0) >= 90 and "error" or "on_surface" + )) + end + if drive.available_spare_percent ~= nil then + local spare = number(drive.available_spare_percent, 100) + local spareThreshold = number(drive.available_spare_threshold_percent, 10) + table.insert(wearDetails, metric( + "shield-check", + noctalia.tr("metrics.available_spare"), + formatPercent(drive.available_spare_percent), + spare <= math.max(5, spareThreshold / 2) and "error" + or (spare <= spareThreshold and "secondary" or "on_surface") + )) + end + + local powerDetails = { + metric("clock", noctalia.tr("metrics.power_on"), formatPowerOn(drive)), + } + + local thermalDetails = {} + if type(drive.temperature_sensors_c) == "table" and #drive.temperature_sensors_c > 0 then + local sensors = {} + for sensorIndex, sensor in ipairs(drive.temperature_sensors_c) do + table.insert(sensors, string.format("S%d %.0f°", sensorIndex, number(sensor, 0))) + end + table.insert(thermalDetails, metric("temperature-sun", noctalia.tr("metrics.temperature_sensors"), + table.concat(sensors, " · "), tempColor)) + end + if drive.device_warning_temperature_c ~= nil or drive.device_critical_temperature_c ~= nil then + local warningLimit = tonumber(drive.device_warning_temperature_c) + local criticalLimit = tonumber(drive.device_critical_temperature_c) + table.insert(thermalDetails, metric("temperature-cog", noctalia.tr("metrics.device_temperature_limits"), + (warningLimit ~= nil and string.format("%.0f°", warningLimit) or noctalia.tr("common.not_available")) + .. " / " + .. (criticalLimit ~= nil and string.format("%.0f°", criticalLimit) or noctalia.tr("common.not_available")))) + end + if drive.power_cycles ~= nil then + table.insert(powerDetails, metric("repeat", noctalia.tr("metrics.power_cycles"), tostring(math.floor(number(drive.power_cycles, 0))))) + end + if drive.start_stop_count ~= nil then + table.insert(powerDetails, metric("player-play", noctalia.tr("metrics.start_stop_count"), + tostring(math.floor(number(drive.start_stop_count, 0))))) + end + if drive.load_cycle_count ~= nil then + table.insert(powerDetails, metric("refresh", noctalia.tr("metrics.load_cycle_count"), + tostring(math.floor(number(drive.load_cycle_count, 0))))) + end + + local errorDetails = {} + if drive.unsafe_shutdowns ~= nil then + table.insert(errorDetails, metric( + "bolt-off", + noctalia.tr("metrics.unsafe_shutdowns"), + tostring(math.floor(number(drive.unsafe_shutdowns, 0))), + number(drive.unsafe_shutdowns, 0) > 0 and "secondary" or "on_surface" + )) + end + + if drive.media_errors ~= nil then + table.insert(errorDetails, metric( + "alert-circle", + noctalia.tr("metrics.media_errors"), + tostring(math.floor(number(drive.media_errors, 0))), + number(drive.media_errors, 0) > 0 and "error" or "on_surface" + )) + end + + local integrityDetails = {} + if drive.error_log_entries ~= nil and drive.error_log_entries ~= drive.media_errors then + table.insert(integrityDetails, metric( + "file-alert", + noctalia.tr("metrics.error_log_entries"), + tostring(math.floor(number(drive.error_log_entries, 0))), + number(drive.error_log_entries, 0) > 0 and "secondary" or "on_surface" + )) + end + if drive.critical_warning ~= nil then + table.insert(integrityDetails, metric( + "alert-octagon", + noctalia.tr("metrics.critical_warning"), + string.format("0x%02X", math.floor(number(drive.critical_warning, 0))), + number(drive.critical_warning, 0) > 0 and "error" or "primary" + )) + end + if drive.reallocated_sectors ~= nil then + table.insert(integrityDetails, metric( + "replace", + noctalia.tr("metrics.reallocated"), + tostring(math.floor(number(drive.reallocated_sectors, 0))), + number(drive.reallocated_sectors, 0) > 0 and "error" or "primary" + )) + end + if drive.pending_sectors ~= nil then + table.insert(integrityDetails, metric( + "hourglass", + noctalia.tr("metrics.pending"), + tostring(math.floor(number(drive.pending_sectors, 0))), + number(drive.pending_sectors, 0) > 0 and "error" or "primary" + )) + end + if drive.uncorrectable_errors ~= nil then + table.insert(integrityDetails, metric( + "alert-hexagon", + noctalia.tr("metrics.uncorrectable"), + tostring(math.floor(number(drive.uncorrectable_errors, 0))), + number(drive.uncorrectable_errors, 0) > 0 and "error" or "primary" + )) + end + if drive.spin_retry_count ~= nil then + table.insert(integrityDetails, metric( + "repeat", + noctalia.tr("metrics.spin_retry_count"), + tostring(math.floor(number(drive.spin_retry_count, 0))), + number(drive.spin_retry_count, 0) > 0 and "error" or "primary" + )) + end + if drive.command_timeout_count ~= nil then + table.insert(integrityDetails, metric( + "clock-exclamation", + noctalia.tr("metrics.command_timeout_count"), + tostring(math.floor(number(drive.command_timeout_count, 0))), + number(drive.command_timeout_count, 0) > 0 and "secondary" or "primary" + )) + end + if drive.interface_crc_errors ~= nil then + table.insert(integrityDetails, metric( + "link-off", + noctalia.tr("metrics.interface_crc_errors"), + tostring(math.floor(number(drive.interface_crc_errors, 0))), + number(drive.interface_crc_errors, 0) > 0 and "secondary" or "primary" + )) + end + + local identity = { + ui.label({ text = driveName(drive), fontWeight = "bold", color = "on_surface" }), + ui.label({ + text = transport .. " • " .. formatBytes(drive.capacity_bytes) .. " • " .. device, + fontSize = 11, + color = "on_surface_variant", + }), + } + local compactMetrics = { + metric("temperature", isHdd and noctalia.tr("metrics.temperature") or noctalia.tr("metrics.hotspot_temperature"), + tempText, tempColor), + isHdd and metric("clock", noctalia.tr("metrics.power_on"), formatPowerOn(drive)) + or metric("battery-vertical", noctalia.tr("metrics.life_remaining"), lifeText, lifeColor), + metric(healthIcon, noctalia.tr("metrics.smart_health"), healthText, healthColor), + } + + local children = { + ui.row({ gap = 10, align = "center" }, { + ui.column({ width = 36, height = 36, radius = 10, fill = tempColor .. "/0.16", align = "center", justify = "center" }, { + ui.glyph({ name = drive.kind == "ssd" and "server" or "disc", size = 19, color = tempColor }), + }), + ui.column({ gap = 2, flexGrow = 1 }, identity), + ui.row({ fill = healthColor .. "/0.16", radius = 8, paddingH = 8, paddingV = 4, align = "center", gap = 4 }, { + ui.glyph({ name = healthIcon, size = 12, color = healthColor }), + ui.label({ text = healthText, fontSize = 11, fontWeight = "bold", color = healthColor }), + }), + ui.button({ + glyph = expanded and "chevron-up" or "chevron-down", variant = "ghost", controlSize = "sm", + tooltip = noctalia.tr(expanded and "panel.collapse" or "panel.expand"), + enabled = index <= 32, onClick = "onDrive" .. tostring(index) .. "Clicked", + }), + }), + ui.row({ gap = 14, align = "center" }, compactMetrics), + } + + if expanded then + if drive.serial ~= nil and drive.serial ~= "" then + table.insert(children, ui.label({ + text = noctalia.tr("metrics.serial", { value = tostring(drive.serial) }), + fontSize = 10, color = "on_surface_variant/0.78", maxLines = 1, + })) + end + if type(drive.mount_points) == "table" and #drive.mount_points > 0 then + table.insert(children, ui.label({ + text = noctalia.tr("metrics.mounted_at", { paths = table.concat(drive.mount_points, " · ") }), + fontSize = 10, color = "primary/0.88", maxLines = 2, + })) + end + table.insert(children, ui.row({ gap = 16, align = "center" }, details)) + table.insert(children, ui.row({ gap = 16, align = "center" }, progressRows)) + appendMetricRows(children, wearDetails) + appendMetricRows(children, ioDetails) + appendMetricRows(children, powerDetails) + appendMetricRows(children, thermalDetails) + appendMetricRows(children, errorDetails) + appendMetricRows(children, integrityDetails) + local trends = trendCard(drive) + if trends ~= nil then + table.insert(children, trends) + end + table.insert(children, selfTestCard(drive)) + table.insert(children, ui.row({ gap = 7, justify = "end" }, { + ui.button({ text = noctalia.tr("preferences.edit"), glyph = "settings", variant = "ghost", + controlSize = "sm", onClick = "onEditExpandedDriveClicked" }), + })) + end + + if drive.smart_sleeping then + table.insert(children, ui.row({ gap = 7, align = "center", fill = "primary/0.08", radius = 8, padding = 8 }, { + ui.glyph({ name = "moon", size = 13, color = "primary" }), + ui.label({ text = noctalia.tr("smart.sleeping"), fontSize = 11, color = "on_surface_variant", flexGrow = 1 }), + })) + elseif not drive.smart_available then + table.insert(children, ui.row({ gap = 7, align = "center", fill = "secondary/0.12", radius = 8, padding = 8 }, { + ui.glyph({ name = "info-circle", size = 13, color = "secondary" }), + ui.label({ text = noctalia.tr("smart.access_limited"), fontSize = 11, color = "on_surface_variant", flexGrow = 1 }), + })) + end + + if expanded and drive.smart_completeness == "partial" then + table.insert(children, ui.row({ gap = 7, align = "center", fill = "secondary/0.12", radius = 8, padding = 8 }, { + ui.glyph({ name = "file-alert", size = 13, color = "secondary" }), + ui.label({ text = noctalia.tr("smart.partial_details"), fontSize = 11, color = "on_surface_variant", flexGrow = 1 }), + })) + for _, message in ipairs(drive.smart_messages or {}) do + table.insert(children, ui.label({ text = "• " .. tostring(message.message or message), + fontSize = 10, color = "on_surface_variant", maxLines = 3 })) + end + end + if expanded and editingDriveId == id then + table.insert(children, preferenceEditor(drive)) + end + + return ui.column({ + key = tostring(drive.id or device), + gap = 12, + padding = 14, + radius = 14, + fill = "surface_variant/0.34", + border = "outline/0.38", + borderWidth = 1, + align = "stretch", + }, children) +end + +local function visibleDrives() + local drives = {} + local showHdd = noctalia.getConfig("show_hdd") ~= false + if snapshot ~= nil and type(snapshot.disks) == "table" then + for _, drive in ipairs(snapshot.disks) do + if (drive.kind == "ssd" or showHdd) and drive.hidden ~= true then + table.insert(drives, drive) + end + end + end + return drives +end + +local function hiddenDrivesCard() + local hidden = {} + if snapshot ~= nil and type(snapshot.disks) == "table" then + for _, drive in ipairs(snapshot.disks) do + if drive.hidden == true then + table.insert(hidden, drive) + end + end + end + if #hidden == 0 then + return nil + end + hiddenSelection = math.max(1, math.min(hiddenSelection, #hidden)) + local options = {} + for _, drive in ipairs(hidden) do + table.insert(options, driveName(drive)) + end + return ui.column({ gap = 7, padding = 10, radius = 9, fill = "surface_variant/0.22" }, { + ui.label({ text = noctalia.tr("preferences.hidden_drives"), fontWeight = "bold", fontSize = 11 }), + ui.row({ gap = 7, align = "center" }, { + ui.select({ options = options, selectedIndex = hiddenSelection - 1, controlSize = "sm", + flexGrow = 1, onChange = "onHiddenDriveSelected" }), + ui.button({ text = noctalia.tr("preferences.restore"), variant = "outline", controlSize = "sm", + onClick = "onRestoreHiddenDriveClicked" }), + }), + }) +end + +local function render() + if not opened then + return + end + + local summary = snapshot and snapshot.summary or {} + local drives = visibleDrives() + currentDrives = drives + currentIssues = type(snapshot and snapshot.issues) == "table" and snapshot.issues or {} + local includeHdd = noctalia.getConfig("show_hdd") ~= false + local hottest = tonumber(includeHdd and summary.hottest_drive_temperature_c or summary.hottest_ssd_temperature_c) + local hottestDriveName = includeHdd and summary.hottest_drive_name or summary.hottest_ssd_drive_name + local remaining = tonumber(summary.worst_ssd_remaining_life_percent) + local lowestLifeDriveName = summary.worst_ssd_life_drive_name + local ssdCount = number(summary.ssd_count, 0) + local hddCount = includeHdd and number(summary.hdd_count, 0) or 0 + local driveCount = ssdCount + hddCount + local smartAvailable = includeHdd and number(summary.smart_available_count, + number(summary.ssd_smart_available_count, 0) + number(summary.hdd_smart_available_count, 0)) + or number(summary.ssd_smart_available_count, 0) + local headerStatus = snapshot and snapshot.collecting and noctalia.tr("panel.refreshing") or noctalia.tr("panel.updated", { + time = tostring(snapshot and snapshot.generated_at_local or noctalia.tr("common.never")), + }) + + local body = {} + if snapshot == nil then + table.insert(body, ui.column({ gap = 10, align = "center", justify = "center", flexGrow = 1 }, { + ui.glyph({ name = "server-off", size = 36, color = "on_surface_variant" }), + ui.label({ text = noctalia.tr("panel.waiting"), color = "on_surface_variant" }), + })) + else + local dependencyStatus = dependencyCard(snapshot.dependencies) + if dependencyStatus ~= nil then + table.insert(body, dependencyStatus) + end + local collectorStatus = collectorCard(snapshot.system_collector) + if collectorStatus ~= nil then + table.insert(body, collectorStatus) + end + local collectorSettings = collectorSettingsCard(snapshot.system_collector) + if collectorSettings ~= nil then + table.insert(body, collectorSettings) + end + + local hottestColor = temperatureColor(hottest) + local summaryCards = { + summaryCard(tostring(driveCount), noctalia.tr("summary.drives"), + noctalia.tr("summary.drive_mix", { ssds = ssdCount, hdds = hddCount }), "primary"), + summaryCard(hottest ~= nil and string.format("%.0f °C", hottest) or "-- °C", + noctalia.tr("summary.hottest"), hottestDriveName, hottestColor), + } + if ssdCount > 0 then + table.insert(summaryCards, summaryCard(formatPercent(remaining), + noctalia.tr("summary.lowest_ssd_life"), lowestLifeDriveName, "secondary")) + end + table.insert(body, ui.row({ gap = 10, align = "stretch" }, summaryCards)) + + local alerts = alertsCard(currentIssues) + if alerts ~= nil then + table.insert(body, alerts) + end + + local sleeping = number(summary.sleeping_count, 0) + if smartAvailable + sleeping < driveCount then + table.insert(body, ui.row({ gap = 8, padding = 10, radius = 10, fill = "secondary/0.12", align = "center" }, { + ui.glyph({ name = "shield-lock", size = 16, color = "secondary" }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("smart.partial_title"), fontWeight = "bold", color = "secondary" }), + ui.label({ text = noctalia.tr("smart.partial_body", { available = smartAvailable, + total = driveCount - sleeping }), fontSize = 11, color = "on_surface_variant" }), + }), + })) + end + + if snapshot.collector_error ~= nil and snapshot.collector_error ~= "" then + table.insert(body, ui.label({ text = tostring(snapshot.collector_error), color = "error", fontSize = 11 })) + end + + if #drives == 0 then + table.insert(body, ui.label({ text = noctalia.tr("panel.no_drives"), color = "on_surface_variant" })) + else + for index, drive in ipairs(drives) do + table.insert(body, driveCard(drive, index)) + end + end + local hiddenStatus = hiddenDrivesCard() + if hiddenStatus ~= nil then + table.insert(body, hiddenStatus) + end + end + + panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, { + ui.row({ align = "center", gap = 10 }, { + ui.column({ width = 38, height = 38, radius = 11, fill = "primary/0.16", align = "center", justify = "center" }, { + ui.glyph({ name = "server-2", size = 20, color = "primary" }), + }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = noctalia.tr("panel.title"), fontSize = 17, fontWeight = "bold", color = "on_surface" }), + ui.label({ text = headerStatus, fontSize = 11, color = "on_surface_variant" }), + }), + ui.button({ glyph = "settings", variant = showCollectorSettings and "primary" or "ghost", + tooltip = noctalia.tr("collector.settings_title"), onClick = "onToggleCollectorSettingsClicked" }), + ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefreshClicked" }), + ui.button({ glyph = "x", variant = "ghost", onClick = "onCloseClicked" }), + }), + ui.scroll({ flexGrow = 1, gap = 12 }, body), + })) +end + +local function selfTestSignature(drive) + if drive == nil then return "" end + return table.concat({ + tostring(drive.self_test_state or ""), + tostring(drive.self_test_status or ""), + tostring(drive.self_test_lifetime_hours or ""), + tostring(drive.self_test_error_count or ""), + }, "|") +end + +local function reconcileSelfTestLaunch(value) + if selfTestLaunch == nil or selfTestLaunch.state == "authorizing" or selfTestLaunch.state == "failed" then + return + end + local drive = nil + for _, candidate in ipairs(value and value.disks or {}) do + if driveId(candidate) == selfTestLaunch.drive_id then + drive = candidate + break + end + end + if drive == nil then return end + if tostring(drive.self_test_state) == "running" then + selfTestLaunch.observed_running = true + return + end + local generated = number(value and value.generated_at_epoch, 0) + local changed = generated > number(selfTestLaunch.accepted_snapshot_epoch, 0) + and selfTestSignature(drive) ~= tostring(selfTestLaunch.baseline_signature or "") + local expired = os.time() - number(selfTestLaunch.accepted_at, os.time()) >= 180 + if selfTestLaunch.observed_running or changed or expired then + selfTestLaunch = nil + end +end + +noctalia.state.watch("snapshot", function(value) + snapshot = value + reconcileSelfTestLaunch(value) + render() +end) + +noctalia.state.watch("drive_history", function(value) + history = type(value) == "table" and value or { drives = {} } + render() +end) + +noctalia.state.watch("drive_preferences", function(value) + if type(value) == "table" then + preferences = value + end + render() +end) + +local function toggleDriveAt(index) + local drive = currentDrives[index] + if drive == nil then + return + end + local id = driveId(drive) + if expandedDriveId == id then + expandedDriveId = nil + editingDriveId = nil + pendingSelfTest = nil + else + expandedDriveId = id + end + render() +end + +function onDrive1Clicked() toggleDriveAt(1) end +function onDrive2Clicked() toggleDriveAt(2) end +function onDrive3Clicked() toggleDriveAt(3) end +function onDrive4Clicked() toggleDriveAt(4) end +function onDrive5Clicked() toggleDriveAt(5) end +function onDrive6Clicked() toggleDriveAt(6) end +function onDrive7Clicked() toggleDriveAt(7) end +function onDrive8Clicked() toggleDriveAt(8) end +function onDrive9Clicked() toggleDriveAt(9) end +function onDrive10Clicked() toggleDriveAt(10) end +function onDrive11Clicked() toggleDriveAt(11) end +function onDrive12Clicked() toggleDriveAt(12) end +function onDrive13Clicked() toggleDriveAt(13) end +function onDrive14Clicked() toggleDriveAt(14) end +function onDrive15Clicked() toggleDriveAt(15) end +function onDrive16Clicked() toggleDriveAt(16) end +function onDrive17Clicked() toggleDriveAt(17) end +function onDrive18Clicked() toggleDriveAt(18) end +function onDrive19Clicked() toggleDriveAt(19) end +function onDrive20Clicked() toggleDriveAt(20) end +function onDrive21Clicked() toggleDriveAt(21) end +function onDrive22Clicked() toggleDriveAt(22) end +function onDrive23Clicked() toggleDriveAt(23) end +function onDrive24Clicked() toggleDriveAt(24) end +function onDrive25Clicked() toggleDriveAt(25) end +function onDrive26Clicked() toggleDriveAt(26) end +function onDrive27Clicked() toggleDriveAt(27) end +function onDrive28Clicked() toggleDriveAt(28) end +function onDrive29Clicked() toggleDriveAt(29) end +function onDrive30Clicked() toggleDriveAt(30) end +function onDrive31Clicked() toggleDriveAt(31) end +function onDrive32Clicked() toggleDriveAt(32) end + +local function sendDismissRequest(request) + dismissRequestNonce += 1 + request.nonce = dismissRequestNonce + noctalia.state.set("dismiss_alert_request", request) +end + +local function dismissAlertAt(index) + local issue = currentIssues[index] + if issue ~= nil and issue.id ~= nil then + sendDismissRequest({ id = tostring(issue.id) }) + end +end + +function onDismissAlert1Clicked() dismissAlertAt(1) end +function onDismissAlert2Clicked() dismissAlertAt(2) end +function onDismissAlert3Clicked() dismissAlertAt(3) end +function onDismissAlert4Clicked() dismissAlertAt(4) end +function onDismissAlert5Clicked() dismissAlertAt(5) end +function onDismissAlert6Clicked() dismissAlertAt(6) end +function onDismissAlert7Clicked() dismissAlertAt(7) end +function onDismissAlert8Clicked() dismissAlertAt(8) end +function onDismissAlert9Clicked() dismissAlertAt(9) end +function onDismissAlert10Clicked() dismissAlertAt(10) end +function onDismissAlert11Clicked() dismissAlertAt(11) end +function onDismissAlert12Clicked() dismissAlertAt(12) end +function onDismissAlert13Clicked() dismissAlertAt(13) end +function onDismissAlert14Clicked() dismissAlertAt(14) end +function onDismissAlert15Clicked() dismissAlertAt(15) end +function onDismissAlert16Clicked() dismissAlertAt(16) end +function onDismissAlert17Clicked() dismissAlertAt(17) end +function onDismissAlert18Clicked() dismissAlertAt(18) end +function onDismissAlert19Clicked() dismissAlertAt(19) end +function onDismissAlert20Clicked() dismissAlertAt(20) end +function onDismissAlert21Clicked() dismissAlertAt(21) end +function onDismissAlert22Clicked() dismissAlertAt(22) end +function onDismissAlert23Clicked() dismissAlertAt(23) end +function onDismissAlert24Clicked() dismissAlertAt(24) end +function onDismissAlert25Clicked() dismissAlertAt(25) end +function onDismissAlert26Clicked() dismissAlertAt(26) end +function onDismissAlert27Clicked() dismissAlertAt(27) end +function onDismissAlert28Clicked() dismissAlertAt(28) end +function onDismissAlert29Clicked() dismissAlertAt(29) end +function onDismissAlert30Clicked() dismissAlertAt(30) end +function onDismissAlert31Clicked() dismissAlertAt(31) end +function onDismissAlert32Clicked() dismissAlertAt(32) end + +function onDismissAllAlertsClicked() + if #currentIssues > 0 then + sendDismissRequest({ all = true }) + end +end + +local function expandedDrive() + for _, drive in ipairs(currentDrives) do + if driveId(drive) == expandedDriveId then + return drive + end + end + return nil +end + +local function setPreferenceDrafts(drive) + local entry = preferenceFor(driveId(drive)) + aliasDraft = tostring(entry.alias or "") + warningDraft = entry.warning_temperature ~= nil and tostring(entry.warning_temperature) or "" + criticalDraft = entry.critical_temperature ~= nil and tostring(entry.critical_temperature) or "" + lifeDraft = entry.life_warning_percent ~= nil and tostring(entry.life_warning_percent) or "" + alertsDraft = entry.alerts_enabled ~= false + presenceDraft = entry.presence_alert_enabled == nil and drive.presence_alert_enabled == true + or entry.presence_alert_enabled == true +end + +function onEditExpandedDriveClicked() + local drive = expandedDrive() + if drive == nil then return end + editingDriveId = driveId(drive) + setPreferenceDrafts(drive) + render() +end + +function onAliasChanged(value) aliasDraft = tostring(value or "") end +function onWarningThresholdChanged(value) warningDraft = tostring(value or "") end +function onCriticalThresholdChanged(value) criticalDraft = tostring(value or "") end +function onLifeThresholdChanged(value) lifeDraft = tostring(value or "") end + +function onDriveAlertsChanged(value) + if editingDriveId ~= nil then + alertsDraft = value == true or tostring(value) == "true" + render() + end +end + +function onPresenceAlertsChanged(value) + if editingDriveId ~= nil then + presenceDraft = value == true or tostring(value) == "true" + render() + end +end + +local function optionalThreshold(value, minimum, maximum) + local trimmed = noctalia.string.trim(tostring(value or "")) + if trimmed == "" then return nil end + local parsed = tonumber(trimmed) + if parsed == nil then return false end + return clamp(parsed, minimum, maximum) +end + +function onSaveDrivePreferencesClicked() + if editingDriveId == nil then return end + local warning = optionalThreshold(warningDraft, 30, 100) + local critical = optionalThreshold(criticalDraft, 31, 110) + local life = optionalThreshold(lifeDraft, 1, 100) + if warning == false or critical == false or life == false or (warning ~= nil and critical ~= nil and critical <= warning) then + noctalia.notifyError(noctalia.tr("preferences.title"), noctalia.tr("preferences.invalid_thresholds")) + return + end + local entry = preferenceFor(editingDriveId) + local previous = {} + for _, key in ipairs(EDITABLE_PREFERENCE_FIELDS) do previous[key] = entry[key] end + entry.alias = noctalia.string.trim(aliasDraft) + entry.warning_temperature = warning + entry.critical_temperature = critical + entry.life_warning_percent = life + entry.alerts_enabled = alertsDraft + entry.presence_alert_enabled = presenceDraft + if savePreferences() then + editingDriveId = nil + noctalia.notify(noctalia.tr("preferences.title"), noctalia.tr("preferences.saved")) + else + for _, key in ipairs(EDITABLE_PREFERENCE_FIELDS) do entry[key] = previous[key] end + end + render() +end + +function onCancelDrivePreferencesClicked() + editingDriveId = nil + render() +end + +local function ensureOrder() + preferences.order = type(preferences.order) == "table" and preferences.order or {} + local seen = {} + for _, id in ipairs(preferences.order) do seen[tostring(id)] = true end + for _, drive in ipairs(snapshot and snapshot.disks or {}) do + local id = driveId(drive) + if not seen[id] then + table.insert(preferences.order, id) + seen[id] = true + end + end +end + +local function moveEditingDrive(delta) + if editingDriveId == nil then return end + ensureOrder() + for index, id in ipairs(preferences.order) do + if tostring(id) == editingDriveId then + local destination = math.max(1, math.min(#preferences.order, index + delta)) + preferences.order[index], preferences.order[destination] = preferences.order[destination], preferences.order[index] + if not savePreferences() then + preferences.order[index], preferences.order[destination] = preferences.order[destination], preferences.order[index] + render() + end + return + end + end +end + +function onMoveDriveUpClicked() moveEditingDrive(-1) end +function onMoveDriveDownClicked() moveEditingDrive(1) end + +function onHideDriveClicked() + if editingDriveId == nil then return end + local entry = preferenceFor(editingDriveId) + local previous = entry.hidden + entry.hidden = true + if savePreferences() then + editingDriveId = nil + expandedDriveId = nil + else + entry.hidden = previous + end + render() +end + +function onHiddenDriveSelected(value) + hiddenSelection = math.max(1, (tonumber(value) or 0) + 1) +end + +function onRestoreHiddenDriveClicked() + local hidden = {} + for _, drive in ipairs(snapshot and snapshot.disks or {}) do + if drive.hidden == true then table.insert(hidden, drive) end + end + local drive = hidden[hiddenSelection] + if drive ~= nil then + local entry = preferenceFor(driveId(drive)) + local previous = entry.hidden + entry.hidden = false + if savePreferences() then + hiddenSelection = 1 + else + entry.hidden = previous + end + render() + end +end + +function onStartShortSelfTestClicked() + local drive = expandedDrive() + if drive ~= nil then + if selfTestLaunch ~= nil and selfTestLaunch.drive_id == driveId(drive) and selfTestLaunch.state == "failed" then + selfTestLaunch = nil + end + pendingSelfTest = { drive_id = driveId(drive), kind = "short", device = drive.smart_device or drive.device } + render() + end +end + +function onStartLongSelfTestClicked() + local drive = expandedDrive() + if drive ~= nil then + if selfTestLaunch ~= nil and selfTestLaunch.drive_id == driveId(drive) and selfTestLaunch.state == "failed" then + selfTestLaunch = nil + end + pendingSelfTest = { drive_id = driveId(drive), kind = "long", device = drive.smart_device or drive.device } + render() + end +end + +function onCancelSelfTestClicked() + pendingSelfTest = nil + render() +end + +local function selfTestError(result) + if result.timedOut == true then + return noctalia.tr("self_test.authorization_timeout") + end + if tonumber(result.exitCode) == 126 then + return noctalia.tr("self_test.authorization_cancelled") + end + local detail = noctalia.string.trim(tostring(result.stderr or "")) + if detail == "" then detail = noctalia.string.trim(tostring(result.stdout or "")) end + if #detail > 240 then detail = detail:sub(1, 237) .. "..." end + return detail ~= "" and detail or noctalia.tr("self_test.launch_failed") +end + +function onConfirmSelfTestClicked() + if pendingSelfTest == nil then return end + local request = pendingSelfTest + pendingSelfTest = nil + local drive = expandedDrive() + selfTestLaunch = { + drive_id = request.drive_id, + kind = request.kind, + state = "authorizing", + baseline_signature = selfTestSignature(drive), + } + render() + local command = "pkexec /usr/local/libexec/noctalia-drive-health/smart-action.sh " + .. shellQuote(request.kind) .. " " .. shellQuote(request.device) + local launched = noctalia.runAsync(command, function(result) + if selfTestLaunch == nil or selfTestLaunch.drive_id ~= request.drive_id then return end + local exitCode = tonumber(result.exitCode) + -- smartctl bits 3-7 report existing drive-health findings, not whether the + -- self-test command was accepted. Bits 0-2 are the command/device failures. + local accepted = result.timedOut ~= true and exitCode ~= nil and exitCode >= 0 and exitCode % 8 == 0 + if accepted then + selfTestLaunch.state = "starting" + selfTestLaunch.accepted_at = os.time() + selfTestLaunch.accepted_snapshot_epoch = number(snapshot and snapshot.generated_at_epoch, 0) + noctalia.notify(noctalia.tr("self_test.title"), noctalia.tr("self_test.started_background")) + local nonce = number(noctalia.state.get("refresh_nonce"), 0) + 1 + noctalia.state.set("refresh_nonce", nonce) + else + selfTestLaunch.state = "failed" + selfTestLaunch.error = selfTestError(result) + noctalia.notifyError(noctalia.tr("self_test.title"), selfTestLaunch.error) + end + render() + end, 120000) + if not launched then + selfTestLaunch.state = "failed" + selfTestLaunch.error = noctalia.tr("self_test.launch_failed") + noctalia.notifyError(noctalia.tr("self_test.title"), selfTestLaunch.error) + render() + end +end + +function onInstallCollectorClicked() + local command = snapshot and snapshot.system_collector and snapshot.system_collector.install_command or nil + if command == nil or command == "" then return end + if noctalia.runInTerminal(command) then + noctalia.notify(noctalia.tr("collector.title"), noctalia.tr("collector.terminal_opened")) + end +end + +function onStartCollectorClicked() + local command = snapshot and snapshot.system_collector and snapshot.system_collector.enable_command or nil + if command == nil or command == "" then return end + if noctalia.runInTerminal(command) then + noctalia.notify(noctalia.tr("collector.title"), noctalia.tr("collector.start_terminal_opened")) + end +end + +function onPauseCollectorClicked() + local command = snapshot and snapshot.system_collector and snapshot.system_collector.disable_command or nil + if command == nil or command == "" then return end + if noctalia.runInTerminal(command) then + noctalia.notify(noctalia.tr("collector.title"), noctalia.tr("collector.pause_terminal_opened")) + end +end + +function onToggleCollectorSettingsClicked() + showCollectorSettings = not showCollectorSettings + confirmUninstall = false + render() +end + +function onOpenPluginSettingsClicked() + panel.close() + noctalia.runAsync("noctalia msg settings-open plugins", function(_result) end, 5000) + noctalia.notify(noctalia.tr("collector.settings_title"), noctalia.tr("collector.settings_opened")) +end + +function onCopyCollectorCommandClicked() + local collector = snapshot and snapshot.system_collector or nil + local command = collector and collector.status == "stale" and collector.enable_command + or (collector and collector.install_command or nil) + if command ~= nil and noctalia.copyToClipboard(command, "text/plain") then + noctalia.notify(noctalia.tr("collector.title"), noctalia.tr("dependencies.copied")) + end +end + +function onUninstallCollectorClicked() + if not confirmUninstall then + confirmUninstall = true + render() + return + end + local command = snapshot and snapshot.system_collector and snapshot.system_collector.uninstall_command or nil + if command ~= nil and noctalia.runInTerminal(command) then + noctalia.notify(noctalia.tr("collector.title"), noctalia.tr("collector.uninstall_terminal_opened")) + end + confirmUninstall = false + render() +end + +function onOpen(_context) + opened = true + render() +end + +function onClose() + opened = false + pendingSelfTest = nil + confirmUninstall = false + showCollectorSettings = false +end + +function onRefreshClicked() + local nonce = number(noctalia.state.get("refresh_nonce"), 0) + 1 + noctalia.state.set("refresh_nonce", nonce) +end + +function onInstallDependenciesClicked() + local dependencies = snapshot and snapshot.dependencies or nil + local command = dependencies and dependencies.install_command or nil + if command == nil or command == "" then + noctalia.notifyError(noctalia.tr("dependencies.title"), noctalia.tr("dependencies.manual_install")) + return + end + + noctalia.notify(noctalia.tr("dependencies.install_title"), noctalia.tr("dependencies.install_body")) + noctalia.runInTerminal(command) +end + +function onCopyInstallCommandClicked() + local dependencies = snapshot and snapshot.dependencies or nil + local command = dependencies and dependencies.install_command or nil + if command == nil or command == "" then + return + end + if noctalia.copyToClipboard(command, "text/plain") then + noctalia.notify(noctalia.tr("dependencies.title"), noctalia.tr("dependencies.copied")) + end +end + +function onCloseClicked() + panel.close() +end diff --git a/drive-health/plugin.toml b/drive-health/plugin.toml new file mode 100644 index 0000000..8ed7f41 --- /dev/null +++ b/drive-health/plugin.toml @@ -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 diff --git a/drive-health/scripts/collect_raw.sh b/drive-health/scripts/collect_raw.sh new file mode 100755 index 0000000..c6bfae3 --- /dev/null +++ b/drive-health/scripts/collect_raw.sh @@ -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 &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 diff --git a/drive-health/service.luau b/drive-health/service.luau new file mode 100644 index 0000000..beb1d6b --- /dev/null +++ b/drive-health/service.luau @@ -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 diff --git a/drive-health/tests/alert_harness.lua b/drive-health/tests/alert_harness.lua new file mode 100644 index 0000000..c968052 --- /dev/null +++ b/drive-health/tests/alert_harness.lua @@ -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") diff --git a/drive-health/tests/collector_harness.lua b/drive-health/tests/collector_harness.lua new file mode 100644 index 0000000..f2678c4 --- /dev/null +++ b/drive-health/tests/collector_harness.lua @@ -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") diff --git a/drive-health/tests/fixtures/bin/lsblk b/drive-health/tests/fixtures/bin/lsblk new file mode 100755 index 0000000..845dc11 --- /dev/null +++ b/drive-health/tests/fixtures/bin/lsblk @@ -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 diff --git a/drive-health/tests/fixtures/bin/smartctl b/drive-health/tests/fixtures/bin/smartctl new file mode 100755 index 0000000..7efb840 --- /dev/null +++ b/drive-health/tests/fixtures/bin/smartctl @@ -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 diff --git a/drive-health/tests/history_harness.lua b/drive-health/tests/history_harness.lua new file mode 100644 index 0000000..1674ee6 --- /dev/null +++ b/drive-health/tests/history_harness.lua @@ -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") diff --git a/drive-health/tests/panel_harness.lua b/drive-health/tests/panel_harness.lua new file mode 100644 index 0000000..c01e0c0 --- /dev/null +++ b/drive-health/tests/panel_harness.lua @@ -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") diff --git a/drive-health/tests/test_collect_raw.sh b/drive-health/tests/test_collect_raw.sh new file mode 100755 index 0000000..b6e0126 --- /dev/null +++ b/drive-health/tests/test_collect_raw.sh @@ -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" diff --git a/drive-health/tests/test_packaging.sh b/drive-health/tests/test_packaging.sh new file mode 100644 index 0000000..4972447 --- /dev/null +++ b/drive-health/tests/test_packaging.sh @@ -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" diff --git a/drive-health/tests/widget_harness.lua b/drive-health/tests/widget_harness.lua new file mode 100644 index 0000000..19c5a03 --- /dev/null +++ b/drive-health/tests/widget_harness.lua @@ -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") diff --git a/drive-health/thumbnail.webp b/drive-health/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..e4e9987cf5945cc7e4470c02aa7361b9a1e2c700 GIT binary patch literal 63032 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
  • daR)n(09iHh6o3jG0_B^|NQbJW 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 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