diff --git a/link-ip-monitor/README.md b/link-ip-monitor/README.md new file mode 100644 index 0000000..39cf6e4 --- /dev/null +++ b/link-ip-monitor/README.md @@ -0,0 +1,64 @@ +# link-ip-monitor +link-ip-monitor pings a list of IPs, hostnames or links on an interval, tracks +each host's online/offline status and response time, and notifies when one +goes down or comes back up. + +## Plugin +| Field | Value | +| --- | --- | +| ID | `nilsonlinux/link-ip-monitor` | +| Entries | Bar widget: `status`; panel: `panel`; service: `monitor` | + +## Requirements +Uses the system `ping` binary (`iputils`, present on most Linux distros). +No other external dependency. + +## Usage +Add the `status` widget to the bar: it turns green when every monitored +host is responding, red with a count badge when one or more are down, and +neutral when the list is empty. + +Click the widget to open the panel. Use the `+` button in the panel header +to reveal the add-host form — accepts an IP (`8.8.8.8`), a hostname +(`example.com`) or a full link (scheme, port, path and query are stripped +automatically, keeping only the host to ping). An optional description can +be set alongside it. + +Each row in the panel shows the label (or the host, if no label was set), a +status pill, the response time in ms while online, a drag handle to +reorder the list, and a trash button to remove that entry (asks for +confirmation). Right-click the widget, or use the refresh button in the +panel, to force an immediate check. + +Open the panel directly with: +```sh +noctalia msg panel-toggle nilsonlinux/link-ip-monitor:panel +``` + +Force an immediate check via IPC: +```sh +noctalia msg plugin nilsonlinux/link-ip-monitor:monitor all refresh +``` + +The host list itself is not stored in plugin settings — it lives in the +service's own persisted state (`pluginDataDir()/state.json`) and is +managed entirely from the panel (add / remove / reorder), since the +plugin API has no way to write settings back from a script. + +## Settings +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `interval_seconds` | `int` | `30` | How often each host is checked. | +| `timeout_seconds` | `int` | `1` | How long to wait for a ping reply before treating it as a failure. | +| `notify_on_recovery` | `bool` | `true` | Send a notification when a host that was down responds again. | +| `glyph` (widget) | `glyph` | `activity` | Icon shown in the bar for the `status` widget. | + +## Notes +Notification text and panel labels come from `translations/.json` +via `noctalia.tr()` (English and `pt-BR` included). `noctalia.notify()` / +`noctalia.notifyError()` accept only a title and body — the plugin API +exposes no urgency parameter, so the "host down" alert's appearance (the +red border from `notifyError`) can't be further customized from the +plugin. Response time (ms) is parsed from `ping`'s own text output, so it +depends on that output containing a `= ms` pattern regardless of +the system's locale. diff --git a/link-ip-monitor/panel.luau b/link-ip-monitor/panel.luau new file mode 100644 index 0000000..a01f367 --- /dev/null +++ b/link-ip-monitor/panel.luau @@ -0,0 +1,359 @@ +--!nonstrict +-- panel.luau +-- Window shown when clicking the bar widget: a form to add an IP, +-- hostname or link (with an optional description field below it), and +-- the list of monitored hosts — each with its label (if set), a status +-- pill, response time in ms (when online), a drag handle to reorder, and +-- a remove button (with confirmation). + +local ips = {} -- { {ip=, label=, status=, latency_ms=}, ... } +local draftIp = "" +local draftLabel = "" +local draftKey = 0 +local draftError = "" +local pendingDelete = nil +local showAddForm = false + +local DRAG_TYPE = "link-ip-monitor-host" + +local STATUS_COLORS = { + up = { fill = "#2e7d32", text = "#ffffff" }, + down = { fill = "#c62828", text = "#ffffff" }, + checking = { fill = "#5f6368", text = "#ffffff" }, +} + +local render + +-- Accepts an IP, a plain hostname, or a link (http://..., https://...) +-- and returns just the host — scheme, port, path and query are dropped. +-- Same logic as service.luau (duplicated here just to give immediate +-- feedback in the field). +local function extractHost(raw) + local s = noctalia.string.trim(raw or "") + s = s:gsub("^%a[%w+.-]*://", "") + s = s:match("^([^/%?#]+)") or s + s = s:match("^([^:]+)") or s + return s +end + +local function isValidIPv4(host) + local a, b, c, d = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") + if not a then + return false + end + for _, n in ipairs({ a, b, c, d }) do + local num = tonumber(n) + if num == nil or num < 0 or num > 255 then + return false + end + end + return true +end + +local function isValidHostname(host) + if host == "" or #host > 253 then + return false + end + local labelCount = 0 + for label in host:gmatch("[^%.]+") do + labelCount = labelCount + 1 + if #label == 0 or #label > 63 then + return false + end + if not label:match("^%w[%w%-]*%w$") and not label:match("^%w$") then + return false + end + end + return labelCount > 0 +end + +local function normalizeHost(raw) + local host = extractHost(raw) + if host == "" then + return nil + end + if isValidIPv4(host) or isValidHostname(host) then + return host + end + return nil +end + +local STATUS_TR_KEY = { + up = "panel.status_online", + down = "panel.status_offline", + checking = "panel.status_checking", +} + +local function pill(status) + local colors = STATUS_COLORS[status] or STATUS_COLORS.checking + local label = noctalia.tr(STATUS_TR_KEY[status] or "panel.status_checking") + return ui.row({ + fill = colors.fill, + radius = 8, + paddingH = 8, + paddingV = 2, + align = "center", + justify = "center", + }, { + ui.label({ text = label, fontSize = 11, fontWeight = "bold", color = colors.text }), + }) +end + +-- Thin strip between rows (and before the first / after the last) that +-- accepts a dropped host, indicating where it will land. +local function insertionZone(index) + return ui.dropZone({ + key = "gap-" .. index, + accepts = { DRAG_TYPE }, + value = tostring(index), + onDrop = "onIpDropped", + height = 3, + radius = 4, + expandOnDrag = true, + hitSlop = 24, + }) +end + +function onIpDropped(payload, value) + local insertAt = tonumber(value) + if type(payload) ~= "string" or payload == "" or insertAt == nil then + return + end + noctalia.state.set("link_ip_monitor.cmd", { op = "reorder", ip = payload, index = insertAt }) +end + +local function hostRow(item) + local id = item.ip + local deleting = pendingDelete == id + + local actions + if deleting then + actions = ui.row({ gap = 4, align = "center" }, { + ui.button({ + glyph = "check", + variant = "destructive", + controlSize = "sm", + tooltip = noctalia.tr("panel.tooltip_confirm_remove"), + onClick = function() + noctalia.state.set("link_ip_monitor.cmd", { op = "remove", ip = id }) + pendingDelete = nil + render() + end, + }), + ui.button({ + glyph = "close", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.tooltip_cancel"), + onClick = function() + pendingDelete = nil + render() + end, + }), + }) + else + actions = ui.button({ + glyph = "trash", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.tooltip_remove"), + onClick = function() + pendingDelete = id + render() + end, + }) + end + + -- Left column: label (if set) in bold + host as a caption; without a + -- label, the host becomes the main text. + local infoChildren = {} + local hasLabel = item.label ~= nil and item.label ~= "" + if hasLabel then + table.insert(infoChildren, ui.label({ text = item.label, fontWeight = "bold", fontSize = 13 })) + table.insert(infoChildren, ui.label({ text = item.ip, color = "on_surface_variant", fontSize = 10 })) + else + table.insert(infoChildren, ui.label({ text = item.ip, fontWeight = "bold", fontSize = 13 })) + end + + -- Right column: status pill + response time in ms (only while online + -- and when the measurement exists). + local statusChildren = { pill(item.status) } + if item.status == "up" and item.latency_ms ~= nil then + table.insert(statusChildren, ui.label({ + text = tostring(math.floor(item.latency_ms + 0.5)) .. " ms", + fontSize = 10, + color = "on_surface_variant", + })) + end + + return ui.row({ + key = "ip-" .. id, + gap = 8, + padding = 10, + radius = 8, + fill = "surface_variant/0.35", + align = "center", + justify = "space_between", + }, { + ui.dragSource({ + key = "grip-" .. id, + dragType = DRAG_TYPE, + payload = id, + previewAncestor = 1, + liftFromLayout = true, + width = 20, + height = 20, + align = "center", + justify = "center", + tooltip = noctalia.tr("panel.tooltip_drag"), + }, { + ui.glyph({ name = "menu-2", size = 14, color = "on_surface_variant" }), + }), + ui.column({ flexGrow = 1, gap = 1 }, infoChildren), + ui.column({ align = "end", gap = 2 }, statusChildren), + actions, + }) +end + +local function emptyState() + return ui.column({ gap = 10, align = "center", padding = 32, flexGrow = 1, justify = "center" }, { + ui.glyph({ name = "activity", size = 32, color = "outline" }), + ui.label({ text = noctalia.tr("panel.empty"), color = "outline" }), + }) +end + +render = function() + local body + + if #ips == 0 then + body = emptyState() + else + local rows = {} + for i, item in ipairs(ips) do + table.insert(rows, insertionZone(i)) + table.insert(rows, hostRow(item)) + end + table.insert(rows, insertionZone(#ips + 1)) + body = ui.scroll({ gap = 6, flexGrow = 1 }, rows) + end + + local column = {} + + table.insert(column, ui.row({ align = "center", justify = "space_between" }, { + ui.label({ text = noctalia.tr("title"), fontSize = 16, fontWeight = "bold", flexGrow = 1 }), + ui.button({ + glyph = showAddForm and "minus" or "plus", + variant = showAddForm and "primary" or "ghost", + controlSize = "sm", + tooltip = showAddForm and noctalia.tr("panel.tooltip_close_form") or noctalia.tr("panel.tooltip_add_host"), + onClick = function() + showAddForm = not showAddForm + if not showAddForm then + draftIp = "" + draftLabel = "" + draftError = "" + draftKey += 1 + end + render() + end, + }), + ui.button({ + glyph = "refresh", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.tooltip_check_now"), + onClick = function() noctalia.state.set("link_ip_monitor.cmd", { op = "refresh" }) end, + }), + ui.button({ + glyph = "close", + variant = "ghost", + controlSize = "sm", + onClick = function() panel.close() end, + }), + })) + + if showAddForm then + table.insert(column, ui.row({ + gap = 6, + align = "center", + padding = 8, + radius = 8, + fill = "surface_variant/0.35", + }, { + ui.column({ flexGrow = 1, gap = 6 }, { + ui.input({ + key = "add-ip-" .. draftKey, + value = draftIp, + placeholder = noctalia.tr("panel.add_placeholder"), + onChange = function(value) draftIp = value; draftError = "" end, + onSubmit = "onAdd", + }), + ui.input({ + key = "add-label-" .. draftKey, + value = draftLabel, + placeholder = noctalia.tr("panel.add_label_placeholder"), + onChange = function(value) draftLabel = value end, + onSubmit = "onAdd", + }), + (draftError ~= "" and ui.label({ text = draftError, color = "#e05252", fontSize = 11 }) or nil), + }), + ui.button({ glyph = "plus", variant = "primary", controlSize = "sm", tooltip = noctalia.tr("panel.tooltip_add"), onClick = "onAdd" }), + })) + end + + table.insert(column, body) + + panel.render(ui.column({ gap = 8, padding = 8, flexGrow = 1 }, column)) +end + +function onAdd() + local host = normalizeHost(draftIp) + if noctalia.string.trim(draftIp) == "" then + return + end + if host == nil then + draftError = noctalia.tr("panel.invalid_format") + showAddForm = true + render() + return + end + + local label = noctalia.string.trim(draftLabel) + draftIp = "" + draftLabel = "" + draftKey += 1 + draftError = "" + showAddForm = false + noctalia.state.set("link_ip_monitor.cmd", { op = "add", ip = host, label = label }) + render() +end + +function onOpen(_context) + ips = noctalia.state.get("link_ip_monitor.statuses") or {} + pendingDelete = nil + draftIp = "" + draftLabel = "" + draftError = "" + draftKey += 1 + showAddForm = false + render() +end + +noctalia.state.watch("link_ip_monitor.statuses", function(value) + ips = (type(value) == "table") and value or {} + + if pendingDelete ~= nil then + local stillThere = false + for _, item in ipairs(ips) do + if item.ip == pendingDelete then + stillThere = true + break + end + end + if not stillThere then + pendingDelete = nil + end + end + + render() +end) diff --git a/link-ip-monitor/plugin.toml b/link-ip-monitor/plugin.toml new file mode 100644 index 0000000..d8d31a7 --- /dev/null +++ b/link-ip-monitor/plugin.toml @@ -0,0 +1,76 @@ +id = "nilsonlinux/link-ip-monitor" +name = "Link/IP Monitor" +version = "1.0.0" +plugin_api = 14 +author = "Nilsonlinux" +license = "MIT" +icon = "activity" +description = "It pings a list of IPs, hosts, or links at intervals and notifies you when one goes down (or comes back up)." +tags = ["network", "indicator", "bar", "panel", "service"] +dependencies = ["ping"] + +# --------------------------------------------------------------------------- +# Plugin settings: shared by ALL entries (service + widget). Edited in +# Settings -> Plugins. +# --------------------------------------------------------------------------- + +[[setting]] +key = "interval_seconds" +type = "int" +label_key = "settings.interval_seconds.label" +description_key = "settings.interval_seconds.description" +default = 30 +min = 5 +max = 3600 + +[[setting]] +key = "timeout_seconds" +type = "int" +label_key = "settings.timeout_seconds.label" +description_key = "settings.timeout_seconds.description" +default = 1 +min = 1 +max = 10 + +[[setting]] +key = "notify_on_recovery" +type = "bool" +label_key = "settings.notify_on_recovery.label" +description_key = "settings.notify_on_recovery.description" +default = true + +# --------------------------------------------------------------------------- +# Service: runs in the background, pings hosts, and publishes state +# --------------------------------------------------------------------------- + +[[service]] +id = "monitor" +entry = "service.luau" + +# --------------------------------------------------------------------------- +# Bar widget: icon with a badge for the number of hosts that are down +# --------------------------------------------------------------------------- + +[[widget]] +id = "status" +entry = "widget.luau" + +[[widget.setting]] +key = "glyph" +type = "glyph" +label_key = "settings.glyph.label" +description_key = "settings.glyph.description" +default = "activity" + +# --------------------------------------------------------------------------- +# Panel: list of hosts with a status pill, opened by clicking the widget +# --------------------------------------------------------------------------- + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 320 +height = 400 +placement = "attached" +position = "auto" +open_near_click = true diff --git a/link-ip-monitor/service.luau b/link-ip-monitor/service.luau new file mode 100644 index 0000000..bdec324 --- /dev/null +++ b/link-ip-monitor/service.luau @@ -0,0 +1,307 @@ +--!nonstrict +-- service.luau +-- Headless service: keeps the host list (IP, hostname or link — each with +-- an optional label), pings every entry, measures latency, and notifies +-- on the up -> down transition (and, when enabled, down -> up). +-- +-- Persistence: pluginDataDir()/state.json ({ ips = { {ip=, label=}, ... } }) +-- State: link_ip_monitor.statuses = { {ip=, label=, status=, latency_ms=}, ... } +-- link_ip_monitor.down_count = number +-- Panel commands: ip_monitor.cmd = +-- { op = "add"|"remove"|"refresh"|"reorder", ip = "...", label? = "...", index? = N } + +local ipList = {} -- { {ip=, label=}, ... } — "ip" here is the already-normalized host (no scheme/port/path) +local hostState = {} -- host -> true (up) / false (down) / nil (not checked yet) +local hostLatency = {} -- host -> latency in ms (number) / nil +local dataPath = nil + +local function trim(s) + return noctalia.string.trim(s or "") +end + +-- Validates IPv4 in N.N.N.N form, each octet between 0 and 255. +local function isValidIPv4(host) + local a, b, c, d = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") + if not a then + return false + end + for _, n in ipairs({ a, b, c, d }) do + local num = tonumber(n) + if num == nil or num < 0 or num > 255 then + return false + end + end + return true +end + +-- Validates a hostname (dot-separated labels, letters/digits/hyphen, no +-- leading/trailing hyphen per label). +local function isValidHostname(host) + if host == "" or #host > 253 then + return false + end + local labelCount = 0 + for label in host:gmatch("[^%.]+") do + labelCount = labelCount + 1 + if #label == 0 or #label > 63 then + return false + end + if not label:match("^%w[%w%-]*%w$") and not label:match("^%w$") then + return false + end + end + return labelCount > 0 +end + +-- Accepts an IP, a plain hostname, or a link (http://..., https://...) +-- and returns just the host — scheme, port, path and query are dropped. +-- That clean host is what actually becomes the ping target (ping doesn't +-- understand a full URL). +local function extractHost(raw) + local s = trim(raw) + s = s:gsub("^%a[%w+.-]*://", "") -- strip a scheme like http:// or https:// + s = s:match("^([^/%?#]+)") or s -- cut path/query/fragment + s = s:match("^([^:]+)") or s -- cut an explicit port (host:port) + return s +end + +local function normalizeHost(raw) + local host = extractHost(raw) + if host == "" then + return nil + end + if isValidIPv4(host) or isValidHostname(host) then + return host + end + return nil +end + +local function indexOfIp(ip) + for i, entry in ipairs(ipList) do + if entry.ip == ip then + return i + end + end + return nil +end + +local function loadState() + local dir = noctalia.pluginDataDir() + if dir == nil then + return + end + dataPath = dir .. "/state.json" + + local raw = noctalia.readFile(dataPath) + if raw ~= nil then + local ok, decoded = pcall(noctalia.json.decode, raw) + if ok and type(decoded) == "table" and type(decoded.ips) == "table" then + for _, entry in ipairs(decoded.ips) do + if type(entry) == "table" and type(entry.ip) == "string" then + table.insert(ipList, { ip = entry.ip, label = trim(entry.label) }) + elseif type(entry) == "string" then + -- backward compatibility with the old format (list of plain strings, no label) + table.insert(ipList, { ip = entry, label = "" }) + end + end + end + end + -- no file yet (first run): ipList stays empty until the user adds + -- something from the panel. +end + +local function saveState() + if dataPath == nil then + return + end + local ok, encoded = pcall(noctalia.json.encode, { ips = ipList }) + if ok and type(encoded) == "string" then + pcall(noctalia.writeFile, dataPath, encoded) + end +end + +local function publishState() + local statuses = {} + local downCount = 0 + + for _, entry in ipairs(ipList) do + local up = hostState[entry.ip] + local status + if up == true then + status = "up" + elseif up == false then + status = "down" + downCount = downCount + 1 + else + status = "checking" + end + + table.insert(statuses, { + ip = entry.ip, + label = entry.label, + status = status, + latency_ms = (status == "up") and hostLatency[entry.ip] or nil, + }) + end + + noctalia.state.set("link_ip_monitor.statuses", statuses) + noctalia.state.set("link_ip_monitor.down_count", downCount) +end + +local function checkHost(entry) + local ip = entry.ip + local timeout = noctalia.getConfig("timeout_seconds") or 1 + -- -c 1: send 1 packet; -W: timeout in seconds (iputils/Linux). ping + -- resolves a hostname via DNS normally, so a host like "example.com" + -- (extracted from a link) works just like an IP. + local cmd = string.format("ping -c 1 -W %d %s", timeout, ip) + + noctalia.runAsync(cmd, function(res) + local success = res.exitCode == 0 + + local latency = nil + if success and type(res.stdout) == "string" then + -- Ignores the word before "=" or "<" (time=, tempo=, temps=...) — + -- ping's output changes with the system locale. Only looks for the + -- number that comes right before "ms". + local ms = res.stdout:match("[=<]%s*(%d+%.?%d*)%s*ms") + latency = ms and tonumber(ms) or nil + end + + local wasUp = hostState[ip] + hostState[ip] = success + hostLatency[ip] = latency + + if wasUp ~= success then + local display = (entry.label ~= "" and entry.label) or ip + if success == false then + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("notify.host_down", { host = display })) + elseif wasUp == false and success == true and noctalia.getConfig("notify_on_recovery") then + noctalia.notify(noctalia.tr("title"), noctalia.tr("notify.host_up", { host = display })) + end + end + + publishState() + end, (timeout + 2) * 1000) +end + +local function checkAll() + for _, entry in ipairs(ipList) do + checkHost(entry) + end +end + +local function applyInterval() + local seconds = noctalia.getConfig("interval_seconds") or 30 + noctalia.setUpdateInterval(seconds * 1000) +end + +-- Returns ok, reason ("invalid" | "duplicate") on failure. +local function addIp(raw, label) + local host = normalizeHost(raw) + if host == nil then + return false, "invalid" + end + if indexOfIp(host) ~= nil then + return false, "duplicate" + end + local entry = { ip = host, label = trim(label) } + table.insert(ipList, entry) + saveState() + publishState() + checkHost(entry) + return true +end + +local function removeIp(ip) + local idx = indexOfIp(ip) + if idx ~= nil then + table.remove(ipList, idx) + end + hostState[ip] = nil + hostLatency[ip] = nil + saveState() + publishState() +end + +-- Moves `ip` to 1-based position `index` in the final list (drag and drop +-- in the panel) — same logic as the official world_clock's reorderZone(). +local function reorderIp(ip, index) + local fromIdx = indexOfIp(ip) + if fromIdx == nil then + return false + end + local insertAt = tonumber(index) + if insertAt == nil then + return false + end + local entry = table.remove(ipList, fromIdx) + if fromIdx < insertAt then + insertAt -= 1 + end + insertAt = math.max(1, math.min(insertAt, #ipList + 1)) + table.insert(ipList, insertAt, entry) + saveState() + publishState() + return true +end + +-- --------------------------------------------------------------------------- +-- Commands coming from the panel, via noctalia.state (same pattern as the +-- official world_clock: the panel sets "ip_monitor.cmd", the service +-- watches it, processes it, and clears it back to nil). +-- --------------------------------------------------------------------------- + +noctalia.state.watch("link_ip_monitor.cmd", function(cmd) + if type(cmd) ~= "table" then + return + end + local op = cmd.op + if op == "add" then + local ok, reason = addIp(cmd.ip, cmd.label) + if not ok then + local display = tostring(cmd.ip or "") + if reason == "invalid" then + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("notify.invalid_host", { host = display })) + elseif reason == "duplicate" then + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("notify.duplicate_host", { host = display })) + end + end + elseif op == "remove" then + removeIp(cmd.ip) + elseif op == "reorder" then + reorderIp(cmd.ip, cmd.index) + elseif op == "refresh" then + checkAll() + end + noctalia.state.set("link_ip_monitor.cmd", nil) +end) + +-- --------------------------------------------------------------------------- +-- Lifecycle +-- --------------------------------------------------------------------------- + +loadState() +applyInterval() +publishState() +checkAll() + +function update() + checkAll() +end + +function onConfigChanged() + applyInterval() +end + +-- Still works via CLI too, if needed: +-- noctalia msg plugin nilsonlinux/link-ip-monitor:monitor all refresh +function onIpc(event, payload) + if event == "refresh" then + checkAll() + elseif event == "add" and payload then + addIp(payload, "") + elseif event == "remove" and payload then + removeIp(payload) + end +end diff --git a/link-ip-monitor/thumbnail.webp b/link-ip-monitor/thumbnail.webp new file mode 100644 index 0000000..462b112 Binary files /dev/null and b/link-ip-monitor/thumbnail.webp differ diff --git a/link-ip-monitor/translations/en.json b/link-ip-monitor/translations/en.json new file mode 100644 index 0000000..bdf79af --- /dev/null +++ b/link-ip-monitor/translations/en.json @@ -0,0 +1,49 @@ +{ + "title": "Link/IP Monitor", + "settings": { + "interval_seconds": { + "label": "Check interval (s)", + "description": "How often each host is checked." + }, + "timeout_seconds": { + "label": "Ping timeout (s)", + "description": "How long to wait for a reply before treating it as a failure." + }, + "notify_on_recovery": { + "label": "Notify when a host comes back", + "description": "Send a notification when a host that was down responds again." + }, + "glyph": { + "label": "Bar icon", + "description": "Icon shown in the bar for this widget." + } + }, + "notify": { + "host_down": "{host} is unreachable", + "host_up": "{host} is responding again", + "invalid_host": "{host} is not a valid IP, host or link", + "duplicate_host": "{host} is already in the list" + }, + "panel": { + "empty": "No hosts configured yet", + "add_placeholder": "IP, host or link", + "add_label_placeholder": "Description (optional) — e.g. Router, Server", + "invalid_format": "Invalid — enter an IP (0.0.0.0), a host or a link", + "status_online": "Online", + "status_offline": "Offline", + "status_checking": "Checking…", + "tooltip_check_now": "Check now", + "tooltip_add": "Add", + "tooltip_remove": "Remove", + "tooltip_confirm_remove": "Confirm removal", + "tooltip_cancel": "Cancel", + "tooltip_drag": "Drag to reorder", + "tooltip_close_form": "Close", + "tooltip_add_host": "Add host" + }, + "widget": { + "tooltip_all_up": "All monitored hosts are responding", + "tooltip_down_prefix": "Down:", + "checking_now": "Checking now…" + } +} diff --git a/link-ip-monitor/translations/pt-BR.json b/link-ip-monitor/translations/pt-BR.json new file mode 100644 index 0000000..fd9c064 --- /dev/null +++ b/link-ip-monitor/translations/pt-BR.json @@ -0,0 +1,49 @@ +{ + "title": "Link/IP Monitor", + "settings": { + "interval_seconds": { + "label": "Intervalo entre checagens (s)", + "description": "Com que frequência cada host é checado." + }, + "timeout_seconds": { + "label": "Timeout do ping (s)", + "description": "Quanto tempo esperar por resposta antes de considerar falha." + }, + "notify_on_recovery": { + "label": "Notificar quando o host volta", + "description": "Envia uma notificação quando um host que estava fora do ar volta a responder." + }, + "glyph": { + "label": "Ícone da barra", + "description": "Ícone mostrado na barra para este widget." + } + }, + "notify": { + "host_down": "{host} está inacessível", + "host_up": "{host} voltou a responder", + "invalid_host": "{host} não é um IP, host ou link válido", + "duplicate_host": "{host} já está na lista" + }, + "panel": { + "empty": "Nenhum host configurado ainda", + "add_placeholder": "IP, host ou link", + "add_label_placeholder": "Descrição (opcional) — ex: Roteador, Servidor", + "invalid_format": "Inválido — informe um IP (0.0.0.0), um host ou um link", + "status_online": "Online", + "status_offline": "Offline", + "status_checking": "Checando…", + "tooltip_check_now": "Checar agora", + "tooltip_add": "Adicionar", + "tooltip_remove": "Remover", + "tooltip_confirm_remove": "Confirmar remoção", + "tooltip_cancel": "Cancelar", + "tooltip_drag": "Arraste para reordenar", + "tooltip_close_form": "Fechar", + "tooltip_add_host": "Adicionar host" + }, + "widget": { + "tooltip_all_up": "Todos os hosts monitorados estão respondendo", + "tooltip_down_prefix": "Fora do ar:", + "checking_now": "Checando agora…" + } +} diff --git a/link-ip-monitor/widget.luau b/link-ip-monitor/widget.luau new file mode 100644 index 0000000..9dc9fde --- /dev/null +++ b/link-ip-monitor/widget.luau @@ -0,0 +1,87 @@ +--!nonstrict +-- widget.luau +-- Bar widget: configurable icon (setting "glyph"); turns green when +-- everything responds, red with a count badge when something is down. +-- Click opens the panel with the full list. + +local downCount = 0 +local statuses = {} + +local COLOR_DOWN = "#e05252" +local COLOR_UP = "#2e7d32" + +local function downList() + local list = {} + for _, s in ipairs(statuses) do + if s.status == "down" then + local text = (s.label ~= nil and s.label ~= "") and (s.label .. " (" .. s.ip .. ")") or s.ip + table.insert(list, text) + end + end + return list +end + +local function render() + local children = {} + local glyphName = noctalia.getConfig("glyph") or "activity" + local hasHosts = #statuses > 0 + + if downCount > 0 then + table.insert(children, ui.row({ + fill = COLOR_DOWN, + radius = 8, + paddingH = 5, + paddingV = 1, + align = "center", + justify = "center", + }, { + ui.label({ text = tostring(downCount), fontSize = 10, fontWeight = "bold", color = "#ffffff" }), + })) + table.insert(children, ui.glyph({ name = glyphName, size = 14, color = COLOR_DOWN })) + elseif hasHosts then + -- everything responding: green icon, no badge + table.insert(children, ui.glyph({ name = glyphName, size = 14, color = COLOR_UP })) + else + -- no hosts configured yet: neutral color (neither green nor red) + table.insert(children, ui.glyph({ name = glyphName, size = 14 })) + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 4, align = "center" }, children)) + + local down = downList() + if #down > 0 then + barWidget.setTooltip(noctalia.tr("widget.tooltip_down_prefix") .. "\n" .. table.concat(down, "\n")) + else + barWidget.setTooltip(noctalia.tr("widget.tooltip_all_up")) + end +end + +noctalia.state.watch("link_ip_monitor.down_count", function(value) + downCount = value or 0 + render() +end) + +noctalia.state.watch("link_ip_monitor.statuses", function(value) + statuses = (type(value) == "table") and value or {} + render() +end) + +render() + +function update() + noctalia.setUpdateInterval(5000) -- just keeps the widget "alive"; the real data comes from state +end + +function onConfigChanged() + render() -- pick up the new icon when the user changes it in the widget settings +end + +function onClick() + noctalia.togglePanel("nilsonlinux/link-ip-monitor:panel") +end + +function onRightClick() + noctalia.state.set("link_ip_monitor.cmd", { op = "refresh" }) + noctalia.notify(noctalia.tr("title"), noctalia.tr("widget.checking_now")) +end