Enhancement: add additional fields to Tailscale Widget (#6589)

This commit is contained in:
finlay-mcaree 2026-04-22 13:01:31 -07:00 committed by GitHub
parent bf55e8acab
commit d721bb661f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 182 additions and 16 deletions

View File

@ -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:

View File

@ -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",

View File

@ -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 <Container service={service} error={statsError ?? statsData} />;
if (tailscaleError || tailscaleData?.message) {
return <Container service={service} error={tailscaleError ?? tailscaleData} />;
}
if (!statsData) {
if (!tailscaleData) {
return (
<Container service={service}>
<Block label="tailscale.address" />
@ -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 (
<Container service={service}>
<Block label="tailscale.address" value={address} />
<Block label="tailscale.last_seen" value={getLastSeen()} />
<Block label="tailscale.expires" value={getExpiry()} />
<Block label="tailscale.user" value={user} />
<Block label="tailscale.hostname" value={hostname} />
<Block label="tailscale.name" value={name} />
<Block label="tailscale.client_version" value={clientVersionString} />
<Block label="tailscale.os" value={os} />
<Block label="tailscale.created" value={created} />
<Block label="tailscale.authorized" value={getBooleanAsString(authorized)} />
<Block label="tailscale.is_external" value={getBooleanAsString(isExternal)} />
<Block label="tailscale.update_available" value={getBooleanAsString(updateAvailable)} />
<Block label="tailscale.tags" value={tagsString} />
</Container>
);
}

View File

@ -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(
<Component service={{ widget: { type: "tailscale", fields: ["address", "last_seen", "expires", "user"] } }} />,
{ 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(
<Component service={{ widget: { type: "tailscale", fields: ["hostname", "name", "client_version", "os"] } }} />,
{ 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(
<Component
service={{
widget: { type: "tailscale", fields: ["created", "authorized", "is_external", "update_available"] },
}}
/>,
{ 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(
<Component service={{ widget: { type: "tailscale", fields: ["address", "last_seen", "expires", "tags"] } }} />,
{ 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(
<Component
service={{ widget: { type: "tailscale", fields: ["address", "last_seen", "expires", "user", "hostname"] } }}
/>,
{ 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(<Component service={{ widget: { type: "tailscale", fields: [] } }} />, {
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(<Component service={{ widget: { type: "tailscale" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(0);
expect(container.textContent).toContain("API error");
});
});