Tailscale status, peers, exit nodes, and preference toggles for Noctalia v5.
790 lines
22 KiB
Luau
790 lines
22 KiB
Luau
--!nonstrict
|
||
-- Tailscale manager panel.
|
||
|
||
local STATE_KEY = "ts_snapshot"
|
||
local COMMAND_KEY = "ts_command"
|
||
local RESULT_KEY = "ts_action_result"
|
||
|
||
local snapshot = noctalia.state.get(STATE_KEY) or {
|
||
available = false,
|
||
configured = false,
|
||
loading = true,
|
||
busy = false,
|
||
installed = false,
|
||
backendState = "",
|
||
running = false,
|
||
hostname = "",
|
||
dnsName = "",
|
||
ipv4 = "",
|
||
ipv6 = "",
|
||
tailnet = "",
|
||
peers = {},
|
||
exitNodes = {},
|
||
onlineCount = 0,
|
||
peerCount = 0,
|
||
exitNode = "",
|
||
shieldsUp = false,
|
||
acceptRoutes = false,
|
||
runSSH = false,
|
||
acceptDNS = false,
|
||
advertiseExitNode = false,
|
||
exitNodeAllowLAN = false,
|
||
health = {},
|
||
error = "",
|
||
updatedAt = 0,
|
||
revision = 0,
|
||
}
|
||
|
||
local tab = "status" -- status | peers | exit
|
||
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 formatBytes(n)
|
||
n = tonumber(n) or 0
|
||
if n >= 1e9 then return string.format("%.1fG", n / 1e9) end
|
||
if n >= 1e6 then return string.format("%.1fM", n / 1e6) end
|
||
if n >= 1e3 then return string.format("%.1fK", n / 1e3) end
|
||
return tostring(math.floor(n))
|
||
end
|
||
|
||
-- Connected = green; stopped = grey. Never primary (yellow) or error (red).
|
||
local COLOR_OK = "#73c936"
|
||
local COLOR_OFF = "#8a8a8a"
|
||
|
||
local function statusColor(ok)
|
||
return (ok == true) and COLOR_OK or COLOR_OFF
|
||
end
|
||
|
||
-- Title/status: official-style Tailscale brand mark (3×3 dots), green/grey by state.
|
||
local function statusIcon(running, size)
|
||
size = size or 24
|
||
local ok = running == true
|
||
return ui.image({
|
||
path = ok and "assets/tailscale-green.png" or "assets/tailscale-grey.png",
|
||
width = size,
|
||
height = size,
|
||
fit = "contain",
|
||
})
|
||
end
|
||
|
||
local function listButton(props)
|
||
props.contentAlign = "start"
|
||
props.controlSize = props.controlSize or "md"
|
||
return ui.button(props)
|
||
end
|
||
|
||
-- Toggle chip: fixed label; primary when on, outline when off (no selected —
|
||
-- selected can collapse the control to glyph-only in dense toolbars).
|
||
local function stateToggle(props)
|
||
local on = props.on == true
|
||
local text = props.label
|
||
if type(text) ~= "string" or text == "" then
|
||
text = "—"
|
||
end
|
||
return ui.button({
|
||
text = text,
|
||
glyph = props.glyph,
|
||
variant = on and "primary" or "outline",
|
||
enabled = props.enabled ~= false,
|
||
onClick = props.onClick,
|
||
})
|
||
end
|
||
|
||
local function selectedPeer()
|
||
if tab ~= "peers" then return nil end
|
||
for _, p in ipairs(snapshot.peers or {}) do
|
||
if p.id == selectedId then return p end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local function selectedExit()
|
||
if tab ~= "exit" then return nil end
|
||
for _, e in ipairs(snapshot.exitNodes or {}) do
|
||
if e.id == selectedId then return e 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 onOff(v)
|
||
return v and tr("status.on") or tr("status.off")
|
||
end
|
||
|
||
local function statusRows()
|
||
local rows = {}
|
||
local items = {
|
||
{
|
||
id = "st-state",
|
||
glyph = snapshot.running and "network" or "network-off",
|
||
text = tr("status.state", { state = snapshot.backendState ~= "" and snapshot.backendState or "—" }),
|
||
ok = snapshot.running == true,
|
||
},
|
||
{
|
||
id = "st-tailnet",
|
||
glyph = "world",
|
||
text = tr("status.tailnet", { name = snapshot.tailnet ~= "" and snapshot.tailnet or "—" }),
|
||
ok = true,
|
||
},
|
||
{
|
||
id = "st-ips",
|
||
glyph = "network",
|
||
text = tr("status.ips", {
|
||
v4 = snapshot.ipv4 ~= "" and snapshot.ipv4 or "—",
|
||
v6 = snapshot.ipv6 ~= "" and snapshot.ipv6 or "",
|
||
}),
|
||
ok = true,
|
||
},
|
||
{
|
||
id = "st-dns",
|
||
glyph = "world-www",
|
||
text = tr("status.dns", { dns = snapshot.dnsName ~= "" and snapshot.dnsName or "—" }),
|
||
ok = true,
|
||
},
|
||
{
|
||
id = "st-exit",
|
||
glyph = "world",
|
||
text = tr("status.exit", {
|
||
name = (snapshot.exitNode ~= "" and snapshot.exitNode) or tr("status.none"),
|
||
}),
|
||
ok = snapshot.exitNode == "" or snapshot.exitNodeOnline,
|
||
},
|
||
{
|
||
id = "st-prefs",
|
||
glyph = "settings",
|
||
text = tr("status.prefs", {
|
||
shields = onOff(snapshot.shieldsUp),
|
||
routes = onOff(snapshot.acceptRoutes),
|
||
ssh = onOff(snapshot.runSSH),
|
||
dns = onOff(snapshot.acceptDNS),
|
||
}),
|
||
ok = true,
|
||
},
|
||
}
|
||
if snapshot.version and snapshot.version ~= "" then
|
||
table.insert(items, {
|
||
id = "st-ver",
|
||
glyph = "info-circle",
|
||
text = tr("status.version", { version = snapshot.version }),
|
||
ok = true,
|
||
})
|
||
end
|
||
for _, h in ipairs(snapshot.health or {}) do
|
||
table.insert(items, {
|
||
id = "st-health-" .. tostring(#items),
|
||
glyph = "alert-triangle",
|
||
text = tr("panel.health", { msg = h }),
|
||
ok = false,
|
||
})
|
||
end
|
||
|
||
for _, item in ipairs(items) do
|
||
if matchesFilter(item.text) then
|
||
local selected = item.id == selectedId
|
||
table.insert(rows, listButton({
|
||
key = item.id,
|
||
text = item.text,
|
||
glyph = item.glyph,
|
||
variant = selected and "primary" or "outline",
|
||
selected = selected,
|
||
onClick = function()
|
||
selectedId = item.id
|
||
feedback = ""
|
||
render()
|
||
end,
|
||
}))
|
||
end
|
||
end
|
||
return rows
|
||
end
|
||
|
||
local function peerRows()
|
||
local rows = {}
|
||
for _, p in ipairs(snapshot.peers or {}) do
|
||
local onlineTag = p.online and "online" or "offline"
|
||
local exitTag = p.exitNodeOption and "exit" or ""
|
||
if matchesFilter(p.name, p.hostName, p.dnsName, p.ipv4, p.os, p.relay, onlineTag, exitTag) then
|
||
local selected = p.id == selectedId
|
||
local text = `{p.name} · {p.online and "online" or "offline"} · {p.ipv4}`
|
||
.. (p.exitNodeOption and " · exit" or "")
|
||
.. (p.active and " · active" or "")
|
||
table.insert(rows, listButton({
|
||
key = "peer-" .. p.id,
|
||
text = text,
|
||
glyph = p.online and "device-desktop" or "device-desktop-off",
|
||
variant = selected and "primary" or "outline",
|
||
selected = selected,
|
||
onClick = function()
|
||
selectedId = p.id
|
||
feedback = ""
|
||
render()
|
||
end,
|
||
}))
|
||
end
|
||
end
|
||
return rows
|
||
end
|
||
|
||
local function exitRows()
|
||
local rows = {}
|
||
for _, e in ipairs(snapshot.exitNodes or {}) do
|
||
if matchesFilter(e.name, e.host, e.ip, e.status) then
|
||
local selected = e.id == selectedId
|
||
local text = `{e.name} · {e.ip}`
|
||
.. (e.selected and " · selected" or "")
|
||
.. (e.online and "" or " · offline")
|
||
table.insert(rows, listButton({
|
||
key = "exit-" .. e.id,
|
||
text = text,
|
||
glyph = e.selected and "world" or "world-off",
|
||
variant = selected and "primary" or "outline",
|
||
selected = selected,
|
||
onClick = function()
|
||
selectedId = e.id
|
||
feedback = ""
|
||
render()
|
||
end,
|
||
}))
|
||
end
|
||
end
|
||
return rows
|
||
end
|
||
|
||
local function itemList()
|
||
local rows
|
||
if tab == "status" then
|
||
rows = statusRows()
|
||
elseif tab == "peers" then
|
||
rows = peerRows()
|
||
else
|
||
rows = exitRows()
|
||
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 == "status" then
|
||
local up = snapshot.running == true
|
||
return ui.column({ gap = 6, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, {
|
||
ui.row({ gap = 8, align = "center" }, {
|
||
statusIcon(up, 18),
|
||
ui.label({
|
||
text = snapshot.hostname ~= "" and snapshot.hostname or "Tailscale",
|
||
fontWeight = "bold",
|
||
flexGrow = 1,
|
||
maxLines = 1,
|
||
}),
|
||
ui.label({
|
||
text = snapshot.backendState ~= "" and snapshot.backendState or "—",
|
||
color = statusColor(up),
|
||
fontSize = 12,
|
||
}),
|
||
}),
|
||
ui.row({ gap = 6 }, {
|
||
ui.button({
|
||
text = tr("actions.up"),
|
||
glyph = "player-play",
|
||
variant = "primary",
|
||
enabled = not busy and not snapshot.running,
|
||
onClick = "onUp",
|
||
}),
|
||
ui.button({
|
||
text = tr("actions.down"),
|
||
glyph = "player-stop",
|
||
variant = "outline",
|
||
enabled = not busy and snapshot.running,
|
||
onClick = "onDown",
|
||
}),
|
||
stateToggle({
|
||
on = snapshot.shieldsUp == true,
|
||
label = tr("actions.shields_on"),
|
||
glyph = "shield",
|
||
enabled = not busy,
|
||
onClick = "onToggleShields",
|
||
}),
|
||
stateToggle({
|
||
on = snapshot.runSSH == true,
|
||
label = tr("actions.ssh_on"),
|
||
glyph = "terminal-2",
|
||
enabled = not busy,
|
||
onClick = "onToggleSSH",
|
||
}),
|
||
}),
|
||
ui.row({ gap = 6 }, {
|
||
stateToggle({
|
||
on = snapshot.acceptRoutes == true,
|
||
label = tr("actions.routes_on"),
|
||
glyph = "route",
|
||
enabled = not busy,
|
||
onClick = "onToggleRoutes",
|
||
}),
|
||
stateToggle({
|
||
on = snapshot.advertiseExitNode == true,
|
||
label = tr("actions.advertise_on"),
|
||
glyph = "world-upload",
|
||
enabled = not busy,
|
||
onClick = "onToggleAdvertise",
|
||
}),
|
||
stateToggle({
|
||
on = snapshot.exitNodeAllowLAN == true,
|
||
label = tr("actions.lan_on"),
|
||
glyph = "home",
|
||
enabled = not busy,
|
||
onClick = "onToggleLAN",
|
||
}),
|
||
ui.button({
|
||
text = tr("actions.copy_ip"),
|
||
glyph = "copy",
|
||
variant = "ghost",
|
||
enabled = snapshot.ipv4 ~= "",
|
||
onClick = "onCopySelfIp",
|
||
}),
|
||
}),
|
||
})
|
||
end
|
||
|
||
if tab == "peers" then
|
||
local p = selectedPeer()
|
||
if not p 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 = p.online and "device-desktop" or "device-desktop-off",
|
||
size = 18,
|
||
color = statusColor(p.online),
|
||
}),
|
||
ui.label({ text = p.name, fontWeight = "bold", flexGrow = 1, maxLines = 1 }),
|
||
ui.label({
|
||
text = p.online and "online" or "offline",
|
||
color = statusColor(p.online),
|
||
fontSize = 12,
|
||
}),
|
||
}),
|
||
ui.label({
|
||
text = tr("peer.detail", {
|
||
os = p.os ~= "" and p.os or "—",
|
||
relay = p.relay ~= "" and p.relay or "—",
|
||
v4 = p.ipv4 ~= "" and p.ipv4 or "—",
|
||
}),
|
||
color = "on_surface_variant",
|
||
fontSize = 12,
|
||
maxLines = 2,
|
||
}),
|
||
ui.label({
|
||
text = p.dnsName,
|
||
color = "on_surface_variant",
|
||
fontSize = 11,
|
||
visible = p.dnsName ~= "",
|
||
maxLines = 1,
|
||
}),
|
||
ui.row({ gap = 6 }, {
|
||
ui.button({ text = tr("actions.ping"), glyph = "activity", variant = "primary", enabled = not busy, onClick = "onPing" }),
|
||
ui.button({ text = tr("actions.ssh"), glyph = "terminal-2", variant = "outline", enabled = not busy, onClick = "onSSH" }),
|
||
ui.button({ text = tr("actions.copy_ip"), glyph = "copy", variant = "ghost", enabled = p.ipv4 ~= "", onClick = "onCopyPeerIp" }),
|
||
ui.button({ text = tr("actions.copy_dns"), glyph = "copy", variant = "ghost", enabled = p.dnsName ~= "", onClick = "onCopyPeerDns" }),
|
||
ui.button({
|
||
text = tr("actions.use_exit"),
|
||
glyph = "world",
|
||
variant = "ghost",
|
||
enabled = not busy and p.exitNodeOption,
|
||
visible = p.exitNodeOption == true,
|
||
onClick = "onUsePeerExit",
|
||
}),
|
||
}),
|
||
})
|
||
end
|
||
|
||
-- exit nodes tab
|
||
local e = selectedExit()
|
||
if not e then
|
||
return ui.column({ gap = 6, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, {
|
||
ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" }),
|
||
ui.row({ gap = 6 }, {
|
||
ui.button({
|
||
text = tr("actions.clear_exit"),
|
||
glyph = "x",
|
||
variant = "outline",
|
||
enabled = not busy and snapshot.exitNode ~= "",
|
||
onClick = "onClearExit",
|
||
}),
|
||
}),
|
||
})
|
||
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 = "world", size = 18, color = statusColor(e.online) }),
|
||
ui.label({ text = e.name, fontWeight = "bold", flexGrow = 1, maxLines = 1 }),
|
||
ui.label({
|
||
text = e.selected and "selected" or (e.online and "available" or "offline"),
|
||
color = e.selected and "primary" or statusColor(e.online),
|
||
fontSize = 12,
|
||
}),
|
||
}),
|
||
ui.label({
|
||
text = tr("exit.detail", { ip = e.ip, status = e.status ~= "" and e.status or "—" }),
|
||
color = "on_surface_variant",
|
||
fontSize = 12,
|
||
maxLines = 2,
|
||
}),
|
||
ui.row({ gap = 6 }, {
|
||
ui.button({
|
||
text = tr("actions.use_exit"),
|
||
glyph = "world",
|
||
variant = "primary",
|
||
enabled = not busy and not e.selected,
|
||
onClick = "onUseExit",
|
||
}),
|
||
ui.button({
|
||
text = tr("actions.clear_exit"),
|
||
glyph = "x",
|
||
variant = "outline",
|
||
enabled = not busy and (e.selected or snapshot.exitNode ~= ""),
|
||
onClick = "onClearExit",
|
||
}),
|
||
ui.button({
|
||
text = tr("actions.copy_ip"),
|
||
glyph = "copy",
|
||
variant = "ghost",
|
||
onClick = "onCopyExitIp",
|
||
}),
|
||
}),
|
||
})
|
||
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.installed then
|
||
table.insert(notes, ui.label({ text = tr("panel.not_installed"), 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.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 exitLabel = snapshot.exitNode ~= "" and snapshot.exitNode or "—"
|
||
local summary = tr("panel.summary", {
|
||
state = snapshot.backendState ~= "" and snapshot.backendState or "—",
|
||
online = snapshot.onlineCount or 0,
|
||
total = snapshot.peerCount or 0,
|
||
exit = exitLabel,
|
||
})
|
||
|
||
-- Title row: large status indicator next to "Tailscale"
|
||
local titleUp = snapshot.running == true
|
||
panel.render(ui.column({ flexGrow = 1, gap = 10 }, {
|
||
ui.row({ align = "center", gap = 10 }, {
|
||
statusIcon(titleUp, 28),
|
||
ui.column({ flexGrow = 1, gap = 0 }, {
|
||
ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold" }),
|
||
ui.label({
|
||
text = tr("panel.host", {
|
||
host = snapshot.hostname ~= "" and snapshot.hostname
|
||
or (snapshot.tailnet ~= "" and snapshot.tailnet or "—"),
|
||
}) .. (snapshot.ipv4 ~= "" and (` · {snapshot.ipv4}`) or ""),
|
||
fontSize = 11,
|
||
color = "on_surface_variant",
|
||
}),
|
||
}),
|
||
ui.button({ text = tr("actions.open_admin"), glyph = "external-link", variant = "outline", onClick = "onAdmin" }),
|
||
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.peers"), "peers", "onTabPeers"),
|
||
tabButton(tr("tabs.exit"), "exit", "onTabExit"),
|
||
}),
|
||
|
||
ui.label({
|
||
text = summary,
|
||
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
|
||
-- Always re-render: toggle flags (routes, advertise exit, LAN, …) live on the snapshot.
|
||
snapshot = value
|
||
if selectedId ~= "" then
|
||
if tab == "peers" and not selectedPeer() then
|
||
selectedId = ""
|
||
elseif tab == "exit" and not selectedExit() then
|
||
selectedId = ""
|
||
end
|
||
end
|
||
dirty = true
|
||
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") end
|
||
function onAdmin() send("open_admin") end
|
||
function onUp() send("up") end
|
||
function onDown() send("down") end
|
||
|
||
function onToggleShields()
|
||
send("set_shields", { enabled = not snapshot.shieldsUp })
|
||
end
|
||
function onToggleSSH()
|
||
send("set_ssh", { enabled = not snapshot.runSSH })
|
||
end
|
||
function onToggleRoutes()
|
||
send("set_accept_routes", { enabled = not snapshot.acceptRoutes })
|
||
end
|
||
function onToggleAdvertise()
|
||
-- Explicit next state: prefs AdvertiseRoutes default routes mean "on".
|
||
local nextOn = not (snapshot.advertiseExitNode == true)
|
||
send("set_advertise_exit", { enabled = nextOn })
|
||
end
|
||
function onToggleLAN()
|
||
send("set_allow_lan", { enabled = not snapshot.exitNodeAllowLAN })
|
||
end
|
||
|
||
function onCopySelfIp()
|
||
if snapshot.ipv4 ~= "" then
|
||
send("copy", { text = snapshot.ipv4 })
|
||
end
|
||
end
|
||
|
||
function onPing()
|
||
local p = selectedPeer()
|
||
if p then
|
||
send("ping", { host = p.ipv4 ~= "" and p.ipv4 or p.name })
|
||
end
|
||
end
|
||
function onSSH()
|
||
local p = selectedPeer()
|
||
if p then
|
||
send("ssh", { host = p.dnsName ~= "" and p.dnsName or p.name })
|
||
end
|
||
end
|
||
function onCopyPeerIp()
|
||
local p = selectedPeer()
|
||
if p and p.ipv4 ~= "" then
|
||
send("copy", { text = p.ipv4 })
|
||
end
|
||
end
|
||
function onCopyPeerDns()
|
||
local p = selectedPeer()
|
||
if p and p.dnsName ~= "" then
|
||
send("copy", { text = p.dnsName })
|
||
end
|
||
end
|
||
function onUsePeerExit()
|
||
local p = selectedPeer()
|
||
if p then
|
||
send("set_exit_node", { node = p.ipv4 ~= "" and p.ipv4 or p.name })
|
||
end
|
||
end
|
||
|
||
function onUseExit()
|
||
local e = selectedExit()
|
||
if e then
|
||
send("set_exit_node", { node = e.ip })
|
||
end
|
||
end
|
||
function onClearExit()
|
||
send("clear_exit_node")
|
||
end
|
||
function onCopyExitIp()
|
||
local e = selectedExit()
|
||
if e then
|
||
send("copy", { text = e.ip })
|
||
end
|
||
end
|
||
|
||
local function switchTab(next)
|
||
tab = next
|
||
selectedId = ""
|
||
filterKey += 1
|
||
render()
|
||
end
|
||
|
||
function onTabStatus() switchTab("status") end
|
||
function onTabPeers() switchTab("peers") end
|
||
function onTabExit() switchTab("exit") end
|
||
|
||
function onFilterChange(value)
|
||
filterText = if type(value) == "string" then value else ""
|
||
render()
|
||
end
|
||
|
||
function onClearFilter()
|
||
filterText = ""
|
||
filterKey += 1
|
||
render()
|
||
end
|