Files
community-plugins/opnsense/panel.luau
T
Dave HammerandGitHub 38d6255b98 Add davemhammer/opnsense (#315)
OPNsense health, interfaces, gateways, rules, logs, and service control.
2026-08-09 10:24:22 -04:00

733 lines
21 KiB
Luau

--!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