diff --git a/disk-monitor/README.md b/disk-monitor/README.md new file mode 100644 index 00000000..3ce6e6d9 --- /dev/null +++ b/disk-monitor/README.md @@ -0,0 +1,32 @@ +# Disk Monitor + +A bar widget and panel that monitors all connected disks and displays free space. The bar shows disk space at a glance, while the detailed panel provides usage bars, SSD detection, and disk selection. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `rael2pac/disk-monitor` | +| Entries | Bar widget: `bar`; panel: `panel`; service: `disk-poller` | + +## Usage + +The bar widget shows free space for each disk. Click it to open the panel with detailed usage information including capacity bars, mount points, and SSD/HDD detection. + +```sh +noctalia msg panel-toggle rael2pac/disk-monitor:panel +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `refreshInterval` | `int` | `30` | Seconds between disk usage polls (min 10, max 300). | +| `displayFormat` | `string` | `name_free` | Bar display format: `name_free`, `percent`, or `size`. | +| `hideOnEmpty` | `bool` | `false` | Hide the bar widget when no disks are detected. | + +## Notes + +- Uses `lsblk` to detect all block devices and `df` for filesystem usage. +- Automatically detects SSD vs HDD using the `ROTA` flag. +- Supports ext4, btrfs, xfs, ntfs, and vfat filesystems. diff --git a/disk-monitor/panel.luau b/disk-monitor/panel.luau new file mode 100644 index 00000000..938c40cf --- /dev/null +++ b/disk-monitor/panel.luau @@ -0,0 +1,234 @@ +-- panel.luau -- Monitor de Discos: painel com seletor e config + +local disks = {} +local panelOpen = false + +local function tr(key, fallback) + local v = noctalia.tr(key) + if v == nil or v == key then return fallback or key end + return v +end + +local function parseBytes(str) + if not str or str == "?" then return 0 end + local num, unit = str:match("^([%d%.]+)(.+)$") + if not num then return 0 end + num = tonumber(num) + if not num then return 0 end + unit = unit:upper() + if unit == "T" or unit == "TB" then return num * 1024 * 1024 * 1024 * 1024 + elseif unit == "G" or unit == "GB" then return num * 1024 * 1024 * 1024 + elseif unit == "M" or unit == "MB" then return num * 1024 * 1024 + elseif unit == "K" or unit == "KB" then return num * 1024 + else return num end +end + +local function getPercentUsed(disk) + if disk.percent then + return tonumber(disk.percent:match("(%d+)")) or 0 + end + local totalBytes = parseBytes(disk.size) + local usedBytes = parseBytes(disk.used) + if totalBytes == 0 then return 0 end + return math.floor((usedBytes / totalBytes) * 100) +end + +local function getPercentColor(pct) + if pct >= 80 then return "#ef4444" + elseif pct >= 60 then return "#facc15" + else return "#22c55e" end +end + +local function getBarWidth(pct) + local filled = math.floor(pct / 5) + if filled > 20 then filled = 20 end + if filled < 0 then filled = 0 end + local empty = 20 - filled + return string.rep("█", filled) .. string.rep("░", empty) +end + +local function getDiskIcon(disk) + if disk.removable then + local name = (disk.devName or ""):lower() + if name:match("sd") and not name:match("nvme") then + return "device-sd-card" + end + return "device-usb" + end + return "database" +end + +local function stripSlash(mp) + if not mp then return "?" end + if mp == "/" then return "/" end + return mp:gsub("^/", "") +end + +local function formatSize(disk) + local used = parseBytes(disk.used) + local total = parseBytes(disk.size) + if total == 0 then return (disk.used or "?") .. " / " .. (disk.size or "?") end + local usedGB = used / (1024 * 1024 * 1024) + local totalGB = total / (1024 * 1024 * 1024) + if totalGB >= 1 then + return string.format("%.1f / %.1f GB", usedGB, totalGB) + else + return (disk.used or "?") .. " / " .. (disk.size or "?") + end +end + +function onSelect1() setDisk(1) end +function onSelect2() setDisk(2) end +function onSelect3() setDisk(3) end +function onSelect4() setDisk(4) end +function onSelect5() setDisk(5) end +function onSelect6() setDisk(6) end +function onSelect7() setDisk(7) end +function onSelect8() setDisk(8) end + +function setDisk(idx) + local d = disks[idx] + if not d then return end + noctalia.state.set("selectedDisk", d.mountpoint) + render() +end + +function onCycleFormat() + local formats = { "name_free", "free_only", "percent", "name_percent" } + local current = noctalia.state.get("displayFormat") or noctalia.getConfig("displayFormat") or "name_free" + local nextIdx = 1 + for i, f in ipairs(formats) do + if f == current then + nextIdx = (i % #formats) + 1 + break + end + end + noctalia.state.set("displayFormat", formats[nextIdx]) + render() +end + +function render() + local body = {} + local currentDisplay = noctalia.state.get("selectedDisk") or "/" + + if #disks == 0 then + table.insert(body, ui.column({ align = "center", justify = "center", flexGrow = 1, padding = 30 }, { + ui.glyph({ name = "database", size = 28, color = "secondary" }), + ui.label({ text = tr("panel.noDisks", "Nenhum disco detectado"), fontSize = 14, color = "on_surface/0.6" }), + })) + else + for i, d in ipairs(disks) do + local pct = getPercentUsed(d) + local color = getPercentColor(pct) + local bar = getBarWidth(pct) + local icon = getDiskIcon(d) + local label = stripSlash(d.mountpoint) + local isSelected = (d.mountpoint == currentDisplay) + local variant = isSelected and "default" or "outline" + + local tagLabel = "" + if d.ssd then tagLabel = "SSD" end + if d.removable then + if tagLabel ~= "" then tagLabel = tagLabel .. " " end + tagLabel = tagLabel .. "USB" + end + + local modelStr = "" + if d.model and d.model ~= "" then + modelStr = d.model + end + + local cb = "onSelect" .. i + + table.insert(body, ui.column({ gap = 3, paddingH = 8, paddingV = 6, fill = "surface_variant", radius = 8 }, { + ui.row({ gap = 8, align = "center" }, { + ui.button({ glyph = icon, variant = variant, controlSize = "sm", onClick = cb }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.row({ gap = 6, align = "center" }, { + ui.label({ text = label, fontSize = 14, fontWeight = "bold", color = "on_surface" }), + ui.label({ text = tagLabel, fontSize = 11, color = color }), + }), + ui.label({ text = modelStr, fontSize = 10, color = "on_surface/0.5" }), + }), + ui.label({ text = tostring(pct) .. "%", fontSize = 14, fontWeight = "bold", color = color }), + }), + ui.row({ gap = 6, align = "center" }, { + ui.label({ text = bar, fontFamily = "monospace", fontSize = 11, color = color }), + ui.label({ text = formatSize(d), fontSize = 11, color = "on_surface/0.7" }), + }), + ui.row({ gap = 4, align = "center" }, { + ui.label({ text = d.devName, fontSize = 10, color = "on_surface/0.4" }), + ui.label({ text = (d.fstype or "?"), fontSize = 10, color = "on_surface/0.4" }), + }), + })) + end + end + + local currentFormat = noctalia.state.get("displayFormat") or noctalia.getConfig("displayFormat") or "name_free" + local hideOnEmpty = noctalia.getConfig("hideOnEmpty") + if hideOnEmpty == nil then hideOnEmpty = false end + local refreshInterval = noctalia.getConfig("refreshInterval") or 30 + + local formatLabel = "Espaco Livre" + if currentFormat == "free_only" then formatLabel = "Apenas Livre" + elseif currentFormat == "percent" then formatLabel = "Percentual" + elseif currentFormat == "name_percent" then formatLabel = "Livre + Percentual" + end + + table.insert(body, ui.column({ gap = 6, paddingH = 8, paddingV = 8, fill = "surface_variant", radius = 8 }, { + ui.label({ text = tr("panel.config", "Configuracoes"), fontSize = 13, fontWeight = "bold", color = "primary" }), + ui.row({ gap = 8, align = "center" }, { + ui.label({ text = tr("panel.format", "Formato na barra:"), fontSize = 11, color = "on_surface/0.7", flexGrow = 1 }), + ui.button({ text = formatLabel, glyph = "adjustments", variant = "outline", controlSize = "sm", onClick = "onCycleFormat" }), + }), + ui.row({ gap = 8, align = "center" }, { + ui.label({ text = tr("panel.interval", "Atualizar a cada:"), fontSize = 11, color = "on_surface/0.7", flexGrow = 1 }), + ui.label({ text = tostring(refreshInterval) .. "s", fontSize = 11, fontWeight = "bold", color = "on_surface" }), + }), + ui.row({ gap = 8, align = "center" }, { + ui.label({ text = tr("panel.hideEmpty", "Ocultar vazio:"), fontSize = 11, color = "on_surface/0.7", flexGrow = 1 }), + ui.label({ text = hideOnEmpty and "Sim" or "Nao", fontSize = 11, fontWeight = "bold", color = "on_surface" }), + }), + })) + + panel.render(ui.column({ flexGrow = 1, gap = 6, padding = 12, fill = "surface", radius = 12 }, { + ui.row({ gap = 10, align = "center" }, { + ui.glyph({ name = "settings", size = 18, color = "primary" }), + ui.label({ text = tr("panel.title", "Monitor de Discos"), fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }), + ui.button({ glyph = "x", variant = "ghost", controlSize = "sm", onClick = "onCloseClicked" }), + }), + + ui.scroll({ flexGrow = 1, gap = 6 }, body), + })) +end + +function onOpen(_context) + panelOpen = true + local raw = noctalia.state.get("disks") + if raw and raw ~= "" then + local d = noctalia.json.decode(raw) + if d then disks = d else disks = {} end + else + disks = {} + end + render() +end + +function onClose() + panelOpen = false + disks = {} +end + +function onCloseClicked() + panel.close() +end + +noctalia.state.watch("disks", function(value) + if value and value ~= "" then + local d = noctalia.json.decode(value) + if d then disks = d else disks = {} end + else + disks = {} + end + if panelOpen then render() end +end) diff --git a/disk-monitor/plugin.toml b/disk-monitor/plugin.toml new file mode 100644 index 00000000..0e2c355b --- /dev/null +++ b/disk-monitor/plugin.toml @@ -0,0 +1,48 @@ +id = "rael2pac/disk-monitor" +name = "Disk Monitor" +version = "1.0.0" +plugin_api = 3 +author = "rael2pac" +license = "MIT" +icon = "database" +description = "Monitor disk usage with free space on the bar and detailed panel" +tags = ["bar", "panel", "system", "hardware", "utility"] + +[[service]] +id = "disk-poller" +entry = "service.luau" + +[[widget]] +id = "bar" +entry = "widget.luau" + + [[widget.setting]] + key = "displayFormat" + type = "string" + label_key = "settings.displayFormat.label" + description_key = "settings.displayFormat.description" + default = "name_free" + + [[widget.setting]] + key = "hideOnEmpty" + type = "bool" + label_key = "settings.hideOnEmpty.label" + description_key = "settings.hideOnEmpty.description" + default = false + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 500 +height = 350 +placement = "attached" +open_near_click = false + +[[setting]] +key = "refreshInterval" +type = "int" +label_key = "settings.refreshInterval.label" +description_key = "settings.refreshInterval.description" +default = 30 +min = 10 +max = 300 diff --git a/disk-monitor/service.luau b/disk-monitor/service.luau new file mode 100644 index 00000000..1a80bd6c --- /dev/null +++ b/disk-monitor/service.luau @@ -0,0 +1,108 @@ +-- service.luau -- Monitor de Discos: poll lsblk for disk info + +local disks = {} + +local function parseLsblk(text) + local list = {} + if not text or text == "" then return list end + local ok, data = pcall(noctalia.json.decode, text) + if not ok or not data or not data.blockdevices then return list end + + local function addDisk(dev, parentName, parentModel, parentRm) + if dev.mountpoint and dev.mountpoint ~= "" then + local diskType = dev.type or "part" + if diskType ~= "loop" and diskType ~= "ram" and diskType ~= "rom" then + local isRemovable = dev.rm or parentRm or false + local model = dev.model or parentModel or "" + local isSSD = (dev.rota == false) or (dev.rota == 0) + table.insert(list, { + name = dev.name, + devName = dev.name, + size = dev.size or "?", + used = dev.fsused or "?", + avail = dev.fsavail or "?", + fstype = dev.fstype or "?", + mountpoint = dev.mountpoint, + type = diskType, + parent = parentName or dev.name, + percent = dev["fsuse%"] or "?", + removable = isRemovable, + model = model, + ssd = isSSD, + }) + end + end + if dev.children then + local cModel = dev.model or parentModel or "" + local cRm = dev.rm or parentRm or false + for _, child in ipairs(dev.children) do + addDisk(child, dev.name, cModel, cRm) + end + end + end + + for _, dev in ipairs(data.blockdevices) do + addDisk(dev, nil, nil, nil) + end + return list +end + +local function ensureDefaultDisk() + local current = noctalia.state.get("selectedDisk") + if not current or current == "" then + noctalia.state.set("selectedDisk", "/") + end +end + +local function refresh() + noctalia.runAsync("lsblk -J -o NAME,SIZE,FSTYPE,MOUNTPOINT,TYPE,FSSIZE,FSUSED,FSAVAIL,FSUSE%,RM,MODEL,ROTA 2>/dev/null", function(result) + if result.exitCode == 0 and result.stdout and result.stdout ~= "" then + disks = parseLsblk(result.stdout) + else + disks = {} + end + + if #disks == 0 then + noctalia.runAsync("df -h --output=source,size,used,avail,pcent,target -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null", function(dfResult) + if dfResult.exitCode == 0 and dfResult.stdout and dfResult.stdout ~= "" then + for line in dfResult.stdout:gmatch("[^\n]+") do + if not line:match("^Filesystem") then + local dev, size, used, avail, pct, mount = line:match("^(%S+)%s+(%S+)%s+(%S+)%s+(%S+)%s+(%S+)%s+(%S+)$") + if dev and mount then + table.insert(disks, { + name = dev, + devName = dev, + size = size, + used = used, + avail = avail, + percent = pct, + mountpoint = mount, + fstype = "?", + type = "disk", + parent = dev, + removable = false, + model = "", + ssd = false, + }) + end + end + end + end + noctalia.state.set("disks", noctalia.json.encode(disks)) + ensureDefaultDisk() + end) + else + noctalia.state.set("disks", noctalia.json.encode(disks)) + ensureDefaultDisk() + end + end) +end + +local interval = noctalia.getConfig("refreshInterval") or 30 +noctalia.setUpdateInterval(interval * 1000) + +function update() + refresh() +end + +refresh() diff --git a/disk-monitor/thumbnail.webp b/disk-monitor/thumbnail.webp new file mode 100644 index 00000000..6e9cae37 Binary files /dev/null and b/disk-monitor/thumbnail.webp differ diff --git a/disk-monitor/translations/en.json b/disk-monitor/translations/en.json new file mode 100644 index 00000000..6614c401 --- /dev/null +++ b/disk-monitor/translations/en.json @@ -0,0 +1,8 @@ +{ + "settings.displayFormat.label": "Display Format", + "settings.displayFormat.description": "Bar display format: name_free, percent, or size", + "settings.hideOnEmpty.label": "Hide on Empty", + "settings.hideOnEmpty.description": "Hide the bar widget when no disks are detected", + "settings.refreshInterval.label": "Refresh Interval", + "settings.refreshInterval.description": "Seconds between disk usage polls" +} diff --git a/disk-monitor/widget.luau b/disk-monitor/widget.luau new file mode 100644 index 00000000..5712e45c --- /dev/null +++ b/disk-monitor/widget.luau @@ -0,0 +1,136 @@ +-- widget.luau -- Monitor de Discos: bar widget + +local diskData = {} +local loaded = false + +local function tr(key, fallback) + local v = noctalia.tr(key) + if v == nil or v == key then return fallback or key end + return v +end + +local function findDisk(mountpoint) + for _, d in ipairs(diskData) do + if d.mountpoint == mountpoint then + return d + end + end + return nil +end + +local function getDiskIcon(disk) + if disk.removable then + local name = (disk.devName or ""):lower() + if name:match("sd") and not name:match("nvme") then + return "device-sd-card" + end + return "device-usb" + end + return "database" +end + +local function parseBytes(str) + if not str or str == "?" then return 0 end + local num, unit = str:match("^([%d%.]+)(.+)$") + if not num then return 0 end + num = tonumber(num) + if not num then return 0 end + unit = unit:upper() + if unit == "T" or unit == "TB" then return num * 1024 * 1024 * 1024 * 1024 + elseif unit == "G" or unit == "GB" then return num * 1024 * 1024 * 1024 + elseif unit == "M" or unit == "MB" then return num * 1024 * 1024 + elseif unit == "K" or unit == "KB" then return num * 1024 + else return num end +end + +local function getPercentUsed(disk) + if disk.percent then + return tonumber(disk.percent:match("(%d+)")) or 0 + end + local totalBytes = parseBytes(disk.size) + local usedBytes = parseBytes(disk.used) + if totalBytes == 0 then return 0 end + return math.floor((usedBytes / totalBytes) * 100) +end + +local function render() + if not loaded then + barWidget.setVisible(false) + return + end + + local hideOnEmpty = noctalia.getConfig("hideOnEmpty") + if hideOnEmpty == nil then hideOnEmpty = false end + + local displayDisk = noctalia.state.get("selectedDisk") or "/" + local displayFormat = noctalia.state.get("displayFormat") or noctalia.getConfig("displayFormat") or "name_free" + + local disk = findDisk(displayDisk) + + if not disk then + if hideOnEmpty then + barWidget.setVisible(false) + else + barWidget.setVisible(true) + barWidget.render(ui.row({ gap = 5, align = "center" }, { + ui.glyph({ name = "database", size = 16, color = "on_surface_variant" }), + ui.label({ text = tr("widget.noDisk", "Sem dados"), fontSize = 13, color = "on_surface/0.6" }), + })) + end + return + end + + barWidget.setVisible(true) + + local pct = getPercentUsed(disk) + local icon = getDiskIcon(disk) + + local text = "" + if displayFormat == "name_free" then + text = (disk.avail or "?") .. " livres" + elseif displayFormat == "free_only" then + text = disk.avail or "?" + elseif displayFormat == "percent" then + text = tostring(pct) .. "%" + elseif displayFormat == "name_percent" then + text = (disk.avail or "?") .. " (" .. tostring(pct) .. "%)" + else + text = (disk.avail or "?") .. " livres" + end + + barWidget.render(ui.row({ gap = 5, align = "center" }, { + ui.glyph({ name = icon, size = 16, color = "on_surface" }), + ui.label({ text = text, fontSize = 13, fontWeight = "bold", color = "on_surface" }), + })) + + local tooltip = disk.devName .. " (" .. (disk.fstype or "?") .. ") - " .. (disk.used or "?") .. " / " .. (disk.size or "?") + if disk.model and disk.model ~= "" then + tooltip = disk.model .. " | " .. tooltip + end + barWidget.setTooltip(tooltip) +end + +noctalia.state.watch("disks", function(value) + if value and value ~= "" then + local d = noctalia.json.decode(value) + if d then diskData = d else diskData = {} end + else + diskData = {} + end + loaded = true + render() +end) + +noctalia.state.watch("selectedDisk", function(_value) + render() +end) + +noctalia.state.watch("displayFormat", function(_value) + render() +end) + +function onClick() + noctalia.togglePanel("rael2pac/disk-monitor:panel") +end + +render()