feat: add error handling and toast notifications (#8)

- Add errorMessage() utility for user-friendly toast messages
- Show toast on tailscale up/down/set/status failures
- Detect missing tailscale binary (exit code 127)
- Guard toggleTailscale and setExitNode when binary unavailable
- Show 'Tailscale not available' in popout when binary missing
This commit is contained in:
Vybe (Coding Agent) 2026-05-18 20:53:32 +00:00
parent c9d1baf7c4
commit 6129b09943
3 changed files with 67 additions and 3 deletions

View file

@ -11,6 +11,7 @@ PluginComponent {
id: root id: root
property bool isConnected: false property bool isConnected: false
property bool binaryAvailable: true
property string tailscaleIP: "" property string tailscaleIP: ""
property string currentExitNode: "" property string currentExitNode: ""
property var peers: [] property var peers: []
@ -35,6 +36,10 @@ PluginComponent {
id: toggleProcess id: toggleProcess
onExited: (code, status) => { onExited: (code, status) => {
if (code !== 0) {
var action = root.isConnected ? "disconnect" : "connect"
ToastService.showError("tailscalectl", TailscaleLib.errorMessage(action))
}
statusCheck.running = true statusCheck.running = true
} }
} }
@ -48,7 +53,7 @@ PluginComponent {
onExited: (code, status) => { onExited: (code, status) => {
if (code !== 0) { if (code !== 0) {
ToastService.showError("tailscalectl", "Failed to set exit node") ToastService.showError("tailscalectl", TailscaleLib.errorMessage("set"))
} }
statusCheck.running = true statusCheck.running = true
} }
@ -99,11 +104,21 @@ PluginComponent {
root.tailscaleIP = "" root.tailscaleIP = ""
root.currentExitNode = "" root.currentExitNode = ""
root.peers = [] root.peers = []
if (code === 127) {
root.binaryAvailable = false
ToastService.showError("tailscalectl", "Tailscale binary not found")
} else {
ToastService.showError("tailscalectl", TailscaleLib.errorMessage("status"))
}
} }
} }
} }
function toggleTailscale() { function toggleTailscale() {
if (!root.binaryAvailable) {
ToastService.showError("tailscalectl", "Tailscale not available")
return
}
if (root.isConnected) { if (root.isConnected) {
toggleProcess.command = ["tailscale", "down"] toggleProcess.command = ["tailscale", "down"]
} else { } else {
@ -113,6 +128,10 @@ PluginComponent {
} }
function setExitNode(hostname) { function setExitNode(hostname) {
if (!root.binaryAvailable) {
ToastService.showError("tailscalectl", "Tailscale not available")
return
}
exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname) exitNodeProcess.command = TailscaleLib.makeExitNodeCommand(hostname)
exitNodeProcess.running = true exitNodeProcess.running = true
} }
@ -138,6 +157,14 @@ PluginComponent {
width: parent.width width: parent.width
height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerList.height + Theme.spacingM height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerList.height + Theme.spacingM
StyledText {
visible: !root.binaryAvailable
text: "Tailscale not available"
anchors.centerIn: parent
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
Row { Row {
id: statusRow id: statusRow
y: Theme.spacingM y: Theme.spacingM

View file

@ -15,7 +15,19 @@ function makeExitNodeCommand(hostname) {
return ["tailscale", "set", "--exit-node=" + hostname] return ["tailscale", "set", "--exit-node=" + hostname]
} }
function errorMessage(cmd) {
var messages = {
"up": "Failed to connect to Tailscale",
"connect": "Failed to connect to Tailscale",
"down": "Failed to disconnect from Tailscale",
"disconnect": "Failed to disconnect from Tailscale",
"set": "Failed to set exit node",
"status": "Failed to read Tailscale status"
}
return messages[cmd] || "Tailscale command failed"
}
// CommonJS export for Node.js tests (ignored by QML) // CommonJS export for Node.js tests (ignored by QML)
if (typeof module !== "undefined" && module.exports) { if (typeof module !== "undefined" && module.exports) {
module.exports = { parsePeers, makeExitNodeCommand } module.exports = { parsePeers, makeExitNodeCommand, errorMessage }
} }

View file

@ -1,7 +1,7 @@
import { test } from "node:test" import { test } from "node:test"
import assert from "node:assert" import assert from "node:assert"
import lib from "../tailscalectl/lib.js" import lib from "../tailscalectl/lib.js"
const { parsePeers, makeExitNodeCommand } = lib const { parsePeers, makeExitNodeCommand, errorMessage } = lib
test("parsePeers extracts exitNode from ExitNodeOption", () => { test("parsePeers extracts exitNode from ExitNodeOption", () => {
const peerMap = { const peerMap = {
@ -34,3 +34,28 @@ test("makeExitNodeCommand with empty string clears exit node", () => {
const cmd = makeExitNodeCommand("") const cmd = makeExitNodeCommand("")
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="]) assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="])
}) })
test("errorMessage returns user-friendly message for tailscale up failure", () => {
const msg = errorMessage("up", 1)
assert.strictEqual(msg, "Failed to connect to Tailscale")
})
test("errorMessage returns user-friendly message for tailscale down failure", () => {
const msg = errorMessage("down", 1)
assert.strictEqual(msg, "Failed to disconnect from Tailscale")
})
test("errorMessage returns user-friendly message for tailscale set failure", () => {
const msg = errorMessage("set", 1)
assert.strictEqual(msg, "Failed to set exit node")
})
test("errorMessage returns user-friendly message for tailscale status failure", () => {
const msg = errorMessage("status", 1)
assert.strictEqual(msg, "Failed to read Tailscale status")
})
test("errorMessage returns generic message for unknown command", () => {
const msg = errorMessage("unknown", 1)
assert.strictEqual(msg, "Tailscale command failed")
})