1319 lines
50 KiB
Luau
1319 lines
50 KiB
Luau
--!nonstrict
|
|
|
|
-- Collection and normalization service. Raw device access lives in the small
|
|
-- POSIX script; this entry owns all platform-neutral SMART interpretation.
|
|
|
|
local SYSTEM_NAMESPACE = "noctalia-drive-health"
|
|
local SYSTEM_LIBEXEC = "/usr/local/libexec/" .. SYSTEM_NAMESPACE
|
|
local RAW_CACHE = "/run/" .. SYSTEM_NAMESPACE .. "/raw.json"
|
|
-- 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.1"
|
|
local PREFERENCES_FILE = "drive-preferences.json"
|
|
|
|
local collecting = false
|
|
local refreshNonce = 0
|
|
local loggedDependencySignature = nil
|
|
local notifiedCollectorUpgradeSignature = nil
|
|
local dependencyProbe = {
|
|
checked = false,
|
|
running = false,
|
|
forcedPending = false,
|
|
incompatible = {},
|
|
}
|
|
local dependencyProbeGeneration = tonumber(noctalia.state.get("dependency_probe_generation")) or 0
|
|
local drivePreferences = { schema = 1, order = {}, drives = {} }
|
|
|
|
local DEPENDENCIES = {
|
|
{
|
|
command = "lsblk",
|
|
label = "lsblk",
|
|
blocking = true,
|
|
packages = {
|
|
pacman = "util-linux", apt = "util-linux", dnf = "util-linux",
|
|
zypper = "util-linux", apk = "util-linux", xbps = "util-linux",
|
|
emerge = "sys-apps/util-linux",
|
|
},
|
|
},
|
|
{
|
|
command = "smartctl",
|
|
label = "smartctl",
|
|
blocking = false,
|
|
packages = {
|
|
pacman = "smartmontools", apt = "smartmontools", dnf = "smartmontools",
|
|
zypper = "smartmontools", apk = "smartmontools", xbps = "smartmontools",
|
|
emerge = "sys-apps/smartmontools",
|
|
},
|
|
},
|
|
}
|
|
|
|
local PACKAGE_MANAGERS = {
|
|
{ command = "pacman", id = "pacman", label = "pacman", prefix = "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)
|
|
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
|
|
end
|
|
|
|
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
|
|
end
|
|
if type(value) == "number" then
|
|
if value == value and value ~= math.huge and value ~= -math.huge then
|
|
return value
|
|
end
|
|
return nil
|
|
end
|
|
if type(value) ~= "string" then
|
|
return nil
|
|
end
|
|
local cleaned = value:gsub(",", "")
|
|
local token = cleaned:match("%-?%d+%.?%d*")
|
|
return token ~= nil and tonumber(token) or nil
|
|
end
|
|
|
|
local function integer(value)
|
|
local parsed = numeric(value)
|
|
return parsed ~= nil and math.floor(parsed) or nil
|
|
end
|
|
|
|
local function validCollectionId(value)
|
|
if type(value) ~= "string" then
|
|
return nil
|
|
end
|
|
local trimmed = noctalia.string.trim(value)
|
|
if trimmed == "" or #trimmed > 128 or trimmed:find("[%c]") ~= nil then
|
|
return nil
|
|
end
|
|
return trimmed
|
|
end
|
|
|
|
local function clamp(value, minimum, maximum)
|
|
return math.max(minimum, math.min(maximum, value))
|
|
end
|
|
|
|
local function bitSet(value, bit)
|
|
local parsed = integer(value)
|
|
return parsed ~= nil and math.floor(parsed / (2 ^ bit)) % 2 == 1
|
|
end
|
|
|
|
local function validTemperature(value)
|
|
local parsed = numeric(value)
|
|
return parsed ~= nil and parsed >= -20 and parsed <= 150 and parsed or nil
|
|
end
|
|
|
|
local function smartDeviceFor(path)
|
|
local controller = tostring(path):match("^/dev/(nvme%d+)n%d+$")
|
|
return controller ~= nil and "/dev/" .. controller or tostring(path)
|
|
end
|
|
|
|
local function deviceBasename(value)
|
|
local normalized = tostring(value or ""):gsub("/+$", "")
|
|
return normalized:match("([^/]+)$") or ""
|
|
end
|
|
|
|
local function listDirectory(path)
|
|
local entries = noctalia.listDir(path)
|
|
return type(entries) == "table" and entries or {}
|
|
end
|
|
|
|
local function readTemperatureFile(path)
|
|
local raw = noctalia.readFile(path)
|
|
local value = raw ~= nil and tonumber(noctalia.string.trim(raw)) or nil
|
|
if value == nil then
|
|
return nil
|
|
end
|
|
local celsius = math.abs(value) >= 1000 and value / 1000 or value
|
|
if celsius < -20 or celsius > 150 then
|
|
return nil
|
|
end
|
|
return math.floor(celsius * 10 + 0.5) / 10
|
|
end
|
|
|
|
local function sysfsTemperature(devicePath)
|
|
local name = tostring(devicePath):match("([^/]+)$") or tostring(devicePath)
|
|
local candidates = {}
|
|
local controller = name:match("^(nvme%d+)n%d+$")
|
|
if controller ~= nil then
|
|
local controllerPath = "/sys/class/nvme/" .. controller
|
|
for _, entry in ipairs(listDirectory(controllerPath)) do
|
|
if entry:match("^hwmon") then
|
|
table.insert(candidates, controllerPath .. "/" .. entry .. "/temp1_input")
|
|
end
|
|
end
|
|
local hwmonPath = controllerPath .. "/device/hwmon"
|
|
for _, entry in ipairs(listDirectory(hwmonPath)) do
|
|
if entry:match("^hwmon") then
|
|
table.insert(candidates, hwmonPath .. "/" .. entry .. "/temp1_input")
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, entry in ipairs(listDirectory("/sys/class/hwmon")) do
|
|
if entry:match("^hwmon") then
|
|
local root = "/sys/class/hwmon/" .. entry
|
|
local sensorName = noctalia.readFile(root .. "/name")
|
|
if sensorName ~= nil and noctalia.string.trim(sensorName) == "drivetemp"
|
|
and noctalia.fileExists(root .. "/device/block/" .. name) then
|
|
table.insert(candidates, root .. "/temp1_input")
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, path in ipairs(candidates) do
|
|
local temperature = readTemperatureFile(path)
|
|
if temperature ~= nil then
|
|
return temperature
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function walkBlockDevices(nodes, callback)
|
|
for _, node in ipairs(nodes or {}) do
|
|
callback(node)
|
|
walkBlockDevices(node.children, callback)
|
|
end
|
|
end
|
|
|
|
local function mountedUsage(root)
|
|
local used = 0
|
|
local available = 0
|
|
local found = false
|
|
local seen = {}
|
|
local mountPoints = {}
|
|
local seenMountPoints = {}
|
|
walkBlockDevices({ root }, function(node)
|
|
local hasMount = false
|
|
for _, mountpoint in ipairs(node.mountpoints or {}) do
|
|
if type(mountpoint) == "string" and mountpoint ~= "" and mountpoint ~= "[SWAP]" then
|
|
hasMount = true
|
|
if not seenMountPoints[mountpoint] then
|
|
seenMountPoints[mountpoint] = true
|
|
table.insert(mountPoints, mountpoint)
|
|
end
|
|
end
|
|
end
|
|
local identity = tostring(node.kname or node.path or "")
|
|
local fsUsed = integer(node.fsused)
|
|
local fsAvailable = integer(node.fsavail)
|
|
if hasMount and identity ~= "" and not seen[identity] and fsUsed ~= nil and fsAvailable ~= nil then
|
|
seen[identity] = true
|
|
used += fsUsed
|
|
available += fsAvailable
|
|
found = true
|
|
end
|
|
end)
|
|
table.sort(mountPoints, function(left, right)
|
|
if left == right then return false end
|
|
if left == "/" then return true end
|
|
if right == "/" then return false end
|
|
return left < right
|
|
end)
|
|
if not found then
|
|
return nil, nil, nil, mountPoints
|
|
end
|
|
local total = used + available
|
|
local percent = total > 0 and math.floor((used / total * 100) * 10 + 0.5) / 10 or 0
|
|
return used, available, percent, mountPoints
|
|
end
|
|
|
|
local function attributeMap(smart)
|
|
local result = {}
|
|
local ata = type(smart.ata_smart_attributes) == "table" and smart.ata_smart_attributes or {}
|
|
for _, item in ipairs(ata.table or {}) do
|
|
if type(item) == "table" and item.name ~= nil then
|
|
result[tostring(item.name):lower()] = item
|
|
end
|
|
end
|
|
return result
|
|
end
|
|
|
|
local function rawAttribute(attribute)
|
|
if type(attribute) ~= "table" then
|
|
return nil
|
|
end
|
|
local raw = attribute.raw
|
|
return type(raw) == "table" and integer(raw.value) or integer(raw)
|
|
end
|
|
|
|
local function firstAttribute(attributes, names)
|
|
for _, name in ipairs(names) do
|
|
local attribute = attributes[tostring(name):lower()]
|
|
if attribute ~= nil then
|
|
return attribute
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function maximumRawAttribute(attributes, names)
|
|
local maximum = nil
|
|
for _, name in ipairs(names) do
|
|
local value = rawAttribute(attributes[tostring(name):lower()])
|
|
if value ~= nil then
|
|
maximum = maximum == nil and value or math.max(maximum, value)
|
|
end
|
|
end
|
|
return maximum
|
|
end
|
|
|
|
local function remainingLife(attributes)
|
|
local remaining = firstAttribute(attributes, {
|
|
"Percent_Lifetime_Remain", "SSD_Life_Left", "Media_Wearout_Indicator",
|
|
"Wear_Leveling_Count", "Remaining_Lifetime_Perc", "Remaining_Life",
|
|
"Lifetime_Remaining%", "Drive_Life_Remaining%",
|
|
})
|
|
if remaining ~= nil then
|
|
local value = numeric(remaining.value)
|
|
if value ~= nil then
|
|
return math.floor(clamp(value, 0, 100) * 10 + 0.5) / 10, true
|
|
end
|
|
end
|
|
local used = rawAttribute(firstAttribute(attributes, {
|
|
"Percentage_Used_Endurance_Indicator", "Percent_Lifetime_Used", "SSD_Life_Used",
|
|
}))
|
|
return used ~= nil and math.floor(clamp(100 - used, 0, 100) * 10 + 0.5) / 10 or nil, used ~= nil
|
|
end
|
|
|
|
local function lbaBytes(attributes, names, blockSize)
|
|
local count = rawAttribute(firstAttribute(attributes, names))
|
|
return count ~= nil and count * blockSize or nil
|
|
end
|
|
|
|
local function ataDataBytes(attributes, direction, blockSize)
|
|
local lbaNames
|
|
local gibNames
|
|
local chunkNames
|
|
if direction == "written" then
|
|
lbaNames = { "Total_LBAs_Written" }
|
|
gibNames = { "Lifetime_Writes_GiB", "Host_Writes_GiB", "Total_Writes_GiB", "Total_NAND_Writes_GiB", "TLC_NAND_GB_Writes" }
|
|
chunkNames = { "Host_Writes_32MiB" }
|
|
else
|
|
lbaNames = { "Total_LBAs_Read" }
|
|
gibNames = { "Lifetime_Reads_GiB", "Host_Reads_GiB", "Total_Reads_GiB" }
|
|
chunkNames = { "Host_Reads_32MiB" }
|
|
end
|
|
local bytes = lbaBytes(attributes, lbaNames, blockSize)
|
|
if bytes ~= nil then
|
|
return bytes
|
|
end
|
|
local gib = rawAttribute(firstAttribute(attributes, gibNames))
|
|
if gib ~= nil then
|
|
return gib * 1024 * 1024 * 1024
|
|
end
|
|
local chunks = rawAttribute(firstAttribute(attributes, chunkNames))
|
|
return chunks ~= nil and chunks * 32 * 1024 * 1024 or nil
|
|
end
|
|
|
|
local function classifySelfTest(text, passed, value)
|
|
local normalized = tostring(text or ""):lower()
|
|
if passed == true or tonumber(value) == 0 then
|
|
return "passed"
|
|
elseif normalized:find("progress", 1, true) or normalized:find("in progress", 1, true) then
|
|
return "running"
|
|
elseif normalized:find("interrupt", 1, true) or normalized:find("abort", 1, true) then
|
|
return "interrupted"
|
|
elseif normalized ~= "" then
|
|
return "failed"
|
|
end
|
|
return "unknown"
|
|
end
|
|
|
|
local function normalizeSelfTest(smart)
|
|
local ata = type(smart.ata_smart_self_test_log) == "table" and smart.ata_smart_self_test_log or {}
|
|
local standard = type(ata.standard) == "table" and ata.standard or {}
|
|
local ataLatest = type(standard.table) == "table" and standard.table[1] or nil
|
|
if type(ataLatest) == "table" then
|
|
local status = type(ataLatest.status) == "table" and ataLatest.status or {}
|
|
local remaining = numeric(status.remaining_percent)
|
|
return {
|
|
supported = true,
|
|
state = classifySelfTest(status.string, status.passed, status.value),
|
|
status = tostring(status.string or "Unknown"),
|
|
test_type = type(ataLatest.type) == "table" and ataLatest.type.string or nil,
|
|
lifetime_hours = integer(ataLatest.lifetime_hours),
|
|
completion_percent = remaining ~= nil and clamp(100 - remaining, 0, 100) or nil,
|
|
error_count = integer(standard.error_count_total),
|
|
}
|
|
elseif next(standard) ~= nil then
|
|
return { supported = true, state = "never", status = noctalia.tr("self_test.none_recorded") }
|
|
end
|
|
|
|
local nvme = type(smart.nvme_self_test_log) == "table" and smart.nvme_self_test_log or {}
|
|
if next(nvme) ~= nil then
|
|
local operation = type(nvme.current_self_test_operation) == "table" and nvme.current_self_test_operation or {}
|
|
if integer(operation.value) ~= nil and integer(operation.value) ~= 0 then
|
|
return {
|
|
supported = true,
|
|
state = "running",
|
|
status = tostring(operation.string or noctalia.tr("self_test.in_progress")),
|
|
completion_percent = numeric(nvme.current_self_test_completion_percent),
|
|
}
|
|
end
|
|
local latest = type(nvme.table) == "table" and nvme.table[1] or nil
|
|
if type(latest) == "table" then
|
|
local result = type(latest.self_test_result) == "table" and latest.self_test_result or {}
|
|
return {
|
|
supported = true,
|
|
state = classifySelfTest(result.string, result.passed, result.value),
|
|
status = tostring(result.string or "Unknown"),
|
|
test_type = type(latest.self_test_code) == "table" and latest.self_test_code.string or nil,
|
|
lifetime_hours = integer(latest.power_on_hours),
|
|
}
|
|
end
|
|
return { supported = true, state = "never", status = noctalia.tr("self_test.none_recorded") }
|
|
end
|
|
return { supported = false, state = "unsupported", status = noctalia.tr("self_test.log_unavailable") }
|
|
end
|
|
|
|
local function normalizeSmart(smart, blockSize)
|
|
smart = type(smart) == "table" and smart or {}
|
|
blockSize = blockSize or 512
|
|
local nvme = type(smart.nvme_smart_health_information_log) == "table"
|
|
and smart.nvme_smart_health_information_log or {}
|
|
local attributes = attributeMap(smart)
|
|
local isNvme = next(nvme) ~= nil
|
|
local smartctl = type(smart.smartctl) == "table" and smart.smartctl or {}
|
|
local powerMode = type(smart.power_mode) == "table" and smart.power_mode or {}
|
|
local powerModeText = tostring(powerMode.string or ""):lower()
|
|
local smartSleeping = powerModeText:find("standby", 1, true) ~= nil
|
|
or powerModeText:find("sleep", 1, true) ~= nil
|
|
local smartExitStatus = integer(smartctl.exit_status) or integer(smart._collector_exit_code)
|
|
local status = type(smart.smart_status) == "table" and smart.smart_status.passed or nil
|
|
if bitSet(smartExitStatus, 3) or bitSet(smartExitStatus, 4) then
|
|
status = false
|
|
end
|
|
local health = status == true and "passed" or (status == false and "failed" or "unknown")
|
|
local temperatureTable = type(smart.temperature) == "table" and smart.temperature or {}
|
|
local temperature = validTemperature(temperatureTable.current)
|
|
if temperature == nil and isNvme then
|
|
temperature = validTemperature(nvme.temperature)
|
|
end
|
|
if temperature == nil then
|
|
temperature = rawAttribute(firstAttribute(attributes, {
|
|
"Temperature_Celsius", "Airflow_Temperature_Cel", "Temperature_Internal",
|
|
}))
|
|
end
|
|
temperature = validTemperature(temperature)
|
|
local temperatureSensors = {}
|
|
if type(nvme.temperature_sensors) == "table" then
|
|
for _, sensor in ipairs(nvme.temperature_sensors) do
|
|
local parsed = validTemperature(sensor)
|
|
if parsed ~= nil then
|
|
table.insert(temperatureSensors, parsed)
|
|
end
|
|
end
|
|
end
|
|
local hotspotTemperature = temperature
|
|
for _, sensor in ipairs(temperatureSensors) do
|
|
hotspotTemperature = hotspotTemperature == nil and sensor or math.max(hotspotTemperature, sensor)
|
|
end
|
|
local smartMessages = {}
|
|
local hasCollectionError = bitSet(smartExitStatus, 0) or bitSet(smartExitStatus, 1) or bitSet(smartExitStatus, 2)
|
|
for _, message in ipairs(smartctl.messages or {}) do
|
|
if type(message) == "table" then
|
|
local text = tostring(message.string or "")
|
|
if text ~= "" then
|
|
table.insert(smartMessages, { severity = tostring(message.severity or "info"), message = text })
|
|
if tostring(message.severity or ""):lower() == "error" then
|
|
hasCollectionError = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local percentageUsed
|
|
local life
|
|
local lifeEstimated = false
|
|
local dataRead
|
|
local dataWritten
|
|
local powerOnHours
|
|
local powerCycles
|
|
local unsafeShutdowns
|
|
local mediaErrors
|
|
local errorEntries
|
|
local spare
|
|
local criticalWarning
|
|
local spareThreshold
|
|
local warningTemperatureTime
|
|
local criticalTemperatureTime
|
|
|
|
if isNvme then
|
|
percentageUsed = numeric(nvme.percentage_used)
|
|
life = percentageUsed ~= nil and math.floor(clamp(100 - percentageUsed, 0, 100) * 10 + 0.5) / 10 or nil
|
|
local readUnits = integer(nvme.data_units_read)
|
|
local writtenUnits = integer(nvme.data_units_written)
|
|
dataRead = readUnits ~= nil and readUnits * NVME_DATA_UNIT_BYTES or nil
|
|
dataWritten = writtenUnits ~= nil and writtenUnits * NVME_DATA_UNIT_BYTES or nil
|
|
powerOnHours = integer(nvme.power_on_hours)
|
|
powerCycles = integer(nvme.power_cycles)
|
|
unsafeShutdowns = integer(nvme.unsafe_shutdowns)
|
|
mediaErrors = integer(nvme.media_errors)
|
|
errorEntries = integer(nvme.num_err_log_entries)
|
|
spare = numeric(nvme.available_spare)
|
|
spareThreshold = numeric(nvme.available_spare_threshold)
|
|
criticalWarning = integer(nvme.critical_warning)
|
|
warningTemperatureTime = integer(nvme.warning_temp_time)
|
|
criticalTemperatureTime = integer(nvme.critical_comp_time)
|
|
else
|
|
life, lifeEstimated = remainingLife(attributes)
|
|
if life == nil then
|
|
local vendorHealth = firstAttribute(attributes, { "Perc_Write/Erase_Count" })
|
|
local vendorValue = type(vendorHealth) == "table" and numeric(vendorHealth.value) or nil
|
|
if vendorValue ~= nil then
|
|
life = math.floor(clamp(vendorValue, 0, 100) * 10 + 0.5) / 10
|
|
lifeEstimated = true
|
|
end
|
|
end
|
|
percentageUsed = life ~= nil and math.floor((100 - life) * 10 + 0.5) / 10 or nil
|
|
dataRead = ataDataBytes(attributes, "read", blockSize)
|
|
dataWritten = ataDataBytes(attributes, "written", blockSize)
|
|
local powerTime = type(smart.power_on_time) == "table" and smart.power_on_time or {}
|
|
powerOnHours = integer(powerTime.hours)
|
|
powerCycles = rawAttribute(firstAttribute(attributes, { "Power_Cycle_Count" }))
|
|
unsafeShutdowns = rawAttribute(firstAttribute(attributes, {
|
|
"Unsafe_Shutdown_Count", "POR_Recovery_Count", "Unexpect_Power_Loss_Ct",
|
|
}))
|
|
local ataLog = type(smart.ata_smart_error_log) == "table" and smart.ata_smart_error_log or {}
|
|
local summary = type(ataLog.summary) == "table" and ataLog.summary or {}
|
|
mediaErrors = nil
|
|
errorEntries = integer(summary.count)
|
|
local spareAttribute = firstAttribute(attributes, { "Perc_Avail_Resrvd_Space" })
|
|
spare = type(spareAttribute) == "table" and numeric(spareAttribute.value) or nil
|
|
end
|
|
|
|
local selfTest = normalizeSelfTest(smart)
|
|
if bitSet(smartExitStatus, 7) and selfTest.state ~= "failed" then
|
|
selfTest.state = "failed"
|
|
selfTest.status = noctalia.tr("self_test.log_failure")
|
|
end
|
|
|
|
return {
|
|
health = health,
|
|
temperature_c = temperature,
|
|
hotspot_temperature_c = hotspotTemperature,
|
|
temperature_sensors_c = #temperatureSensors > 0 and temperatureSensors or nil,
|
|
device_warning_temperature_c = validTemperature(temperatureTable.op_limit_max),
|
|
device_critical_temperature_c = validTemperature(temperatureTable.critical_limit_max),
|
|
percentage_used = percentageUsed,
|
|
remaining_life_percent = life,
|
|
remaining_life_estimated = lifeEstimated,
|
|
available_spare_percent = spare,
|
|
available_spare_threshold_percent = spareThreshold,
|
|
data_read_bytes = dataRead,
|
|
data_written_bytes = dataWritten,
|
|
power_on_hours = powerOnHours,
|
|
power_on_hours_saturated = not isNvme and powerOnHours == 65535,
|
|
power_cycles = powerCycles,
|
|
unsafe_shutdowns = unsafeShutdowns,
|
|
media_errors = mediaErrors,
|
|
error_log_entries = errorEntries,
|
|
critical_warning = criticalWarning,
|
|
warning_temperature_time_minutes = warningTemperatureTime,
|
|
critical_temperature_time_minutes = criticalTemperatureTime,
|
|
smartctl_exit_status = smartExitStatus,
|
|
smart_sleeping = smartSleeping,
|
|
smart_power_mode = powerMode.string,
|
|
smart_messages = #smartMessages > 0 and smartMessages or nil,
|
|
smart_completeness = hasCollectionError and "partial" or "full",
|
|
smart_error_log_present = bitSet(smartExitStatus, 6),
|
|
smart_prefail_attribute_now = bitSet(smartExitStatus, 4),
|
|
smart_past_threshold = bitSet(smartExitStatus, 5),
|
|
smart_self_test_log_error = bitSet(smartExitStatus, 7),
|
|
self_test_supported = selfTest.supported,
|
|
self_test_state = selfTest.state,
|
|
self_test_status = selfTest.status,
|
|
self_test_type = selfTest.test_type,
|
|
self_test_lifetime_hours = selfTest.lifetime_hours,
|
|
self_test_completion_percent = selfTest.completion_percent,
|
|
self_test_error_count = selfTest.error_count,
|
|
reallocated_sectors = rawAttribute(firstAttribute(attributes, { "Reallocated_Sector_Ct", "Reallocated_Event_Count" })),
|
|
pending_sectors = rawAttribute(firstAttribute(attributes, { "Current_Pending_Sector" })),
|
|
uncorrectable_errors = maximumRawAttribute(attributes, {
|
|
"Offline_Uncorrectable", "Reported_Uncorrect", "Uncorrectable_Error_Cnt",
|
|
}),
|
|
start_stop_count = rawAttribute(firstAttribute(attributes, { "Start_Stop_Count" })),
|
|
load_cycle_count = rawAttribute(firstAttribute(attributes, { "Load_Cycle_Count" })),
|
|
spin_retry_count = rawAttribute(firstAttribute(attributes, { "Spin_Retry_Count" })),
|
|
command_timeout_count = rawAttribute(firstAttribute(attributes, { "Command_Timeout" })),
|
|
interface_crc_errors = rawAttribute(firstAttribute(attributes, { "UDMA_CRC_Error_Count", "CRC_Error_Count" })),
|
|
}
|
|
end
|
|
|
|
local function conciseSmartError(smart)
|
|
local smartctl = type(smart.smartctl) == "table" and smart.smartctl or {}
|
|
for _, message in ipairs(smartctl.messages or {}) do
|
|
local text = type(message) == "table" and tostring(message.string or "") or tostring(message)
|
|
if text:lower():find("permission denied", 1, true) ~= nil then
|
|
return "Permission denied; install the read-only system collector for full SMART data."
|
|
elseif text ~= "" then
|
|
return text
|
|
end
|
|
end
|
|
return "SMART data unavailable."
|
|
end
|
|
|
|
local function copySmartAttributes(smart)
|
|
local result = {}
|
|
local ata = type(smart.ata_smart_attributes) == "table" and smart.ata_smart_attributes or {}
|
|
for _, item in ipairs(ata.table or {}) do
|
|
if type(item) == "table" then
|
|
local raw = type(item.raw) == "table" and item.raw or {}
|
|
table.insert(result, {
|
|
id = integer(item.id), name = item.name, value = integer(item.value),
|
|
worst = integer(item.worst), threshold = integer(item.thresh),
|
|
raw_value = rawAttribute(item), raw_string = raw.string,
|
|
})
|
|
end
|
|
end
|
|
return result
|
|
end
|
|
|
|
local function hasSmartData(smart)
|
|
return type(smart) == "table" and (
|
|
smart.smart_status ~= nil or smart.nvme_smart_health_information_log ~= nil
|
|
or smart.ata_smart_attributes ~= nil
|
|
)
|
|
end
|
|
|
|
local function summarize(disks)
|
|
local ssdCount = 0
|
|
local hddCount = 0
|
|
local smartAvailable = 0
|
|
local ssdSmartAvailable = 0
|
|
local unhealthy = 0
|
|
local ssdUnhealthy = 0
|
|
local partial = 0
|
|
local ssdPartial = 0
|
|
local selfTestFailures = 0
|
|
local ssdSelfTestFailures = 0
|
|
local sleeping = 0
|
|
local hottest = nil
|
|
local hottestDrive = nil
|
|
local hottestSsd = nil
|
|
local hottestSsdDrive = nil
|
|
local lowestLife = nil
|
|
local lowestLifeDrive = nil
|
|
for _, disk in ipairs(disks) do
|
|
local isSsd = disk.kind == "ssd"
|
|
if isSsd then
|
|
ssdCount += 1
|
|
else
|
|
hddCount += 1
|
|
end
|
|
if disk.smart_available then
|
|
smartAvailable += 1
|
|
if isSsd then ssdSmartAvailable += 1 end
|
|
end
|
|
if disk.smart_sleeping then
|
|
sleeping += 1
|
|
end
|
|
if disk.smart_completeness == "partial" then
|
|
partial += 1
|
|
if isSsd then ssdPartial += 1 end
|
|
end
|
|
if disk.health == "failed" then
|
|
unhealthy += 1
|
|
if isSsd then ssdUnhealthy += 1 end
|
|
end
|
|
if disk.self_test_state == "failed" then
|
|
selfTestFailures += 1
|
|
if isSsd then ssdSelfTestFailures += 1 end
|
|
end
|
|
local temperature = tonumber(isSsd and noctalia.getConfig("use_hotspot_temperature") ~= false
|
|
and disk.hotspot_temperature_c or disk.temperature_c)
|
|
if temperature ~= nil and (hottest == nil or temperature > hottest) then
|
|
hottest = temperature
|
|
hottestDrive = disk
|
|
end
|
|
if isSsd then
|
|
if temperature ~= nil and (hottestSsd == nil or temperature > hottestSsd) then
|
|
hottestSsd = temperature
|
|
hottestSsdDrive = disk
|
|
end
|
|
local life = tonumber(disk.remaining_life_percent)
|
|
if life ~= nil and (lowestLife == nil or life < lowestLife) then
|
|
lowestLife = life
|
|
lowestLifeDrive = disk
|
|
end
|
|
end
|
|
end
|
|
return {
|
|
disk_count = #disks,
|
|
ssd_count = ssdCount,
|
|
hdd_count = hddCount,
|
|
smart_available_count = smartAvailable,
|
|
smart_unavailable_count = #disks - smartAvailable - sleeping,
|
|
sleeping_count = sleeping,
|
|
unhealthy_count = unhealthy,
|
|
partial_smart_count = partial,
|
|
self_test_failure_count = selfTestFailures,
|
|
hottest_drive_temperature_c = hottest,
|
|
hottest_drive_id = hottestDrive and hottestDrive.id or nil,
|
|
hottest_drive_name = hottestDrive and hottestDrive.display_name or nil,
|
|
ssd_smart_available_count = ssdSmartAvailable,
|
|
ssd_smart_unavailable_count = ssdCount - ssdSmartAvailable,
|
|
hdd_smart_available_count = smartAvailable - ssdSmartAvailable,
|
|
hdd_smart_unavailable_count = hddCount - (smartAvailable - ssdSmartAvailable),
|
|
ssd_unhealthy_count = ssdUnhealthy,
|
|
ssd_partial_smart_count = ssdPartial,
|
|
ssd_self_test_failure_count = ssdSelfTestFailures,
|
|
hottest_ssd_temperature_c = hottestSsd,
|
|
hottest_ssd_drive_id = hottestSsdDrive and hottestSsdDrive.id or nil,
|
|
hottest_ssd_drive_name = hottestSsdDrive and hottestSsdDrive.display_name or nil,
|
|
worst_ssd_remaining_life_percent = lowestLife,
|
|
worst_ssd_life_drive_id = lowestLifeDrive and lowestLifeDrive.id or nil,
|
|
worst_ssd_life_drive_name = lowestLifeDrive and lowestLifeDrive.display_name or nil,
|
|
}
|
|
end
|
|
|
|
local function loadDrivePreferences()
|
|
local directory = noctalia.pluginDataDir()
|
|
local path = directory ~= nil and directory .. "/" .. PREFERENCES_FILE or nil
|
|
local encoded = path ~= nil and noctalia.readFile(path) or nil
|
|
local decoded = encoded ~= nil and noctalia.json.decode(encoded) or nil
|
|
if type(decoded) == "table" then
|
|
drivePreferences = decoded
|
|
drivePreferences.schema = 1
|
|
drivePreferences.order = type(decoded.order) == "table" and decoded.order or {}
|
|
drivePreferences.drives = type(decoded.drives) == "table" and decoded.drives or {}
|
|
end
|
|
noctalia.state.set("drive_preferences", drivePreferences)
|
|
end
|
|
|
|
local function preferenceFor(id)
|
|
local preferences = type(drivePreferences.drives) == "table" and drivePreferences.drives or {}
|
|
return type(preferences[id]) == "table" and preferences[id] or {}
|
|
end
|
|
|
|
local function orderFor(id)
|
|
for index, value in ipairs(drivePreferences.order or {}) do
|
|
if tostring(value) == tostring(id) then
|
|
return index
|
|
end
|
|
end
|
|
return 100000
|
|
end
|
|
|
|
local function systemCollectorState(raw, source)
|
|
local pluginDirectory = noctalia.pluginDir()
|
|
local enabled = systemCollectorEnabled()
|
|
local installed = noctalia.fileExists(SYSTEM_LIBEXEC .. "/collect_raw.sh")
|
|
local 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
|
|
status = "upgrade-required"
|
|
elseif installed then
|
|
status = "stale"
|
|
else
|
|
status = "not-installed"
|
|
end
|
|
return {
|
|
enabled = enabled,
|
|
installed = installed,
|
|
status = status,
|
|
version = version ~= "" and version or nil,
|
|
expected_version = EXPECTED_COLLECTOR_VERSION,
|
|
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 = 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
|
|
|
|
local function normalizeRaw(raw, source)
|
|
if type(raw) ~= "table" or tonumber(raw.schema) ~= 2 then
|
|
return nil, "Unsupported raw SMART cache schema."
|
|
end
|
|
local lsblk = type(raw.lsblk) == "table" and raw.lsblk or {}
|
|
local smartByDevice = {}
|
|
for _, entry in ipairs(raw.smart or {}) do
|
|
if type(entry) == "table" then
|
|
local smart = type(entry.payload) == "table" and entry.payload or entry
|
|
smart._collector_exit_code = entry.exit_code
|
|
local device = type(smart.device) == "table" and smart.device or {}
|
|
if entry.requested_device ~= nil then
|
|
smartByDevice[tostring(entry.requested_device)] = smart
|
|
end
|
|
if device.name ~= nil then
|
|
smartByDevice[tostring(device.name)] = smart
|
|
end
|
|
end
|
|
end
|
|
|
|
local disks = {}
|
|
for _, block in ipairs(lsblk.blockdevices or {}) do
|
|
local device = tostring(block.path or "")
|
|
local name = deviceBasename(block.name)
|
|
if name == "" then name = deviceBasename(block.kname) end
|
|
if name == "" then name = deviceBasename(device) end
|
|
if not device:match("^/dev/") and name ~= "" then device = "/dev/" .. name end
|
|
local kernelName = deviceBasename(block.kname)
|
|
if kernelName == "" then kernelName = name end
|
|
local virtual = name:match("^zram") or name:match("^loop") or name:match("^ram") or name:match("^sr")
|
|
if block.type == "disk" and device:match("^/dev/") and not virtual then
|
|
local smartDevice = smartDeviceFor(device)
|
|
local smart = smartByDevice[smartDevice] or smartByDevice[device] or {}
|
|
local smartAvailable = hasSmartData(smart)
|
|
local blockSize = integer(smart.logical_block_size) or integer(block["log-sec"]) or 512
|
|
local values = normalizeSmart(smart, blockSize)
|
|
if not smartAvailable then
|
|
values.smart_completeness = "unavailable"
|
|
end
|
|
-- 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)
|
|
local rotational = block.rota == true or tonumber(block.rota) == 1
|
|
local serial = noctalia.string.trim(tostring(block.serial or ""))
|
|
local model = noctalia.string.trim(tostring(block.model or ""))
|
|
local namespace = name:match("^nvme%d+(n%d+)$")
|
|
local id = serial ~= "" and serial .. (namespace ~= nil and ":" .. namespace or "")
|
|
or (kernelName ~= "" and kernelName or device)
|
|
local preferences = preferenceFor(id)
|
|
local removable = block.rm == true or tonumber(block.rm) == 1 or block.hotplug == true or tonumber(block.hotplug) == 1
|
|
local transport = tostring(block.tran or "unknown")
|
|
local defaultPresenceAlert = not removable and transport ~= "usb"
|
|
local drive = {
|
|
id = id,
|
|
device = device,
|
|
smart_device = smartDevice,
|
|
model = model ~= "" and model or name,
|
|
display_name = tostring(preferences.alias or "") ~= "" and tostring(preferences.alias) or (model ~= "" and model or name),
|
|
serial = serial ~= "" and serial or nil,
|
|
transport = transport,
|
|
kind = rotational and "hdd" or "ssd",
|
|
removable = removable,
|
|
hidden = preferences.hidden == true,
|
|
alerts_enabled = preferences.alerts_enabled ~= false,
|
|
presence_alert_enabled = preferences.presence_alert_enabled == nil and defaultPresenceAlert
|
|
or preferences.presence_alert_enabled == true,
|
|
warning_temperature = numeric(preferences.warning_temperature),
|
|
critical_temperature = numeric(preferences.critical_temperature),
|
|
life_warning_percent = numeric(preferences.life_warning_percent),
|
|
display_order = orderFor(id),
|
|
capacity_bytes = integer(block.size),
|
|
storage_used_bytes = used,
|
|
storage_available_bytes = available,
|
|
storage_usage_percent = usagePercent,
|
|
mount_points = #mountPoints > 0 and mountPoints or nil,
|
|
smart_available = smartAvailable,
|
|
smart_error = not smartAvailable and not values.smart_sleeping and conciseSmartError(smart) or nil,
|
|
}
|
|
for key, value in pairs(values) do
|
|
drive[key] = value
|
|
end
|
|
local attributes = copySmartAttributes(smart)
|
|
if #attributes > 0 then
|
|
drive.smart_attributes = attributes
|
|
end
|
|
table.insert(disks, drive)
|
|
end
|
|
end
|
|
|
|
table.sort(disks, function(left, right)
|
|
if left.display_order ~= right.display_order then
|
|
return left.display_order < right.display_order
|
|
elseif left.kind ~= right.kind then
|
|
return left.kind == "ssd"
|
|
elseif left.transport ~= right.transport then
|
|
return left.transport < right.transport
|
|
end
|
|
return left.device < right.device
|
|
end)
|
|
|
|
local epoch = integer(raw.generated_at_epoch) or os.time()
|
|
local smartCount = 0
|
|
for _, disk in ipairs(disks) do
|
|
if disk.smart_available then
|
|
smartCount += 1
|
|
end
|
|
end
|
|
return {
|
|
schema = 1,
|
|
collection_id = validCollectionId(raw.collection_id),
|
|
generated_at = tostring(epoch),
|
|
generated_at_epoch = epoch,
|
|
generated_at_local = noctalia.formatTime("%H:%M:%S", epoch),
|
|
collector_version = raw.collector_version,
|
|
source = source or "direct",
|
|
access = #disks > 0 and smartCount == #disks and "full"
|
|
or (smartCount > 0 and "partial" or "unavailable"),
|
|
disks = disks,
|
|
summary = summarize(disks),
|
|
system_collector = systemCollectorState(raw, source or "direct"),
|
|
}, nil
|
|
end
|
|
|
|
local function detectedPackageManager()
|
|
for _, manager in ipairs(PACKAGE_MANAGERS) do
|
|
if noctalia.commandExists(manager.command) then
|
|
return manager
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local startDependencyProbe
|
|
|
|
local function forceDependencyProbe()
|
|
if dependencyProbe.running then
|
|
dependencyProbe.forcedPending = true
|
|
return
|
|
end
|
|
dependencyProbe.checked = false
|
|
dependencyProbe.incompatible = {}
|
|
startDependencyProbe()
|
|
end
|
|
|
|
startDependencyProbe = function()
|
|
if dependencyProbe.checked or dependencyProbe.running
|
|
or not noctalia.commandExists("lsblk") or not noctalia.commandExists("smartctl") then
|
|
return
|
|
end
|
|
dependencyProbe.running = true
|
|
local command = "if lsblk --json --bytes --output NAME,TYPE >/dev/null 2>&1; then "
|
|
.. "printf 'lsblk=ok\\n'; else printf 'lsblk=bad\\n'; fi; "
|
|
.. "if smartctl --json=c --version >/dev/null 2>&1; then "
|
|
.. "printf 'smartctl=ok\\n'; else printf 'smartctl=bad\\n'; fi"
|
|
local launched = noctalia.runAsync(command, function(result)
|
|
dependencyProbe.running = false
|
|
dependencyProbe.checked = true
|
|
local output = tostring(result.stdout or "")
|
|
local probeFailed = result.timedOut == true or tonumber(result.exitCode) ~= 0
|
|
dependencyProbe.incompatible = {
|
|
lsblk = probeFailed or output:find("lsblk=ok", 1, true) == nil,
|
|
smartctl = probeFailed or output:find("smartctl=ok", 1, true) == nil,
|
|
}
|
|
dependencyProbeGeneration += 1
|
|
noctalia.state.set("dependency_probe_generation", dependencyProbeGeneration)
|
|
if dependencyProbe.forcedPending then
|
|
dependencyProbe.forcedPending = false
|
|
forceDependencyProbe()
|
|
end
|
|
end, 5000)
|
|
if not launched then
|
|
dependencyProbe.running = false
|
|
dependencyProbe.checked = true
|
|
dependencyProbe.incompatible = { lsblk = true, smartctl = true }
|
|
if dependencyProbe.forcedPending then
|
|
dependencyProbe.forcedPending = false
|
|
forceDependencyProbe()
|
|
end
|
|
end
|
|
end
|
|
|
|
local function checkDependencies()
|
|
startDependencyProbe()
|
|
local missing = {}
|
|
local blocking = false
|
|
for _, dependency in ipairs(DEPENDENCIES) do
|
|
local exists = noctalia.commandExists(dependency.command)
|
|
if not exists or dependencyProbe.incompatible[dependency.command] == true then
|
|
dependency.incompatible = exists
|
|
table.insert(missing, dependency)
|
|
blocking = blocking or dependency.blocking
|
|
end
|
|
end
|
|
local manager = detectedPackageManager()
|
|
local packages = {}
|
|
local seen = {}
|
|
local labels = {}
|
|
local signatureParts = {}
|
|
for _, dependency in ipairs(missing) do
|
|
table.insert(labels, dependency.incompatible
|
|
and dependency.label .. " (installed but incompatible)"
|
|
or dependency.label .. " (" .. dependency.command .. ")")
|
|
table.insert(signatureParts, dependency.command)
|
|
local packageName = manager ~= nil and dependency.packages[manager.id] or nil
|
|
if packageName ~= nil and not seen[packageName] then
|
|
seen[packageName] = true
|
|
table.insert(packages, packageName)
|
|
end
|
|
end
|
|
table.sort(signatureParts)
|
|
local installCommand = nil
|
|
if manager ~= nil and #packages > 0 and noctalia.commandExists("pkexec") then
|
|
installCommand = manager.prefix .. table.concat(packages, " ")
|
|
end
|
|
local signature = table.concat(signatureParts, ",")
|
|
local logSignature = signature == "" and "ready" or signature
|
|
if logSignature ~= loggedDependencySignature then
|
|
loggedDependencySignature = logSignature
|
|
noctalia.log(signature == "" and "Dependency check passed: lsblk and smartctl are available."
|
|
or "Dependency check found missing commands: " .. table.concat(labels, ", "))
|
|
end
|
|
return {
|
|
ready = #missing == 0,
|
|
blocking = blocking,
|
|
missing = labels,
|
|
missing_text = table.concat(labels, ", "),
|
|
signature = signature,
|
|
package_manager = manager ~= nil and manager.label or nil,
|
|
install_command = installCommand,
|
|
can_install = installCommand ~= nil,
|
|
checking = dependencyProbe.running,
|
|
}
|
|
end
|
|
|
|
local function freshJson(path)
|
|
local info = noctalia.fileInfo(path)
|
|
if type(info) ~= "table" or info.isDir or tonumber(info.mtime) == nil then
|
|
return nil
|
|
end
|
|
local age = os.time() - tonumber(info.mtime)
|
|
if age < 0 or age > CACHE_MAX_AGE_SECONDS then
|
|
return nil
|
|
end
|
|
local encoded = noctalia.readFile(path)
|
|
if encoded == nil then
|
|
return nil
|
|
end
|
|
return noctalia.json.decode(encoded)
|
|
end
|
|
|
|
local function publishError(message, dependencies)
|
|
local snapshot = noctalia.state.get("collector_snapshot") or {}
|
|
snapshot.collecting = false
|
|
snapshot.collector_error = message
|
|
snapshot.dependencies = dependencies
|
|
snapshot.summary = snapshot.summary or {}
|
|
snapshot.system_collector = systemCollectorState(nil, "error")
|
|
noctalia.state.set("collector_snapshot", snapshot)
|
|
end
|
|
|
|
local function publish(snapshot, dependencies)
|
|
snapshot.collecting = false
|
|
snapshot.collector_error = nil
|
|
snapshot.dependencies = dependencies
|
|
local collector = snapshot.system_collector
|
|
if type(collector) == "table" and collector.enabled == true
|
|
and collector.status == "upgrade-required" then
|
|
local signature = tostring(collector.version or "unknown")
|
|
.. "->" .. tostring(collector.expected_version or "unknown")
|
|
if signature ~= notifiedCollectorUpgradeSignature then
|
|
notifiedCollectorUpgradeSignature = signature
|
|
noctalia.notify(noctalia.tr("collector.update_title"), noctalia.tr("collector.update_body", {
|
|
current = tostring(collector.version or noctalia.tr("common.unknown")),
|
|
expected = tostring(collector.expected_version or noctalia.tr("common.unknown")),
|
|
}))
|
|
end
|
|
end
|
|
noctalia.state.set("collector_snapshot", snapshot)
|
|
end
|
|
|
|
local function 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
|
|
end
|
|
local dependencies = checkDependencies()
|
|
if dependencies.blocking then
|
|
publishError(noctalia.tr("dependencies.collection_blocked", { missing = dependencies.missing_text }), dependencies)
|
|
return
|
|
end
|
|
|
|
if systemCollectorEnabled() then
|
|
local rawCache = freshJson(RAW_CACHE)
|
|
if type(rawCache) == "table" and tonumber(rawCache.schema) == 2 then
|
|
local snapshot, normalizeError = normalizeRaw(rawCache, "system-cache")
|
|
if snapshot ~= nil then
|
|
refreshSystemCacheInventory(rawCache, dependencies)
|
|
return
|
|
end
|
|
noctalia.log("Raw SMART cache rejected: " .. tostring(normalizeError))
|
|
end
|
|
end
|
|
|
|
local pluginDir = noctalia.pluginDir()
|
|
if pluginDir == nil or pluginDir == "" then
|
|
publishError("Cannot resolve the plugin directory.", dependencies)
|
|
return
|
|
end
|
|
|
|
collecting = true
|
|
local current = noctalia.state.get("collector_snapshot") or {}
|
|
current.collecting = true
|
|
current.dependencies = dependencies
|
|
noctalia.state.set("collector_snapshot", current)
|
|
local command = "sh " .. shellQuote(pluginDir .. "/scripts/collect_raw.sh")
|
|
local launched = noctalia.runAsync(command, function(result)
|
|
collecting = false
|
|
local resolvedDependencies = checkDependencies()
|
|
if result.timedOut then
|
|
publishError("SMART collection timed out.", resolvedDependencies)
|
|
return
|
|
elseif result.exitCode ~= 0 then
|
|
local reason = noctalia.string.trim(result.stderr or "")
|
|
publishError(reason ~= "" and reason or "SMART collection failed.", resolvedDependencies)
|
|
return
|
|
end
|
|
local raw, decodeError = noctalia.json.decode(result.stdout or "")
|
|
if raw == nil then
|
|
publishError("Invalid raw collector response: " .. tostring(decodeError or "unknown JSON error"), resolvedDependencies)
|
|
return
|
|
end
|
|
local snapshot, normalizeError = normalizeRaw(raw, "direct")
|
|
if snapshot == nil then
|
|
publishError(tostring(normalizeError or "SMART normalization failed."), resolvedDependencies)
|
|
return
|
|
end
|
|
publish(snapshot, resolvedDependencies)
|
|
end, 60000)
|
|
if not launched then
|
|
collecting = false
|
|
publishError("Noctalia could not start the SMART collector.", dependencies)
|
|
end
|
|
end
|
|
|
|
loadDrivePreferences()
|
|
|
|
noctalia.state.watch("drive_preferences", function(value)
|
|
if type(value) == "table" then
|
|
drivePreferences = value
|
|
drivePreferences.order = type(value.order) == "table" and value.order or {}
|
|
drivePreferences.drives = type(value.drives) == "table" and value.drives or {}
|
|
collect()
|
|
end
|
|
end)
|
|
|
|
noctalia.state.watch("refresh_nonce", function(value)
|
|
local nextNonce = tonumber(value) or 0
|
|
if nextNonce ~= refreshNonce then
|
|
refreshNonce = nextNonce
|
|
forceDependencyProbe()
|
|
collect()
|
|
end
|
|
end)
|
|
|
|
noctalia.state.watch("dependency_probe_generation", function(value)
|
|
local generation = tonumber(value) or 0
|
|
if generation > dependencyProbeGeneration then
|
|
dependencyProbeGeneration = generation
|
|
end
|
|
collect()
|
|
end)
|
|
|
|
noctalia.setUpdateInterval(30000)
|
|
|
|
function update()
|
|
collect()
|
|
end
|
|
|
|
function onConfigChanged()
|
|
local seconds = tonumber(noctalia.getConfig("refresh_seconds")) or 30
|
|
noctalia.setUpdateInterval(math.max(15, math.min(300, seconds)) * 1000)
|
|
collect()
|
|
end
|
|
|
|
local function lifecycleActionError(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 notifyLifecycleFailure(action, detail)
|
|
noctalia.log("Drive Health could not " .. action .. ": " .. detail)
|
|
noctalia.notifyError(noctalia.tr("collector.title"), noctalia.tr("privileged_action.failed", {
|
|
action = action,
|
|
error = detail,
|
|
}))
|
|
end
|
|
|
|
local function launchLifecycleAction(command, action, observeResult)
|
|
if not noctalia.commandExists("pkexec") then
|
|
notifyLifecycleFailure(action, noctalia.tr("collector.authorization_required"))
|
|
return
|
|
end
|
|
|
|
local launched
|
|
if observeResult then
|
|
launched = noctalia.runAsync(command, function(result)
|
|
if result.timedOut == true or tonumber(result.exitCode) ~= 0 then
|
|
notifyLifecycleFailure(action, lifecycleActionError(result))
|
|
end
|
|
end, 120000)
|
|
else
|
|
-- onExit destroys this VM as soon as the callback returns. Launch without
|
|
-- a completion callback so the authorization request survives teardown.
|
|
launched = noctalia.runAsync(command)
|
|
end
|
|
if not launched then
|
|
notifyLifecycleFailure(action, noctalia.tr("privileged_action.launch_failed"))
|
|
end
|
|
end
|
|
|
|
local function legacyUninstallCommand()
|
|
-- Older collector installations did not copy an uninstaller into libexec.
|
|
-- Keep this fallback independent of plugin files because those are removed
|
|
-- immediately after an uninstall hook returns.
|
|
local serviceName = SYSTEM_NAMESPACE
|
|
local cleanup = "systemctl disable --now " .. serviceName .. ".timer 2>/dev/null || true; "
|
|
.. "systemctl stop " .. serviceName .. ".service 2>/dev/null || true; "
|
|
.. "rm -f /etc/systemd/system/" .. serviceName .. ".service "
|
|
.. "/etc/systemd/system/" .. serviceName .. ".timer; "
|
|
.. "rm -rf /etc/systemd/system/" .. serviceName .. ".timer.d "
|
|
.. "/usr/local/libexec/" .. serviceName .. " /run/" .. serviceName .. "; "
|
|
.. "systemctl daemon-reload; "
|
|
.. "systemctl reset-failed " .. serviceName .. ".service 2>/dev/null || true"
|
|
return "pkexec /bin/sh -c " .. shellQuote(cleanup)
|
|
end
|
|
|
|
function onEnable()
|
|
local manageScript = SYSTEM_LIBEXEC .. "/manage-collector.sh"
|
|
if systemCollectorEnabled() and noctalia.fileExists(manageScript) then
|
|
launchLifecycleAction(
|
|
"pkexec " .. shellQuote(manageScript) .. " start",
|
|
noctalia.tr("collector.action_start"),
|
|
true
|
|
)
|
|
end
|
|
end
|
|
|
|
function onExit(_signal, reason)
|
|
if reason == "disable" then
|
|
local manageScript = SYSTEM_LIBEXEC .. "/manage-collector.sh"
|
|
if noctalia.fileExists(manageScript) then
|
|
launchLifecycleAction(
|
|
"pkexec " .. shellQuote(manageScript) .. " pause",
|
|
noctalia.tr("collector.action_pause"),
|
|
false
|
|
)
|
|
end
|
|
elseif reason == "uninstall" then
|
|
local installedCollector = SYSTEM_LIBEXEC .. "/collect_raw.sh"
|
|
if not noctalia.fileExists(installedCollector) then
|
|
return
|
|
end
|
|
local uninstallScript = SYSTEM_LIBEXEC .. "/uninstall-collector.sh"
|
|
local command = noctalia.fileExists(uninstallScript)
|
|
and "pkexec " .. shellQuote(uninstallScript)
|
|
or legacyUninstallCommand()
|
|
launchLifecycleAction(command, noctalia.tr("collector.action_remove"), false)
|
|
end
|
|
end
|
|
|
|
function onIpc(event, _payload)
|
|
if event == "check-dependencies" then
|
|
forceDependencyProbe()
|
|
collect()
|
|
elseif event == "refresh" then
|
|
collect()
|
|
elseif event == "test-alert" then
|
|
noctalia.notify(noctalia.tr("alerts.test_title"), noctalia.tr("alerts.test_body"))
|
|
elseif event == "export-snapshot" then
|
|
local snapshot = noctalia.state.get("collector_snapshot")
|
|
local directory = noctalia.pluginDataDir()
|
|
local encoded = snapshot ~= nil and noctalia.json.encode(snapshot, true) or nil
|
|
if directory ~= nil and encoded ~= nil then
|
|
noctalia.writeFile(directory .. "/last-collector-snapshot.json", encoded)
|
|
end
|
|
end
|
|
end
|
|
|
|
onConfigChanged()
|