feat: add exit node selection to Tailscale widget
- Extract peer parsing and exit-node command logic into lib.js - Add ↗ button on peers that are ExitNodeOption candidates - Highlight active exit node in primary color - Add clear (×) button next to current exit node label - Add unit tests for parsePeers and makeExitNodeCommand
This commit is contained in:
parent
2d438415ee
commit
0c2376b3bb
3 changed files with 109 additions and 8 deletions
|
|
@ -5,6 +5,7 @@ import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
import qs.Modules.Plugins
|
import qs.Modules.Plugins
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
|
import "./lib.js" as TailscaleLib
|
||||||
|
|
||||||
PluginComponent {
|
PluginComponent {
|
||||||
id: root
|
id: root
|
||||||
|
|
@ -42,6 +43,17 @@ PluginComponent {
|
||||||
id: copyProcess
|
id: copyProcess
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: exitNodeProcess
|
||||||
|
|
||||||
|
onExited: (code, status) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
ToastService.showError("tailscalectl", "Failed to set exit node")
|
||||||
|
}
|
||||||
|
statusCheck.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: detectClipboard
|
id: detectClipboard
|
||||||
|
|
||||||
|
|
@ -70,14 +82,7 @@ PluginComponent {
|
||||||
root.isConnected = data.BackendState === "Running"
|
root.isConnected = data.BackendState === "Running"
|
||||||
root.tailscaleIP = (data.Self?.TailscaleIPs?.[0]) || ""
|
root.tailscaleIP = (data.Self?.TailscaleIPs?.[0]) || ""
|
||||||
root.currentExitNode = data.CurrentExitNode?.HostName || ""
|
root.currentExitNode = data.CurrentExitNode?.HostName || ""
|
||||||
root.peers = Object.keys(data.Peer || {}).map(function(key) {
|
root.peers = TailscaleLib.parsePeers(data.Peer || {})
|
||||||
var p = data.Peer[key]
|
|
||||||
return {
|
|
||||||
hostname: p.HostName || key,
|
|
||||||
ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "",
|
|
||||||
online: p.Online || false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
root.isConnected = false
|
root.isConnected = false
|
||||||
root.tailscaleIP = ""
|
root.tailscaleIP = ""
|
||||||
|
|
@ -107,6 +112,11 @@ PluginComponent {
|
||||||
toggleProcess.running = true
|
toggleProcess.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setExitNode(hostname) {
|
||||||
|
exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname)
|
||||||
|
exitNodeProcess.running = true
|
||||||
|
}
|
||||||
|
|
||||||
function copyToClipboard(text) {
|
function copyToClipboard(text) {
|
||||||
if (!root.clipboardCmd || root.clipboardCmd === "none") {
|
if (!root.clipboardCmd || root.clipboardCmd === "none") {
|
||||||
ToastService.showError("tailscalectl", "No clipboard tool found")
|
ToastService.showError("tailscalectl", "No clipboard tool found")
|
||||||
|
|
@ -172,6 +182,25 @@ PluginComponent {
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
visible: root.currentExitNode !== ""
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
hoverEnabled: true
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: clearExitNodeText.implicitWidth
|
||||||
|
height: clearExitNodeText.implicitHeight
|
||||||
|
onClicked: {
|
||||||
|
root.setExitNode("")
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: clearExitNodeText
|
||||||
|
text: "×"
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ListView {
|
ListView {
|
||||||
|
|
@ -229,6 +258,25 @@ PluginComponent {
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
visible: modelData.exitNode
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
hoverEnabled: true
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: exitNodeButton.implicitWidth
|
||||||
|
height: exitNodeButton.implicitHeight
|
||||||
|
onClicked: {
|
||||||
|
root.setExitNode(modelData.hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: exitNodeButton
|
||||||
|
text: "↗"
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: root.currentExitNode === modelData.hostname ? Theme.primary : Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
tailscalectl/lib.js
Normal file
18
tailscalectl/lib.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
function parsePeers(peerMap) {
|
||||||
|
if (!peerMap) return []
|
||||||
|
return Object.keys(peerMap).map(function (key) {
|
||||||
|
var p = peerMap[key]
|
||||||
|
return {
|
||||||
|
hostname: p.HostName || key,
|
||||||
|
ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "",
|
||||||
|
online: p.Online || false,
|
||||||
|
exitNode: p.ExitNodeOption || false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeExitNodeCommand(hostname) {
|
||||||
|
return ["tailscale", "set", "--exit-node=" + hostname]
|
||||||
|
}
|
||||||
|
|
||||||
|
export { parsePeers, makeExitNodeCommand }
|
||||||
35
test/lib.test.js
Normal file
35
test/lib.test.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { test } from "node:test"
|
||||||
|
import assert from "node:assert"
|
||||||
|
import { parsePeers, makeExitNodeCommand } from "../tailscalectl/lib.js"
|
||||||
|
|
||||||
|
test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
||||||
|
const peerMap = {
|
||||||
|
"peer-1": {
|
||||||
|
HostName: "router",
|
||||||
|
TailscaleIPs: ["100.64.0.1"],
|
||||||
|
Online: true,
|
||||||
|
ExitNodeOption: true
|
||||||
|
},
|
||||||
|
"peer-2": {
|
||||||
|
HostName: "laptop",
|
||||||
|
TailscaleIPs: ["100.64.0.2"],
|
||||||
|
Online: true,
|
||||||
|
ExitNodeOption: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const peers = parsePeers(peerMap)
|
||||||
|
|
||||||
|
assert.strictEqual(peers[0].exitNode, true)
|
||||||
|
assert.strictEqual(peers[1].exitNode, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("makeExitNodeCommand returns tailscale set command for hostname", () => {
|
||||||
|
const cmd = makeExitNodeCommand("router")
|
||||||
|
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("makeExitNodeCommand with empty string clears exit node", () => {
|
||||||
|
const cmd = makeExitNodeCommand("")
|
||||||
|
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="])
|
||||||
|
})
|
||||||
Loading…
Add table
Reference in a new issue