* add Drive Health * declare optional package manager commands * use live Drive Health screenshot * hide redundant dependency subtitle * fix drive error counter alerts * stabilize transient SMART availability * address collector packaging review * refactor: use generic collector service name * test: harden collector packaging checks
142 lines
4.4 KiB
Luau
142 lines
4.4 KiB
Luau
--!nonstrict
|
|
|
|
-- Bounded, low-write-rate drive history. Alert evaluation remains in
|
|
-- service.luau; this service only persists trend samples and publishes them.
|
|
|
|
local HISTORY_FILE = "history.json"
|
|
local history = { schema = 1, drives = {} }
|
|
local historyDirty = false
|
|
|
|
local function number(value, fallback)
|
|
local parsed = tonumber(value)
|
|
return parsed ~= nil and parsed or fallback
|
|
end
|
|
|
|
local function historyPath()
|
|
local directory = noctalia.pluginDataDir()
|
|
return directory ~= nil and directory .. "/" .. HISTORY_FILE or nil
|
|
end
|
|
|
|
local function loadHistory()
|
|
local path = historyPath()
|
|
local encoded = path ~= nil and noctalia.readFile(path) or nil
|
|
local decoded = encoded ~= nil and noctalia.json.decode(encoded) or nil
|
|
if type(decoded) == "table" and tonumber(decoded.schema) == 1 then
|
|
history = decoded
|
|
history.drives = type(decoded.drives) == "table" and decoded.drives or {}
|
|
end
|
|
end
|
|
|
|
local function saveHistory()
|
|
local path = historyPath()
|
|
if path == nil then
|
|
return false
|
|
end
|
|
local encoded, encodeError = noctalia.json.encode(history, true)
|
|
if encoded == nil then
|
|
noctalia.log("Unable to encode SMART history: " .. tostring(encodeError))
|
|
return false
|
|
end
|
|
local temporary = path .. ".tmp"
|
|
local written, writeError = noctalia.writeFile(temporary, encoded)
|
|
if not written then
|
|
noctalia.log("Unable to write SMART history: " .. tostring(writeError))
|
|
return false
|
|
end
|
|
local renamed, renameError = noctalia.renameFile(temporary, path)
|
|
if not renamed then
|
|
noctalia.log("Unable to commit SMART history: " .. tostring(renameError))
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function publishHistory()
|
|
noctalia.state.set("drive_history", history)
|
|
end
|
|
|
|
local function recordSnapshot(snapshot)
|
|
if type(snapshot) ~= "table" or type(snapshot.disks) ~= "table" or snapshot.collector_error ~= nil then
|
|
return
|
|
end
|
|
local epoch = math.floor(number(snapshot.generated_at_epoch, os.time()))
|
|
local interval = math.max(15, math.min(1440,
|
|
number(noctalia.getConfig("history_interval_minutes"), 60))) * 60
|
|
local retention = math.max(1, math.min(365,
|
|
number(noctalia.getConfig("history_retention_days"), 30))) * 86400
|
|
local cutoff = epoch - retention
|
|
local changed = false
|
|
|
|
for _, drive in ipairs(snapshot.disks) do
|
|
local id = tostring(drive.id or drive.serial or drive.device or "")
|
|
if id ~= "" then
|
|
local entry = history.drives[id]
|
|
if type(entry) ~= "table" then
|
|
entry = { name = drive.display_name or drive.model or drive.device, samples = {} }
|
|
history.drives[id] = entry
|
|
end
|
|
entry.name = drive.display_name or drive.model or drive.device
|
|
entry.kind = drive.kind
|
|
entry.samples = type(entry.samples) == "table" and entry.samples or {}
|
|
|
|
local retained = {}
|
|
for _, sample in ipairs(entry.samples) do
|
|
if type(sample) == "table" and number(sample.epoch, 0) >= cutoff then
|
|
table.insert(retained, sample)
|
|
else
|
|
changed = true
|
|
end
|
|
end
|
|
entry.samples = retained
|
|
local latest = retained[#retained]
|
|
if latest == nil or epoch - number(latest.epoch, 0) >= interval then
|
|
table.insert(retained, {
|
|
epoch = epoch,
|
|
temperature_c = tonumber(drive.temperature_c),
|
|
hotspot_temperature_c = tonumber(drive.hotspot_temperature_c),
|
|
remaining_life_percent = tonumber(drive.remaining_life_percent),
|
|
storage_usage_percent = tonumber(drive.storage_usage_percent),
|
|
data_written_bytes = tonumber(drive.data_written_bytes),
|
|
})
|
|
changed = true
|
|
end
|
|
end
|
|
end
|
|
|
|
for id, entry in pairs(history.drives) do
|
|
local samples = type(entry) == "table" and entry.samples or nil
|
|
if type(samples) ~= "table" or (#samples > 0 and number(samples[#samples].epoch, 0) < cutoff) then
|
|
history.drives[id] = nil
|
|
changed = true
|
|
end
|
|
end
|
|
|
|
history.updated_at_epoch = epoch
|
|
if changed then
|
|
historyDirty = true
|
|
end
|
|
if historyDirty and saveHistory() then
|
|
historyDirty = false
|
|
end
|
|
publishHistory()
|
|
end
|
|
|
|
loadHistory()
|
|
publishHistory()
|
|
|
|
noctalia.state.watch("snapshot", function(snapshot)
|
|
recordSnapshot(snapshot)
|
|
end)
|
|
|
|
local initial = noctalia.state.get("snapshot")
|
|
if initial ~= nil then
|
|
recordSnapshot(initial)
|
|
end
|
|
|
|
function onConfigChanged()
|
|
local snapshot = noctalia.state.get("snapshot")
|
|
if snapshot ~= nil then
|
|
recordSnapshot(snapshot)
|
|
end
|
|
end
|