Add davemhammer/tailscale (#307)
Tailscale status, peers, exit nodes, and preference toggles for Noctalia v5.
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
--!nonstrict
|
||||
-- /ts launcher: Tailscale peers, exit nodes, connect/disconnect.
|
||||
|
||||
local STATE_KEY = "ts_snapshot"
|
||||
local COMMAND_KEY = "ts_command"
|
||||
local PANEL_ID = "davemhammer/tailscale:manager"
|
||||
local MAX_ROWS = 40
|
||||
|
||||
local snapshot = noctalia.state.get(STATE_KEY) or {
|
||||
available = false,
|
||||
loading = true,
|
||||
running = false,
|
||||
installed = false,
|
||||
peers = {},
|
||||
exitNodes = {},
|
||||
onlineCount = 0,
|
||||
peerCount = 0,
|
||||
backendState = "",
|
||||
exitNode = "",
|
||||
hostname = "",
|
||||
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 "circles",
|
||||
}
|
||||
end
|
||||
|
||||
local function ensureSnapshot()
|
||||
if snapshot.loading or not snapshot.available then
|
||||
send("refresh")
|
||||
end
|
||||
end
|
||||
|
||||
local function topCategories()
|
||||
local state = snapshot.backendState ~= "" and snapshot.backendState or "—"
|
||||
local summary = `{state} · {snapshot.onlineCount or 0}/{snapshot.peerCount or 0} online`
|
||||
if snapshot.exitNode and snapshot.exitNode ~= "" then
|
||||
summary = summary .. " · exit " .. snapshot.exitNode
|
||||
end
|
||||
return {
|
||||
{
|
||||
id = "cat:peers",
|
||||
title = noctalia.tr("launcher.cat.peers"),
|
||||
subtitle = noctalia.tr("launcher.cat.peers-sub"),
|
||||
glyph = "device-desktop",
|
||||
score = 100,
|
||||
},
|
||||
{
|
||||
id = "cat:exit",
|
||||
title = noctalia.tr("launcher.cat.exit"),
|
||||
subtitle = noctalia.tr("launcher.cat.exit-sub"),
|
||||
glyph = "world",
|
||||
score = 95,
|
||||
},
|
||||
{
|
||||
id = "act:status",
|
||||
title = noctalia.tr("launcher.cat.status"),
|
||||
subtitle = summary,
|
||||
glyph = "info-circle",
|
||||
score = 90,
|
||||
},
|
||||
{
|
||||
id = "act:up",
|
||||
title = noctalia.tr("launcher.cat.up"),
|
||||
subtitle = noctalia.tr("launcher.cat.up-sub"),
|
||||
glyph = "player-play",
|
||||
score = 85,
|
||||
},
|
||||
{
|
||||
id = "act:down",
|
||||
title = noctalia.tr("launcher.cat.down"),
|
||||
subtitle = noctalia.tr("launcher.cat.down-sub"),
|
||||
glyph = "player-stop",
|
||||
score = 80,
|
||||
},
|
||||
{
|
||||
id = "act:panel",
|
||||
title = noctalia.tr("launcher.cat.panel"),
|
||||
subtitle = noctalia.tr("launcher.cat.panel-sub"),
|
||||
glyph = "layout-dashboard",
|
||||
score = 70,
|
||||
},
|
||||
{
|
||||
id = "act:admin",
|
||||
title = noctalia.tr("launcher.cat.admin"),
|
||||
subtitle = noctalia.tr("launcher.cat.admin-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 peerActions(peer)
|
||||
local ref = peer.id
|
||||
return {
|
||||
{
|
||||
id = "peeract:ping:" .. ref,
|
||||
title = noctalia.tr("launcher.action.ping"),
|
||||
subtitle = peer.name .. " · " .. (peer.ipv4 ~= "" and peer.ipv4 or peer.dnsName),
|
||||
glyph = "activity",
|
||||
score = 100,
|
||||
},
|
||||
{
|
||||
id = "peeract:ssh:" .. ref,
|
||||
title = noctalia.tr("launcher.action.ssh"),
|
||||
subtitle = peer.dnsName ~= "" and peer.dnsName or peer.name,
|
||||
glyph = "terminal-2",
|
||||
score = 95,
|
||||
},
|
||||
{
|
||||
id = "peeract:copyip:" .. ref,
|
||||
title = noctalia.tr("launcher.action.copy_ip"),
|
||||
subtitle = peer.ipv4,
|
||||
glyph = "copy",
|
||||
score = 90,
|
||||
},
|
||||
{
|
||||
id = "peeract:copydns:" .. ref,
|
||||
title = noctalia.tr("launcher.action.copy_dns"),
|
||||
subtitle = peer.dnsName,
|
||||
glyph = "copy",
|
||||
score = 85,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local function findPeer(id)
|
||||
for _, p in ipairs(snapshot.peers or {}) do
|
||||
if p.id == id then return p end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function findExit(id)
|
||||
for _, e in ipairs(snapshot.exitNodes or {}) do
|
||||
if e.id == id then return e end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function listPeers(filter)
|
||||
local rows = {}
|
||||
for _, p in ipairs(snapshot.peers or {}) do
|
||||
local sc = scoreText(filter, p.name, p.hostName, p.dnsName, p.ipv4, p.os)
|
||||
if sc then
|
||||
table.insert(rows, {
|
||||
id = "peer:" .. p.id,
|
||||
title = p.name,
|
||||
subtitle = (p.online and "online" or "offline")
|
||||
.. (p.ipv4 ~= "" and (" · " .. p.ipv4) or "")
|
||||
.. (p.exitNodeOption and " · exit" or ""),
|
||||
glyph = p.online and "device-desktop" or "device-desktop-off",
|
||||
score = sc + (p.online and 10 or 0),
|
||||
})
|
||||
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
|
||||
return rows
|
||||
end
|
||||
|
||||
local function listExits(filter)
|
||||
local rows = {
|
||||
{
|
||||
id = "act:clear-exit",
|
||||
title = noctalia.tr("launcher.action.clear_exit"),
|
||||
subtitle = snapshot.exitNode ~= "" and snapshot.exitNode or "—",
|
||||
glyph = "x",
|
||||
score = 200,
|
||||
},
|
||||
}
|
||||
for _, e in ipairs(snapshot.exitNodes or {}) do
|
||||
local sc = scoreText(filter, e.name, e.host, e.ip, e.status)
|
||||
if sc then
|
||||
table.insert(rows, {
|
||||
id = "exit:" .. e.id,
|
||||
title = e.name,
|
||||
subtitle = e.ip .. (e.selected and " · selected" or "") .. (e.online and "" or " · offline"),
|
||||
glyph = "world",
|
||||
score = sc + (e.selected and 20 or 0),
|
||||
})
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b)
|
||||
return (a.score or 0) > (b.score or 0)
|
||||
end)
|
||||
return rows
|
||||
end
|
||||
|
||||
function search(query)
|
||||
ensureSnapshot()
|
||||
query = trim(query)
|
||||
|
||||
if not snapshot.installed then
|
||||
launcher.setResults(query, { statusRow(noctalia.tr("launcher.missing"), "", "alert-triangle") })
|
||||
return
|
||||
end
|
||||
|
||||
if snapshot.loading and not snapshot.available then
|
||||
launcher.setResults(query, { statusRow(noctalia.tr("launcher.loading"), snapshot.hostname, "loader") })
|
||||
return
|
||||
end
|
||||
|
||||
local head, rest = query:match("^(%S+)%s*(.-)$")
|
||||
head = lower(head or "")
|
||||
rest = trim(rest or "")
|
||||
|
||||
if head == "" then
|
||||
launcher.setResults(query, topCategories())
|
||||
return
|
||||
end
|
||||
|
||||
if head == "peers" or head == "peer" or head == "p" then
|
||||
-- peer actions if exact match id after peers
|
||||
local peerId = rest:match("^id:(%S+)") or ""
|
||||
if peerId ~= "" then
|
||||
local peer = findPeer(peerId)
|
||||
if peer then
|
||||
launcher.setResults(query, peerActions(peer))
|
||||
return
|
||||
end
|
||||
end
|
||||
-- if rest matches a single peer name exactly, show actions
|
||||
if rest ~= "" then
|
||||
local matches = {}
|
||||
for _, p in ipairs(snapshot.peers or {}) do
|
||||
if lower(p.name) == lower(rest) or lower(p.dnsName) == lower(rest) or p.ipv4 == rest then
|
||||
table.insert(matches, p)
|
||||
end
|
||||
end
|
||||
if #matches == 1 then
|
||||
launcher.setResults(query, peerActions(matches[1]))
|
||||
return
|
||||
end
|
||||
end
|
||||
launcher.setResults(query, listPeers(rest))
|
||||
return
|
||||
end
|
||||
|
||||
if head == "exit" or head == "exits" or head == "e" then
|
||||
launcher.setResults(query, listExits(rest))
|
||||
return
|
||||
end
|
||||
|
||||
if head == "status" or head == "up" or head == "down" or head == "panel" or head == "refresh" or head == "admin" then
|
||||
local map = {
|
||||
status = "act:status",
|
||||
up = "act:up",
|
||||
down = "act:down",
|
||||
panel = "act:panel",
|
||||
refresh = "act:refresh",
|
||||
admin = "act:admin",
|
||||
}
|
||||
-- still show categories filtered
|
||||
local rows = {}
|
||||
for _, row in ipairs(topCategories()) do
|
||||
if row.id == map[head] or scoreText(query, row.title, row.subtitle) then
|
||||
table.insert(rows, row)
|
||||
end
|
||||
end
|
||||
launcher.setResults(query, #rows > 0 and rows or topCategories())
|
||||
return
|
||||
end
|
||||
|
||||
-- free text: peers + categories
|
||||
local rows = listPeers(query)
|
||||
for _, row in ipairs(topCategories()) do
|
||||
local sc = scoreText(query, row.title, row.subtitle)
|
||||
if sc then
|
||||
row.score = sc
|
||||
table.insert(rows, row)
|
||||
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
|
||||
launcher.setResults(query, rows)
|
||||
end
|
||||
|
||||
function activate(id)
|
||||
if type(id) ~= "string" or id == "" then
|
||||
return
|
||||
end
|
||||
|
||||
if id == "cat:peers" then
|
||||
launcher.setQuery("peers ")
|
||||
return
|
||||
end
|
||||
if id == "cat:exit" then
|
||||
launcher.setQuery("exit ")
|
||||
return
|
||||
end
|
||||
if id == "act:status" then
|
||||
local state = snapshot.backendState or "—"
|
||||
noctalia.notify(noctalia.tr("title"), `{state} · {snapshot.onlineCount or 0}/{snapshot.peerCount or 0} online · {snapshot.ipv4 or ""}`)
|
||||
return
|
||||
end
|
||||
if id == "act:up" then
|
||||
send("up")
|
||||
return
|
||||
end
|
||||
if id == "act:down" then
|
||||
send("down")
|
||||
return
|
||||
end
|
||||
if id == "act:panel" then
|
||||
noctalia.togglePanel(PANEL_ID)
|
||||
return
|
||||
end
|
||||
if id == "act:admin" then
|
||||
send("open_admin")
|
||||
return
|
||||
end
|
||||
if id == "act:refresh" then
|
||||
send("refresh")
|
||||
return
|
||||
end
|
||||
if id == "act:clear-exit" then
|
||||
send("clear_exit_node")
|
||||
return
|
||||
end
|
||||
|
||||
local peerId = id:match("^peer:(.+)$")
|
||||
if peerId then
|
||||
launcher.setQuery("peers id:" .. peerId)
|
||||
return
|
||||
end
|
||||
|
||||
local exitId = id:match("^exit:(.+)$")
|
||||
if exitId then
|
||||
local e = findExit(exitId)
|
||||
if e then
|
||||
send("set_exit_node", { node = e.ip })
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local act, ref = id:match("^peeract:([^:]+):(.+)$")
|
||||
if act and ref then
|
||||
local peer = findPeer(ref)
|
||||
if not peer then return end
|
||||
if act == "ping" then
|
||||
send("ping", { host = peer.ipv4 ~= "" and peer.ipv4 or peer.name })
|
||||
elseif act == "ssh" then
|
||||
send("ssh", { host = peer.dnsName ~= "" and peer.dnsName or peer.name })
|
||||
elseif act == "copyip" then
|
||||
send("copy", { text = peer.ipv4 })
|
||||
elseif act == "copydns" then
|
||||
send("copy", { text = peer.dnsName })
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user