Add davemhammer/opnsense (#315)
OPNsense health, interfaces, gateways, rules, logs, and service control.
@@ -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.
|
||||
@@ -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 |
|
After Width: | Height: | Size: 431 B |
@@ -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 |
|
After Width: | Height: | Size: 426 B |
@@ -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 |
|
After Width: | Height: | Size: 438 B |
@@ -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 |
|
After Width: | Height: | Size: 433 B |
@@ -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 |
@@ -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 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 49 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||