From d721bb661fe6ff7da640a7efa5ac5adbd03d8f0e Mon Sep 17 00:00:00 2001
From: finlay-mcaree <13505775+finlay-mcaree@users.noreply.github.com>
Date: Wed, 22 Apr 2026 13:01:31 -0700
Subject: [PATCH] Enhancement: add additional fields to Tailscale Widget
(#6589)
---
docs/widgets/services/tailscale.md | 2 +-
public/locales/en/common.json | 14 ++-
src/widgets/tailscale/component.jsx | 44 +++++++-
src/widgets/tailscale/component.test.jsx | 138 +++++++++++++++++++++--
4 files changed, 182 insertions(+), 16 deletions(-)
diff --git a/docs/widgets/services/tailscale.md b/docs/widgets/services/tailscale.md
index e4f3ec2eb..f33482282 100644
--- a/docs/widgets/services/tailscale.md
+++ b/docs/widgets/services/tailscale.md
@@ -9,7 +9,7 @@ You will need to generate an API access token from the [keys page](https://login
To find your device ID, go to the [machine overview page](https://login.tailscale.com/admin/machines) and select your machine. In the "Machine Details" section, copy your `ID`. It will end with `CNTRL`.
-Allowed fields: `["address", "last_seen", "expires"]`.
+Allowed fields: `[ "address", "last_seen", "expires", "user", "hostname", "name", "client_version", "os", "created", "authorized", "is_external", "update_available", "tags" ]`.
```yaml
widget:
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 9528341b8..6d2204774 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -344,6 +344,16 @@
"address": "Address",
"expires": "Expires",
"never": "Never",
+ "user": "User",
+ "hostname": "Hostname",
+ "name": "Name",
+ "client_version": "Client Version",
+ "os": "OS",
+ "created": "Created",
+ "authorized": "Authorized",
+ "is_external": "Is External",
+ "update_available": "Update Available",
+ "tags": "Tags",
"last_seen": "Last Seen",
"now": "Now",
"years": "{{number}}y",
@@ -352,7 +362,9 @@
"hours": "{{number}}h",
"minutes": "{{number}}m",
"seconds": "{{number}}s",
- "ago": "{{value}} Ago"
+ "ago": "{{value}} Ago",
+ "true": "Yes",
+ "false": "No"
},
"technitium": {
"totalQueries": "Queries",
diff --git a/src/widgets/tailscale/component.jsx b/src/widgets/tailscale/component.jsx
index b95cb016b..f303fd4c9 100644
--- a/src/widgets/tailscale/component.jsx
+++ b/src/widgets/tailscale/component.jsx
@@ -9,13 +9,13 @@ export default function Component({ service }) {
const { widget } = service;
- const { data: statsData, error: statsError } = useWidgetAPI(widget, "device");
+ const { data: tailscaleData, error: tailscaleError } = useWidgetAPI(widget, "device");
- if (statsError || statsData?.message) {
- return ;
+ if (tailscaleError || tailscaleData?.message) {
+ return ;
}
- if (!statsData) {
+ if (!tailscaleData) {
return (
@@ -25,12 +25,29 @@ export default function Component({ service }) {
);
}
+ const MAX_ALLOWED_FIELDS = 4;
+ if (widget.fields?.length == 0 || !widget.fields) {
+ widget.fields = ["address", "last_seen", "expires"];
+ } else if (widget.fields?.length > MAX_ALLOWED_FIELDS) {
+ widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
+ }
+
const {
addresses: [address],
keyExpiryDisabled,
lastSeen,
expires,
- } = statsData;
+ user,
+ hostname,
+ name,
+ clientVersion,
+ os,
+ created,
+ authorized,
+ isExternal,
+ updateAvailable,
+ tags,
+ } = tailscaleData;
const now = new Date();
const compareDifferenceInTwoDates = (priorDate, futureDate) => {
@@ -62,11 +79,28 @@ export default function Component({ service }) {
return compareDifferenceInTwoDates(now, date);
};
+ const getBooleanAsString = (value) => {
+ return value ? t("tailscale.true") : t("tailscale.false");
+ };
+
+ const clientVersionString = clientVersion ? clientVersion.toString() : "-";
+ const tagsString = tags && Array.isArray(tags) ? tags.join(", ") : "-";
+
return (
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/widgets/tailscale/component.test.jsx b/src/widgets/tailscale/component.test.jsx
index 602d6ba1c..13d6851d4 100644
--- a/src/widgets/tailscale/component.test.jsx
+++ b/src/widgets/tailscale/component.test.jsx
@@ -22,6 +22,23 @@ describe("widgets/tailscale/component", () => {
vi.useRealTimers();
});
+ const fullData = {
+ addresses: ["127.0.0.1"],
+ keyExpiryDisabled: false,
+ expires: "2020-06-01T00:00:00Z",
+ lastSeen: "2019-12-31T23:55:00Z",
+ user: "fin@example.com",
+ hostname: "localhost",
+ name: "localhost.tail1234.ts.net",
+ clientVersion: "1.1.0",
+ os: "linux",
+ created: "2019-06-01T00:00:00Z",
+ authorized: true,
+ isExternal: false,
+ updateAvailable: true,
+ tags: ["server", "prod"],
+ };
+
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
@@ -35,14 +52,108 @@ describe("widgets/tailscale/component", () => {
expect(screen.getByText("tailscale.expires")).toBeInTheDocument();
});
- it("renders address and expiry/last-seen strings when loaded", () => {
+ describe("fields group: address, last_seen, expires, user", () => {
+ it("renders only the specified 4 fields", () => {
+ useWidgetAPI.mockReturnValue({ data: fullData, error: undefined });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expectBlockValue(container, "tailscale.address", "127.0.0.1");
+ expectBlockValue(container, "tailscale.last_seen", "tailscale.ago");
+ expectBlockValue(container, "tailscale.expires", "tailscale.weeks");
+ expectBlockValue(container, "tailscale.user", "fin@example.com");
+ });
+ });
+
+ describe("fields group: hostname, name, client_version, os", () => {
+ it("renders only the specified 4 fields", () => {
+ useWidgetAPI.mockReturnValue({ data: fullData, error: undefined });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expectBlockValue(container, "tailscale.hostname", "localhost");
+ expectBlockValue(container, "tailscale.name", "localhost.tail1234.ts.net");
+ expectBlockValue(container, "tailscale.client_version", "1.1.0");
+ expectBlockValue(container, "tailscale.os", "linux");
+ });
+ });
+
+ describe("fields group: created, authorized, is_external, update_available", () => {
+ it("renders only the specified 4 fields", () => {
+ useWidgetAPI.mockReturnValue({ data: fullData, error: undefined });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expectBlockValue(container, "tailscale.created", "2019-06-01T00:00:00Z");
+ expectBlockValue(container, "tailscale.authorized", "tailscale.true");
+ expectBlockValue(container, "tailscale.is_external", "tailscale.false");
+ expectBlockValue(container, "tailscale.update_available", "tailscale.true");
+ });
+ });
+
+ describe("fields group: tags with defaults", () => {
+ it("renders tags alongside default fields", () => {
+ useWidgetAPI.mockReturnValue({ data: fullData, error: undefined });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expectBlockValue(container, "tailscale.address", "127.0.0.1");
+ expectBlockValue(container, "tailscale.tags", "server, prod");
+ });
+ });
+
+ describe("fields truncation", () => {
+ it("truncates to 4 fields when more than 4 are specified", () => {
+ useWidgetAPI.mockReturnValue({ data: fullData, error: undefined });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(findServiceBlockByLabel(container, "tailscale.hostname")).toBeFalsy();
+ });
+
+ it("defaults to address, last_seen, expires when fields is empty", () => {
+ useWidgetAPI.mockReturnValue({ data: fullData, error: undefined });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expectBlockValue(container, "tailscale.address", "127.0.0.1");
+ expectBlockValue(container, "tailscale.last_seen", "tailscale.ago");
+ expectBlockValue(container, "tailscale.expires", "tailscale.weeks");
+ });
+ });
+
+ it("renders never for expires if key expiry is disabled", () => {
useWidgetAPI.mockReturnValue({
- data: {
- addresses: ["100.64.0.1"],
- keyExpiryDisabled: true,
- lastSeen: "2019-12-31T23:00:00Z",
- expires: "2021-01-01T00:00:00Z",
- },
+ data: { ...fullData, keyExpiryDisabled: true },
error: undefined,
});
@@ -50,8 +161,17 @@ describe("widgets/tailscale/component", () => {
settings: { hideErrors: false },
});
- expectBlockValue(container, "tailscale.address", "100.64.0.1");
- expect(findServiceBlockByLabel(container, "tailscale.last_seen")?.textContent).toContain("tailscale.ago");
expectBlockValue(container, "tailscale.expires", "tailscale.never");
});
+
+ it("renders error message when API returns an error", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "API error" } });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(0);
+ expect(container.textContent).toContain("API error");
+ });
});