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