feat: add adaptive SMART refresh controls (#96)

This commit is contained in:
Gustavo Rosa
2026-07-24 14:13:17 -04:00
committed by GitHub
parent 27c6a808da
commit ff289e523f
14 changed files with 512 additions and 89 deletions
+20 -16
View File
@@ -18,17 +18,17 @@ 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`,
`cat`, `chmod`, `mv`, `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.
`smartmontools` package and `lsblk` from `util-linux`. `systemctl` 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.
The dependency card uses the desktop authorization dialog before it runs a
package-manager command for `pacman`, `apt-get`, `dnf`, `zypper`, `apk`,
`xbps-install`, or `emerge`. The exact command remains visible for review.
## Usage
@@ -44,11 +44,12 @@ 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.
Full SMART installation uses the desktop authorization dialog and runs in the
background. After approval, the installer adds a hardened systemd oneshot and
timer. Starting, pausing, removing the collector, and changing its interval
use the same dialog. 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
@@ -66,7 +67,8 @@ do not produce one-scan notification noise.
| 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. |
| `refresh_seconds` | `int` | `30` | Seconds between lightweight user-session refreshes (15–300). This updates drive inventory, mounts, and non-waking sysfs temperatures; the root timer refreshes full SMART data every 15 minutes. |
| `full_smart_refresh_minutes` | `int` | `15` | Minutes between privileged full SMART reads (1–1440). Applying a changed interval requires explicit administrator approval from the collector controls. |
| `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. |
@@ -93,8 +95,8 @@ services communicate through Noctalia state and do not require manual IPC.
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.
package-manager commands are generated locally and require desktop
administrator authorization.
The plugin stores bounded local state in its Noctalia data directory:
@@ -107,6 +109,8 @@ 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`;
- `/usr/local/libexec/noctalia-drive-health/manage-collector.sh`;
- `/usr/local/libexec/noctalia-drive-health/uninstall-collector.sh`;
- `/etc/systemd/system/noctalia-drive-health.service`;
- `/etc/systemd/system/noctalia-drive-health.timer`;
- `/run/noctalia-drive-health/raw.json`.
@@ -121,8 +125,8 @@ 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.
approve the desktop authorization dialog. Removing the Noctalia entry alone
does not silently remove system files.
## Development
+94 -26
View File
@@ -6,9 +6,14 @@
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
-- The root collector performs full SMART reads every 15 minutes. The desktop
-- service refreshes lsblk and sysfs temperatures between those runs, so allow
-- a little scheduler slack before falling back to an expensive direct read.
local CACHE_MAX_AGE_SECONDS = 1200
local LSBLK_INVENTORY_COMMAND = "lsblk --json --bytes --paths --output "
.. "NAME,KNAME,PATH,PKNAME,TYPE,TRAN,ROTA,RM,HOTPLUG,SIZE,LOG-SEC,PHY-SEC,MODEL,SERIAL,FSTYPE,FSSIZE,FSUSED,FSAVAIL,MOUNTPOINTS"
local NVME_DATA_UNIT_BYTES = 512000
local EXPECTED_COLLECTOR_VERSION = "2.0.0"
local EXPECTED_COLLECTOR_VERSION = "2.0.1"
local PREFERENCES_FILE = "drive-preferences.json"
local collecting = false
@@ -48,13 +53,13 @@ local DEPENDENCIES = {
}
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 " },
{ command = "pacman", id = "pacman", label = "pacman", prefix = "pkexec pacman -S --needed --noconfirm " },
{ command = "apt-get", id = "apt", label = "APT", prefix = "pkexec apt-get install --yes " },
{ command = "dnf", id = "dnf", label = "DNF", prefix = "pkexec dnf install --assumeyes " },
{ command = "zypper", id = "zypper", label = "Zypper", prefix = "pkexec zypper --non-interactive install " },
{ command = "apk", id = "apk", label = "APK", prefix = "pkexec apk add " },
{ command = "xbps-install", id = "xbps", label = "XBPS", prefix = "pkexec xbps-install -S -y " },
{ command = "emerge", id = "emerge", label = "Portage", prefix = "pkexec emerge --ask=n " },
}
local function shellQuote(value)
@@ -65,6 +70,11 @@ local function systemCollectorEnabled()
return noctalia.getConfig("system_collector_enabled") == true
end
local function fullSmartRefreshMinutes()
local configured = tonumber(noctalia.getConfig("full_smart_refresh_minutes")) or 15
return math.max(1, math.min(1440, math.floor(configured)))
end
local function numeric(value)
if value == nil or type(value) == "boolean" then
return nil
@@ -711,11 +721,24 @@ local function systemCollectorState(raw, source)
local pluginDirectory = noctalia.pluginDir()
local enabled = systemCollectorEnabled()
local installed = noctalia.fileExists(SYSTEM_LIBEXEC .. "/collect_raw.sh")
local pkexecAvailable = noctalia.commandExists("pkexec")
local reportedVersion = type(raw) == "table" and tostring(raw.collector_version or "") or ""
local version = source == "system-cache" and reportedVersion or ""
local refreshMinutes = fullSmartRefreshMinutes()
local intervalScript = SYSTEM_LIBEXEC .. "/set-collector-interval.sh"
local manageScript = SYSTEM_LIBEXEC .. "/manage-collector.sh"
local uninstallScript = SYSTEM_LIBEXEC .. "/uninstall-collector.sh"
local installScript = pluginDirectory ~= nil
and pluginDirectory .. "/packaging/install-system-collector.sh" or nil
local sourceUninstallScript = pluginDirectory ~= nil
and pluginDirectory .. "/packaging/uninstall-system-collector.sh" or nil
local status
if not enabled then
status = "disabled"
elseif installed and not noctalia.fileExists(manageScript) then
-- Older collectors did not include the fixed, root-owned lifecycle helper.
-- Ask for an upgrade rather than falling back to a shell or terminal.
status = "upgrade-required"
elseif source == "system-cache" and version == EXPECTED_COLLECTOR_VERSION then
status = "healthy"
elseif source == "system-cache" and version ~= EXPECTED_COLLECTOR_VERSION then
@@ -731,20 +754,23 @@ local function systemCollectorState(raw, source)
status = status,
version = version ~= "" and version or nil,
expected_version = EXPECTED_COLLECTOR_VERSION,
smart_refresh_minutes = refreshMinutes,
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,
authorization_available = pkexecAvailable,
install_command = installScript ~= nil and pkexecAvailable and "pkexec "
.. shellQuote(installScript)
.. " --interval-minutes " .. tostring(refreshMinutes) or nil,
uninstall_command = pkexecAvailable and ((noctalia.fileExists(uninstallScript)
and "pkexec " .. shellQuote(uninstallScript))
or (sourceUninstallScript ~= nil and "pkexec " .. shellQuote(sourceUninstallScript))) or nil,
enable_command = installed and noctalia.fileExists(manageScript) and pkexecAvailable
and "pkexec " .. shellQuote(manageScript) .. " start" or nil,
disable_command = installed and noctalia.fileExists(manageScript) and pkexecAvailable
and "pkexec " .. shellQuote(manageScript) .. " pause" or nil,
interval_command = installed and noctalia.fileExists(intervalScript) and pkexecAvailable
and "pkexec " .. shellQuote(intervalScript) .. " " .. tostring(refreshMinutes) or nil,
}
end
@@ -787,10 +813,15 @@ local function normalizeRaw(raw, source)
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
-- sysfs provides a lightweight, non-waking temperature update. Prefer
-- it when available so the bar and temperature alerts remain current
-- between the 15-minute full SMART snapshots.
local liveTemperature = sysfsTemperature(device)
if liveTemperature ~= nil then
values.temperature_c = liveTemperature
values.hotspot_temperature_c = liveTemperature
values.temperature_source = "sysfs"
elseif values.hotspot_temperature_c == nil then
values.hotspot_temperature_c = values.temperature_c
end
local used, available, usagePercent, mountPoints = mountedUsage(block)
@@ -963,7 +994,7 @@ local function checkDependencies()
end
table.sort(signatureParts)
local installCommand = nil
if manager ~= nil and #packages > 0 and noctalia.commandExists("sudo") then
if manager ~= nil and #packages > 0 and noctalia.commandExists("pkexec") then
installCommand = manager.prefix .. table.concat(packages, " ")
end
local signature = table.concat(signatureParts, ",")
@@ -1032,6 +1063,43 @@ local function publish(snapshot, dependencies)
noctalia.state.set("collector_snapshot", snapshot)
end
local function refreshSystemCacheInventory(rawCache, dependencies)
collecting = true
local current = noctalia.state.get("collector_snapshot") or {}
current.collecting = true
current.dependencies = dependencies
noctalia.state.set("collector_snapshot", current)
local launched = noctalia.runAsync(LSBLK_INVENTORY_COMMAND, function(result)
collecting = false
local resolvedDependencies = checkDependencies()
local inventory = result.exitCode == 0 and not result.timedOut
and noctalia.json.decode(result.stdout or "") or nil
if type(inventory) == "table" and type(inventory.blockdevices) == "table" then
rawCache.lsblk = inventory
end
-- A failed lightweight inventory refresh must not discard a healthy SMART
-- cache. It simply leaves connection and mount information at its last
-- known values until the next refresh.
local snapshot, normalizeError = normalizeRaw(rawCache, "system-cache")
if snapshot == nil then
publishError(tostring(normalizeError or "SMART normalization failed."), resolvedDependencies)
return
end
publish(snapshot, resolvedDependencies)
end, 15000)
if not launched then
collecting = false
local snapshot, normalizeError = normalizeRaw(rawCache, "system-cache")
if snapshot == nil then
publishError(tostring(normalizeError or "SMART normalization failed."), dependencies)
else
publish(snapshot, dependencies)
end
end
end
local function collect()
if collecting then
return
@@ -1047,7 +1115,7 @@ local function collect()
if type(rawCache) == "table" and tonumber(rawCache.schema) == 2 then
local snapshot, normalizeError = normalizeRaw(rawCache, "system-cache")
if snapshot ~= nil then
publish(snapshot, dependencies)
refreshSystemCacheInventory(rawCache, dependencies)
return
end
noctalia.log("Raw SMART cache rejected: " .. tostring(normalizeError))
@@ -8,7 +8,41 @@ fi
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
service_name="noctalia-drive-health"
target_user="${SUDO_USER:-${1:-}}"
target_user="${SUDO_USER:-}"
if [[ -z "$target_user" && "${PKEXEC_UID:-}" =~ ^[0-9]+$ ]]; then
target_user="$(id -un "$PKEXEC_UID" 2>/dev/null || true)"
fi
interval_minutes=15
while (($# > 0)); do
case "$1" in
--interval-minutes)
if (($# < 2)); then
echo "--interval-minutes requires a value." >&2
exit 2
fi
interval_minutes="$2"
shift 2
;;
--help)
echo "usage: $0 [--interval-minutes 1-1440] [desktop-user]"
exit 0
;;
*)
if [[ -n "$target_user" ]]; then
echo "Unexpected argument: $1" >&2
exit 2
fi
target_user="$1"
shift
;;
esac
done
if ! [[ "$interval_minutes" =~ ^[0-9]+$ ]] || ((10#$interval_minutes < 1 || 10#$interval_minutes > 1440)); then
echo "Interval must be a whole number of minutes from 1 to 1440." >&2
exit 2
fi
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
@@ -34,6 +68,15 @@ install -Dm0755 \
install -Dm0755 \
"$project_dir/packaging/smart-action.sh" \
"/usr/local/libexec/$service_name/smart-action.sh"
install -Dm0755 \
"$project_dir/packaging/set-collector-interval.sh" \
"/usr/local/libexec/$service_name/set-collector-interval.sh"
install -Dm0755 \
"$project_dir/packaging/manage-collector.sh" \
"/usr/local/libexec/$service_name/manage-collector.sh"
install -Dm0755 \
"$project_dir/packaging/uninstall-system-collector.sh" \
"/usr/local/libexec/$service_name/uninstall-collector.sh"
install -Dm0644 \
"$rendered_service" \
"/etc/systemd/system/$service_name.service"
@@ -44,6 +87,7 @@ install -Dm0644 \
systemctl daemon-reload
systemctl enable --now "$service_name.timer"
systemctl start "$service_name.service"
/usr/local/libexec/$service_name/set-collector-interval.sh "$interval_minutes"
echo "Installed the read-only SMART collector."
echo "Cache: /run/$service_name/raw.json"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
if (( EUID != 0 )); then
echo "Run this command through the administrator authorization dialog." >&2
exit 1
fi
if (($# != 1)) || [[ "$1" != "start" && "$1" != "pause" ]]; then
echo "usage: $0 {start|pause}" >&2
exit 2
fi
service_name="noctalia-drive-health"
case "$1" in
start)
systemctl daemon-reload
systemctl enable --now "$service_name.timer"
systemctl start "$service_name.service"
echo "Started the Noctalia Drive Health collector."
;;
pause)
systemctl disable --now "$service_name.timer"
systemctl stop "$service_name.service"
echo "Paused the Noctalia Drive Health collector."
;;
esac
@@ -3,7 +3,7 @@ Description=Refresh SMART data for Noctalia
[Timer]
OnBootSec=20s
OnUnitActiveSec=30s
OnUnitActiveSec=15min
AccuracySec=5s
Unit=noctalia-drive-health.service
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
if (( EUID != 0 )); then
echo "Run this command with sudo." >&2
exit 1
fi
if (($# != 1)) || ! [[ "$1" =~ ^[0-9]+$ ]] || ((10#$1 < 1 || 10#$1 > 1440)); then
echo "usage: $0 MINUTES (1-1440)" >&2
exit 2
fi
service_name="noctalia-drive-health"
interval_minutes=$((10#$1))
dropin_dir="/etc/systemd/system/$service_name.timer.d"
dropin_path="$dropin_dir/interval.conf"
temporary=$(mktemp)
trap 'rm -f -- "$temporary"' EXIT
printf '[Timer]\nOnUnitActiveSec=\nOnUnitActiveSec=%smin\n' "$interval_minutes" >"$temporary"
install -d -m0755 "$dropin_dir"
install -m0644 "$temporary" "$dropin_path"
systemctl daemon-reload
systemctl restart "$service_name.timer"
systemctl start "$service_name.service"
echo "Noctalia full SMART refresh interval set to $interval_minutes minute(s)."
+149 -16
View File
@@ -11,6 +11,8 @@ local currentIssues = {}
local dismissRequestNonce = 0
local pendingSelfTest = nil
local selfTestLaunch = nil
local intervalApply = nil
local privilegedAction = nil
local confirmUninstall = false
local showCollectorSettings = false
local hiddenSelection = 1
@@ -260,6 +262,18 @@ local function dependencyCard(dependencies)
}))
end
if privilegedAction ~= nil and privilegedAction.scope == "dependencies" then
table.insert(children, ui.label({
text = privilegedAction.state == "authorizing"
and noctalia.tr("privileged_action.authorizing", { action = noctalia.tr("dependencies.action_install") })
or noctalia.tr("privileged_action.failed", { action = noctalia.tr("dependencies.action_install"),
error = tostring(privilegedAction.error or "") }),
fontSize = 10,
color = privilegedAction.state == "authorizing" and "secondary" or "error",
maxLines = 2,
}))
end
local actions = {}
if dependencies.can_install and dependencies.install_command ~= nil then
table.insert(actions, ui.button({
@@ -378,6 +392,15 @@ local function collectorSettingsCard(collector)
table.insert(actions, 1, ui.button({ text = noctalia.tr("collector.stop_service"), variant = "ghost",
controlSize = "sm", onClick = "onPauseCollectorClicked" }))
end
if collector.installed and collector.interval_command ~= nil then
table.insert(actions, 1, ui.button({
text = noctalia.tr("collector.apply_interval", {
minutes = tostring(collector.smart_refresh_minutes or 15),
}), variant = "outline", controlSize = "sm", enabled = (intervalApply == nil
or intervalApply.state ~= "authorizing") and (privilegedAction == nil
or privilegedAction.state ~= "authorizing"), onClick = "onApplyCollectorIntervalClicked",
}))
end
local children = {
ui.row({ gap = 8, align = "center" }, {
@@ -411,12 +434,34 @@ local function collectorSettingsCard(collector)
}),
ui.label({ text = noctalia.tr("collector.settings_hint"), fontSize = 10,
color = "on_surface_variant", maxLines = 2 }),
ui.label({ text = noctalia.tr("collector.interval", {
minutes = tostring(collector.smart_refresh_minutes or 15),
}), fontSize = 10, color = "on_surface_variant" }),
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 intervalApply ~= nil then
table.insert(children, ui.label({
text = intervalApply.state == "authorizing" and noctalia.tr("collector.interval_authorizing")
or noctalia.tr("collector.interval_failed", { error = tostring(intervalApply.error or "") }),
fontSize = 10, color = intervalApply.state == "authorizing" and "secondary" or "error", maxLines = 2,
}))
end
if privilegedAction ~= nil and privilegedAction.scope == "collector" then
local action = noctalia.tr("collector.action_" .. privilegedAction.kind)
table.insert(children, ui.label({
text = privilegedAction.state == "authorizing"
and noctalia.tr("privileged_action.authorizing", { action = action })
or noctalia.tr("privileged_action.failed", { action = action,
error = tostring(privilegedAction.error or "") }),
fontSize = 10,
color = privilegedAction.state == "authorizing" and "secondary" or "error",
maxLines = 2,
}))
end
if collector.installed then
table.insert(children, ui.button({ text = noctalia.tr("collector.remove"),
variant = confirmUninstall and "destructive" or "ghost", controlSize = "sm",
@@ -1448,27 +1493,113 @@ function onConfirmSelfTestClicked()
end
end
local function privilegedActionError(result)
if result.timedOut == true then
return noctalia.tr("privileged_action.timeout")
end
if tonumber(result.exitCode) == 126 then
return noctalia.tr("privileged_action.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("privileged_action.command_failed")
end
local function runPrivilegedAction(command, details)
if command == nil or command == "" or (privilegedAction ~= nil and privilegedAction.state == "authorizing")
or (intervalApply ~= nil and intervalApply.state == "authorizing") then
return
end
privilegedAction = { state = "authorizing", scope = details.scope, kind = details.kind }
render()
local launched = noctalia.runAsync(command, function(result)
if result.timedOut ~= true and tonumber(result.exitCode) == 0 then
privilegedAction = nil
noctalia.notify(details.title, noctalia.tr("privileged_action.completed", { action = details.action }))
local nonce = number(noctalia.state.get("refresh_nonce"), 0) + 1
noctalia.state.set("refresh_nonce", nonce)
else
privilegedAction = {
state = "failed", scope = details.scope, kind = details.kind,
error = privilegedActionError(result),
}
noctalia.notifyError(details.title, noctalia.tr("privileged_action.failed", {
action = details.action, error = privilegedAction.error,
}))
end
render()
end, 120000)
if not launched then
privilegedAction = {
state = "failed", scope = details.scope, kind = details.kind,
error = noctalia.tr("privileged_action.launch_failed"),
}
noctalia.notifyError(details.title, noctalia.tr("privileged_action.failed", {
action = details.action, error = privilegedAction.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
runPrivilegedAction(command, {
scope = "collector", kind = "install", title = noctalia.tr("collector.title"),
action = noctalia.tr("collector.action_install"),
})
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
runPrivilegedAction(command, {
scope = "collector", kind = "start", title = noctalia.tr("collector.title"),
action = noctalia.tr("collector.action_start"),
})
end
function onPauseCollectorClicked()
local command = snapshot and snapshot.system_collector and snapshot.system_collector.disable_command or nil
runPrivilegedAction(command, {
scope = "collector", kind = "pause", title = noctalia.tr("collector.title"),
action = noctalia.tr("collector.action_pause"),
})
end
function onApplyCollectorIntervalClicked()
if (intervalApply ~= nil and intervalApply.state == "authorizing")
or (privilegedAction ~= nil and privilegedAction.state == "authorizing") then return end
local command = snapshot and snapshot.system_collector and snapshot.system_collector.interval_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"))
intervalApply = { state = "authorizing" }
render()
local launched = noctalia.runAsync(command, function(result)
if result.timedOut ~= true and tonumber(result.exitCode) == 0 then
intervalApply = nil
noctalia.notify(noctalia.tr("collector.title"), noctalia.tr("collector.interval_applied"))
local nonce = number(noctalia.state.get("refresh_nonce"), 0) + 1
noctalia.state.set("refresh_nonce", nonce)
else
local error
if result.timedOut == true then
error = noctalia.tr("collector.interval_timeout")
elseif tonumber(result.exitCode) == 126 then
error = noctalia.tr("collector.interval_cancelled")
else
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
error = detail ~= "" and detail or noctalia.tr("collector.interval_command_failed")
end
intervalApply = { state = "failed", error = error }
noctalia.notifyError(noctalia.tr("collector.title"), error)
end
render()
end, 120000)
if not launched then
intervalApply = { state = "failed", error = noctalia.tr("collector.interval_launch_failed") }
noctalia.notifyError(noctalia.tr("collector.title"), intervalApply.error)
render()
end
end
@@ -1500,11 +1631,11 @@ function onUninstallCollectorClicked()
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()
runPrivilegedAction(command, {
scope = "collector", kind = "remove", title = noctalia.tr("collector.title"),
action = noctalia.tr("collector.action_remove"),
})
end
function onOpen(_context)
@@ -1532,8 +1663,10 @@ function onInstallDependenciesClicked()
return
end
noctalia.notify(noctalia.tr("dependencies.install_title"), noctalia.tr("dependencies.install_body"))
noctalia.runInTerminal(command)
runPrivilegedAction(command, {
scope = "dependencies", kind = "install", title = noctalia.tr("dependencies.title"),
action = noctalia.tr("dependencies.action_install"),
})
end
function onCopyInstallCommandClicked()
+11 -2
View File
@@ -1,11 +1,11 @@
id = "gustav0ar/drive-health"
name = "Drive Health"
version = "1.2.1"
version = "1.2.2"
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"]
dependencies = ["lsblk", "smartctl", "sh", "date", "dirname", "mkdir", "mktemp", "rm", "sed", "cat", "chmod", "mv", "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."
@@ -26,6 +26,15 @@ default = 30
min = 15
max = 300
[[setting]]
key = "full_smart_refresh_minutes"
type = "int"
label_key = "settings.full_smart_refresh_minutes.label"
description_key = "settings.full_smart_refresh_minutes.description"
default = 15
min = 1
max = 1440
[[setting]]
key = "warning_temperature"
type = "int"
+1 -1
View File
@@ -8,7 +8,7 @@ set -eu
LC_ALL=C
export LC_ALL
collector_version="2.0.0"
collector_version="2.0.1"
generated_at_epoch=$(date +%s)
collection_id=""
if [ -r /proc/sys/kernel/random/uuid ]; then
+31 -6
View File
@@ -26,13 +26,17 @@ local available = {
local rawFixture = {
schema = 2,
collector_version = mode == "outdated-raw-cache" and "0.6.0" or "2.0.0",
collector_version = mode == "outdated-raw-cache" and "0.6.0" or "2.0.1",
collection_id = "fixture-collection-id",
generated_at_epoch = 1700000000,
lsblk = { blockdevices = {} },
smart = {},
}
if mode == "raw-cache" or mode == "outdated-raw-cache" then
files["/usr/local/libexec/noctalia-drive-health/manage-collector.sh"] = "installed"
end
local function translate(key, substitutions)
local value = key
for name, replacement in pairs(substitutions or {}) do
@@ -47,6 +51,7 @@ noctalia = {
if key == "system_collector_enabled" then
return collectorEnabled
end
if key == "full_smart_refresh_minutes" then return 15 end
return nil
end,
pluginDir = function() return "/mock/plugin" end,
@@ -166,14 +171,14 @@ elseif mode == "probe-timeout" then
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(dependencies.install_command == "pkexec pacman -S --needed --noconfirm 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(dependencies.install_command == "pkexec pacman -S --needed --noconfirm 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
@@ -181,8 +186,9 @@ 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",
and snapshot.system_collector.version == "2.0.1"
and snapshot.system_collector.expected_version == "2.0.1"
and snapshot.system_collector.smart_refresh_minutes == 15,
"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)
@@ -191,7 +197,7 @@ 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",
and snapshot.system_collector.expected_version == "2.0.1",
"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"
@@ -465,6 +471,24 @@ local healthyRaw = assert(normalizeRaw({
assert(healthyRaw.disks[1].smart_available == true and healthyRaw.disks[1].smart_error == nil,
"healthy SMART data retained a contradictory error message")
directories["/sys/class/nvme/nvme0"] = { "hwmon0" }
files["/sys/class/nvme/nvme0/hwmon0/temp1_input"] = "51000\n"
local liveTemperatureRaw = assert(normalizeRaw({
schema = 2, generated_at_epoch = 1700000000,
lsblk = { blockdevices = { {
name = "nvme0n1", kname = "nvme0n1", path = "/dev/nvme0n1", type = "disk", tran = "nvme",
rota = false, size = 1000000000, model = "Live Temperature NVMe", serial = "LIVE1",
mountpoints = {}, children = {},
} } },
smart = { { requested_device = "/dev/nvme0", payload = {
smart_status = { passed = true }, temperature = { current = 42 },
} } },
}, "test"))
assert(liveTemperatureRaw.disks[1].temperature_c == 51
and liveTemperatureRaw.disks[1].hotspot_temperature_c == 51
and liveTemperatureRaw.disks[1].temperature_source == "sysfs",
"fresh sysfs temperature did not override the stale SMART-cache value")
local sleeping = assert(normalizeRaw({
schema = 2, generated_at_epoch = 1700000000,
lsblk = { blockdevices = { {
@@ -511,6 +535,7 @@ local oversizedId = assert(normalizeRaw({
assert(oversizedId.collection_id == nil, "oversized collection ID was preserved")
files["/usr/local/libexec/noctalia-drive-health/collect_raw.sh"] = "installed"
files["/usr/local/libexec/noctalia-drive-health/manage-collector.sh"] = "installed"
collectorEnabled = true
state.collector_snapshot = { summary = {}, system_collector = { status = "healthy" } }
publishError("fixture failure", { ready = true, blocking = false })
+60 -7
View File
@@ -77,10 +77,12 @@ state.snapshot = {
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'" },
enable_command = "pkexec '/usr/local/libexec/noctalia-drive-health/manage-collector.sh' start",
disable_command = "pkexec '/usr/local/libexec/noctalia-drive-health/manage-collector.sh' pause",
install_command = "pkexec '/mock/plugin/packaging/install-system-collector.sh' --interval-minutes 15",
uninstall_command = "pkexec '/usr/local/libexec/noctalia-drive-health/uninstall-collector.sh'",
interval_command = "pkexec '/usr/local/libexec/noctalia-drive-health/set-collector-interval.sh' 15",
smart_refresh_minutes = 15 },
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,
@@ -180,8 +182,14 @@ assert(containsText(rendered, "collector.settings_title")
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")
assert(asyncCommand == "pkexec '/usr/local/libexec/noctalia-drive-health/manage-collector.sh' pause",
"collector settings did not use the fixed Polkit pause helper")
assert(terminalCommand == nil, "collector pause opened a terminal")
assert(containsText(rendered, "privileged_action.authorizing"),
"collector pause did not show authorization progress")
asyncCallback({ exitCode = 126, stdout = "", stderr = "", timedOut = false })
assert(errors[#errors].body:match("privileged_action.failed"),
"cancelled collector pause did not report a useful error")
terminalCommand = nil
onOpenPluginSettingsClicked()
assert(asyncCommand == "noctalia msg settings-open plugins",
@@ -383,10 +391,20 @@ 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,
install_command = "pkexec pacman -S --needed --noconfirm util-linux", package_manager = "pacman", can_install = true,
}
watchers.snapshot(state.snapshot)
assert(containsText(rendered, "dependencies.title"), "missing dependency card did not render")
asyncCommand = nil
asyncCallback = nil
terminalCommand = nil
onInstallDependenciesClicked()
assert(asyncCommand == "pkexec pacman -S --needed --noconfirm util-linux",
"dependency installation did not use Polkit")
assert(terminalCommand == nil, "dependency installation opened a terminal")
asyncCallback({ exitCode = 126, stdout = "", stderr = "", timedOut = false })
assert(errors[#errors].body:match("privileged_action.failed"),
"cancelled dependency installation did not report an error")
state.snapshot.dependencies = { ready = true }
state.snapshot.disks = { state.snapshot.disks[2] }
@@ -401,4 +419,39 @@ assert(not containsText(rendered, "summary.lowest_ssd_life"),
assert(countText(rendered, "Fixture HDD") >= 2,
"HDD-only temperature summary did not identify its drive")
-- Changing the full-SMART schedule uses Polkit in the background, rather than
-- placing a password-bearing command in a terminal. Keep the panel open so
-- the in-progress and error states are visible to the user.
onToggleCollectorSettingsClicked()
asyncCommand = nil
asyncCallback = nil
terminalCommand = nil
local refreshNonceBeforeInterval = state.refresh_nonce or 0
onApplyCollectorIntervalClicked()
assert(asyncCommand == "pkexec '/usr/local/libexec/noctalia-drive-health/set-collector-interval.sh' 15",
"collector interval update did not use the Polkit helper")
assert(terminalCommand == nil, "collector interval update opened a terminal")
assert(containsText(rendered, "collector.interval_authorizing"),
"collector interval authorization progress was not shown")
local intervalSuccess = assert(asyncCallback, "collector interval callback was not registered")
intervalSuccess({ exitCode = 0, stdout = "", stderr = "", timedOut = false })
assert((state.refresh_nonce or 0) == refreshNonceBeforeInterval + 1,
"successful collector interval update did not refresh the panel")
assert(notifications[#notifications].body == "collector.interval_applied",
"successful collector interval update did not notify the user")
onApplyCollectorIntervalClicked()
local intervalCancelled = assert(asyncCallback, "second collector interval callback was not registered")
intervalCancelled({ exitCode = 126, stdout = "", stderr = "", timedOut = false })
assert(containsText(rendered, "collector.interval_failed"),
"cancelled collector interval authorization did not render an inline result")
assert(errors[#errors].body == "collector.interval_cancelled",
"cancelled collector interval authorization did not notify the user")
onApplyCollectorIntervalClicked()
local intervalRejected = assert(asyncCallback, "failed collector interval callback was not registered")
intervalRejected({ exitCode = 1, stdout = "", stderr = "Authentication failed", timedOut = false })
assert(errors[#errors].body == "Authentication failed",
"collector interval authentication failure did not show the returned error")
print("panel rendering tests passed")
+1 -1
View File
@@ -7,7 +7,7 @@ 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 .collector_version == "2.0.1"
and (.collection_id | type == "string" and length > 0)
and ([.lsblk.blockdevices[].name] | sort) == ["/dev/nvme0n1", "/dev/sda", "/dev/zram0"]
and (.smart | length) == 2
+9 -1
View File
@@ -4,6 +4,8 @@ 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"
interval_script="$project_dir/packaging/set-collector-interval.sh"
manage_script="$project_dir/packaging/manage-collector.sh"
fixture=$(mktemp -d "${TMPDIR:-/tmp}/drive-health-packaging.XXXXXX")
trap 'rm -rf -- "$fixture"' EXIT HUP INT TERM
@@ -14,6 +16,12 @@ 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"
grep -q '^OnUnitActiveSec=15min$' "$fixture/noctalia-drive-health.timer"
bash -n "$interval_script"
bash -n "$manage_script"
grep -q 'packaging/manage-collector.sh' "$project_dir/packaging/install-system-collector.sh"
grep -q 'packaging/uninstall-system-collector.sh' "$project_dir/packaging/install-system-collector.sh"
if grep -R -q 'noctalia-smart-monito[r]' "$project_dir"; then
echo "generic legacy collector namespace must not be read, modified, or removed" >&2
@@ -27,7 +35,7 @@ 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 \
lsblk smartctl sh date dirname mkdir mktemp rm sed cat chmod mv env bash \
install systemctl pkexec id tr pacman apt-get dnf zypper apk xbps-install emerge; do
case "$declared_dependencies" in
*\"$dependency\"*) ;;
+33 -11
View File
@@ -37,31 +37,39 @@
"warning_temperature_time_increase": "{drive} accumulated {count} new minute(s) above its warning temperature."
},
"collector": {
"action_install": "install or upgrade the collector",
"action_pause": "pause the collector",
"action_remove": "remove the collector",
"action_start": "start the collector",
"basic_features": "Drive discovery, mounted folders, storage use, and temperatures exposed by Linux.",
"basic_title": "Basic monitoring — no elevated service",
"copy_install": "Copy command",
"full_features": "Reliable health, endurance, error counters, sensor details, and background self-test progress.",
"full_title": "Full SMART — optional",
"install": "Install collector",
"interval": "Full SMART refresh: every {minutes} min",
"interval_applied": "Full SMART refresh schedule updated.",
"interval_authorizing": "Waiting for administrator approval to update the full SMART schedule…",
"interval_cancelled": "Administrator approval was cancelled.",
"interval_command_failed": "The administrator command did not complete.",
"interval_failed": "Could not update the full SMART schedule: {error}",
"interval_launch_failed": "Could not open the administrator authorization dialog.",
"interval_timeout": "Administrator approval timed out. Try again when you are ready to approve it.",
"open_settings": "Open Plugins page",
"pause": "Pause service",
"pause_terminal_opened": "A terminal opened. Review the command and approve sudo to stop the collector timer.",
"remove": "Remove collector",
"remove_confirm": "Click Remove collector again to open the explicit privileged uninstall command.",
"settings_hint": "Enable or disable Full SMART from Settings → Plugins → Drive Health. Privileged actions always open a terminal for review.",
"remove_confirm": "Click Remove collector again to request authorization for the explicit uninstall action.",
"settings_hint": "Enable or disable Full SMART from Settings → Plugins → Drive Health. All privileged actions use the desktop authorization dialog.",
"settings_opened": "Opened the Plugins page. Select the gear on Drive Health to change its settings.",
"settings_title": "Collector settings",
"start": "Start collector",
"start_terminal_opened": "A terminal opened. Review the command and approve sudo to enable and refresh the collector.",
"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.",
"stop_service": "Stop background service",
"terminal_opened": "A terminal opened. Review the command and approve sudo to install or upgrade.",
"title": "System collector",
"uninstall_terminal_opened": "A terminal opened. Review the removal command and approve sudo to continue.",
"update_body": "Full SMART is enabled. Update collector {current} to {expected} from the Drive Health panel to keep all features working.",
"update_title": "Drive Health collector update available",
"upgrade": "Upgrade collector"
@@ -75,16 +83,17 @@
"unknown": "unknown"
},
"dependencies": {
"action_install": "install dependencies",
"alert_body": "Install the missing dependencies to enable complete monitoring: {missing}. Open the Drive Health panel to review the command.",
"alert_title": "Drive Health setup required",
"collection_blocked": "Drive collection is paused because required commands are missing: {missing}",
"command": "Suggested {manager} command",
"copied": "Installation command copied.",
"copy_command": "Copy command",
"install": "Open installer",
"install_body": "A terminal is opening with the suggested command. Review it and approve the sudo and package-manager prompts to continue.",
"install_title": "Review the dependency installation",
"manual_install": "No supported package manager with sudo was detected. Install the listed commands manually, then recheck.",
"install": "Install",
"install_body": "The desktop administrator authorization dialog will request approval before installing the required packages.",
"install_title": "Install dependencies",
"manual_install": "No supported package manager with desktop authorization was detected. Install the listed commands manually, then recheck.",
"missing": "Missing commands: {missing}",
"package_manager": "package manager",
"recheck": "Recheck",
@@ -160,6 +169,15 @@
"title": "Drive preferences",
"warning_temperature": "Warning °C"
},
"privileged_action": {
"authorizing": "Waiting for administrator approval to {action}…",
"cancelled": "Administrator approval was cancelled.",
"command_failed": "The administrator command did not complete.",
"completed": "Completed: {action}.",
"failed": "Could not {action}: {error}",
"launch_failed": "Could not open the administrator authorization dialog.",
"timeout": "Administrator approval timed out. Try again when you are ready to approve it."
},
"self_test": {
"authorization_cancelled": "Administrator approval was cancelled.",
"authorization_required": "Polkit (pkexec) is required for background self-tests. Install your distribution's polkit package.",
@@ -199,6 +217,10 @@
"description": "Warn when an established internal drive disappears for several scans.",
"label": "Missing-drive alerts"
},
"full_smart_refresh_minutes": {
"description": "Minutes between privileged full SMART reads. Default: 15 minutes. Change the value here, then use Apply schedule in the collector controls and approve the administrator prompt.",
"label": "Full SMART refresh interval"
},
"history_interval_minutes": {
"description": "Minutes between persisted temperature and endurance samples.",
"label": "History sample interval"
@@ -220,7 +242,7 @@
"label": "Recovery notifications"
},
"refresh_seconds": {
"description": "Seconds between plugin refreshes. The optional root collector refreshes its cache every 30 seconds independently.",
"description": "Seconds between lightweight plugin refreshes for drive inventory, mounts, and available sysfs temperatures.",
"label": "Refresh interval"
},
"show_hdd": {