Add davemhammer/opnsense (#315)

OPNsense health, interfaces, gateways, rules, logs, and service control.
This commit is contained in:
Dave Hammer
2026-08-09 10:24:22 -04:00
committed by GitHub
parent ad943f87ae
commit 38d6255b98
18 changed files with 2642 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
# OPNsense
Monitor OPNsense system health, interfaces, gateways, services, firewall rules, and recent firewall logs from Noctalia.
## Plugin
| Field | Value |
| --- | --- |
| ID | `davemhammer/opnsense` |
| Entries | Bar widget: `status`; panel: `manager`; service: `service`; launcher: `opn` |
| Launcher Prefix | `/opn` |
## Requirements
- Network access to your OPNsense REST API
- An API key + secret with permission to read status (and control services if you use restart/start/stop)
- On `PATH` (declared in `plugin.toml` `dependencies`):
- `curl` — on-demand firewall log fetch
- `jq` — slim log JSON for the panel
- `xdg-open` — open the OPNsense web UI
## Usage
Configure **Base URL**, **API key**, and **API secret** under plugin settings (key/secret are sensitive string fields).
Add the **status** bar widget (`davemhammer/opnsense:status`). Click to open the manager panel.
Panel tabs: **Status**, **Interfaces**, **Gateways**, **Services**, **Rules**, **Logs**. Logs load only when you open the Logs tab (last 100 events).
Launcher: `/opn` for categories and quick actions.
```sh
noctalia msg panel-toggle davemhammer/opnsense:manager
```
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `base_url` | `string` | `https://192.168.1.1` | OPNsense base URL (no trailing `/api`). |
| `api_key` | `string` | _(empty)_ | API key (basic auth username). |
| `api_secret` | `string` | _(empty)_ | API secret (basic auth password). |
| `allow_insecure_tls` | `bool` | `true` | Skip TLS certificate verification (default **on** for common LAN self-signed certs; set **false** when you have a trusted cert). |
| `refresh_interval` | `int` | `20` | Core status poll interval in seconds. |
| `notify_on_issue` | `bool` | `true` | Notify when a new subsystem/gateway issue appears. |
| `web_ui_url` | `string` | _(empty)_ | Override URL for “Open Web UI”; empty uses `base_url`. |
| `show_label` | `bool` (widget) | `true` | Show OK / issue label on the bar. |
| `ok_color` | `select` (widget) | `tertiary` | Bar color when status is OK. |
| `warn_color` | `select` (widget) | `error` | Bar color when issues are present. |
## IPC
```sh
noctalia msg panel-toggle davemhammer/opnsense:manager
noctalia msg plugin davemhammer/opnsense:service all refresh
noctalia msg plugin davemhammer/opnsense:service all logs
```
## Notes
- Uses `noctalia.http` for status/rules/services (Basic Auth). Log fetch uses `curl` + `jq` with `?limit=100` so large log dumps do not stall Luau. Web UI opens via `xdg-open`.
- API credentials are stored in Noctalia settings (not in this repo). Prefer a restricted API key.
- `allow_insecure_tls` applies to both `noctalia.http` and the log `curl` request. Default is **true** (verification skipped); turn it **off** when the firewall presents a certificate you trust.
- Service control mutates the firewall only when you request start/stop/restart.
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<image width="200" height="200" display="none" image-rendering="optimizeSpeed" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAFeklEQVR4nOzdP48VVRjA4VlCQgIm
2EChhSQWGgpzGxtiAvoBoLQE1I5CKNhSxUr3FrLFlrhraedSWemSGBobYiIGE+NujERZGwtJMDHX
XMJNXCCv98/MnHNmnucDjKfwl/Mezuzc/aPRqCrF7tebx29/8M73qdfRN4cHJ64OVjcvpV5HCvtS
L4D8/Xnr5sXb77+9knodKQiEqexuXb98690zn6ReR9sEwtT6uJMIhJn0bScRCDMb7yR9iUQgzKUv
45ZAmFsfxi2BsJCuj1sCYWFdHrcEQi26Om4JhNp0cScRCLXq2k4iEGrXpYO7QGhEV8YtgdCYLoxb
AqFR451ke33lbOp1zEsgNG5nY7hWaiQCoQ2HdjaGGyWOWwKhNSUe3AVCq0o7uAuE1pV0TyIQkihl
3BIIyZQwbgmEpHK/JxEIyeV8TyIQcpDtPYlAyEaOB3eBkJXcDu4CITs53ZMIhCzlMm4JhGzlMG4J
hKyl3kkEQvZS7iQCoQipDu4CoRgpxi2BUJS2xy2BUJw2X3AUCEXa2RiutTFuCYRSHWpj3BIIRWv6
4C4QitfkTiIQOqGpe5Kl37/6YvDH1vUzdT+4CQ/u3T144Ohzz6ReB/P5a/vOy/e377zR5H/jyKnT
w+NXri3X9bylnz/9+NzOxnC9rgc2bPfkjd2jqRfBfLbXV1r5f+3w4MTVwermpTqeZcSic+q8JxEI
nVTXhyAEQlfV8iEIgdBpi96TCITOW+SeRCD0wrz3JAKhN+YZtwRCr8w6bgmE3pllJxEIvTTtTiIQ
emuag7tA6LX/G7cEQu9F45ZAIBi3BAKPPG3cEgj8x+PjlkDgMY92ko8qgcDT7W5df6sSCMQEAgGB
QEAgEBAIBAQCAYFAQCAQEAgEBAIBgUBAIBAQCAQEAgGBQEAgEBAIBAQCAYFAQCAQEAgEBAIBgUBA
IBAQCAQEAgGBQEAgEBAIBAQCAYFAQCAQEAgEBAIBgUBAIBAQCAQEAgGBQEAgEBAIBAQCAYFAQCAQ
EAgEBAIBgUBAIBAQCAQEAgGBQEAgEBAIBAQCAYFAQCAQEAgEBAIBgUBAIBAQCAQEAgGBQEAgEBAI
BAQCAYFAQCAQEAgEBAIBgUBAIBAQCATGgfyWehGQq33Hzi9/efDYSxupFwI5ejhivTL8/IJI4EkP
Azlw9Pn7r372zXmRwF57Dul2EthrTyCTneTw4MTFdEuCfDz1n3kHq5urdhII7kGMWxAEYtyCKW7S
jVv02VSvmhi36KupAnFPQl/N9LKinYS+mSkQB3f6Zq7X3R3c6Yu5/x7EuEUfzB2IcYs+WPgvCsfj
1pFTp5frWQ7kpZY/uT1+5drQTkIXLY1Go9oe9u3Z19bvb985V9sDn/RPVVW/NPh8mvXDC+cur6Re
xJT+PnZ++WatgTy49+vB7y6/udZwJJRr6+SN3ddTL2IWtX7VxMGdrmnksz/uSeiKxr6L5Z6ELmgs
EOMWXdD4lxXdk1CyVj496p6EUrX2bV4Hd0rU6serHdwpTauBOLhTmiQ/f2DcohTJfh/EuEUJkgXi
QxCUIPkvTNlJyFnyQBzcyVnyQCYc3MlRNoFUxi0ylFUgxi1yk1UgE15wJBdZBjL24oUP14xbpJZt
IO5JyEG2gUw4uJNS9oE4uJNS9oFMuCchhWICqYxbJFBUIMYt2lZUIBPuSWhLkYFUPgRBS4oNpHJw
pwVFB1I5uNOw4gNxcKdJxQcyYdyiCZ0JpDJu0YBOBeIFR+rWqUAm7CTUpZOBOLhTl04GMuHgzqI6
HUhl3GJBnQ/EuMUiOh/IhHGLefQmkMq4xRx6FYh7Ema1P/UCUhjvJD+tvXe3qqpnU6+lZ35MvYBZ
LY1Go9RrgGz9GwAA//8/Z0LNgEyjNwAAAABJRU5ErkJggg==
"/>
<path d="m44 0v44h112v112h44v-92.93l-63.1-63.07zm112 156h-112v-112h-44v92l64 64h92z" fill="#c03e14"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OPNsense</title><path fill="#73c936" d="M5.25 0v5.25h13.5v13.5H24V7.5L16.5 0Zm13.5 18.75H5.25V5.25H0V16.5L7.5 24h11.25Z"/></svg>

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OPNsense</title><path fill="#8a8a8a" d="M5.25 0v5.25h13.5v13.5H24V7.5L16.5 0Zm13.5 18.75H5.25V5.25H0V16.5L7.5 24h11.25Z"/></svg>

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OPNsense</title><path fill="#E44A20" d="M5.25 0v5.25h13.5v13.5H24V7.5L16.5 0Zm13.5 18.75H5.25V5.25H0V16.5L7.5 24h11.25Z"/></svg>

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OPNsense</title><path fill="#f43841" d="M5.25 0v5.25h13.5v13.5H24V7.5L16.5 0Zm13.5 18.75H5.25V5.25H0V16.5L7.5 24h11.25Z"/></svg>

After

Width:  |  Height:  |  Size: 206 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OPNsense</title><path d="M5.25 0v5.25h13.5v13.5H24V7.5L16.5 0Zm13.5 18.75H5.25V5.25H0V16.5L7.5 24h11.25Z"/></svg>

After

Width:  |  Height:  |  Size: 191 B

+292
View File
@@ -0,0 +1,292 @@
--!nonstrict
-- /opn launcher for OPNsense.
local STATE_KEY = "opn_snapshot"
local COMMAND_KEY = "opn_command"
local PANEL_ID = "davemhammer/opnsense:manager"
local MAX_ROWS = 40
local snapshot = noctalia.state.get(STATE_KEY) or {
available = false,
configured = false,
loading = true,
widgets = {},
interfaces = {},
gateways = {},
services = {},
issueCount = 0,
host = "",
error = "",
}
noctalia.state.watch(STATE_KEY, function(value)
if type(value) == "table" then
snapshot = value
end
end)
local function trim(s)
return noctalia.string.trim(tostring(s or ""))
end
local function lower(s)
return string.lower(tostring(s or ""))
end
local function send(action, values)
local command = { action = action, requestId = "launcher-" .. tostring(os.time()) }
if type(values) == "table" then
for k, v in pairs(values) do command[k] = v end
end
noctalia.state.set(COMMAND_KEY, command)
end
local function scoreText(filter, ...)
if filter == "" then return 1 end
local best = nil
for i = 1, select("#", ...) do
local text = tostring(select(i, ...) or "")
if text ~= "" then
local s = noctalia.fuzzyScore(filter, text)
if s ~= nil and (best == nil or s > best) then best = s end
if best == nil and lower(text):find(lower(filter), 1, true) then best = 0.5 end
end
end
return best
end
local function statusRow(title, subtitle, glyph)
return { id = "", title = title, subtitle = subtitle, glyph = glyph or "shield" }
end
local function topCategories()
return {
{ id = "cat:status", title = noctalia.tr("launcher.cat.status"), subtitle = noctalia.tr("launcher.cat.status-sub"), glyph = "heart-rate-monitor", score = 100 },
{ id = "cat:interfaces", title = noctalia.tr("launcher.cat.interfaces"), subtitle = noctalia.tr("launcher.cat.interfaces-sub"), glyph = "network", score = 90 },
{ id = "cat:gateways", title = noctalia.tr("launcher.cat.gateways"), subtitle = noctalia.tr("launcher.cat.gateways-sub"), glyph = "router", score = 85 },
{ id = "cat:services", title = noctalia.tr("launcher.cat.services"), subtitle = noctalia.tr("launcher.cat.services-sub"), glyph = "settings", score = 80 },
{ id = "cat:rules", title = noctalia.tr("launcher.cat.rules"), subtitle = noctalia.tr("launcher.cat.rules-sub"), glyph = "list-check", score = 75 },
{ id = "cat:logs", title = noctalia.tr("launcher.cat.logs"), subtitle = noctalia.tr("launcher.cat.logs-sub"), glyph = "file-text", score = 72 },
{ id = "act:panel", title = noctalia.tr("launcher.cat.panel"), subtitle = noctalia.tr("launcher.cat.panel-sub"), glyph = "layout-dashboard", score = 70 },
{ id = "act:ui", title = noctalia.tr("launcher.cat.ui"), subtitle = noctalia.tr("launcher.cat.ui-sub"), glyph = "external-link", score = 60 },
{ id = "act:refresh", title = noctalia.tr("launcher.cat.refresh"), subtitle = noctalia.tr("launcher.cat.refresh-sub"), glyph = "refresh", score = 50 },
}
end
local function listStatus(filter)
local rows = {}
for _, w in ipairs(snapshot.widgets or {}) do
local s = scoreText(filter, w.name, w.status, w.message)
if s ~= nil then
table.insert(rows, {
id = "st:" .. w.id,
title = w.name,
subtitle = w.status .. (w.message ~= "" and (" · " .. w.message) or ""),
glyph = w.ok and "circle-check" or "circle-x",
score = s,
})
end
end
if #rows == 0 then rows[1] = statusRow(noctalia.tr("launcher.no-matches"), filter, "search") end
return rows
end
local function listIfaces(filter)
local rows = {}
for _, i in ipairs(snapshot.interfaces or {}) do
local s = scoreText(filter, i.name, i.description, i.status, i.ipv4)
if s ~= nil then
table.insert(rows, {
id = "if:" .. i.id,
title = i.name,
subtitle = `{i.status} · {i.ipv4}`,
glyph = "network",
score = s,
})
end
end
if #rows == 0 then rows[1] = statusRow(noctalia.tr("launcher.no-matches"), filter, "search") end
return rows
end
local function listGateways(filter)
local rows = {}
for _, g in ipairs(snapshot.gateways or {}) do
local s = scoreText(filter, g.name, g.status, g.address)
if s ~= nil then
table.insert(rows, {
id = "gw:" .. g.id,
title = g.name,
subtitle = `{g.status} · {g.address}`,
glyph = "router",
score = s,
})
end
end
if #rows == 0 then rows[1] = statusRow(noctalia.tr("launcher.no-matches"), filter, "search") end
return rows
end
local function serviceActions(name)
return {
{ id = "svcact:restart:" .. name, title = noctalia.tr("launcher.action.restart"), subtitle = name, glyph = "refresh", score = 100 },
{ id = "svcact:start:" .. name, title = noctalia.tr("launcher.action.start"), subtitle = name, glyph = "player-play", score = 90 },
{ id = "svcact:stop:" .. name, title = noctalia.tr("launcher.action.stop"), subtitle = name, glyph = "player-stop", score = 80 },
{ id = "svcact:copy:" .. name, title = noctalia.tr("launcher.action.copy"), subtitle = name, glyph = "copy", score = 70 },
}
end
local function listServices(filter)
local rows = {}
for _, s in ipairs(snapshot.services or {}) do
local sc = scoreText(filter, s.name, s.status, s.description)
if sc ~= nil then
table.insert(rows, {
id = "svc:" .. s.id,
title = s.name,
subtitle = s.status .. (s.description ~= "" and (" · " .. s.description) or ""),
glyph = s.running and "player-play" or "player-stop",
score = sc,
})
end
end
table.sort(rows, function(a, b) return (a.score or 0) > (b.score or 0) end)
while #rows > MAX_ROWS do table.remove(rows) end
if #rows == 0 then rows[1] = statusRow(noctalia.tr("launcher.no-matches"), filter, "search") end
return rows
end
local function findService(name)
name = trim(name)
for _, s in ipairs(snapshot.services or {}) do
if s.name == name or s.id == name then return s end
end
local hits = {}
local q = lower(name)
for _, s in ipairs(snapshot.services or {}) do
if lower(s.name):find(q, 1, true) then table.insert(hits, s) end
end
if #hits == 1 then return hits[1] end
return nil
end
function onQuery(query)
if not snapshot.configured then
launcher.setResults(query, {
statusRow(noctalia.tr("panel.not_configured"), "", "settings"),
{ id = "act:panel", title = noctalia.tr("launcher.cat.panel"), subtitle = "Configure in plugin settings", glyph = "layout-dashboard" },
})
return
end
if snapshot.loading and not snapshot.available then
send("refresh")
launcher.setResults(query, { statusRow(noctalia.tr("launcher.loading"), snapshot.host, "loader") })
return
end
if not snapshot.available then
launcher.setResults(query, {
statusRow(noctalia.tr("launcher.unavailable"), snapshot.error or "", "cloud-off"),
{ id = "act:refresh", title = noctalia.tr("launcher.cat.refresh"), subtitle = "", glyph = "refresh" },
})
return
end
local text = trim(query)
if text == "" then
launcher.setResults(query, topCategories())
return
end
local tokens = {}
for t in text:gmatch("%S+") do table.insert(tokens, t) end
local head = lower(tokens[1] or "")
local rest = table.concat(tokens, " ", 2)
local function is(name, aliases)
if head == name then return true end
for _, a in ipairs(aliases) do if head == a then return true end end
return false
end
if is("status", { "st", "health" }) then
launcher.setResults(query, listStatus(rest))
return
end
if is("interfaces", { "iface", "if", "int" }) then
launcher.setResults(query, listIfaces(rest))
return
end
if is("gateways", { "gw", "gateway" }) then
launcher.setResults(query, listGateways(rest))
return
end
if is("services", { "svc", "service", "s" }) then
local svc = findService(rest)
if svc and (lower(rest) == lower(svc.name) or rest:find(svc.name, 1, true)) and rest ~= "" then
if lower(rest) == lower(svc.name) then
launcher.setResults(query, serviceActions(svc.name))
return
end
end
launcher.setResults(query, listServices(rest))
return
end
local rows = {}
for _, row in ipairs(topCategories()) do
local s = scoreText(text, row.title, row.id)
if s ~= nil then
row.score = s
table.insert(rows, row)
end
end
for _, r in ipairs(listServices(text)) do
if r.id ~= "" then table.insert(rows, r) end
end
if #rows == 0 then rows[1] = statusRow(noctalia.tr("launcher.no-matches"), text, "search") end
launcher.setResults(query, rows)
end
function onActivate(id)
if id == nil or id == "" then return end
if id == "cat:status" then launcher.setQuery("status "); return end
if id == "cat:interfaces" then launcher.setQuery("interfaces "); return end
if id == "cat:gateways" then launcher.setQuery("gateways "); return end
if id == "cat:services" then launcher.setQuery("services "); return end
if id == "cat:rules" then noctalia.togglePanel(PANEL_ID); return end
if id == "cat:logs" then noctalia.togglePanel(PANEL_ID); return end
if id == "act:panel" then noctalia.togglePanel(PANEL_ID); return end
if id == "act:ui" then send("open_ui"); return end
if id == "act:refresh" then
send("refresh")
noctalia.notify(noctalia.tr("title"), noctalia.tr("widget.refresh_requested"))
return
end
local svc = id:match("^svc:(.+)$")
if svc then
launcher.setQuery("services " .. svc .. " ")
return
end
local act, name = id:match("^svcact:([%w]+):(.+)$")
if act and name then
if act == "restart" then send("restart_service", { name = name })
elseif act == "start" then send("start_service", { name = name })
elseif act == "stop" then send("stop_service", { name = name })
elseif act == "copy" then send("copy", { name = name })
end
return
end
local st = id:match("^st:(.+)$")
if st then send("copy", { name = st }); return end
local iface = id:match("^if:(.+)$")
if iface then send("copy", { name = iface }); return end
local gw = id:match("^gw:(.+)$")
if gw then send("copy", { name = gw }); return end
end
+732
View File
@@ -0,0 +1,732 @@
--!nonstrict
-- OPNsense manager panel.
local STATE_KEY = "opn_snapshot"
local COMMAND_KEY = "opn_command"
local RESULT_KEY = "opn_action_result"
local snapshot = noctalia.state.get(STATE_KEY) or {
available = false,
configured = false,
loading = true,
busy = false,
host = "",
widgets = {},
interfaces = {},
gateways = {},
services = {},
rules = {},
logs = {},
info = {},
resources = {},
issueCount = 0,
okCount = 0,
blockLogCount = 0,
error = "",
updatedAt = 0,
revision = 0,
}
local tab = "status"
local selectedId = ""
local filterText = ""
local filterKey = 0
local requestCounter = 0
local feedback = ""
local feedbackError = false
local dirty = true
local render
local function tr(key, subst)
return noctalia.tr(key, subst)
end
local function nextRequestId()
requestCounter += 1
return `panel-{requestCounter}`
end
local function send(action, values)
local command = { action = action, requestId = nextRequestId() }
if type(values) == "table" then
for k, v in pairs(values) do
command[k] = v
end
end
noctalia.state.set(COMMAND_KEY, command)
return command.requestId
end
local function lower(s)
return string.lower(tostring(s or ""))
end
local function haystackContains(needle, ...)
if needle == "" then return true end
for i = 1, select("#", ...) do
local part = lower(select(i, ...))
if part ~= "" and part:find(needle, 1, true) then
return true
end
end
return false
end
local function matchesFilter(...)
local q = noctalia.string.trim(filterText)
if q == "" then return true end
for raw in q:gmatch("%S+") do
local neg = false
local term = raw
if term:sub(1, 1) == "!" then
neg = true
term = term:sub(2)
end
term = lower(term)
if term ~= "" then
local hit = haystackContains(term, ...)
if neg then
if hit then return false end
else
if not hit then return false end
end
end
end
return true
end
local function statusColor(ok)
return ok and "tertiary" or "error"
end
local function listButton(props)
props.contentAlign = "start"
props.controlSize = props.controlSize or "md"
return ui.button(props)
end
local function selectedService()
if tab ~= "services" then return nil end
for _, s in ipairs(snapshot.services or {}) do
if s.id == selectedId then return s end
end
return nil
end
local function selectedIface()
if tab ~= "interfaces" then return nil end
for _, i in ipairs(snapshot.interfaces or {}) do
if i.id == selectedId then return i end
end
return nil
end
local function selectedGw()
if tab ~= "gateways" then return nil end
for _, g in ipairs(snapshot.gateways or {}) do
if g.id == selectedId then return g end
end
return nil
end
local function selectedWidget()
if tab ~= "status" then return nil end
for _, w in ipairs(snapshot.widgets or {}) do
if w.id == selectedId then return w end
end
return nil
end
local function selectedRule()
if tab ~= "rules" then return nil end
for _, r in ipairs(snapshot.rules or {}) do
if r.id == selectedId then return r end
end
return nil
end
local function selectedLog()
if tab ~= "logs" then return nil end
for _, l in ipairs(snapshot.logs or {}) do
if l.id == selectedId then return l end
end
return nil
end
local function emptyList(msg)
return ui.column({
key = "empty-" .. tab,
align = "center",
justify = "center",
padding = 24,
gap = 8,
flexGrow = 1,
}, {
ui.glyph({ name = "search", size = 36, color = "on_surface_variant" }),
ui.label({ text = msg, color = "on_surface_variant", textAlign = "center" }),
})
end
local function itemColumn(rows)
return ui.column({
key = "items-" .. tab,
align = "stretch",
justify = "start",
gap = 8,
flexGrow = 1,
}, rows)
end
local function statusRows()
local rows = {}
for _, w in ipairs(snapshot.widgets or {}) do
if matchesFilter(w.name, w.status, w.message) then
local selected = w.id == selectedId
table.insert(rows, listButton({
key = "st-" .. w.id,
text = `{w.name} · {w.status}` .. (w.message ~= "" and (` · {w.message}`) or ""),
glyph = w.ok and "circle-check" or "circle-x",
variant = selected and "primary" or "outline",
selected = selected,
onClick = function()
selectedId = w.id
feedback = ""
render()
end,
}))
end
end
return rows
end
local function ifaceRows()
local rows = {}
for _, i in ipairs(snapshot.interfaces or {}) do
if matchesFilter(i.name, i.description, i.status, i.ipv4) then
local selected = i.id == selectedId
local label = i.description ~= "" and (`{i.name} ({i.description})`) or i.name
table.insert(rows, listButton({
key = "if-" .. i.id,
text = `{label} · {i.status} · {i.ipv4} · ↓{i.inBytes} ↑{i.outBytes}`,
glyph = "network",
variant = selected and "primary" or "outline",
selected = selected,
onClick = function()
selectedId = i.id
feedback = ""
render()
end,
}))
end
end
return rows
end
local function gwRows()
local rows = {}
for _, g in ipairs(snapshot.gateways or {}) do
if matchesFilter(g.name, g.status, g.address, g.rtt) then
local selected = g.id == selectedId
table.insert(rows, listButton({
key = "gw-" .. g.id,
text = `{g.name} · {g.status} · {g.address} · rtt {g.rtt}`,
glyph = "router",
variant = selected and "primary" or "outline",
selected = selected,
onClick = function()
selectedId = g.id
feedback = ""
render()
end,
}))
end
end
return rows
end
local function serviceRows()
local rows = {}
for _, s in ipairs(snapshot.services or {}) do
if matchesFilter(s.name, s.status, s.description) then
local selected = s.id == selectedId
table.insert(rows, listButton({
key = "svc-" .. s.id,
text = `{s.name} · {s.status}` .. (s.description ~= "" and (` · {s.description}`) or ""),
glyph = s.running and "player-play" or "player-stop",
variant = selected and "primary" or "outline",
selected = selected,
onClick = function()
selectedId = s.id
feedback = ""
render()
end,
}))
end
end
return rows
end
local function ruleRows()
local rows = {}
for _, r in ipairs(snapshot.rules or {}) do
if matchesFilter(
r.description, r.action, r.direction, r.source, r.destination,
r.protocol, r.interface, r.enabled and "enabled" or "disabled",
r.automatic and "automatic" or "manual"
) then
local selected = r.id == selectedId
local en = r.enabled and "" or " [off]"
local auto = r.automatic and " auto" or ""
local text = `{r.action}{en}{auto} · {r.direction} · {r.description} · {r.source} → {r.destination}`
table.insert(rows, listButton({
key = "rule-" .. r.id,
text = text,
glyph = lower(r.action) == "block" and "ban" or "shield-check",
variant = selected and "primary" or "outline",
selected = selected,
onClick = function()
selectedId = r.id
feedback = ""
render()
end,
}))
end
end
return rows
end
local function logRows()
local rows = {}
for _, l in ipairs(snapshot.logs or {}) do
if matchesFilter(
l.action, l.direction, l.interface, l.protocol,
l.src, l.dst, l.label, l.time
) then
local selected = l.id == selectedId
local text = `{l.time} · {l.action} {l.direction} · {l.interface} · {l.protocol} · {l.src} → {l.dst}`
table.insert(rows, listButton({
key = "log-" .. l.id,
text = text,
glyph = l.blocked and "ban" or "arrow-right",
variant = selected and "primary" or "outline",
selected = selected,
onClick = function()
selectedId = l.id
feedback = ""
render()
end,
}))
end
end
return rows
end
local function itemList()
local rows
if tab == "status" then rows = statusRows()
elseif tab == "interfaces" then rows = ifaceRows()
elseif tab == "gateways" then rows = gwRows()
elseif tab == "services" then rows = serviceRows()
elseif tab == "rules" then rows = ruleRows()
else rows = logRows()
end
if #rows == 0 then
return emptyList(tr("panel.empty"))
end
return itemColumn(rows)
end
local function toolbar()
local busy = snapshot.busy == true
if tab == "services" then
local s = selectedService()
if not s then
return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" })
end
return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "settings", size = 18, color = s.running and "tertiary" or "on_surface_variant" }),
ui.label({ text = s.name, fontWeight = "bold", flexGrow = 1, maxLines = 1 }),
ui.label({ text = s.status, color = s.running and "tertiary" or "on_surface_variant", fontSize = 12 }),
}),
ui.label({
text = s.description,
color = "on_surface_variant",
fontSize = 12,
visible = s.description ~= "",
maxLines = 2,
}),
ui.row({ gap = 6 }, {
ui.button({ text = tr("actions.restart"), glyph = "refresh", variant = "primary", enabled = not busy, onClick = "onRestart" }),
ui.button({ text = tr("actions.start"), glyph = "player-play", variant = "outline", enabled = not busy and not s.running, onClick = "onStart" }),
ui.button({ text = tr("actions.stop"), glyph = "player-stop", variant = "outline", enabled = not busy and s.running, onClick = "onStop" }),
ui.button({ text = tr("actions.copy"), glyph = "copy", variant = "ghost", onClick = "onCopyService" }),
}),
})
end
if tab == "rules" then
local r = selectedRule()
if not r then
return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" })
end
return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({
name = lower(r.action) == "block" and "ban" or "shield-check",
size = 18,
color = lower(r.action) == "block" and "error" or "tertiary",
}),
ui.label({ text = r.description, fontWeight = "bold", flexGrow = 1, maxLines = 1 }),
ui.label({
text = `{r.action} · {r.direction}` .. (r.enabled and "" or " · off"),
color = lower(r.action) == "block" and "error" or "tertiary",
fontSize = 12,
}),
}),
ui.label({
text = tr("rule.detail", {
src = r.source ~= "" and r.source or "any",
dst = r.destination ~= "" and r.destination or "any",
proto = r.protocol ~= "" and r.protocol or "any",
iface = r.interface ~= "" and r.interface or "—",
}),
color = "on_surface_variant",
fontSize = 12,
maxLines = 2,
}),
ui.label({
text = tr("rule.stats", {
packets = r.packets,
bytes = r.bytes,
evaluations = r.evaluations,
}),
color = "on_surface_variant",
fontSize = 11,
}),
ui.row({ gap = 6 }, {
ui.button({ text = tr("actions.copy"), glyph = "copy", variant = "outline", onClick = "onCopyRule" }),
}),
})
end
if tab == "logs" then
local l = selectedLog()
if not l then
return ui.label({
text = tr("logs.hint", { n = #(snapshot.logs or {}), blocks = snapshot.blockLogCount or 0 }),
color = "on_surface_variant",
})
end
return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({
name = l.blocked and "ban" or "arrow-right",
size = 18,
color = l.blocked and "error" or "tertiary",
}),
ui.label({
text = `{l.action} {l.direction} · {l.protocol}`,
fontWeight = "bold",
flexGrow = 1,
maxLines = 1,
}),
ui.label({ text = l.time, color = "on_surface_variant", fontSize = 12 }),
}),
ui.label({
text = tr("logs.flow", { src = l.src, dst = l.dst, iface = l.interface }),
color = "on_surface_variant",
fontSize = 12,
maxLines = 2,
}),
ui.label({
text = l.label,
color = "on_surface_variant",
fontSize = 11,
visible = l.label ~= "",
maxLines = 2,
}),
ui.row({ gap = 6 }, {
ui.button({ text = tr("actions.copy"), glyph = "copy", variant = "outline", onClick = "onCopyLog" }),
}),
})
end
local item = selectedWidget() or selectedIface() or selectedGw()
if not item then
return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" })
end
local title = item.name or item.id
local detail = item.message or item.description or item.address or ""
return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, {
ui.row({ gap = 8, align = "center" }, {
ui.glyph({ name = "info-circle", size = 18, color = statusColor(item.ok ~= false) }),
ui.label({ text = tostring(title), fontWeight = "bold", flexGrow = 1, maxLines = 1 }),
ui.label({
text = tostring(item.status or ""),
color = statusColor(item.ok ~= false),
fontSize = 12,
}),
}),
ui.label({
text = tostring(detail),
color = "on_surface_variant",
fontSize = 12,
visible = detail ~= "",
maxLines = 3,
}),
ui.row({ gap = 6 }, {
ui.button({ text = tr("actions.copy"), glyph = "copy", variant = "outline", onClick = "onCopySelected" }),
}),
})
end
local function tabButton(label, id, cb)
return ui.button({
text = label,
selected = tab == id,
variant = tab == id and "primary" or "ghost",
onClick = cb,
})
end
render = function()
dirty = false
local notes = {}
if not snapshot.configured then
table.insert(notes, ui.label({ text = tr("panel.not_configured"), color = "error", maxLines = 3 }))
end
if snapshot.loading then
table.insert(notes, ui.label({ text = tr("panel.loading"), color = "on_surface_variant" }))
end
if snapshot.logsLoading then
table.insert(notes, ui.label({ text = tr("panel.logs_loading"), color = "on_surface_variant" }))
end
if snapshot.busy then
table.insert(notes, ui.label({ text = tr("panel.busy"), color = "primary" }))
end
if type(snapshot.error) == "string" and snapshot.error ~= "" then
table.insert(notes, ui.label({ text = snapshot.error, color = "error", maxLines = 3 }))
end
if feedback ~= "" then
table.insert(notes, ui.label({
text = feedback,
color = feedbackError and "error" or "tertiary",
maxLines = 2,
}))
end
local load = ""
if type(snapshot.resources) == "table" then
load = tostring(snapshot.resources.load or "")
end
local summary = tr("panel.summary", {
ok = snapshot.okCount or 0,
issues = snapshot.issueCount or 0,
load = load ~= "" and load or "—",
})
local version = ""
if type(snapshot.info) == "table" then
version = tostring(snapshot.info.version or "")
end
local titleIcon = "assets/opnsense-grey.png"
if snapshot.available == true then
local issues = tonumber(snapshot.issueCount) or 0
titleIcon = issues == 0 and "assets/opnsense-orange.png" or "assets/opnsense-red.png"
end
panel.render(ui.column({ flexGrow = 1, gap = 10 }, {
ui.row({ align = "center", gap = 10 }, {
ui.image({
path = titleIcon,
width = 28,
height = 28,
fit = "contain",
}),
ui.column({ flexGrow = 1, gap = 0 }, {
ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold" }),
ui.label({
text = tr("panel.host", { host = snapshot.host ~= "" and snapshot.host or "—" })
.. (version ~= "" and (` · {version}`) or ""),
fontSize = 11,
color = "on_surface_variant",
}),
}),
ui.button({ text = tr("actions.open_ui"), glyph = "external-link", variant = "outline", onClick = "onOpenUi" }),
ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefresh" }),
ui.button({ glyph = "close", onClick = "onClose" }),
}),
ui.row({ gap = 4, align = "center" }, {
tabButton(tr("tabs.status"), "status", "onTabStatus"),
tabButton(tr("tabs.interfaces"), "interfaces", "onTabInterfaces"),
tabButton(tr("tabs.gateways"), "gateways", "onTabGateways"),
tabButton(tr("tabs.services"), "services", "onTabServices"),
tabButton(tr("tabs.rules"), "rules", "onTabRules"),
tabButton(tr("tabs.logs"), "logs", "onTabLogs"),
}),
ui.label({
text = summary .. (
tab == "logs" and (` · {tr("logs.summary", { n = #(snapshot.logs or {}), blocks = snapshot.blockLogCount or 0 })}`)
or (tab == "rules" and (` · {tr("rules.summary", { n = #(snapshot.rules or {}) })}`) or "")
),
color = "on_surface_variant",
fontSize = 11,
maxLines = 1,
}),
ui.row({ gap = 8, align = "center" }, {
ui.input({
key = `filter-{tab}-{filterKey}`,
value = filterText,
placeholder = tr("filter.placeholder"),
flexGrow = 1,
controlSize = "sm",
onChange = "onFilterChange",
}),
ui.button({
glyph = "x",
variant = "ghost",
visible = filterText ~= "",
onClick = "onClearFilter",
}),
}),
toolbar(),
ui.column({ gap = 3, align = "stretch" }, notes),
ui.scroll({
key = "scroll-" .. tab,
flexGrow = 1,
gap = 8,
align = "stretch",
}, { itemList() }),
ui.label({
text = (snapshot.updatedAt or 0) > 0
and tr("panel.updated", { time = noctalia.formatTime("%H:%M:%S", snapshot.updatedAt) })
or "",
color = "on_surface_variant",
fontSize = 11,
}),
}))
end
noctalia.state.watch(STATE_KEY, function(value)
if type(value) ~= "table" then return end
local changed = value.revision ~= snapshot.revision
or value.busy ~= snapshot.busy
or value.loading ~= snapshot.loading
or value.logsLoading ~= snapshot.logsLoading
or value.error ~= snapshot.error
or value.issueCount ~= snapshot.issueCount
or value.blockLogCount ~= snapshot.blockLogCount
or #(value.logs or {}) ~= #(snapshot.logs or {})
or #(value.rules or {}) ~= #(snapshot.rules or {})
snapshot = value
if selectedId ~= "" then
if not (selectedWidget() or selectedIface() or selectedGw() or selectedService()
or selectedRule() or selectedLog()) then
selectedId = ""
end
end
if changed then dirty = true end
end)
noctalia.state.watch(RESULT_KEY, function(result)
if type(result) ~= "table" then return end
if type(result.requestId) ~= "string" or not result.requestId:match("^panel%-") then return end
feedback = tostring(result.message or "")
feedbackError = result.ok ~= true
dirty = true
end)
panel.setWantsSecondTicks(true)
function onOpen(_context)
feedback = ""
send("refresh")
render()
end
function update()
if dirty then render() end
end
function onClose() panel.close() end
function onRefresh()
send("refresh")
if tab == "logs" then
send("fetch_logs")
end
end
function onOpenUi() send("open_ui") end
local function switchTab(next)
tab = next
selectedId = ""
filterKey += 1
render()
end
function onTabStatus() switchTab("status") end
function onTabInterfaces() switchTab("interfaces") end
function onTabGateways() switchTab("gateways") end
function onTabServices() switchTab("services") end
function onTabRules() switchTab("rules") end
function onTabLogs()
switchTab("logs")
-- Logs are large; fetch only when viewing the Logs tab.
if not snapshot.logsLoading then
send("fetch_logs")
end
end
function onFilterChange(value)
filterText = if type(value) == "string" then value else ""
render()
end
function onClearFilter()
filterText = ""
filterKey += 1
render()
end
function onRestart()
local s = selectedService()
if s then send("restart_service", { name = s.name }) end
end
function onStart()
local s = selectedService()
if s then send("start_service", { name = s.name }) end
end
function onStop()
local s = selectedService()
if s then send("stop_service", { name = s.name }) end
end
function onCopyService()
local s = selectedService()
if s then send("copy", { name = s.name }) end
end
function onCopySelected()
local item = selectedWidget() or selectedIface() or selectedGw()
if item then send("copy", { name = item.name or item.id }) end
end
function onCopyRule()
local r = selectedRule()
if r then
send("copy", {
name = `{r.action} {r.direction} {r.description} {r.source} -> {r.destination}`,
})
end
end
function onCopyLog()
local l = selectedLog()
if l then
send("copy", {
name = `{l.time} {l.action} {l.direction} {l.interface} {l.protocol} {l.src} -> {l.dst} {l.label}`,
})
end
end
+120
View File
@@ -0,0 +1,120 @@
# OPNsense firewall status and service control via REST API.
id = "davemhammer/opnsense"
name = "OPNsense"
version = "1.1.6"
plugin_api = 10
author = "davemhammer"
license = "MIT"
dependencies = ["curl", "jq", "xdg-open"]
tags = ["network", "utility", "bar", "panel", "service", "launcher"]
icon = "shield"
description = "Monitor OPNsense health, interfaces, gateways, firewall rules, and logs."
[[setting]]
key = "base_url"
type = "string"
label_key = "settings.base_url.label"
description_key = "settings.base_url.description"
default = "https://192.168.1.1"
[[setting]]
key = "api_key"
type = "string"
label_key = "settings.api_key.label"
description_key = "settings.api_key.description"
default = ""
[[setting]]
key = "api_secret"
type = "string"
label_key = "settings.api_secret.label"
description_key = "settings.api_secret.description"
default = ""
[[setting]]
key = "allow_insecure_tls"
type = "bool"
label_key = "settings.allow_insecure_tls.label"
description_key = "settings.allow_insecure_tls.description"
default = true
[[setting]]
key = "refresh_interval"
type = "int"
label_key = "settings.refresh_interval.label"
description_key = "settings.refresh_interval.description"
default = 20
min = 5
max = 300
[[setting]]
key = "notify_on_issue"
type = "bool"
label_key = "settings.notify_on_issue.label"
description_key = "settings.notify_on_issue.description"
default = true
[[setting]]
key = "web_ui_url"
type = "string"
label_key = "settings.web_ui_url.label"
description_key = "settings.web_ui_url.description"
default = ""
advanced = true
[[widget]]
id = "status"
entry = "widget.luau"
[[widget.setting]]
key = "show_label"
type = "bool"
label_key = "settings.show_label.label"
description_key = "settings.show_label.description"
default = true
[[widget.setting]]
key = "ok_color"
type = "select"
label_key = "settings.ok_color.label"
default = "tertiary"
options = [
{ value = "tertiary", label_key = "colors.tertiary" },
{ value = "primary", label_key = "colors.primary" },
{ value = "secondary", label_key = "colors.secondary" }
]
[[widget.setting]]
key = "warn_color"
type = "select"
label_key = "settings.warn_color.label"
default = "error"
options = [
{ value = "error", label_key = "colors.error" },
{ value = "primary", label_key = "colors.primary" },
{ value = "on_surface_variant", label_key = "colors.muted" }
]
[[panel]]
id = "manager"
entry = "panel.luau"
width = 760
height = 660
placement = "floating"
position = "center"
open_near_click = true
keyboard_focus = "exclusive"
dismiss_on_outside_click = true
[[service]]
id = "service"
entry = "service.luau"
[[launcher_provider]]
id = "opn"
entry = "launcher.luau"
prefix = "opn"
glyph = "shield"
include_in_global_search = false
debounce_ms = 80
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+158
View File
@@ -0,0 +1,158 @@
{
"title": "OPNsense",
"settings": {
"base_url": {
"label": "Base URL",
"description": "OPNsense origin only, e.g. https://192.168.1.1 (no trailing /api)."
},
"api_key": {
"label": "API key",
"description": "System → Access → Users → API keys (key = username)."
},
"api_secret": {
"label": "API secret",
"description": "API secret paired with the key (password)."
},
"allow_insecure_tls": {
"label": "Allow insecure TLS",
"description": "Skip certificate verification (self-signed / private CA)."
},
"refresh_interval": {
"label": "Refresh interval (seconds)",
"description": "How often to poll the API."
},
"notify_on_issue": {
"label": "Notify on new issues",
"description": "Desktop notification when a status widget or gateway becomes unhealthy."
},
"web_ui_url": {
"label": "Web UI URL override",
"description": "Optional alternate URL for “Open UI”. Defaults to base URL."
},
"show_label": {
"label": "Show status text on bar",
"description": "Display OK / issues count next to the icon."
},
"ok_color": {
"label": "Healthy color"
},
"warn_color": {
"label": "Issue color"
}
},
"colors": {
"tertiary": "Tertiary",
"primary": "Primary",
"secondary": "Secondary",
"error": "Error",
"muted": "Muted"
},
"widget": {
"tooltip_ok": "{host} · healthy · {ifaces} interfaces · load {load}",
"tooltip_issues": "{host} · {issues} issue(s) · {detail}",
"tooltip_missing": "Configure base URL + API key/secret in plugin settings",
"tooltip_down": "Unreachable: {error}",
"refresh_requested": "Refreshing OPNsense…",
"label_ok": "OK",
"label_issues": "{n}"
},
"panel": {
"subtitle": "Firewall health & services",
"loading": "Querying API…",
"logs_loading": "Loading firewall logs…",
"busy": "Working…",
"select_hint": "Select an item for actions.",
"updated": "Updated {time}",
"host": "Host: {host}",
"summary": "{ok} healthy · {issues} issue(s) · load {load}",
"empty": "No items match the filter.",
"not_configured": "Set Base URL, API key, and API secret under Settings → Plugins → OPNsense."
},
"result": {
"logs_loaded": "Loaded {n} log events",
"success": "Done",
"failed": "Failed: {error}",
"busy": "Another operation is running.",
"not_configured": "OPNsense API is not configured.",
"restarted": "Restarted {name}",
"started": "Started {name}",
"stopped": "Stopped {name}",
"copied": "Copied {name}",
"issue": "{name}: {status}"
},
"tabs": {
"status": "Status",
"interfaces": "Interfaces",
"gateways": "Gateways",
"services": "Services",
"rules": "Rules",
"logs": "Logs"
},
"filter": {
"placeholder": "Filter… e.g. block or !pass"
},
"rules": {
"summary": "{n} rules"
},
"rule": {
"detail": "{src} → {dst} · {proto} · {iface}",
"stats": "{packets} pkts · {bytes} B · {evaluations} evals"
},
"logs": {
"summary": "{n} events · {blocks} blocks",
"hint": "Select a log line · showing {n} recent · {blocks} blocks in buffer",
"flow": "{src} → {dst} · {iface}"
},
"status": {
"widget": "{name}: {status}",
"message": "{message}"
},
"iface": {
"summary": "{status} · {ipv4} · in {in} · out {out}"
},
"gateway": {
"summary": "{status} · {address} · rtt {rtt}"
},
"service": {
"summary": "{status} · {description}"
},
"actions": {
"refresh": "Refresh",
"open_ui": "Open UI",
"restart": "Restart",
"start": "Start",
"stop": "Stop",
"copy": "Copy"
},
"launcher": {
"loading": "Loading OPNsense…",
"unavailable": "OPNsense unavailable",
"no-matches": "No matches",
"cat": {
"status": "Status widgets",
"status-sub": "Crash reporter, firewall, system checks",
"interfaces": "Interfaces",
"interfaces-sub": "Link and address overview",
"gateways": "Gateways",
"gateways-sub": "Gateway monitor status",
"services": "Services",
"services-sub": "Start / stop / restart",
"rules": "Firewall rules",
"rules-sub": "Filter rules overview",
"logs": "Firewall logs",
"logs-sub": "Recent pass/block events",
"panel": "Open panel",
"panel-sub": "Full OPNsense manager",
"ui": "Open Web UI",
"ui-sub": "Browser dashboard",
"refresh": "Refresh",
"refresh-sub": "Poll API now"
},
"action": {
"restart": "Restart service",
"start": "Start service",
"stop": "Stop service",
"copy": "Copy name"
}
}
}
+127
View File
@@ -0,0 +1,127 @@
--!nonstrict
local PANEL_ID = "davemhammer/opnsense:manager"
local STATE_KEY = "opn_snapshot"
local COMMAND_KEY = "opn_command"
local snapshot = noctalia.state.get(STATE_KEY) or {
available = false,
configured = false,
issueCount = 0,
host = "",
error = "",
resources = {},
interfaces = {},
}
local requestId = 0
local function configString(key, fallback)
local value = noctalia.getConfig(key)
return type(value) == "string" and value or fallback
end
local function brandIcon(configured, available, healthy)
-- Real OPNsense mark (Simple Icons); tinted for state.
if not configured or not available then
return "assets/opnsense-grey.png"
end
if healthy then
return "assets/opnsense-orange.png" -- brand orange when healthy
end
return "assets/opnsense-red.png" -- issues
end
local function render()
local available = snapshot.available == true
local configured = snapshot.configured == true
local issues = tonumber(snapshot.issueCount) or 0
local healthy = available and issues == 0
local showLabel = noctalia.getConfig("show_label") ~= false
local okColor = configString("ok_color", "tertiary")
local warnColor = configString("warn_color", "error")
local color = (not configured or not available) and "on_surface_variant"
or (healthy and okColor or warnColor)
local children = {
ui.image({
path = brandIcon(configured, available, healthy),
width = 16,
height = 16,
fit = "contain",
}),
}
if showLabel then
if not configured then
table.insert(children, ui.label({ text = "…", color = "on_surface_variant" }))
elseif available then
table.insert(children, ui.label({
text = healthy and noctalia.tr("widget.label_ok")
or noctalia.tr("widget.label_issues", { n = issues }),
fontWeight = "bold",
color = color,
}))
end
end
if configured and available then
table.insert(children, ui.box({
width = 7,
height = 7,
radius = 4,
fill = healthy and okColor or warnColor,
}))
end
local container = barWidget.isVertical() and ui.column or ui.row
barWidget.render(container({ gap = 5, align = "center" }, children))
if not configured then
barWidget.setTooltip(noctalia.tr("widget.tooltip_missing"))
elseif not available then
barWidget.setTooltip(noctalia.tr("widget.tooltip_down", {
error = snapshot.error ~= "" and snapshot.error or "unknown",
}))
elseif healthy then
local load = ""
if type(snapshot.resources) == "table" then
load = tostring(snapshot.resources.load or "")
end
barWidget.setTooltip(noctalia.tr("widget.tooltip_ok", {
host = snapshot.host ~= "" and snapshot.host or "opnsense",
ifaces = #(snapshot.interfaces or {}),
load = load ~= "" and load or "—",
}))
else
barWidget.setTooltip(noctalia.tr("widget.tooltip_issues", {
host = snapshot.host ~= "" and snapshot.host or "opnsense",
issues = issues,
detail = snapshot.error ~= "" and snapshot.error or "see panel",
}))
end
end
noctalia.state.watch(STATE_KEY, function(value)
if type(value) == "table" then
snapshot = value
render()
end
end)
noctalia.setUpdateInterval(8000)
render()
function update()
render()
end
function onClick()
noctalia.togglePanel(PANEL_ID)
end
function onRightClick()
requestId += 1
noctalia.state.set(COMMAND_KEY, { action = "refresh", requestId = `widget-{requestId}` })
noctalia.notify(noctalia.tr("title"), noctalia.tr("widget.refresh_requested"))
end