diff --git a/tailscale/README.md b/tailscale/README.md
new file mode 100644
index 0000000..0384602
--- /dev/null
+++ b/tailscale/README.md
@@ -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).
diff --git a/tailscale/assets/tailscale-green.png b/tailscale/assets/tailscale-green.png
new file mode 100644
index 0000000..1f49af7
Binary files /dev/null and b/tailscale/assets/tailscale-green.png differ
diff --git a/tailscale/assets/tailscale-green.svg b/tailscale/assets/tailscale-green.svg
new file mode 100644
index 0000000..f9922d1
--- /dev/null
+++ b/tailscale/assets/tailscale-green.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tailscale/assets/tailscale-grey.png b/tailscale/assets/tailscale-grey.png
new file mode 100644
index 0000000..31b6071
Binary files /dev/null and b/tailscale/assets/tailscale-grey.png differ
diff --git a/tailscale/assets/tailscale-grey.svg b/tailscale/assets/tailscale-grey.svg
new file mode 100644
index 0000000..f5ebc74
--- /dev/null
+++ b/tailscale/assets/tailscale-grey.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tailscale/assets/tailscale-white.png b/tailscale/assets/tailscale-white.png
new file mode 100644
index 0000000..495a384
Binary files /dev/null and b/tailscale/assets/tailscale-white.png differ
diff --git a/tailscale/assets/tailscale-white.svg b/tailscale/assets/tailscale-white.svg
new file mode 100644
index 0000000..f4395dc
--- /dev/null
+++ b/tailscale/assets/tailscale-white.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tailscale/assets/tailscale.svg b/tailscale/assets/tailscale.svg
new file mode 100644
index 0000000..9a05aa6
--- /dev/null
+++ b/tailscale/assets/tailscale.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tailscale/launcher.luau b/tailscale/launcher.luau
new file mode 100644
index 0000000..216e77d
--- /dev/null
+++ b/tailscale/launcher.luau
@@ -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
diff --git a/tailscale/panel.luau b/tailscale/panel.luau
new file mode 100644
index 0000000..1aa53c6
--- /dev/null
+++ b/tailscale/panel.luau
@@ -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
diff --git a/tailscale/plugin.toml b/tailscale/plugin.toml
new file mode 100644
index 0000000..a0bfa7e
--- /dev/null
+++ b/tailscale/plugin.toml
@@ -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
diff --git a/tailscale/service.luau b/tailscale/service.luau
new file mode 100644
index 0000000..7997ca5
--- /dev/null
+++ b/tailscale/service.luau
@@ -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
diff --git a/tailscale/thumbnail.webp b/tailscale/thumbnail.webp
new file mode 100644
index 0000000..9807be4
Binary files /dev/null and b/tailscale/thumbnail.webp differ
diff --git a/tailscale/translations/en.json b/tailscale/translations/en.json
new file mode 100644
index 0000000..ec0e48a
--- /dev/null
+++ b/tailscale/translations/en.json
@@ -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"
+ }
+ }
+}
diff --git a/tailscale/widget.luau b/tailscale/widget.luau
new file mode 100644
index 0000000..93cc2f1
--- /dev/null
+++ b/tailscale/widget.luau
@@ -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