feat: add adaptive SMART refresh controls (#96)
This commit is contained in:
+20
-16
@@ -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
@@ -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"
|
||||
|
||||
Executable
+28
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\"*) ;;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user