From 8350a792ac3292af7c3738c31360af7c39483527 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 27 May 2026 08:44:32 +0200 Subject: [PATCH] Add exit nodes to the panel --- shell/plugins/panels/tailscale/Model.js | 41 ++++++++++++++-------- shell/plugins/panels/tailscale/Panel.qml | 11 ++---- shell/plugins/panels/tailscale/Service.qml | 8 +++-- test/shell.d/tailscale-test.sh | 10 ++++++ 4 files changed, 45 insertions(+), 25 deletions(-) diff --git a/shell/plugins/panels/tailscale/Model.js b/shell/plugins/panels/tailscale/Model.js index c18a764f..e7039516 100644 --- a/shell/plugins/panels/tailscale/Model.js +++ b/shell/plugins/panels/tailscale/Model.js @@ -52,6 +52,21 @@ function accountLabel(account) { return String(account.id || "Unknown account") } +function peerFromStatus(id, peer) { + return { + id: id, + HostName: displayHostName(peer.HostName, peer.DNSName), + DNSName: cleanDnsName(peer.DNSName), + TailscaleIPs: filterIPv4(peer.TailscaleIPs || []), + TailscaleIPv6: filterIPv6(peer.TailscaleIPs || []), + Online: peer.Online === true, + OS: String(peer.OS || ""), + Tags: peer.Tags || [], + ExitNodeOption: peer.ExitNodeOption === true, + ExitNode: peer.ExitNode === true + } +} + function parseStatus(raw) { var text = String(raw || "").trim() if (text === "") return { ok: true, unavailable: true, message: "Disconnected" } @@ -62,28 +77,24 @@ function parseStatus(raw) { var self = data.Self || {} var selfIps = filterIPv4(self.TailscaleIPs || data.TailscaleIPs || []) var peers = [] + var exitNodes = [] var rawPeers = data.Peer || {} for (var id in rawPeers) { var peer = rawPeers[id] || {} - if (peer.Online !== true) continue - peers.push({ - id: id, - HostName: displayHostName(peer.HostName, peer.DNSName), - DNSName: cleanDnsName(peer.DNSName), - TailscaleIPs: filterIPv4(peer.TailscaleIPs || []), - TailscaleIPv6: filterIPv6(peer.TailscaleIPs || []), - Online: true, - OS: String(peer.OS || ""), - Tags: peer.Tags || [], - ExitNodeOption: peer.ExitNodeOption === true, - ExitNode: peer.ExitNode === true - }) + var normalized = peerFromStatus(id, peer) + if (normalized.Online) { + peers.push(normalized) + if (normalized.ExitNodeOption) exitNodes.push(normalized) + } } peers.sort(function(a, b) { return String(a.HostName).localeCompare(String(b.HostName)) }) + exitNodes.sort(function(a, b) { + return String(a.HostName).localeCompare(String(b.HostName)) + }) return { ok: true, @@ -95,7 +106,8 @@ function parseStatus(raw) { selfName: displayHostName(self.HostName, self.DNSName), selfDnsName: cleanDnsName(self.DNSName), selfIp: selfIps.length > 0 ? selfIps[0] : "", - peers: peers + peers: peers, + exitNodes: exitNodes } } catch (e) { return { ok: false, unavailable: true, message: "Status error", error: "Failed to parse tailscale status" } @@ -143,6 +155,7 @@ if (typeof module !== "undefined") { displayHostName: displayHostName, osIcon: osIcon, accountLabel: accountLabel, + peerFromStatus: peerFromStatus, parseStatus: parseStatus, parseAccounts: parseAccounts } diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index 5711de55..a81a22d2 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -39,14 +39,7 @@ Panel { readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied readonly property bool showPeers: tailscale.running && tailscale.peers.length > 0 - readonly property var exitNodes: { - var nodes = [] - for (var i = 0; i < tailscale.peers.length; i++) { - var peer = tailscale.peers[i] - if (peer && peer.ExitNodeOption === true) nodes.push(peer) - } - return nodes - } + readonly property var exitNodes: tailscale.exitNodes readonly property bool showExitNodes: tailscale.running && exitNodes.length > 0 readonly property color iconColor: tailscale.running ? foreground : dim readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" @@ -877,7 +870,7 @@ Panel { Text { id: exitNodeGlyph - text: tailscale.osIcon(peer ? peer.OS : "") + text: "󱇢" color: exitNodeRow.activeExitNode || exitNodeRow.settingExitNode ? root.foreground : root.dim font.family: root.fontFamily font.pixelSize: Style.font.body diff --git a/shell/plugins/panels/tailscale/Service.qml b/shell/plugins/panels/tailscale/Service.qml index 25bf36d2..846bc42b 100644 --- a/shell/plugins/panels/tailscale/Service.qml +++ b/shell/plugins/panels/tailscale/Service.qml @@ -20,6 +20,7 @@ Item { property string selfIp: "" property string authUrl: "" property var peers: [] + property var exitNodes: [] property var accounts: [] property string selectedAccountId: "" property string selectedAccountLabel: "" @@ -158,6 +159,7 @@ Item { selfIp = "" authUrl = "" peers = [] + exitNodes = [] accounts = [] selectedAccountId = "" selectedAccountLabel = "" @@ -188,6 +190,7 @@ Item { selfDnsName = parsed.selfDnsName selfIp = parsed.selfIp peers = parsed.running ? parsed.peers : [] + exitNodes = parsed.running ? parsed.exitNodes : [] if (needsLogin) statusText = "Needs login" else if (running) { @@ -253,8 +256,9 @@ Item { function setExitNode(peer) { if (!installed || !running || !peer || exitNodeProcess.running) return - var target = exitNodeTarget(peer) - if (target === "") return + var active = peer.ExitNode === true + var target = active ? "" : exitNodeTarget(peer) + if (!active && target === "") return _exitNodeOutput = "" _exitNodeError = "" settingExitNodeId = String(peer.id || "") diff --git a/test/shell.d/tailscale-test.sh b/test/shell.d/tailscale-test.sh index 076db86d..47d4f15b 100644 --- a/test/shell.d/tailscale-test.sh +++ b/test/shell.d/tailscale-test.sh @@ -47,6 +47,15 @@ const status = tailscale.parseStatus(JSON.stringify({ Online: false, OS: 'linux' }, + offlineExit: { + HostName: 'mbu-ser9', + DNSName: 'mbu-ser9.tail32f559.ts.net.', + TailscaleIPs: ['100.125.28.77', 'fd7a:115c:a1e0::1037:1c4d'], + Online: false, + OS: 'linux', + ExitNodeOption: true, + ExitNode: false + }, onlineA: { HostName: 'alpha', DNSName: 'alpha.tail32f559.ts.net.', @@ -62,6 +71,7 @@ assertEqual(status.selfIp, '100.74.97.73', 'tailscale parses self IP') assertDeepEqual(status.peers.map(peer => peer.HostName), ['alpha', 'zed'], 'tailscale filters offline peers and sorts online peers') assertDeepEqual(status.peers[0].TailscaleIPv6, ['fd7a:115c:a1e0::1901:334b'], 'tailscale preserves peer IPv6 addresses for copy menu') assert(status.peers[1].ExitNodeOption && status.peers[1].ExitNode, 'tailscale preserves exit node flags') +assertDeepEqual(status.exitNodes.map(peer => peer.HostName), ['zed'], 'tailscale lists only online exit nodes') const stopped = tailscale.parseStatus(JSON.stringify({ BackendState: 'Stopped',