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