Add davemhammer/tailscale (#307)
Tailscale status, peers, exit nodes, and preference toggles for Noctalia v5.
@@ -0,0 +1,68 @@
|
||||
# Tailscale
|
||||
|
||||
Manage Tailscale connection state, peers, exit nodes, and preference toggles from Noctalia.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `davemhammer/tailscale` |
|
||||
| Entries | Bar widget: `status`; panel: `manager`; service: `service`; launcher: `ts` |
|
||||
| Launcher Prefix | `/ts` |
|
||||
|
||||
## Requirements
|
||||
|
||||
Install these on `PATH` (declared in `plugin.toml` `dependencies`):
|
||||
|
||||
- `tailscale` — status, prefs, up/down, set, ping, ssh (requires a running `tailscaled`)
|
||||
- `jq` — slim prefs extract from `tailscale debug prefs`
|
||||
- `xdg-open` — open the admin console URL
|
||||
|
||||
You need permission to operate the daemon (operator user or equivalent).
|
||||
|
||||
## Usage
|
||||
|
||||
Add the **status** bar widget (`davemhammer/tailscale:status`). Click for the panel.
|
||||
|
||||
Panel tabs:
|
||||
|
||||
- **Status** — connect/disconnect, shields, SSH, accept routes, advertise exit, allow LAN
|
||||
- **Peers** — ping, SSH, copy IP/DNS, use as exit if offered
|
||||
- **Exit nodes** — select or clear an exit node
|
||||
|
||||
Toggle chips use a fixed label and highlight when the setting is **on**.
|
||||
|
||||
Launcher: `/ts`, `/ts peers`, `/ts exit`, `/ts up`, `/ts down`.
|
||||
|
||||
```sh
|
||||
noctalia msg panel-toggle davemhammer/tailscale:manager
|
||||
```
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `refresh_interval` | `int` | `10` | Status poll interval in seconds. |
|
||||
| `notify_on_peer_change` | `bool` | `true` | Notify when a peer goes online/offline. |
|
||||
| `tailscale_bin` | `string` | `tailscale` | CLI path. |
|
||||
| `admin_url` | `string` | _(empty)_ | Override admin console URL (default login.tailscale.com admin). |
|
||||
| `ssh_user` | `string` | _(empty)_ | Default user for `tailscale ssh`. |
|
||||
| `show_counts` | `bool` (widget) | `true` | Show online/total peers on the bar. |
|
||||
|
||||
## IPC
|
||||
|
||||
```sh
|
||||
noctalia msg panel-toggle davemhammer/tailscale:manager
|
||||
noctalia msg plugin davemhammer/tailscale:service all refresh
|
||||
noctalia msg plugin davemhammer/tailscale:service all up
|
||||
noctalia msg plugin davemhammer/tailscale:service all down
|
||||
noctalia msg plugin davemhammer/tailscale:service all toggle
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Shells out to `tailscale status --json`, `tailscale debug prefs | jq …` (safe field projection), `tailscale exit-node list`, `tailscale set …`, `tailscale up` / `down`, optional `tailscale ping` / `tailscale ssh` in a terminal, and `xdg-open` for the admin URL.
|
||||
- Advertise-exit state is read from prefs `AdvertiseRoutes` (`0.0.0.0/0` / `::/0`), not only `ExitNodeOption`.
|
||||
- Network: only through the Tailscale CLI/daemon (no separate HTTP client in the plugin).
|
||||
- Filesystem: no plugin-written credentials; uses local Tailscale state via the CLI.
|
||||
- Brand mark assets are bundled under `assets/` (Simple Icons style 3×3 dots).
|
||||
|
After Width: | Height: | Size: 937 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Tailscale</title><path fill="#73c936" d="M24 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-9 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm0-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm6-6a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM3 24a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm18 .5a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm9-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-3 2.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM3 5.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/></svg>
|
||||
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 918 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Tailscale</title><path fill="#8a8a8a" d="M24 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-9 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm0-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm6-6a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM3 24a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm18 .5a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm9-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-3 2.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM3 5.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/></svg>
|
||||
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 753 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Tailscale</title><path fill="#ffffff" d="M24 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-9 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm0-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm6-6a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM3 24a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm18 .5a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm9-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-3 2.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM3 5.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/></svg>
|
||||
|
After Width: | Height: | Size: 645 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Tailscale</title><path d="M24 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-9 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm0-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm6-6a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM3 24a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm18 .5a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm9-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-3 2.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM3 5.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/></svg>
|
||||
|
After Width: | Height: | Size: 630 B |
@@ -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
|
||||
@@ -0,0 +1,789 @@
|
||||
--!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
|
||||
@@ -0,0 +1,86 @@
|
||||
# Tailscale VPN status, peers, and exit-node control.
|
||||
|
||||
id = "davemhammer/tailscale"
|
||||
name = "Tailscale"
|
||||
version = "1.0.5"
|
||||
plugin_api = 10
|
||||
author = "davemhammer"
|
||||
license = "MIT"
|
||||
dependencies = ["tailscale", "jq", "xdg-open"]
|
||||
tags = ["network", "utility", "bar", "panel", "service", "launcher"]
|
||||
icon = "circles"
|
||||
description = "Manage Tailscale connection, peers, exit nodes, and preference toggles."
|
||||
|
||||
[[setting]]
|
||||
key = "refresh_interval"
|
||||
type = "int"
|
||||
label_key = "settings.refresh_interval.label"
|
||||
description_key = "settings.refresh_interval.description"
|
||||
default = 10
|
||||
min = 3
|
||||
max = 120
|
||||
|
||||
[[setting]]
|
||||
key = "notify_on_peer_change"
|
||||
type = "bool"
|
||||
label_key = "settings.notify_on_peer_change.label"
|
||||
description_key = "settings.notify_on_peer_change.description"
|
||||
default = true
|
||||
|
||||
[[setting]]
|
||||
key = "tailscale_bin"
|
||||
type = "string"
|
||||
label_key = "settings.tailscale_bin.label"
|
||||
description_key = "settings.tailscale_bin.description"
|
||||
default = "tailscale"
|
||||
advanced = true
|
||||
|
||||
[[setting]]
|
||||
key = "admin_url"
|
||||
type = "string"
|
||||
label_key = "settings.admin_url.label"
|
||||
description_key = "settings.admin_url.description"
|
||||
default = ""
|
||||
advanced = true
|
||||
|
||||
[[setting]]
|
||||
key = "ssh_user"
|
||||
type = "string"
|
||||
label_key = "settings.ssh_user.label"
|
||||
description_key = "settings.ssh_user.description"
|
||||
default = ""
|
||||
advanced = true
|
||||
|
||||
[[widget]]
|
||||
id = "status"
|
||||
entry = "widget.luau"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "show_counts"
|
||||
type = "bool"
|
||||
label_key = "settings.show_counts.label"
|
||||
description_key = "settings.show_counts.description"
|
||||
default = true
|
||||
|
||||
[[panel]]
|
||||
id = "manager"
|
||||
entry = "panel.luau"
|
||||
width = 720
|
||||
height = 640
|
||||
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 = "ts"
|
||||
entry = "launcher.luau"
|
||||
prefix = "ts"
|
||||
glyph = "circles"
|
||||
include_in_global_search = false
|
||||
debounce_ms = 80
|
||||
@@ -0,0 +1,857 @@
|
||||
--!nonstrict
|
||||
-- Tailscale backend: status, peers, exit nodes, preference toggles.
|
||||
|
||||
local STATE_KEY = "ts_snapshot"
|
||||
local COMMAND_KEY = "ts_command"
|
||||
local RESULT_KEY = "ts_action_result"
|
||||
|
||||
local MAX_PEERS = 200
|
||||
local STUCK_SEC = 20
|
||||
|
||||
local snapshot = {
|
||||
available = false,
|
||||
loading = true,
|
||||
busy = false,
|
||||
installed = false,
|
||||
backendState = "",
|
||||
running = false,
|
||||
hostname = "",
|
||||
dnsName = "",
|
||||
ipv4 = "",
|
||||
ipv6 = "",
|
||||
tailnet = "",
|
||||
magicDNS = "",
|
||||
version = "",
|
||||
health = {},
|
||||
peers = {},
|
||||
exitNodes = {},
|
||||
onlineCount = 0,
|
||||
peerCount = 0,
|
||||
offlineCount = 0,
|
||||
exitNode = "",
|
||||
exitNodeOnline = false,
|
||||
shieldsUp = false,
|
||||
acceptRoutes = false,
|
||||
runSSH = false,
|
||||
acceptDNS = false,
|
||||
advertiseExitNode = false,
|
||||
exitNodeAllowLAN = false,
|
||||
operatorUser = "",
|
||||
error = "",
|
||||
updatedAt = 0,
|
||||
revision = 0,
|
||||
}
|
||||
|
||||
local refreshGeneration = 0
|
||||
local refreshPending = false
|
||||
local refreshAgain = false
|
||||
local refreshStartedAt = 0
|
||||
local actionBusy = false
|
||||
local dataSignature = ""
|
||||
local prevOnline = {} -- id -> true
|
||||
|
||||
local function trim(value)
|
||||
return noctalia.string.trim(tostring(value or ""))
|
||||
end
|
||||
|
||||
local function shellQuote(value)
|
||||
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function shellCommand(args)
|
||||
local quoted = {}
|
||||
for _, value in ipairs(args) do
|
||||
table.insert(quoted, shellQuote(value))
|
||||
end
|
||||
return table.concat(quoted, " ")
|
||||
end
|
||||
|
||||
local function tsBin()
|
||||
local bin = trim(noctalia.getConfig("tailscale_bin"))
|
||||
if bin == "" then
|
||||
return "tailscale"
|
||||
end
|
||||
return bin
|
||||
end
|
||||
|
||||
local function refreshIntervalMs()
|
||||
local seconds = tonumber(noctalia.getConfig("refresh_interval")) or 10
|
||||
seconds = math.max(3, math.min(120, math.floor(seconds)))
|
||||
return seconds * 1000
|
||||
end
|
||||
|
||||
local function nowSec()
|
||||
if type(noctalia.nowMs) == "function" then
|
||||
local ms = noctalia.nowMs()
|
||||
if type(ms) == "number" and ms > 0 then
|
||||
return math.floor(ms / 1000)
|
||||
end
|
||||
end
|
||||
return os.time()
|
||||
end
|
||||
|
||||
local function runTs(args, callback, timeoutMs)
|
||||
return noctalia.runAsync(shellCommand(args), callback, timeoutMs or 25000)
|
||||
end
|
||||
|
||||
local function updateRevision(signature)
|
||||
if signature ~= dataSignature then
|
||||
dataSignature = signature
|
||||
snapshot.revision += 1
|
||||
end
|
||||
end
|
||||
|
||||
local function publishSnapshot()
|
||||
snapshot.busy = actionBusy
|
||||
noctalia.state.set(STATE_KEY, snapshot)
|
||||
end
|
||||
|
||||
local function actionResult(command, ok, message, extra)
|
||||
local result = {
|
||||
requestId = command and command.requestId or "",
|
||||
action = command and command.action or "",
|
||||
ok = ok,
|
||||
message = message or "",
|
||||
}
|
||||
if type(extra) == "table" then
|
||||
for k, v in pairs(extra) do
|
||||
result[k] = v
|
||||
end
|
||||
end
|
||||
noctalia.state.set(RESULT_KEY, result)
|
||||
end
|
||||
|
||||
local function notifyOk(msg)
|
||||
noctalia.notify(noctalia.tr("title"), msg)
|
||||
end
|
||||
|
||||
local function notifyErr(msg)
|
||||
noctalia.notifyError(noctalia.tr("title"), msg)
|
||||
end
|
||||
|
||||
local function asString(v)
|
||||
if v == nil then
|
||||
return ""
|
||||
end
|
||||
if type(v) == "boolean" then
|
||||
return v and "true" or "false"
|
||||
end
|
||||
return tostring(v)
|
||||
end
|
||||
|
||||
local function firstIp(list)
|
||||
if type(list) ~= "table" then
|
||||
return "", ""
|
||||
end
|
||||
local v4, v6 = "", ""
|
||||
for _, ip in ipairs(list) do
|
||||
local s = asString(ip)
|
||||
if s:find(":", 1, true) then
|
||||
if v6 == "" then
|
||||
v6 = s
|
||||
end
|
||||
elseif s ~= "" and v4 == "" then
|
||||
v4 = s
|
||||
end
|
||||
end
|
||||
return v4, v6
|
||||
end
|
||||
|
||||
local function shortDns(dns)
|
||||
dns = asString(dns):gsub("%.$", "")
|
||||
return dns
|
||||
end
|
||||
|
||||
local function hostLabel(peer)
|
||||
local dns = shortDns(peer.DNSName or peer.dnsName or "")
|
||||
if dns ~= "" then
|
||||
local base = dns:match("^([^.]+)")
|
||||
if base and base ~= "" then
|
||||
return base
|
||||
end
|
||||
return dns
|
||||
end
|
||||
return asString(peer.HostName or peer.hostName or peer.id or "peer")
|
||||
end
|
||||
|
||||
local function parseStatus(data)
|
||||
local peers = {}
|
||||
local onlineCount, offlineCount = 0, 0
|
||||
local selfInfo = type(data.Self) == "table" and data.Self or {}
|
||||
local ipv4, ipv6 = firstIp(data.TailscaleIPs or selfInfo.TailscaleIPs)
|
||||
if ipv4 == "" then
|
||||
ipv4, ipv6 = firstIp(selfInfo.TailscaleIPs)
|
||||
end
|
||||
|
||||
local peerMap = data.Peer
|
||||
if type(peerMap) == "table" then
|
||||
for id, peer in pairs(peerMap) do
|
||||
if type(peer) == "table" and #peers < MAX_PEERS then
|
||||
local p4, p6 = firstIp(peer.TailscaleIPs)
|
||||
local online = peer.Online == true
|
||||
local active = peer.Active == true
|
||||
local exitOpt = peer.ExitNodeOption == true
|
||||
local isExit = peer.ExitNode == true
|
||||
local name = hostLabel(peer)
|
||||
local osName = asString(peer.OS)
|
||||
local relay = asString(peer.Relay)
|
||||
local lastSeen = asString(peer.LastSeen)
|
||||
if online then
|
||||
onlineCount += 1
|
||||
else
|
||||
offlineCount += 1
|
||||
end
|
||||
table.insert(peers, {
|
||||
id = asString(peer.ID or id),
|
||||
name = name,
|
||||
hostName = asString(peer.HostName),
|
||||
dnsName = shortDns(peer.DNSName),
|
||||
ipv4 = p4,
|
||||
ipv6 = p6,
|
||||
online = online,
|
||||
active = active,
|
||||
os = osName,
|
||||
relay = relay,
|
||||
exitNode = isExit,
|
||||
exitNodeOption = exitOpt,
|
||||
rxBytes = tonumber(peer.RxBytes) or 0,
|
||||
txBytes = tonumber(peer.TxBytes) or 0,
|
||||
lastSeen = lastSeen,
|
||||
ok = online,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(peers, function(a, b)
|
||||
if a.online ~= b.online then
|
||||
return a.online
|
||||
end
|
||||
if a.active ~= b.active then
|
||||
return a.active
|
||||
end
|
||||
return a.name < b.name
|
||||
end)
|
||||
|
||||
local health = {}
|
||||
if type(data.Health) == "table" then
|
||||
for _, h in ipairs(data.Health) do
|
||||
local s = trim(h)
|
||||
if s ~= "" then
|
||||
table.insert(health, s)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local tailnet = ""
|
||||
local magic = asString(data.MagicDNSSuffix)
|
||||
if type(data.CurrentTailnet) == "table" then
|
||||
tailnet = asString(data.CurrentTailnet.Name)
|
||||
if magic == "" then
|
||||
magic = asString(data.CurrentTailnet.MagicDNSSuffix)
|
||||
end
|
||||
end
|
||||
|
||||
local exitNode = ""
|
||||
local exitOnline = false
|
||||
if type(data.ExitNodeStatus) == "table" then
|
||||
local ips = data.ExitNodeStatus.TailscaleIPs
|
||||
if type(ips) == "table" and ips[1] then
|
||||
exitNode = asString(ips[1]):gsub("/.*$", "")
|
||||
end
|
||||
exitOnline = data.ExitNodeStatus.Online == true
|
||||
-- resolve name from peers if possible
|
||||
local eid = asString(data.ExitNodeStatus.ID)
|
||||
if eid ~= "" then
|
||||
for _, p in ipairs(peers) do
|
||||
if p.id == eid then
|
||||
exitNode = p.name
|
||||
break
|
||||
end
|
||||
end
|
||||
if exitNode == "" or exitNode:match("^%d") then
|
||||
for _, p in ipairs(peers) do
|
||||
if p.id == eid then
|
||||
exitNode = p.name
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local backend = asString(data.BackendState)
|
||||
local running = backend == "Running"
|
||||
|
||||
return {
|
||||
backendState = backend,
|
||||
running = running,
|
||||
hostname = asString(selfInfo.HostName),
|
||||
dnsName = shortDns(selfInfo.DNSName),
|
||||
ipv4 = ipv4,
|
||||
ipv6 = ipv6,
|
||||
tailnet = tailnet,
|
||||
magicDNS = magic,
|
||||
version = asString(data.Version),
|
||||
health = health,
|
||||
peers = peers,
|
||||
onlineCount = onlineCount,
|
||||
peerCount = #peers,
|
||||
offlineCount = offlineCount,
|
||||
exitNode = exitNode,
|
||||
exitNodeOnline = exitOnline,
|
||||
advertiseExitNode = selfInfo.ExitNodeOption == true,
|
||||
}
|
||||
end
|
||||
|
||||
local function parseExitNodeList(stdout)
|
||||
local list = {}
|
||||
for line in (tostring(stdout or "") .. "\n"):gmatch("(.-)\n") do
|
||||
line = trim(line)
|
||||
if line ~= ""
|
||||
and not line:match("^IP%s")
|
||||
and not line:match("^#")
|
||||
and not line:match("^To ")
|
||||
and not line:match("^%-%-")
|
||||
then
|
||||
-- columns: IP HOSTNAME COUNTRY CITY STATUS...
|
||||
local ip, host, rest = line:match("^(%S+)%s+(%S+)%s+(.*)$")
|
||||
if ip and host and ip:match("^%d+%.%d+") then
|
||||
local status = trim(rest)
|
||||
local selected = status:lower():find("selected", 1, true) ~= nil
|
||||
local offline = status:lower():find("offline", 1, true) ~= nil
|
||||
table.insert(list, {
|
||||
id = ip,
|
||||
ip = ip,
|
||||
name = host:match("^([^.]+)") or host,
|
||||
host = host,
|
||||
status = status,
|
||||
selected = selected,
|
||||
online = not offline,
|
||||
ok = not offline,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(list, function(a, b)
|
||||
if a.selected ~= b.selected then
|
||||
return a.selected
|
||||
end
|
||||
if a.online ~= b.online then
|
||||
return a.online
|
||||
end
|
||||
return a.name < b.name
|
||||
end)
|
||||
return list
|
||||
end
|
||||
|
||||
local function routesIncludeDefaultExit(routes)
|
||||
if type(routes) ~= "table" then
|
||||
return false
|
||||
end
|
||||
local hasV4, hasV6 = false, false
|
||||
for _, r in ipairs(routes) do
|
||||
local s = asString(r)
|
||||
if s == "0.0.0.0/0" then
|
||||
hasV4 = true
|
||||
elseif s == "::/0" then
|
||||
hasV6 = true
|
||||
end
|
||||
end
|
||||
-- Advertising as an exit node is stored as default routes, not ExitNodeOption.
|
||||
return hasV4 or hasV6
|
||||
end
|
||||
|
||||
local function parsePrefs(data)
|
||||
if type(data) ~= "table" then
|
||||
return {}
|
||||
end
|
||||
return {
|
||||
shieldsUp = data.ShieldsUp == true,
|
||||
acceptRoutes = data.RouteAll == true,
|
||||
runSSH = data.RunSSH == true,
|
||||
acceptDNS = data.CorpDNS == true,
|
||||
exitNodeAllowLAN = data.ExitNodeAllowLANAccess == true,
|
||||
-- Authoritative for "advertise exit node" (status Self.ExitNodeOption is often stale).
|
||||
advertiseExitNode = routesIncludeDefaultExit(data.AdvertiseRoutes),
|
||||
operatorUser = asString(data.OperatorUser),
|
||||
wantRunning = data.WantRunning == true,
|
||||
}
|
||||
end
|
||||
|
||||
local function notifyPeerChanges(peers)
|
||||
if noctalia.getConfig("notify_on_peer_change") == false then
|
||||
return
|
||||
end
|
||||
local current = {}
|
||||
for _, p in ipairs(peers) do
|
||||
current[p.id] = p.online
|
||||
local was = prevOnline[p.id]
|
||||
if was == true and not p.online then
|
||||
notifyErr(noctalia.tr("result.peer_offline", { name = p.name }))
|
||||
elseif was == false and p.online then
|
||||
notifyOk(noctalia.tr("result.peer_online", { name = p.name }))
|
||||
end
|
||||
end
|
||||
-- only seed after first successful sample
|
||||
if next(prevOnline) ~= nil or #peers > 0 then
|
||||
prevOnline = {}
|
||||
for id, online in pairs(current) do
|
||||
prevOnline[id] = online
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local refreshAll
|
||||
|
||||
local function forceUnstick(reason)
|
||||
noctalia.log("tailscale: " .. reason)
|
||||
refreshPending = false
|
||||
refreshStartedAt = 0
|
||||
snapshot.loading = false
|
||||
if snapshot.error == "" then
|
||||
snapshot.error = reason
|
||||
end
|
||||
noctalia.setUpdateInterval(refreshIntervalMs())
|
||||
publishSnapshot()
|
||||
end
|
||||
|
||||
local function applyBag(statusData, prefsData, exitStdout, errors)
|
||||
local st = {}
|
||||
local prefs = {}
|
||||
local exits = {}
|
||||
|
||||
local okS, errS = pcall(function()
|
||||
st = parseStatus(statusData or {})
|
||||
end)
|
||||
if not okS then
|
||||
table.insert(errors, "status parse: " .. tostring(errS))
|
||||
st = {}
|
||||
end
|
||||
|
||||
local okP, errP = pcall(function()
|
||||
prefs = parsePrefs(prefsData)
|
||||
end)
|
||||
if not okP then
|
||||
table.insert(errors, "prefs parse: " .. tostring(errP))
|
||||
end
|
||||
|
||||
local okE, errE = pcall(function()
|
||||
exits = parseExitNodeList(exitStdout)
|
||||
end)
|
||||
if not okE then
|
||||
table.insert(errors, "exit list: " .. tostring(errE))
|
||||
end
|
||||
|
||||
-- resolve exit node display from selected exit list entry
|
||||
local exitLabel = st.exitNode or ""
|
||||
for _, e in ipairs(exits) do
|
||||
if e.selected then
|
||||
exitLabel = e.name
|
||||
st.exitNodeOnline = e.online
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
pcall(notifyPeerChanges, st.peers or {})
|
||||
|
||||
local available = snapshot.installed and (st.backendState ~= "" or #(st.peers or {}) > 0 or st.hostname ~= "")
|
||||
-- Even when Stopped, status JSON is valid.
|
||||
if st.backendState ~= "" then
|
||||
available = snapshot.installed
|
||||
end
|
||||
|
||||
snapshot.available = available == true
|
||||
snapshot.loading = false
|
||||
snapshot.backendState = st.backendState or ""
|
||||
snapshot.running = st.running == true
|
||||
snapshot.hostname = st.hostname or ""
|
||||
snapshot.dnsName = st.dnsName or ""
|
||||
snapshot.ipv4 = st.ipv4 or ""
|
||||
snapshot.ipv6 = st.ipv6 or ""
|
||||
snapshot.tailnet = st.tailnet or ""
|
||||
snapshot.magicDNS = st.magicDNS or ""
|
||||
snapshot.version = st.version or ""
|
||||
snapshot.health = st.health or {}
|
||||
snapshot.peers = st.peers or {}
|
||||
snapshot.exitNodes = exits
|
||||
snapshot.onlineCount = st.onlineCount or 0
|
||||
snapshot.peerCount = st.peerCount or 0
|
||||
snapshot.offlineCount = st.offlineCount or 0
|
||||
snapshot.exitNode = exitLabel
|
||||
snapshot.exitNodeOnline = st.exitNodeOnline == true
|
||||
snapshot.shieldsUp = prefs.shieldsUp == true
|
||||
snapshot.acceptRoutes = prefs.acceptRoutes == true
|
||||
snapshot.runSSH = prefs.runSSH == true
|
||||
snapshot.acceptDNS = prefs.acceptDNS == true
|
||||
-- Prefer prefs (AdvertiseRoutes); fall back to status Self.ExitNodeOption.
|
||||
if prefs.advertiseExitNode ~= nil then
|
||||
snapshot.advertiseExitNode = prefs.advertiseExitNode == true
|
||||
else
|
||||
snapshot.advertiseExitNode = st.advertiseExitNode == true
|
||||
end
|
||||
snapshot.exitNodeAllowLAN = prefs.exitNodeAllowLAN == true
|
||||
snapshot.operatorUser = prefs.operatorUser or ""
|
||||
snapshot.error = available and "" or (errors[1] or "no data")
|
||||
if available and type(st.health) == "table" and #st.health > 0 and not st.running then
|
||||
-- keep health visible without treating as hard error when stopped intentionally
|
||||
if snapshot.error == "" and st.backendState == "Stopped" then
|
||||
snapshot.error = ""
|
||||
end
|
||||
end
|
||||
snapshot.updatedAt = nowSec()
|
||||
refreshPending = false
|
||||
refreshStartedAt = 0
|
||||
noctalia.setUpdateInterval(refreshIntervalMs())
|
||||
|
||||
updateRevision(table.concat({
|
||||
snapshot.backendState,
|
||||
snapshot.ipv4,
|
||||
tostring(snapshot.onlineCount),
|
||||
tostring(snapshot.peerCount),
|
||||
snapshot.exitNode,
|
||||
asString(snapshot.shieldsUp),
|
||||
asString(snapshot.runSSH),
|
||||
asString(snapshot.advertiseExitNode),
|
||||
}, "|"))
|
||||
publishSnapshot()
|
||||
end
|
||||
|
||||
refreshAll = function()
|
||||
if refreshPending and refreshStartedAt > 0 and (nowSec() - refreshStartedAt) >= STUCK_SEC then
|
||||
forceUnstick("refresh timed out")
|
||||
end
|
||||
if refreshPending then
|
||||
refreshAgain = true
|
||||
return
|
||||
end
|
||||
refreshPending = true
|
||||
refreshAgain = false
|
||||
refreshStartedAt = nowSec()
|
||||
refreshGeneration += 1
|
||||
local generation = refreshGeneration
|
||||
|
||||
local bin = tsBin()
|
||||
snapshot.installed = noctalia.commandExists(bin) or noctalia.commandExists("tailscale")
|
||||
if not snapshot.installed then
|
||||
snapshot.available = false
|
||||
snapshot.loading = false
|
||||
snapshot.error = noctalia.tr("result.missing")
|
||||
snapshot.peers = {}
|
||||
snapshot.exitNodes = {}
|
||||
snapshot.onlineCount = 0
|
||||
snapshot.peerCount = 0
|
||||
refreshPending = false
|
||||
refreshStartedAt = 0
|
||||
updateRevision("missing")
|
||||
publishSnapshot()
|
||||
return
|
||||
end
|
||||
|
||||
if not snapshot.available then
|
||||
snapshot.loading = true
|
||||
publishSnapshot()
|
||||
end
|
||||
noctalia.setUpdateInterval(1000)
|
||||
|
||||
local pending = 3
|
||||
local bag = { status = nil, prefs = nil, exits = "" }
|
||||
local errors = {}
|
||||
local finished = false
|
||||
|
||||
local function finish()
|
||||
if generation ~= refreshGeneration then
|
||||
return
|
||||
end
|
||||
pending -= 1
|
||||
if pending > 0 or finished then
|
||||
return
|
||||
end
|
||||
finished = true
|
||||
local okApply, errApply = pcall(applyBag, bag.status, bag.prefs, bag.exits, errors)
|
||||
if not okApply then
|
||||
noctalia.log(`tailscale: apply failed: {tostring(errApply)}`)
|
||||
snapshot.loading = false
|
||||
snapshot.error = "refresh failed: " .. tostring(errApply)
|
||||
refreshPending = false
|
||||
refreshStartedAt = 0
|
||||
noctalia.setUpdateInterval(refreshIntervalMs())
|
||||
publishSnapshot()
|
||||
end
|
||||
if refreshAgain then
|
||||
refreshAgain = false
|
||||
refreshAll()
|
||||
end
|
||||
end
|
||||
|
||||
-- status --json (slim: only decode once; peer count is usually small)
|
||||
runTs({ bin, "status", "--json" }, function(result)
|
||||
if generation ~= refreshGeneration then
|
||||
return
|
||||
end
|
||||
local okInner, errInner = pcall(function()
|
||||
if not result or result.exitCode ~= 0 then
|
||||
local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout) or "status failed")
|
||||
table.insert(errors, err ~= "" and err or "status failed")
|
||||
return
|
||||
end
|
||||
local data = noctalia.json.decode(result.stdout or "")
|
||||
if data == nil then
|
||||
table.insert(errors, "invalid status JSON")
|
||||
return
|
||||
end
|
||||
bag.status = data
|
||||
end)
|
||||
if not okInner then
|
||||
table.insert(errors, "status: " .. tostring(errInner))
|
||||
end
|
||||
finish()
|
||||
end, 20000)
|
||||
|
||||
-- prefs without secrets (jq projects safe fields only)
|
||||
local prefsCmd = shellCommand({ bin, "debug", "prefs" })
|
||||
.. " | jq -c '{WantRunning,ShieldsUp,RunSSH,RouteAll,ExitNodeID,ExitNodeAllowLANAccess,CorpDNS,AdvertiseRoutes,OperatorUser,AdvertiseTags}'"
|
||||
noctalia.runAsync(prefsCmd, function(result)
|
||||
if generation ~= refreshGeneration then
|
||||
return
|
||||
end
|
||||
local okInner, errInner = pcall(function()
|
||||
if result and result.exitCode == 0 and trim(result.stdout) ~= "" then
|
||||
bag.prefs = noctalia.json.decode(result.stdout)
|
||||
end
|
||||
end)
|
||||
if not okInner then
|
||||
table.insert(errors, "prefs: " .. tostring(errInner))
|
||||
end
|
||||
finish()
|
||||
end, 15000)
|
||||
|
||||
runTs({ bin, "exit-node", "list" }, function(result)
|
||||
if generation ~= refreshGeneration then
|
||||
return
|
||||
end
|
||||
local okInner, errInner = pcall(function()
|
||||
if result and result.exitCode == 0 then
|
||||
bag.exits = result.stdout or ""
|
||||
end
|
||||
end)
|
||||
if not okInner then
|
||||
table.insert(errors, "exits: " .. tostring(errInner))
|
||||
end
|
||||
finish()
|
||||
end, 15000)
|
||||
end
|
||||
|
||||
local function finishAction(command, ok, message)
|
||||
actionBusy = false
|
||||
actionResult(command, ok, message)
|
||||
if ok then
|
||||
notifyOk(message)
|
||||
else
|
||||
notifyErr(message)
|
||||
end
|
||||
publishSnapshot()
|
||||
refreshAll()
|
||||
end
|
||||
|
||||
local function runAction(command, args, okMsg, failPrefix)
|
||||
if actionBusy then
|
||||
actionResult(command, false, noctalia.tr("result.busy"))
|
||||
return
|
||||
end
|
||||
if not snapshot.installed then
|
||||
actionResult(command, false, noctalia.tr("result.missing"))
|
||||
return
|
||||
end
|
||||
actionBusy = true
|
||||
publishSnapshot()
|
||||
local bin = tsBin()
|
||||
local full = { bin }
|
||||
for _, a in ipairs(args) do
|
||||
table.insert(full, a)
|
||||
end
|
||||
runTs(full, function(result)
|
||||
local ok = result and result.exitCode == 0
|
||||
if ok then
|
||||
finishAction(command, true, okMsg)
|
||||
else
|
||||
local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout) or failPrefix)
|
||||
if err == "" then
|
||||
err = failPrefix
|
||||
end
|
||||
finishAction(command, false, noctalia.tr("result.failed", { error = err }))
|
||||
end
|
||||
end, 60000)
|
||||
end
|
||||
|
||||
local function setBoolFlag(command, flag, enabled, okMsg)
|
||||
-- tailscale set --flag / --flag=false (explicit false required to turn off)
|
||||
local arg
|
||||
if enabled then
|
||||
arg = "--" .. flag .. "=true"
|
||||
else
|
||||
arg = "--" .. flag .. "=false"
|
||||
end
|
||||
runAction(command, { "set", arg }, okMsg, "set failed")
|
||||
end
|
||||
|
||||
local function openAdmin()
|
||||
local url = trim(noctalia.getConfig("admin_url"))
|
||||
if url == "" then
|
||||
url = "https://login.tailscale.com/admin/machines"
|
||||
end
|
||||
noctalia.runAsync("xdg-open " .. shellQuote(url))
|
||||
end
|
||||
|
||||
local function executeAction(command)
|
||||
if type(command) ~= "table" or type(command.action) ~= "string" then
|
||||
return
|
||||
end
|
||||
local action = command.action
|
||||
|
||||
if action == "refresh" then
|
||||
refreshAll()
|
||||
return
|
||||
end
|
||||
if action == "up" then
|
||||
runAction(command, { "up" }, noctalia.tr("result.up"), "up failed")
|
||||
return
|
||||
end
|
||||
if action == "down" then
|
||||
runAction(command, { "down" }, noctalia.tr("result.down"), "down failed")
|
||||
return
|
||||
end
|
||||
if action == "set_exit_node" then
|
||||
local node = trim(command.node or command.ip or command.id)
|
||||
if node == "" then
|
||||
actionResult(command, false, noctalia.tr("result.failed", { error = "missing exit node" }))
|
||||
return
|
||||
end
|
||||
runAction(command, { "set", "--exit-node=" .. node }, noctalia.tr("result.exit_set", { name = node }), "exit node failed")
|
||||
return
|
||||
end
|
||||
if action == "clear_exit_node" then
|
||||
runAction(command, { "set", "--exit-node=" }, noctalia.tr("result.exit_cleared"), "clear exit failed")
|
||||
return
|
||||
end
|
||||
if action == "set_shields" then
|
||||
local on = command.enabled == true or command.enabled == "true"
|
||||
setBoolFlag(command, "shields-up", on, on and noctalia.tr("result.shields_on") or noctalia.tr("result.shields_off"))
|
||||
return
|
||||
end
|
||||
if action == "set_ssh" then
|
||||
local on = command.enabled == true or command.enabled == "true"
|
||||
setBoolFlag(command, "ssh", on, on and noctalia.tr("result.ssh_on") or noctalia.tr("result.ssh_off"))
|
||||
return
|
||||
end
|
||||
if action == "set_accept_routes" then
|
||||
local on = command.enabled == true or command.enabled == "true"
|
||||
setBoolFlag(command, "accept-routes", on, on and noctalia.tr("result.routes_on") or noctalia.tr("result.routes_off"))
|
||||
return
|
||||
end
|
||||
if action == "set_advertise_exit" then
|
||||
-- Accept bool, string "true"/"false", or missing (treat as toggle off only when false).
|
||||
local on = command.enabled == true or command.enabled == "true" or command.enabled == 1
|
||||
if command.enabled == false or command.enabled == "false" or command.enabled == 0 then
|
||||
on = false
|
||||
end
|
||||
setBoolFlag(
|
||||
command,
|
||||
"advertise-exit-node",
|
||||
on,
|
||||
on and noctalia.tr("result.advertise_on") or noctalia.tr("result.advertise_off")
|
||||
)
|
||||
return
|
||||
end
|
||||
if action == "set_allow_lan" then
|
||||
local on = command.enabled == true or command.enabled == "true"
|
||||
setBoolFlag(command, "exit-node-allow-lan-access", on, on and noctalia.tr("result.lan_on") or noctalia.tr("result.lan_off"))
|
||||
return
|
||||
end
|
||||
if action == "ping" then
|
||||
local host = trim(command.host or command.name or command.ip)
|
||||
if host == "" then
|
||||
actionResult(command, false, noctalia.tr("result.failed", { error = "missing host" }))
|
||||
return
|
||||
end
|
||||
local bin = tsBin()
|
||||
local cmd = shellCommand({ bin, "ping", "-c", "3", host })
|
||||
if type(noctalia.runInTerminal) == "function" then
|
||||
noctalia.runInTerminal(cmd)
|
||||
else
|
||||
noctalia.runAsync(cmd)
|
||||
end
|
||||
actionResult(command, true, noctalia.tr("result.ping_started", { name = host }))
|
||||
notifyOk(noctalia.tr("result.ping_started", { name = host }))
|
||||
return
|
||||
end
|
||||
if action == "ssh" then
|
||||
local host = trim(command.host or command.name or command.dnsName)
|
||||
if host == "" then
|
||||
actionResult(command, false, noctalia.tr("result.failed", { error = "missing host" }))
|
||||
return
|
||||
end
|
||||
local user = trim(command.user or noctalia.getConfig("ssh_user"))
|
||||
local target = user ~= "" and (user .. "@" .. host) or host
|
||||
local bin = tsBin()
|
||||
local cmd = shellCommand({ bin, "ssh", target })
|
||||
if type(noctalia.runInTerminal) == "function" then
|
||||
noctalia.runInTerminal(cmd)
|
||||
else
|
||||
noctalia.runAsync(cmd)
|
||||
end
|
||||
actionResult(command, true, noctalia.tr("result.ssh_started", { name = target }))
|
||||
notifyOk(noctalia.tr("result.ssh_started", { name = target }))
|
||||
return
|
||||
end
|
||||
if action == "copy" then
|
||||
local text = trim(command.text or command.name)
|
||||
if text ~= "" then
|
||||
noctalia.copyToClipboard(text, "text/plain")
|
||||
actionResult(command, true, noctalia.tr("result.copied", { name = text }))
|
||||
notifyOk(noctalia.tr("result.copied", { name = text }))
|
||||
end
|
||||
return
|
||||
end
|
||||
if action == "open_admin" then
|
||||
openAdmin()
|
||||
actionResult(command, true, noctalia.tr("result.admin_opened"))
|
||||
return
|
||||
end
|
||||
actionResult(command, false, "Unknown action: " .. action)
|
||||
end
|
||||
|
||||
noctalia.state.watch(COMMAND_KEY, executeAction)
|
||||
noctalia.setUpdateInterval(refreshIntervalMs())
|
||||
refreshAll()
|
||||
|
||||
function update()
|
||||
refreshAll()
|
||||
end
|
||||
|
||||
function onConfigChanged()
|
||||
noctalia.setUpdateInterval(refreshIntervalMs())
|
||||
refreshPending = false
|
||||
refreshStartedAt = 0
|
||||
refreshAll()
|
||||
end
|
||||
|
||||
function onIpc(event, payload)
|
||||
if event == "refresh" then
|
||||
refreshPending = false
|
||||
refreshStartedAt = 0
|
||||
refreshAll()
|
||||
elseif event == "up" then
|
||||
executeAction({ action = "up", requestId = "ipc-up" })
|
||||
elseif event == "down" then
|
||||
executeAction({ action = "down", requestId = "ipc-down" })
|
||||
elseif event == "toggle" then
|
||||
if snapshot.running then
|
||||
executeAction({ action = "down", requestId = "ipc-toggle" })
|
||||
else
|
||||
executeAction({ action = "up", requestId = "ipc-toggle" })
|
||||
end
|
||||
elseif type(payload) == "table" and type(payload.action) == "string" then
|
||||
executeAction(payload)
|
||||
end
|
||||
end
|
||||
|
After Width: | Height: | Size: 48 KiB |
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"title": "Tailscale",
|
||||
"settings": {
|
||||
"refresh_interval": {
|
||||
"label": "Refresh interval (seconds)",
|
||||
"description": "How often to poll tailscale status."
|
||||
},
|
||||
"notify_on_peer_change": {
|
||||
"label": "Notify on peer online/offline",
|
||||
"description": "Desktop notification when a peer changes connectivity."
|
||||
},
|
||||
"tailscale_bin": {
|
||||
"label": "tailscale binary",
|
||||
"description": "Command name or absolute path to the tailscale CLI."
|
||||
},
|
||||
"admin_url": {
|
||||
"label": "Admin console URL",
|
||||
"description": "Override the admin machines URL opened from the panel."
|
||||
},
|
||||
"ssh_user": {
|
||||
"label": "Default SSH user",
|
||||
"description": "Optional user for tailscale ssh (empty uses the CLI default)."
|
||||
},
|
||||
"show_counts": {
|
||||
"label": "Show counts on bar",
|
||||
"description": "Display online/total peer counts on the widget."
|
||||
},
|
||||
"ok_color": {
|
||||
"label": "Connected color (green)"
|
||||
},
|
||||
"warn_color": {
|
||||
"label": "Disconnected color (grey)"
|
||||
}
|
||||
},
|
||||
"colors": {
|
||||
"tertiary": "Tertiary",
|
||||
"primary": "Primary",
|
||||
"secondary": "Secondary",
|
||||
"error": "Error",
|
||||
"muted": "Muted"
|
||||
},
|
||||
"widget": {
|
||||
"tooltip_ok": "{state} · {online}/{total} peers online · exit {exit}",
|
||||
"tooltip_stopped": "Tailscale is stopped · {total} known peers",
|
||||
"tooltip_missing": "tailscale CLI not found",
|
||||
"tooltip_down": "Tailscale unavailable: {error}",
|
||||
"refresh_requested": "Refreshing Tailscale…"
|
||||
},
|
||||
"panel": {
|
||||
"loading": "Querying Tailscale…",
|
||||
"busy": "Working…",
|
||||
"empty": "No items match the current filter.",
|
||||
"select_hint": "Select an item for actions.",
|
||||
"updated": "Updated {time}",
|
||||
"host": "{host}",
|
||||
"summary": "{state} · {online}/{total} online · exit {exit}",
|
||||
"health": "{msg}",
|
||||
"not_installed": "tailscale is not installed or not on PATH."
|
||||
},
|
||||
"tabs": {
|
||||
"status": "Status",
|
||||
"peers": "Peers",
|
||||
"exit": "Exit nodes"
|
||||
},
|
||||
"filter": {
|
||||
"placeholder": "Filter… e.g. edge or !online"
|
||||
},
|
||||
"status": {
|
||||
"state": "State: {state}",
|
||||
"tailnet": "Tailnet: {name}",
|
||||
"ips": "IPs: {v4} {v6}",
|
||||
"dns": "DNS: {dns}",
|
||||
"exit": "Exit node: {name}",
|
||||
"version": "Version: {version}",
|
||||
"prefs": "Shields {shields} · Routes {routes} · SSH {ssh} · DNS {dns}",
|
||||
"on": "on",
|
||||
"off": "off",
|
||||
"none": "none"
|
||||
},
|
||||
"peer": {
|
||||
"detail": "{os} · {relay} · {v4}",
|
||||
"traffic": "↓{rx} ↑{tx}"
|
||||
},
|
||||
"exit": {
|
||||
"detail": "{ip} · {status}"
|
||||
},
|
||||
"actions": {
|
||||
"up": "Connect",
|
||||
"down": "Disconnect",
|
||||
"refresh": "Refresh",
|
||||
"open_admin": "Admin",
|
||||
"clear_exit": "Clear exit",
|
||||
"use_exit": "Use exit",
|
||||
"copy_ip": "Copy IP",
|
||||
"copy_dns": "Copy DNS",
|
||||
"ping": "Ping",
|
||||
"ssh": "SSH",
|
||||
"shields_on": "Shields up",
|
||||
"shields_off": "Shields down",
|
||||
"routes_on": "Accept routes",
|
||||
"routes_off": "Ignore routes",
|
||||
"ssh_on": "Enable SSH",
|
||||
"ssh_off": "Disable SSH",
|
||||
"advertise_on": "Advertise exit",
|
||||
"advertise_off": "Stop advertising",
|
||||
"lan_on": "Allow LAN",
|
||||
"lan_off": "Block LAN"
|
||||
},
|
||||
"result": {
|
||||
"success": "Done",
|
||||
"failed": "Failed: {error}",
|
||||
"busy": "Another operation is running.",
|
||||
"missing": "tailscale CLI not found.",
|
||||
"up": "Tailscale connected",
|
||||
"down": "Tailscale disconnected",
|
||||
"exit_set": "Exit node set to {name}",
|
||||
"exit_cleared": "Exit node cleared",
|
||||
"shields_on": "Shields up enabled",
|
||||
"shields_off": "Shields up disabled",
|
||||
"ssh_on": "Tailscale SSH enabled",
|
||||
"ssh_off": "Tailscale SSH disabled",
|
||||
"routes_on": "Accepting subnet routes",
|
||||
"routes_off": "Not accepting subnet routes",
|
||||
"advertise_on": "Advertising as exit node",
|
||||
"advertise_off": "No longer advertising exit node",
|
||||
"lan_on": "LAN access allowed with exit node",
|
||||
"lan_off": "LAN access blocked with exit node",
|
||||
"ping_started": "Pinging {name}…",
|
||||
"ssh_started": "Opening SSH to {name}…",
|
||||
"copied": "Copied {name}",
|
||||
"admin_opened": "Opening admin console…",
|
||||
"peer_online": "{name} is online",
|
||||
"peer_offline": "{name} went offline"
|
||||
},
|
||||
"launcher": {
|
||||
"loading": "Loading Tailscale…",
|
||||
"missing": "tailscale not found",
|
||||
"cat": {
|
||||
"status": "Status",
|
||||
"status-sub": "Connection summary",
|
||||
"peers": "Peers",
|
||||
"peers-sub": "Browse machines on the tailnet",
|
||||
"exit": "Exit nodes",
|
||||
"exit-sub": "Choose or clear an exit node",
|
||||
"up": "Connect",
|
||||
"up-sub": "tailscale up",
|
||||
"down": "Disconnect",
|
||||
"down-sub": "tailscale down",
|
||||
"panel": "Open panel",
|
||||
"panel-sub": "Full Tailscale manager",
|
||||
"refresh": "Refresh",
|
||||
"refresh-sub": "Re-query status",
|
||||
"admin": "Admin console",
|
||||
"admin-sub": "Open Tailscale admin in browser"
|
||||
},
|
||||
"action": {
|
||||
"ping": "Ping",
|
||||
"ssh": "SSH",
|
||||
"copy_ip": "Copy IPv4",
|
||||
"copy_dns": "Copy DNS name",
|
||||
"use_exit": "Use as exit node",
|
||||
"clear_exit": "Clear exit node"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
--!nonstrict
|
||||
|
||||
local PANEL_ID = "davemhammer/tailscale:manager"
|
||||
local STATE_KEY = "ts_snapshot"
|
||||
local COMMAND_KEY = "ts_command"
|
||||
|
||||
-- Brand mark (Simple Icons Tailscale 3×3 dots), tinted for connection state.
|
||||
local COLOR_CONNECTED = "#73c936"
|
||||
local COLOR_DISCONNECTED = "on_surface_variant"
|
||||
local ICON_ON = "assets/tailscale-green.png"
|
||||
local ICON_OFF = "assets/tailscale-grey.png"
|
||||
|
||||
local snapshot = noctalia.state.get(STATE_KEY) or {
|
||||
available = false,
|
||||
running = false,
|
||||
onlineCount = 0,
|
||||
peerCount = 0,
|
||||
backendState = "",
|
||||
exitNode = "",
|
||||
error = "",
|
||||
installed = false,
|
||||
}
|
||||
|
||||
local requestId = 0
|
||||
|
||||
local function render()
|
||||
local available = snapshot.available == true
|
||||
local running = snapshot.running == true
|
||||
local online = tonumber(snapshot.onlineCount) or 0
|
||||
local total = tonumber(snapshot.peerCount) or 0
|
||||
local showCounts = noctalia.getConfig("show_counts") ~= false
|
||||
local exitNode = tostring(snapshot.exitNode or "")
|
||||
local hasExit = exitNode ~= ""
|
||||
|
||||
-- Connected = green logo, stopped = grey logo (never red).
|
||||
local color = running and COLOR_CONNECTED or COLOR_DISCONNECTED
|
||||
|
||||
local children = {
|
||||
ui.image({
|
||||
path = running and ICON_ON or ICON_OFF,
|
||||
width = 16,
|
||||
height = 16,
|
||||
fit = "contain",
|
||||
}),
|
||||
}
|
||||
|
||||
if showCounts and available then
|
||||
table.insert(children, ui.label({
|
||||
text = `{online}/{total}`,
|
||||
fontWeight = "bold",
|
||||
color = "on_surface",
|
||||
}))
|
||||
if hasExit then
|
||||
table.insert(children, ui.glyph({
|
||||
name = "world",
|
||||
size = 12,
|
||||
color = (snapshot.exitNodeOnline and COLOR_CONNECTED) or COLOR_DISCONNECTED,
|
||||
}))
|
||||
end
|
||||
table.insert(children, ui.box({
|
||||
width = 7,
|
||||
height = 7,
|
||||
radius = 4,
|
||||
fill = color,
|
||||
}))
|
||||
end
|
||||
|
||||
local container = barWidget.isVertical() and ui.column or ui.row
|
||||
barWidget.render(container({ gap = 5, align = "center" }, children))
|
||||
|
||||
if not snapshot.installed then
|
||||
barWidget.setTooltip(noctalia.tr("widget.tooltip_missing"))
|
||||
elseif not available then
|
||||
barWidget.setTooltip(noctalia.tr("widget.tooltip_down", {
|
||||
error = tostring(snapshot.error or "unknown"),
|
||||
}))
|
||||
elseif not running then
|
||||
barWidget.setTooltip(noctalia.tr("widget.tooltip_stopped", { total = total }))
|
||||
else
|
||||
barWidget.setTooltip(noctalia.tr("widget.tooltip_ok", {
|
||||
state = tostring(snapshot.backendState or "Running"),
|
||||
online = online,
|
||||
total = total,
|
||||
exit = hasExit and exitNode or "—",
|
||||
}))
|
||||
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
|
||||