Add nilsonlinux/link-ip-monitor v1.0.0 (#320)
* Add nilsonlinux/link-ip-monitor v1.0.0 * Add nilsonlinux/link-ip-monitor v1.0.0 * Update plugin ID in README.md * Rename project to link-ip-monitor and update ID Updated the project name and ID in the README. * Update plugin ID and command syntax in README
This commit is contained in:
@@ -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/<locale>.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 `=<number> ms` pattern regardless of
|
||||
the system's locale.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -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…"
|
||||
}
|
||||
}
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user