Files
community-plugins/drive-health/panel.luau
T

1625 lines
65 KiB
Luau

--!nonstrict
local snapshot = noctalia.state.get("snapshot")
local history = noctalia.state.get("drive_history") or { drives = {} }
local preferences = noctalia.state.get("drive_preferences") or { schema = 1, order = {}, drives = {} }
local opened = false
local expandedDriveId = nil
local editingDriveId = nil
local currentDrives = {}
local currentIssues = {}
local dismissRequestNonce = 0
local pendingSelfTest = nil
local selfTestLaunch = nil
local intervalApply = nil
local privilegedAction = nil
local confirmUninstall = false
local showCollectorSettings = false
local hiddenSelection = 1
local aliasDraft = ""
local warningDraft = ""
local criticalDraft = ""
local lifeDraft = ""
local alertsDraft = true
local presenceDraft = false
local toggleDriveAt
local dismissAlertAt
local EDITABLE_PREFERENCE_FIELDS = {
"alias", "warning_temperature", "critical_temperature", "life_warning_percent",
"alerts_enabled", "presence_alert_enabled",
}
local MIN_TREND_SAMPLES = 4
local function number(value, fallback)
local parsed = tonumber(value)
return parsed ~= nil and parsed or fallback
end
local function clamp(value, minimum, maximum)
return math.max(minimum, math.min(maximum, value))
end
local function formatBytes(value)
local bytes = tonumber(value)
if bytes == nil then
return noctalia.tr("common.not_available")
end
local units = { "B", "KiB", "MiB", "GiB", "TiB", "PiB" }
local index = 1
while bytes >= 1024 and index < #units do
bytes /= 1024
index += 1
end
if index <= 3 then
return string.format("%.0f %s", bytes, units[index])
end
return string.format("%.1f %s", bytes, units[index])
end
local function formatHours(value)
local hours = tonumber(value)
if hours == nil then
return noctalia.tr("common.not_available")
end
if hours >= 8760 then
return noctalia.tr("common.duration_years", { value = string.format("%.1f", hours / 8760) })
elseif hours >= 24 then
return noctalia.tr("common.duration_days", { value = string.format("%.0f", hours / 24) })
end
return noctalia.tr("common.duration_hours", { value = string.format("%.0f", hours) })
end
local function formatPowerOn(drive)
local formatted = formatHours(drive.power_on_hours)
return drive.power_on_hours_saturated and "≥ " .. formatted or formatted
end
local function formatPercent(value)
local percent = tonumber(value)
return percent ~= nil and string.format("%.0f%%", percent) or noctalia.tr("common.not_available")
end
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function driveId(drive)
return tostring(drive.id or drive.serial or drive.device or "unknown")
end
local function driveName(drive)
return tostring(drive.display_name or drive.model or drive.device or noctalia.tr("alerts.unknown_drive"))
end
local function preferenceFor(id)
preferences.drives = type(preferences.drives) == "table" and preferences.drives or {}
local entry = preferences.drives[id]
if type(entry) ~= "table" then
entry = {}
preferences.drives[id] = entry
end
return entry
end
local function savePreferences()
local directory = noctalia.pluginDataDir()
if directory == nil then
noctalia.notifyError(noctalia.tr("preferences.title"), noctalia.tr("preferences.save_failed"))
return false
end
preferences.schema = 1
preferences.order = type(preferences.order) == "table" and preferences.order or {}
preferences.drives = type(preferences.drives) == "table" and preferences.drives or {}
local encoded = noctalia.json.encode(preferences, true)
if encoded == nil then
noctalia.notifyError(noctalia.tr("preferences.title"), noctalia.tr("preferences.save_failed"))
return false
end
local path = directory .. "/drive-preferences.json"
local temporary = path .. ".tmp"
if not noctalia.writeFile(temporary, encoded) or not noctalia.renameFile(temporary, path) then
noctalia.notifyError(noctalia.tr("preferences.title"), noctalia.tr("preferences.save_failed"))
return false
end
noctalia.state.set("drive_preferences", preferences)
return true
end
local function temperatureColor(value, drive)
local warning = number(drive and drive.warning_temperature,
number(noctalia.getConfig("warning_temperature"), 65))
local critical = math.max(warning + 1, number(drive and drive.critical_temperature,
number(noctalia.getConfig("critical_temperature"), 80)))
if value == nil then
return "on_surface_variant"
elseif value >= critical then
return "error"
elseif value >= warning then
return "secondary"
end
return "primary"
end
local function metric(icon, label, value, color)
return ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
ui.glyph({ name = icon, size = 14, color = color or "on_surface_variant" }),
ui.column({ gap = 1, flexGrow = 1 }, {
ui.label({ text = label, fontSize = 11, color = "on_surface_variant" }),
ui.label({ text = value, fontWeight = "medium", color = color or "on_surface" }),
}),
})
end
local function progressMetric(label, value, display, fill)
local progress = value ~= nil and clamp(number(value, 0) / 100, 0, 1) or 0
return ui.column({ gap = 5, flexGrow = 1, align = "stretch" }, {
ui.row({ justify = "space_between", align = "center" }, {
ui.label({ text = label, fontSize = 11, color = "on_surface_variant" }),
ui.label({ text = display, fontSize = 11, fontWeight = "bold", color = fill }),
}),
ui.progress({ progress = progress, fill = fill, height = 6, radius = 3 }),
})
end
local function summaryCard(value, label, detail, color)
local children = {
ui.label({ text = value, fontSize = 20, fontWeight = "bold", color = color }),
ui.label({ text = label, fontSize = 11, color = "on_surface_variant" }),
}
if detail ~= nil and tostring(detail) ~= "" then
table.insert(children, ui.label({ text = tostring(detail), fontSize = 9,
color = "on_surface_variant/0.78", maxLines = 1 }))
end
return ui.column({ gap = 2, padding = 10, radius = 10,
fill = color .. "/0.12", flexGrow = 1 }, children)
end
local function healthInfo(drive)
if drive.health == "passed" then
return noctalia.tr("health.passed"), "primary", "check"
elseif drive.health == "failed" then
return noctalia.tr("health.failed"), "error", "alert-triangle"
end
return noctalia.tr("health.unknown"), "on_surface_variant", "help-circle"
end
local function appendMetricRows(target, metrics)
local index = 1
while index <= #metrics do
local row = { metrics[index] }
if metrics[index + 1] ~= nil then
table.insert(row, metrics[index + 1])
end
table.insert(target, ui.row({ gap = 14, align = "center" }, row))
index += 2
end
end
local function alertsCard(issues)
if type(issues) ~= "table" or #issues == 0 then
return nil
end
local header = {
ui.glyph({ name = "alert-triangle", size = 17, color = "error" }),
ui.label({
text = noctalia.tr("alerts.active_title", { count = #issues }),
fontWeight = "bold",
color = "error",
flexGrow = 1,
}),
}
table.insert(header, ui.button({ text = noctalia.tr("alerts.dismiss_all"), variant = "ghost", controlSize = "sm",
onClick = "onDismissAllAlertsClicked" }))
local rows = { ui.row({ gap = 7, align = "center" }, header) }
for index, issue in ipairs(issues) do
local issueIndex = index
local color = issue.severity == "critical" and "error" or "secondary"
table.insert(rows, ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = issue.severity == "critical" and "alert-octagon" or "alert-circle", size = 13, color = color }),
ui.label({ text = tostring(issue.message or issue.title), fontSize = 11, color = "on_surface", flexGrow = 1 }),
ui.button({ glyph = "x", tooltip = noctalia.tr("alerts.dismiss"), variant = "ghost", controlSize = "sm",
onClick = function() dismissAlertAt(issueIndex) end }),
}))
end
return ui.column({ gap = 7, padding = 11, radius = 10, fill = "error/0.10", border = "error/0.30", borderWidth = 1 }, rows)
end
local function dependencyCard(dependencies)
if type(dependencies) ~= "table" or dependencies.ready then
return nil
end
local children = {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "package", size = 18, color = "error" }),
ui.column({ gap = 2, flexGrow = 1 }, {
ui.label({ text = noctalia.tr("dependencies.title"), fontWeight = "bold", color = "error" }),
ui.label({
text = noctalia.tr("dependencies.missing", { missing = tostring(dependencies.missing_text or "") }),
fontSize = 11,
color = "on_surface_variant",
flexGrow = 1,
}),
}),
}),
}
if dependencies.install_command ~= nil and dependencies.install_command ~= "" then
table.insert(children, ui.column({ gap = 4, fill = "surface/0.48", radius = 8, padding = 8 }, {
ui.label({
text = noctalia.tr("dependencies.command", {
manager = tostring(dependencies.package_manager or noctalia.tr("dependencies.package_manager")),
}),
fontSize = 10,
color = "on_surface_variant",
}),
ui.label({ text = tostring(dependencies.install_command), fontSize = 10, color = "on_surface", maxLines = 2 }),
}))
else
table.insert(children, ui.label({
text = noctalia.tr("dependencies.manual_install"),
fontSize = 11,
color = "on_surface_variant",
}))
end
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({
text = noctalia.tr("dependencies.install"),
variant = "primary",
onClick = "onInstallDependenciesClicked",
}))
table.insert(actions, ui.button({
text = noctalia.tr("dependencies.copy_command"),
variant = "outline",
onClick = "onCopyInstallCommandClicked",
}))
end
table.insert(actions, ui.button({
text = noctalia.tr("dependencies.recheck"),
variant = "ghost",
onClick = "onRefreshClicked",
}))
table.insert(children, ui.row({ gap = 8, align = "center" }, actions))
return ui.column({
gap = 9,
padding = 11,
radius = 10,
fill = "error/0.10",
border = "error/0.32",
borderWidth = 1,
}, children)
end
local function collectorCard(collector)
if showCollectorSettings or type(collector) ~= "table" then
return nil
end
local status = tostring(collector.status or "not-installed")
if status == "healthy" or status == "disabled" then
return nil
end
local color = status == "stale" and "error" or "secondary"
local actions = {}
local actionKey = status == "stale" and "collector.start"
or (collector.installed and "collector.upgrade" or "collector.install")
local actionHandler = status == "stale" and "onStartCollectorClicked"
or "onInstallCollectorClicked"
table.insert(actions, ui.button({
text = noctalia.tr(actionKey), variant = "primary", controlSize = "sm", onClick = actionHandler,
}))
table.insert(actions, ui.button({
text = noctalia.tr("collector.copy_install"), variant = "outline", controlSize = "sm",
onClick = "onCopyCollectorCommandClicked",
}))
if collector.installed then
table.insert(actions, ui.button({
text = noctalia.tr("collector.remove"), variant = confirmUninstall and "destructive" or "ghost",
controlSize = "sm", onClick = "onUninstallCollectorClicked",
}))
end
table.insert(actions, ui.button({
text = noctalia.tr("dependencies.recheck"), variant = "ghost", controlSize = "sm",
onClick = "onRefreshClicked",
}))
local children = {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "shield-cog", size = 17, color = color }),
ui.column({ gap = 1, flexGrow = 1 }, {
ui.label({ text = noctalia.tr("collector.title"), fontWeight = "bold", color = color }),
ui.label({
text = noctalia.tr("collector.status_" .. status:gsub("%-", "_"), {
version = tostring(collector.version or collector.expected_version or ""),
}),
fontSize = 11, color = "on_surface_variant",
}),
}),
}),
}
if confirmUninstall then
table.insert(children, ui.label({
text = noctalia.tr("collector.remove_confirm"), fontSize = 11, color = "error",
}))
end
table.insert(children, ui.row({ gap = 7, align = "center" }, actions))
return ui.column({
gap = 8, padding = 10, radius = 10, fill = color .. "/0.08",
border = color .. "/0.24", borderWidth = 1,
}, children)
end
local function collectorSettingsCard(collector)
if not showCollectorSettings or type(collector) ~= "table" then
return nil
end
local status = tostring(collector.status or "not-installed")
local enabled = collector.enabled == true
local color = status == "healthy" and "primary"
or (status == "disabled" and "on_surface_variant"
or (status == "stale" and "error" or "secondary"))
local actions = {
ui.button({ text = noctalia.tr("collector.open_settings"), glyph = "settings", variant = "outline",
controlSize = "sm", onClick = "onOpenPluginSettingsClicked" }),
}
if enabled and status == "not-installed" then
table.insert(actions, 1, ui.button({ text = noctalia.tr("collector.install"), variant = "primary",
controlSize = "sm", onClick = "onInstallCollectorClicked" }))
elseif enabled and status == "upgrade-required" then
table.insert(actions, 1, ui.button({ text = noctalia.tr("collector.upgrade"), variant = "primary",
controlSize = "sm", onClick = "onInstallCollectorClicked" }))
elseif enabled and status == "stale" and collector.enable_command ~= nil then
table.insert(actions, 1, ui.button({ text = noctalia.tr("collector.start"), variant = "primary",
controlSize = "sm", onClick = "onStartCollectorClicked" }))
elseif status == "healthy" and collector.disable_command ~= nil then
table.insert(actions, 1, ui.button({ text = noctalia.tr("collector.pause"), variant = "ghost",
controlSize = "sm", onClick = "onPauseCollectorClicked" }))
elseif not enabled and collector.installed and collector.disable_command ~= nil then
table.insert(actions, 1, ui.button({ text = noctalia.tr("collector.stop_service"), variant = "ghost",
controlSize = "sm", onClick = "onPauseCollectorClicked" }))
end
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" }, {
ui.glyph({ name = "shield-cog", size = 17, color = color }),
ui.column({ gap = 1, flexGrow = 1 }, {
ui.label({ text = noctalia.tr("collector.settings_title"), fontWeight = "bold", color = color }),
ui.label({
text = noctalia.tr("collector.status_" .. status:gsub("%-", "_"), {
version = tostring(collector.version or collector.expected_version or ""),
}),
fontSize = 11, color = "on_surface_variant", maxLines = 2,
}),
}),
}),
ui.row({ gap = 8, padding = 8, radius = 8, fill = "surface/0.42", align = "center" }, {
ui.glyph({ name = "server", size = 14, color = "on_surface_variant" }),
ui.column({ gap = 2, flexGrow = 1 }, {
ui.label({ text = noctalia.tr("collector.basic_title"), fontWeight = "bold", fontSize = 11 }),
ui.label({ text = noctalia.tr("collector.basic_features"), fontSize = 10,
color = "on_surface_variant", maxLines = 2 }),
}),
}),
ui.row({ gap = 8, padding = 8, radius = 8, fill = "primary/0.10", align = "center" }, {
ui.glyph({ name = "shield-check", size = 14, color = "primary" }),
ui.column({ gap = 2, flexGrow = 1 }, {
ui.label({ text = noctalia.tr("collector.full_title"), fontWeight = "bold", fontSize = 11,
color = "primary" }),
ui.label({ text = noctalia.tr("collector.full_features"), fontSize = 10,
color = "on_surface_variant", maxLines = 3 }),
}),
}),
ui.label({ text = noctalia.tr("collector.settings_hint"), fontSize = 10,
color = "on_surface_variant", maxLines = 2 }),
ui.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",
onClick = "onUninstallCollectorClicked" }))
end
return ui.column({
gap = 8, padding = 10, radius = 10, fill = "surface_variant/0.24",
border = "outline/0.38", borderWidth = 1,
}, children)
end
local function trendCard(drive)
local historyDrives = type(history.drives) == "table" and history.drives or {}
local entry = historyDrives[driveId(drive)]
local samples = type(entry) == "table" and entry.samples or {}
if #samples < MIN_TREND_SAMPLES then
return nil
end
local temperatures = {}
local life = {}
local hasTemperatures = true
local hasLife = true
for _, sample in ipairs(samples) do
local temperature = tonumber(sample.hotspot_temperature_c or sample.temperature_c)
local remaining = tonumber(sample.remaining_life_percent)
if temperature == nil then
hasTemperatures = false
else
table.insert(temperatures, clamp(temperature / 100, 0, 1))
end
if remaining == nil then
hasLife = false
else
table.insert(life, clamp(remaining / 100, 0, 1))
end
end
if not hasTemperatures then temperatures = {} end
if not hasLife then life = {} end
if #temperatures < MIN_TREND_SAMPLES and #life < MIN_TREND_SAMPLES then
return nil
end
local primaryValues = #temperatures >= MIN_TREND_SAMPLES and temperatures or life
local secondaryValues = #temperatures >= MIN_TREND_SAMPLES and #life >= MIN_TREND_SAMPLES and life or nil
local primaryColor = #temperatures >= MIN_TREND_SAMPLES and "secondary" or "primary"
local legend = {}
if #temperatures >= MIN_TREND_SAMPLES then
table.insert(legend, ui.label({ text = "● " .. noctalia.tr("history.hotspot"), fontSize = 10, color = "secondary" }))
end
if #life >= MIN_TREND_SAMPLES then
table.insert(legend, ui.label({ text = "● " .. noctalia.tr("history.life"), fontSize = 10, color = "primary" }))
end
return ui.column({ gap = 5, padding = 8, radius = 9, fill = "surface/0.38" }, {
ui.row({ justify = "space_between", align = "center" }, {
ui.label({ text = noctalia.tr("history.title"), fontSize = 11, fontWeight = "bold" }),
ui.label({ text = noctalia.tr("history.samples", { count = #samples }), fontSize = 10, color = "on_surface_variant" }),
}),
ui.graph({ values = primaryValues, values2 = secondaryValues, color = primaryColor, color2 = "primary",
lineWidth = 2, fillOpacity = 0.10, height = 44 }),
ui.row({ gap = 12, align = "center" }, legend),
})
end
local function selfTestCard(drive)
local firmwareState = tostring(drive.self_test_state or "unsupported")
local launch = selfTestLaunch ~= nil and selfTestLaunch.drive_id == driveId(drive) and selfTestLaunch or nil
local state = launch ~= nil and launch.state ~= "failed" and firmwareState ~= "running"
and launch.state or firmwareState
local color = state == "failed" and "error"
or ((state == "running" or state == "authorizing" or state == "starting") and "secondary" or "primary")
local collector = snapshot and snapshot.system_collector or {}
local helper = collector.helper_available == true
local authorization = collector.authorization_available == true
local canStart = helper and authorization
local pending = pendingSelfTest ~= nil and pendingSelfTest.drive_id == driveId(drive)
local completion = tonumber(drive.self_test_completion_percent)
local status = drive.self_test_status or noctalia.tr("self_test.unavailable")
if launch ~= nil and firmwareState ~= "running" then
if launch.state == "authorizing" then
status = noctalia.tr("self_test.authorizing")
elseif launch.state == "starting" then
status = noctalia.tr("self_test.starting")
elseif launch.state == "failed" then
status = launch.error or noctalia.tr("self_test.launch_failed")
end
end
local children = {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "stethoscope", size = 15, color = color }),
ui.column({ gap = 1, flexGrow = 1 }, {
ui.label({ text = noctalia.tr("self_test.title"), fontSize = 11, fontWeight = "bold" }),
ui.label({ text = tostring(status),
fontSize = 10, color = "on_surface_variant" }),
}),
}),
}
if firmwareState == "running" and completion ~= nil then
table.insert(children, ui.column({ gap = 4 }, {
ui.row({ justify = "space_between", align = "center" }, {
ui.label({ text = noctalia.tr("self_test.progress"), fontSize = 10, color = "on_surface_variant" }),
ui.label({ text = formatPercent(completion), fontSize = 10, fontWeight = "bold", color = color }),
}),
ui.progress({ progress = clamp(completion / 100, 0, 1), fill = color, height = 5 }),
}))
end
if drive.self_test_supported then
if pending then
table.insert(children, ui.label({
text = noctalia.tr("self_test.confirm", { type = noctalia.tr("self_test." .. pendingSelfTest.kind) }),
fontSize = 11, color = "secondary",
}))
table.insert(children, ui.row({ gap = 7 }, {
ui.button({ text = noctalia.tr("self_test.confirm_action"), variant = "primary", controlSize = "sm",
onClick = "onConfirmSelfTestClicked" }),
ui.button({ text = noctalia.tr("self_test.cancel"), variant = "ghost", controlSize = "sm",
onClick = "onCancelSelfTestClicked" }),
}))
else
table.insert(children, ui.row({ gap = 7 }, {
ui.button({ text = noctalia.tr("self_test.short"), variant = "outline", controlSize = "sm",
enabled = canStart and firmwareState ~= "running" and state ~= "authorizing" and state ~= "starting",
onClick = "onStartShortSelfTestClicked" }),
ui.button({ text = noctalia.tr("self_test.long"), variant = "outline", controlSize = "sm",
enabled = canStart and firmwareState ~= "running" and state ~= "authorizing" and state ~= "starting",
onClick = "onStartLongSelfTestClicked" }),
}))
if not helper then
table.insert(children, ui.label({ text = noctalia.tr("self_test.helper_required"),
fontSize = 10, color = "on_surface_variant" }))
elseif not authorization then
table.insert(children, ui.label({ text = noctalia.tr("self_test.authorization_required"),
fontSize = 10, color = "error" }))
end
end
end
return ui.column({ gap = 7, padding = 9, radius = 9, fill = color .. "/0.08",
border = color .. "/0.20", borderWidth = 1 }, children)
end
local function preferenceEditor(drive)
local thresholdFields = {
ui.column({ gap = 3, flexGrow = 1 }, {
ui.label({ text = noctalia.tr("preferences.warning_temperature"), fontSize = 10, color = "on_surface_variant" }),
ui.input({ key = driveId(drive) .. "-warning", value = warningDraft, placeholder = "65",
controlSize = "sm", onChange = "onWarningThresholdChanged" }),
}),
ui.column({ gap = 3, flexGrow = 1 }, {
ui.label({ text = noctalia.tr("preferences.critical_temperature"), fontSize = 10, color = "on_surface_variant" }),
ui.input({ key = driveId(drive) .. "-critical", value = criticalDraft, placeholder = "80",
controlSize = "sm", onChange = "onCriticalThresholdChanged" }),
}),
}
if drive.kind ~= "hdd" then
table.insert(thresholdFields, ui.column({ gap = 3, flexGrow = 1 }, {
ui.label({ text = noctalia.tr("preferences.life_warning"), fontSize = 10, color = "on_surface_variant" }),
ui.input({ key = driveId(drive) .. "-life", value = lifeDraft, placeholder = "20",
controlSize = "sm", onChange = "onLifeThresholdChanged" }),
}))
end
return ui.column({ gap = 8, padding = 10, radius = 9, fill = "primary/0.07",
border = "primary/0.24", borderWidth = 1 }, {
ui.row({ justify = "space_between", align = "center" }, {
ui.label({ text = noctalia.tr("preferences.title"), fontWeight = "bold", color = "primary" }),
ui.row({ gap = 5 }, {
ui.button({ glyph = "arrow-up", tooltip = noctalia.tr("preferences.move_up"), variant = "ghost",
controlSize = "sm", onClick = "onMoveDriveUpClicked" }),
ui.button({ glyph = "arrow-down", tooltip = noctalia.tr("preferences.move_down"), variant = "ghost",
controlSize = "sm", onClick = "onMoveDriveDownClicked" }),
}),
}),
ui.label({ text = noctalia.tr("preferences.alias"), fontSize = 10, color = "on_surface_variant" }),
ui.input({ key = driveId(drive) .. "-alias", value = aliasDraft,
placeholder = drive.model or drive.device, controlSize = "sm", onChange = "onAliasChanged" }),
ui.row({ gap = 8, align = "center" }, thresholdFields),
ui.row({ gap = 12, align = "center" }, {
ui.toggle({ checked = alertsDraft, onChange = "onDriveAlertsChanged" }),
ui.label({ text = noctalia.tr("preferences.alerts"), fontSize = 11, flexGrow = 1 }),
ui.toggle({ checked = presenceDraft, onChange = "onPresenceAlertsChanged" }),
ui.label({ text = noctalia.tr("preferences.presence"), fontSize = 11, flexGrow = 1 }),
}),
ui.row({ gap = 7, justify = "end" }, {
ui.button({ text = noctalia.tr("preferences.hide"), variant = "ghost", controlSize = "sm",
onClick = "onHideDriveClicked" }),
ui.button({ text = noctalia.tr("self_test.cancel"), variant = "ghost", controlSize = "sm",
onClick = "onCancelDrivePreferencesClicked" }),
ui.button({ text = noctalia.tr("preferences.save"), variant = "primary", controlSize = "sm",
onClick = "onSaveDrivePreferencesClicked" }),
}),
})
end
local function driveCard(drive, index)
local id = driveId(drive)
local expanded = expandedDriveId == id
local isHdd = drive.kind == "hdd"
local healthText, healthColor, healthIcon = healthInfo(drive)
local temperature = tonumber(drive.temperature_c)
local hotspot = tonumber(drive.hotspot_temperature_c) or temperature
local alertTemperature = noctalia.getConfig("use_hotspot_temperature") == false and temperature or hotspot
local tempColor = temperatureColor(alertTemperature, drive)
local tempText = hotspot ~= nil and string.format("%.0f °C", hotspot) or noctalia.tr("common.not_available")
if temperature ~= nil and hotspot ~= nil and hotspot > temperature then
tempText = noctalia.tr("metrics.hotspot_and_composite", {
hotspot = string.format("%.0f °C", hotspot),
composite = string.format("%.0f °C", temperature),
})
end
local life = tonumber(drive.remaining_life_percent)
local lifeText = formatPercent(life)
if drive.remaining_life_estimated then
lifeText = "~" .. lifeText
end
local lifeWarning = number(drive.life_warning_percent,
number(noctalia.getConfig("life_warning_percent"), 20))
local lifeColor = life ~= nil and (life <= 10 and "error"
or (life <= lifeWarning and "secondary" or "primary")) or "on_surface_variant"
local storage = tonumber(drive.storage_usage_percent)
local storageColor = storage ~= nil and (storage >= 95 and "error" or (storage >= 85 and "secondary" or "primary")) or "on_surface_variant"
local storageText = storage ~= nil and formatPercent(storage) or noctalia.tr("storage.not_mounted")
local transport = string.upper(tostring(drive.transport or "unknown"))
local device = tostring(drive.device or "")
local details = {
metric("temperature", noctalia.tr("metrics.temperature"), tempText, tempColor),
metric(healthIcon, noctalia.tr("metrics.smart_health"), healthText, healthColor),
}
local progressRows = {}
if not isHdd then
table.insert(progressRows, progressMetric(
drive.remaining_life_estimated and noctalia.tr("metrics.life_remaining_estimated") or noctalia.tr("metrics.life_remaining"),
life,
lifeText,
lifeColor
))
end
table.insert(progressRows, progressMetric(noctalia.tr("metrics.storage_used"), storage, storageText, storageColor))
local ioDetails = {}
if drive.data_written_bytes ~= nil then
table.insert(ioDetails, metric("database-export", noctalia.tr("metrics.data_written"), formatBytes(drive.data_written_bytes)))
end
if drive.data_read_bytes ~= nil then
table.insert(ioDetails, metric("database-import", noctalia.tr("metrics.data_read"), formatBytes(drive.data_read_bytes)))
end
local wearDetails = {}
if drive.percentage_used ~= nil then
table.insert(wearDetails, metric(
"gauge",
noctalia.tr("metrics.endurance_used"),
formatPercent(drive.percentage_used),
number(drive.percentage_used, 0) >= 90 and "error" or "on_surface"
))
end
if drive.available_spare_percent ~= nil then
local spare = number(drive.available_spare_percent, 100)
local spareThreshold = number(drive.available_spare_threshold_percent, 10)
table.insert(wearDetails, metric(
"shield-check",
noctalia.tr("metrics.available_spare"),
formatPercent(drive.available_spare_percent),
spare <= math.max(5, spareThreshold / 2) and "error"
or (spare <= spareThreshold and "secondary" or "on_surface")
))
end
local powerDetails = {
metric("clock", noctalia.tr("metrics.power_on"), formatPowerOn(drive)),
}
local thermalDetails = {}
if type(drive.temperature_sensors_c) == "table" and #drive.temperature_sensors_c > 0 then
local sensors = {}
for sensorIndex, sensor in ipairs(drive.temperature_sensors_c) do
table.insert(sensors, string.format("S%d %.0f°", sensorIndex, number(sensor, 0)))
end
table.insert(thermalDetails, metric("temperature-sun", noctalia.tr("metrics.temperature_sensors"),
table.concat(sensors, " · "), tempColor))
end
if drive.device_warning_temperature_c ~= nil or drive.device_critical_temperature_c ~= nil then
local warningLimit = tonumber(drive.device_warning_temperature_c)
local criticalLimit = tonumber(drive.device_critical_temperature_c)
table.insert(thermalDetails, metric("temperature-cog", noctalia.tr("metrics.device_temperature_limits"),
(warningLimit ~= nil and string.format("%.0f°", warningLimit) or noctalia.tr("common.not_available"))
.. " / "
.. (criticalLimit ~= nil and string.format("%.0f°", criticalLimit) or noctalia.tr("common.not_available"))))
end
if drive.power_cycles ~= nil then
table.insert(powerDetails, metric("repeat", noctalia.tr("metrics.power_cycles"), tostring(math.floor(number(drive.power_cycles, 0)))))
end
if drive.start_stop_count ~= nil then
table.insert(powerDetails, metric("player-play", noctalia.tr("metrics.start_stop_count"),
tostring(math.floor(number(drive.start_stop_count, 0)))))
end
if drive.load_cycle_count ~= nil then
table.insert(powerDetails, metric("refresh", noctalia.tr("metrics.load_cycle_count"),
tostring(math.floor(number(drive.load_cycle_count, 0)))))
end
local errorDetails = {}
if drive.unsafe_shutdowns ~= nil then
table.insert(errorDetails, metric(
"bolt-off",
noctalia.tr("metrics.unsafe_shutdowns"),
tostring(math.floor(number(drive.unsafe_shutdowns, 0))),
number(drive.unsafe_shutdowns, 0) > 0 and "secondary" or "on_surface"
))
end
if drive.media_errors ~= nil then
table.insert(errorDetails, metric(
"alert-circle",
noctalia.tr("metrics.media_errors"),
tostring(math.floor(number(drive.media_errors, 0))),
number(drive.media_errors, 0) > 0 and "error" or "on_surface"
))
end
local integrityDetails = {}
if drive.error_log_entries ~= nil and drive.error_log_entries ~= drive.media_errors then
table.insert(integrityDetails, metric(
"file-alert",
noctalia.tr("metrics.error_log_entries"),
tostring(math.floor(number(drive.error_log_entries, 0))),
number(drive.error_log_entries, 0) > 0 and "secondary" or "on_surface"
))
end
if drive.critical_warning ~= nil then
table.insert(integrityDetails, metric(
"alert-octagon",
noctalia.tr("metrics.critical_warning"),
string.format("0x%02X", math.floor(number(drive.critical_warning, 0))),
number(drive.critical_warning, 0) > 0 and "error" or "primary"
))
end
if drive.reallocated_sectors ~= nil then
table.insert(integrityDetails, metric(
"replace",
noctalia.tr("metrics.reallocated"),
tostring(math.floor(number(drive.reallocated_sectors, 0))),
number(drive.reallocated_sectors, 0) > 0 and "error" or "primary"
))
end
if drive.pending_sectors ~= nil then
table.insert(integrityDetails, metric(
"hourglass",
noctalia.tr("metrics.pending"),
tostring(math.floor(number(drive.pending_sectors, 0))),
number(drive.pending_sectors, 0) > 0 and "error" or "primary"
))
end
if drive.uncorrectable_errors ~= nil then
table.insert(integrityDetails, metric(
"alert-hexagon",
noctalia.tr("metrics.uncorrectable"),
tostring(math.floor(number(drive.uncorrectable_errors, 0))),
number(drive.uncorrectable_errors, 0) > 0 and "error" or "primary"
))
end
if drive.spin_retry_count ~= nil then
table.insert(integrityDetails, metric(
"repeat",
noctalia.tr("metrics.spin_retry_count"),
tostring(math.floor(number(drive.spin_retry_count, 0))),
number(drive.spin_retry_count, 0) > 0 and "error" or "primary"
))
end
if drive.command_timeout_count ~= nil then
table.insert(integrityDetails, metric(
"clock-exclamation",
noctalia.tr("metrics.command_timeout_count"),
tostring(math.floor(number(drive.command_timeout_count, 0))),
number(drive.command_timeout_count, 0) > 0 and "secondary" or "primary"
))
end
if drive.interface_crc_errors ~= nil then
table.insert(integrityDetails, metric(
"link-off",
noctalia.tr("metrics.interface_crc_errors"),
tostring(math.floor(number(drive.interface_crc_errors, 0))),
number(drive.interface_crc_errors, 0) > 0 and "secondary" or "primary"
))
end
local identity = {
ui.label({ text = driveName(drive), fontWeight = "bold", color = "on_surface" }),
ui.label({
text = transport .. " • " .. formatBytes(drive.capacity_bytes) .. " • " .. device,
fontSize = 11,
color = "on_surface_variant",
}),
}
local compactMetrics = {
metric("temperature", isHdd and noctalia.tr("metrics.temperature") or noctalia.tr("metrics.hotspot_temperature"),
tempText, tempColor),
isHdd and metric("clock", noctalia.tr("metrics.power_on"), formatPowerOn(drive))
or metric("battery-vertical", noctalia.tr("metrics.life_remaining"), lifeText, lifeColor),
metric(healthIcon, noctalia.tr("metrics.smart_health"), healthText, healthColor),
}
local children = {
ui.row({ gap = 10, align = "center" }, {
ui.column({ width = 36, height = 36, radius = 10, fill = tempColor .. "/0.16", align = "center", justify = "center" }, {
ui.glyph({ name = drive.kind == "ssd" and "server" or "disc", size = 19, color = tempColor }),
}),
ui.column({ gap = 2, flexGrow = 1 }, identity),
ui.row({ fill = healthColor .. "/0.16", radius = 8, paddingH = 8, paddingV = 4, align = "center", gap = 4 }, {
ui.glyph({ name = healthIcon, size = 12, color = healthColor }),
ui.label({ text = healthText, fontSize = 11, fontWeight = "bold", color = healthColor }),
}),
ui.button({
glyph = expanded and "chevron-up" or "chevron-down", variant = "ghost", controlSize = "sm",
tooltip = noctalia.tr(expanded and "panel.collapse" or "panel.expand"),
onClick = function() toggleDriveAt(index) end,
}),
}),
ui.row({ gap = 14, align = "center" }, compactMetrics),
}
if expanded then
if drive.serial ~= nil and drive.serial ~= "" then
table.insert(children, ui.label({
text = noctalia.tr("metrics.serial", { value = tostring(drive.serial) }),
fontSize = 10, color = "on_surface_variant/0.78", maxLines = 1,
}))
end
if type(drive.mount_points) == "table" and #drive.mount_points > 0 then
table.insert(children, ui.label({
text = noctalia.tr("metrics.mounted_at", { paths = table.concat(drive.mount_points, " · ") }),
fontSize = 10, color = "primary/0.88", maxLines = 2,
}))
end
table.insert(children, ui.row({ gap = 16, align = "center" }, details))
table.insert(children, ui.row({ gap = 16, align = "center" }, progressRows))
appendMetricRows(children, wearDetails)
appendMetricRows(children, ioDetails)
appendMetricRows(children, powerDetails)
appendMetricRows(children, thermalDetails)
appendMetricRows(children, errorDetails)
appendMetricRows(children, integrityDetails)
local trends = trendCard(drive)
if trends ~= nil then
table.insert(children, trends)
end
table.insert(children, selfTestCard(drive))
table.insert(children, ui.row({ gap = 7, justify = "end" }, {
ui.button({ text = noctalia.tr("preferences.edit"), glyph = "settings", variant = "ghost",
controlSize = "sm", onClick = "onEditExpandedDriveClicked" }),
}))
end
if drive.smart_sleeping then
table.insert(children, ui.row({ gap = 7, align = "center", fill = "primary/0.08", radius = 8, padding = 8 }, {
ui.glyph({ name = "moon", size = 13, color = "primary" }),
ui.label({ text = noctalia.tr("smart.sleeping"), fontSize = 11, color = "on_surface_variant", flexGrow = 1 }),
}))
elseif not drive.smart_available then
table.insert(children, ui.row({ gap = 7, align = "center", fill = "secondary/0.12", radius = 8, padding = 8 }, {
ui.glyph({ name = "info-circle", size = 13, color = "secondary" }),
ui.label({ text = noctalia.tr("smart.access_limited"), fontSize = 11, color = "on_surface_variant", flexGrow = 1 }),
}))
end
if expanded and drive.smart_completeness == "partial" then
table.insert(children, ui.row({ gap = 7, align = "center", fill = "secondary/0.12", radius = 8, padding = 8 }, {
ui.glyph({ name = "file-alert", size = 13, color = "secondary" }),
ui.label({ text = noctalia.tr("smart.partial_details"), fontSize = 11, color = "on_surface_variant", flexGrow = 1 }),
}))
for _, message in ipairs(drive.smart_messages or {}) do
table.insert(children, ui.label({ text = "• " .. tostring(message.message or message),
fontSize = 10, color = "on_surface_variant", maxLines = 3 }))
end
end
if expanded and editingDriveId == id then
table.insert(children, preferenceEditor(drive))
end
return ui.column({
key = tostring(drive.id or device),
gap = 12,
padding = 14,
radius = 14,
fill = "surface_variant/0.34",
border = "outline/0.38",
borderWidth = 1,
align = "stretch",
}, children)
end
local function visibleDrives()
local drives = {}
local showHdd = noctalia.getConfig("show_hdd") ~= false
if snapshot ~= nil and type(snapshot.disks) == "table" then
for _, drive in ipairs(snapshot.disks) do
if (drive.kind == "ssd" or showHdd) and drive.hidden ~= true then
table.insert(drives, drive)
end
end
end
return drives
end
local function hiddenDrivesCard()
local hidden = {}
if snapshot ~= nil and type(snapshot.disks) == "table" then
for _, drive in ipairs(snapshot.disks) do
if drive.hidden == true then
table.insert(hidden, drive)
end
end
end
if #hidden == 0 then
return nil
end
hiddenSelection = math.max(1, math.min(hiddenSelection, #hidden))
local options = {}
for _, drive in ipairs(hidden) do
table.insert(options, driveName(drive))
end
return ui.column({ gap = 7, padding = 10, radius = 9, fill = "surface_variant/0.22" }, {
ui.label({ text = noctalia.tr("preferences.hidden_drives"), fontWeight = "bold", fontSize = 11 }),
ui.row({ gap = 7, align = "center" }, {
ui.select({ options = options, selectedIndex = hiddenSelection - 1, controlSize = "sm",
flexGrow = 1, onChange = "onHiddenDriveSelected" }),
ui.button({ text = noctalia.tr("preferences.restore"), variant = "outline", controlSize = "sm",
onClick = "onRestoreHiddenDriveClicked" }),
}),
})
end
local function render()
if not opened then
return
end
local summary = snapshot and snapshot.summary or {}
local drives = visibleDrives()
currentDrives = drives
currentIssues = type(snapshot and snapshot.issues) == "table" and snapshot.issues or {}
local includeHdd = noctalia.getConfig("show_hdd") ~= false
local hottest = tonumber(includeHdd and summary.hottest_drive_temperature_c or summary.hottest_ssd_temperature_c)
local hottestDriveName = includeHdd and summary.hottest_drive_name or summary.hottest_ssd_drive_name
local remaining = tonumber(summary.worst_ssd_remaining_life_percent)
local lowestLifeDriveName = summary.worst_ssd_life_drive_name
local ssdCount = number(summary.ssd_count, 0)
local hddCount = includeHdd and number(summary.hdd_count, 0) or 0
local driveCount = ssdCount + hddCount
local smartAvailable = includeHdd and number(summary.smart_available_count,
number(summary.ssd_smart_available_count, 0) + number(summary.hdd_smart_available_count, 0))
or number(summary.ssd_smart_available_count, 0)
local headerStatus = snapshot and snapshot.collecting and noctalia.tr("panel.refreshing") or noctalia.tr("panel.updated", {
time = tostring(snapshot and snapshot.generated_at_local or noctalia.tr("common.never")),
})
local body = {}
if snapshot == nil then
table.insert(body, ui.column({ gap = 10, align = "center", justify = "center", flexGrow = 1 }, {
ui.glyph({ name = "server-off", size = 36, color = "on_surface_variant" }),
ui.label({ text = noctalia.tr("panel.waiting"), color = "on_surface_variant" }),
}))
else
local dependencyStatus = dependencyCard(snapshot.dependencies)
if dependencyStatus ~= nil then
table.insert(body, dependencyStatus)
end
local collectorStatus = collectorCard(snapshot.system_collector)
if collectorStatus ~= nil then
table.insert(body, collectorStatus)
end
local collectorSettings = collectorSettingsCard(snapshot.system_collector)
if collectorSettings ~= nil then
table.insert(body, collectorSettings)
end
local hottestColor = temperatureColor(hottest)
local summaryCards = {
summaryCard(tostring(driveCount), noctalia.tr("summary.drives"),
noctalia.tr("summary.drive_mix", { ssds = ssdCount, hdds = hddCount }), "primary"),
summaryCard(hottest ~= nil and string.format("%.0f °C", hottest) or "-- °C",
noctalia.tr("summary.hottest"), hottestDriveName, hottestColor),
}
if ssdCount > 0 then
table.insert(summaryCards, summaryCard(formatPercent(remaining),
noctalia.tr("summary.lowest_ssd_life"), lowestLifeDriveName, "secondary"))
end
table.insert(body, ui.row({ gap = 10, align = "stretch" }, summaryCards))
local alerts = alertsCard(currentIssues)
if alerts ~= nil then
table.insert(body, alerts)
end
local sleeping = number(summary.sleeping_count, 0)
if smartAvailable + sleeping < driveCount then
table.insert(body, ui.row({ gap = 8, padding = 10, radius = 10, fill = "secondary/0.12", align = "center" }, {
ui.glyph({ name = "shield-lock", size = 16, color = "secondary" }),
ui.column({ gap = 2, flexGrow = 1 }, {
ui.label({ text = noctalia.tr("smart.partial_title"), fontWeight = "bold", color = "secondary" }),
ui.label({ text = noctalia.tr("smart.partial_body", { available = smartAvailable,
total = driveCount - sleeping }), fontSize = 11, color = "on_surface_variant" }),
}),
}))
end
if snapshot.collector_error ~= nil and snapshot.collector_error ~= "" then
table.insert(body, ui.label({ text = tostring(snapshot.collector_error), color = "error", fontSize = 11 }))
end
if #drives == 0 then
table.insert(body, ui.label({ text = noctalia.tr("panel.no_drives"), color = "on_surface_variant" }))
else
for index, drive in ipairs(drives) do
table.insert(body, driveCard(drive, index))
end
end
local hiddenStatus = hiddenDrivesCard()
if hiddenStatus ~= nil then
table.insert(body, hiddenStatus)
end
end
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, {
ui.row({ align = "center", gap = 10 }, {
ui.column({ width = 38, height = 38, radius = 11, fill = "primary/0.16", align = "center", justify = "center" }, {
ui.glyph({ name = "server-2", size = 20, color = "primary" }),
}),
ui.column({ gap = 1, flexGrow = 1 }, {
ui.label({ text = noctalia.tr("panel.title"), fontSize = 17, fontWeight = "bold", color = "on_surface" }),
ui.label({ text = headerStatus, fontSize = 11, color = "on_surface_variant" }),
}),
ui.button({ glyph = "settings", variant = showCollectorSettings and "primary" or "ghost",
tooltip = noctalia.tr("collector.settings_title"), onClick = "onToggleCollectorSettingsClicked" }),
ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefreshClicked" }),
ui.button({ glyph = "x", variant = "ghost", onClick = "onCloseClicked" }),
}),
ui.scroll({ flexGrow = 1, gap = 12 }, body),
}))
end
local function selfTestSignature(drive)
if drive == nil then return "" end
return table.concat({
tostring(drive.self_test_state or ""),
tostring(drive.self_test_status or ""),
tostring(drive.self_test_lifetime_hours or ""),
tostring(drive.self_test_error_count or ""),
}, "|")
end
local function reconcileSelfTestLaunch(value)
if selfTestLaunch == nil or selfTestLaunch.state == "authorizing" or selfTestLaunch.state == "failed" then
return
end
local drive = nil
for _, candidate in ipairs(value and value.disks or {}) do
if driveId(candidate) == selfTestLaunch.drive_id then
drive = candidate
break
end
end
if drive == nil then return end
if tostring(drive.self_test_state) == "running" then
selfTestLaunch.observed_running = true
return
end
local generated = number(value and value.generated_at_epoch, 0)
local changed = generated > number(selfTestLaunch.accepted_snapshot_epoch, 0)
and selfTestSignature(drive) ~= tostring(selfTestLaunch.baseline_signature or "")
local expired = os.time() - number(selfTestLaunch.accepted_at, os.time()) >= 180
if selfTestLaunch.observed_running or changed or expired then
selfTestLaunch = nil
end
end
noctalia.state.watch("snapshot", function(value)
snapshot = value
reconcileSelfTestLaunch(value)
render()
end)
noctalia.state.watch("drive_history", function(value)
history = type(value) == "table" and value or { drives = {} }
render()
end)
noctalia.state.watch("drive_preferences", function(value)
if type(value) == "table" then
preferences = value
end
render()
end)
toggleDriveAt = function(index)
local drive = currentDrives[index]
if drive == nil then
return
end
local id = driveId(drive)
if expandedDriveId == id then
expandedDriveId = nil
editingDriveId = nil
pendingSelfTest = nil
else
expandedDriveId = id
end
render()
end
local function sendDismissRequest(request)
dismissRequestNonce += 1
request.nonce = dismissRequestNonce
noctalia.state.set("dismiss_alert_request", request)
end
dismissAlertAt = function(index)
local issue = currentIssues[index]
if issue ~= nil and issue.id ~= nil then
sendDismissRequest({ id = tostring(issue.id) })
end
end
function onDismissAllAlertsClicked()
if #currentIssues > 0 then
sendDismissRequest({ all = true })
end
end
local function expandedDrive()
for _, drive in ipairs(currentDrives) do
if driveId(drive) == expandedDriveId then
return drive
end
end
return nil
end
local function setPreferenceDrafts(drive)
local entry = preferenceFor(driveId(drive))
aliasDraft = tostring(entry.alias or "")
warningDraft = entry.warning_temperature ~= nil and tostring(entry.warning_temperature) or ""
criticalDraft = entry.critical_temperature ~= nil and tostring(entry.critical_temperature) or ""
lifeDraft = entry.life_warning_percent ~= nil and tostring(entry.life_warning_percent) or ""
alertsDraft = entry.alerts_enabled ~= false
presenceDraft = entry.presence_alert_enabled == nil and drive.presence_alert_enabled == true
or entry.presence_alert_enabled == true
end
function onEditExpandedDriveClicked()
local drive = expandedDrive()
if drive == nil then return end
editingDriveId = driveId(drive)
setPreferenceDrafts(drive)
render()
end
function onAliasChanged(value) aliasDraft = tostring(value or "") end
function onWarningThresholdChanged(value) warningDraft = tostring(value or "") end
function onCriticalThresholdChanged(value) criticalDraft = tostring(value or "") end
function onLifeThresholdChanged(value) lifeDraft = tostring(value or "") end
function onDriveAlertsChanged(value)
if editingDriveId ~= nil then
alertsDraft = value == true or tostring(value) == "true"
render()
end
end
function onPresenceAlertsChanged(value)
if editingDriveId ~= nil then
presenceDraft = value == true or tostring(value) == "true"
render()
end
end
local function optionalThreshold(value, minimum, maximum)
local trimmed = noctalia.string.trim(tostring(value or ""))
if trimmed == "" then return nil end
local parsed = tonumber(trimmed)
if parsed == nil then return false end
return clamp(parsed, minimum, maximum)
end
function onSaveDrivePreferencesClicked()
if editingDriveId == nil then return end
local warning = optionalThreshold(warningDraft, 30, 100)
local critical = optionalThreshold(criticalDraft, 31, 110)
local life = optionalThreshold(lifeDraft, 1, 100)
if warning == false or critical == false or life == false or (warning ~= nil and critical ~= nil and critical <= warning) then
noctalia.notifyError(noctalia.tr("preferences.title"), noctalia.tr("preferences.invalid_thresholds"))
return
end
local entry = preferenceFor(editingDriveId)
local previous = {}
for _, key in ipairs(EDITABLE_PREFERENCE_FIELDS) do previous[key] = entry[key] end
entry.alias = noctalia.string.trim(aliasDraft)
entry.warning_temperature = warning
entry.critical_temperature = critical
entry.life_warning_percent = life
entry.alerts_enabled = alertsDraft
entry.presence_alert_enabled = presenceDraft
if savePreferences() then
editingDriveId = nil
noctalia.notify(noctalia.tr("preferences.title"), noctalia.tr("preferences.saved"))
else
for _, key in ipairs(EDITABLE_PREFERENCE_FIELDS) do entry[key] = previous[key] end
end
render()
end
function onCancelDrivePreferencesClicked()
editingDriveId = nil
render()
end
local function ensureOrder()
preferences.order = type(preferences.order) == "table" and preferences.order or {}
local seen = {}
for _, id in ipairs(preferences.order) do seen[tostring(id)] = true end
for _, drive in ipairs(snapshot and snapshot.disks or {}) do
local id = driveId(drive)
if not seen[id] then
table.insert(preferences.order, id)
seen[id] = true
end
end
end
local function moveEditingDrive(delta)
if editingDriveId == nil then return end
ensureOrder()
for index, id in ipairs(preferences.order) do
if tostring(id) == editingDriveId then
local destination = math.max(1, math.min(#preferences.order, index + delta))
preferences.order[index], preferences.order[destination] = preferences.order[destination], preferences.order[index]
if not savePreferences() then
preferences.order[index], preferences.order[destination] = preferences.order[destination], preferences.order[index]
render()
end
return
end
end
end
function onMoveDriveUpClicked() moveEditingDrive(-1) end
function onMoveDriveDownClicked() moveEditingDrive(1) end
function onHideDriveClicked()
if editingDriveId == nil then return end
local entry = preferenceFor(editingDriveId)
local previous = entry.hidden
entry.hidden = true
if savePreferences() then
editingDriveId = nil
expandedDriveId = nil
else
entry.hidden = previous
end
render()
end
function onHiddenDriveSelected(value)
hiddenSelection = math.max(1, (tonumber(value) or 0) + 1)
end
function onRestoreHiddenDriveClicked()
local hidden = {}
for _, drive in ipairs(snapshot and snapshot.disks or {}) do
if drive.hidden == true then table.insert(hidden, drive) end
end
local drive = hidden[hiddenSelection]
if drive ~= nil then
local entry = preferenceFor(driveId(drive))
local previous = entry.hidden
entry.hidden = false
if savePreferences() then
hiddenSelection = 1
else
entry.hidden = previous
end
render()
end
end
function onStartShortSelfTestClicked()
local drive = expandedDrive()
if drive ~= nil then
if selfTestLaunch ~= nil and selfTestLaunch.drive_id == driveId(drive) and selfTestLaunch.state == "failed" then
selfTestLaunch = nil
end
pendingSelfTest = { drive_id = driveId(drive), kind = "short", device = drive.smart_device or drive.device }
render()
end
end
function onStartLongSelfTestClicked()
local drive = expandedDrive()
if drive ~= nil then
if selfTestLaunch ~= nil and selfTestLaunch.drive_id == driveId(drive) and selfTestLaunch.state == "failed" then
selfTestLaunch = nil
end
pendingSelfTest = { drive_id = driveId(drive), kind = "long", device = drive.smart_device or drive.device }
render()
end
end
function onCancelSelfTestClicked()
pendingSelfTest = nil
render()
end
local function selfTestError(result)
if result.timedOut == true then
return noctalia.tr("self_test.authorization_timeout")
end
if tonumber(result.exitCode) == 126 then
return noctalia.tr("self_test.authorization_cancelled")
end
local detail = noctalia.string.trim(tostring(result.stderr or ""))
if detail == "" then detail = noctalia.string.trim(tostring(result.stdout or "")) end
if #detail > 240 then detail = detail:sub(1, 237) .. "..." end
return detail ~= "" and detail or noctalia.tr("self_test.launch_failed")
end
function onConfirmSelfTestClicked()
if pendingSelfTest == nil then return end
local request = pendingSelfTest
pendingSelfTest = nil
local drive = expandedDrive()
selfTestLaunch = {
drive_id = request.drive_id,
kind = request.kind,
state = "authorizing",
baseline_signature = selfTestSignature(drive),
}
render()
local command = "pkexec /usr/local/libexec/noctalia-drive-health/smart-action.sh "
.. shellQuote(request.kind) .. " " .. shellQuote(request.device)
local launched = noctalia.runAsync(command, function(result)
if selfTestLaunch == nil or selfTestLaunch.drive_id ~= request.drive_id then return end
local exitCode = tonumber(result.exitCode)
-- smartctl bits 3-7 report existing drive-health findings, not whether the
-- self-test command was accepted. Bits 0-2 are the command/device failures.
local accepted = result.timedOut ~= true and exitCode ~= nil and exitCode >= 0 and exitCode % 8 == 0
if accepted then
selfTestLaunch.state = "starting"
selfTestLaunch.accepted_at = os.time()
selfTestLaunch.accepted_snapshot_epoch = number(snapshot and snapshot.generated_at_epoch, 0)
noctalia.notify(noctalia.tr("self_test.title"), noctalia.tr("self_test.started_background"))
local nonce = number(noctalia.state.get("refresh_nonce"), 0) + 1
noctalia.state.set("refresh_nonce", nonce)
else
selfTestLaunch.state = "failed"
selfTestLaunch.error = selfTestError(result)
noctalia.notifyError(noctalia.tr("self_test.title"), selfTestLaunch.error)
end
render()
end, 120000)
if not launched then
selfTestLaunch.state = "failed"
selfTestLaunch.error = noctalia.tr("self_test.launch_failed")
noctalia.notifyError(noctalia.tr("self_test.title"), selfTestLaunch.error)
render()
end
end
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
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
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
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
function onToggleCollectorSettingsClicked()
showCollectorSettings = not showCollectorSettings
confirmUninstall = false
render()
end
function onOpenPluginSettingsClicked()
panel.close()
noctalia.runAsync("noctalia msg settings-open plugins", function(_result) end, 5000)
noctalia.notify(noctalia.tr("collector.settings_title"), noctalia.tr("collector.settings_opened"))
end
function onCopyCollectorCommandClicked()
local collector = snapshot and snapshot.system_collector or nil
local command = collector and collector.status == "stale" and collector.enable_command
or (collector and collector.install_command or nil)
if command ~= nil and noctalia.copyToClipboard(command, "text/plain") then
noctalia.notify(noctalia.tr("collector.title"), noctalia.tr("dependencies.copied"))
end
end
function onUninstallCollectorClicked()
if not confirmUninstall then
confirmUninstall = true
render()
return
end
local command = snapshot and snapshot.system_collector and snapshot.system_collector.uninstall_command or nil
confirmUninstall = false
runPrivilegedAction(command, {
scope = "collector", kind = "remove", title = noctalia.tr("collector.title"),
action = noctalia.tr("collector.action_remove"),
})
end
function onOpen(_context)
opened = true
render()
end
function onClose()
opened = false
pendingSelfTest = nil
confirmUninstall = false
showCollectorSettings = false
end
function onRefreshClicked()
local nonce = number(noctalia.state.get("refresh_nonce"), 0) + 1
noctalia.state.set("refresh_nonce", nonce)
end
function onInstallDependenciesClicked()
local dependencies = snapshot and snapshot.dependencies or nil
local command = dependencies and dependencies.install_command or nil
if command == nil or command == "" then
noctalia.notifyError(noctalia.tr("dependencies.title"), noctalia.tr("dependencies.manual_install"))
return
end
runPrivilegedAction(command, {
scope = "dependencies", kind = "install", title = noctalia.tr("dependencies.title"),
action = noctalia.tr("dependencies.action_install"),
})
end
function onCopyInstallCommandClicked()
local dependencies = snapshot and snapshot.dependencies or nil
local command = dependencies and dependencies.install_command or nil
if command == nil or command == "" then
return
end
if noctalia.copyToClipboard(command, "text/plain") then
noctalia.notify(noctalia.tr("dependencies.title"), noctalia.tr("dependencies.copied"))
end
end
function onCloseClicked()
panel.close()
end