From d5f76637f5b745a1d55e4bbb9d8ec6344013c5c3 Mon Sep 17 00:00:00 2001 From: eccgecko <36884529+eccgecko@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:42:05 +0100 Subject: [PATCH 1/3] refactor: extract isNoExpiry utility for zero-time handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's time.Time{} serialises to '0001-01-01T00:00:00Z' which headscale may return instead of null for tagged nodes (juanfont/headscale#3170). Commit extracts the the existing inline check into a shared function for reuse. No behavior change — mapNodes() already handled both variants. --- app/utils/node-info.ts | 12 ++++++------ tests/unit/utils/node-info.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 tests/unit/utils/node-info.test.ts diff --git a/app/utils/node-info.ts b/app/utils/node-info.ts index f26d096..701a3f9 100644 --- a/app/utils/node-info.ts +++ b/app/utils/node-info.ts @@ -12,6 +12,11 @@ export interface PopulatedNode extends Machine { }; } +const GO_ZERO_TIMES = new Set(["0001-01-01 00:00:00", "0001-01-01T00:00:00Z"]); +export function isNoExpiry(expiry: string | null | undefined): boolean { + return expiry == null || GO_ZERO_TIMES.has(expiry); +} + export function mapNodes( nodes: Machine[], stats?: Record | undefined, @@ -35,12 +40,7 @@ export function mapNodes( routes: Array.from(new Set(node.availableRoutes)), hostInfo: stats?.[node.nodeKey], customRouting, - expired: - node.expiry === "0001-01-01 00:00:00" || - node.expiry === "0001-01-01T00:00:00Z" || - node.expiry === null - ? false - : new Date(node.expiry).getTime() < Date.now(), + expired: isNoExpiry(node.expiry) ? false : new Date(node.expiry!).getTime() < Date.now(), }; }); } diff --git a/tests/unit/utils/node-info.test.ts b/tests/unit/utils/node-info.test.ts new file mode 100644 index 0000000..e8e3cb0 --- /dev/null +++ b/tests/unit/utils/node-info.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "vitest"; + +import { isNoExpiry } from "~/utils/node-info"; + +describe("isNoExpiry", () => { + test("returns true for null", () => { + expect(isNoExpiry(null)).toBe(true); + }); + + test("returns true for undefined", () => { + expect(isNoExpiry(undefined)).toBe(true); + }); + + test("returns true for Go zero-time ISO format", () => { + expect(isNoExpiry("0001-01-01T00:00:00Z")).toBe(true); + }); + + test("returns true for Go zero-time space format", () => { + expect(isNoExpiry("0001-01-01 00:00:00")).toBe(true); + }); + + test("returns false for a real future expiry", () => { + expect(isNoExpiry("2030-01-01T00:00:00Z")).toBe(false); + }); + + test("returns false for a real past expiry", () => { + expect(isNoExpiry("2020-01-01T00:00:00Z")).toBe(false); + }); +}); From d26c23313cb64214f36c95c1d61485499e42e431 Mon Sep 17 00:00:00 2001 From: eccgecko <36884529+eccgecko@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:42:32 +0100 Subject: [PATCH 2/3] fix: show "No expiry" badge for Go zero-time expiry uiTagsForNode() only checked node.expiry === null, missing Go zero-time that headscale returns for tagged nodes after a restart (juanfont/headscale#3170). The !node.expired guard is technically redundant with isNoExpiry but documents intent: "No expiry" is only shown for nodes that never had an expiry set --- app/routes/machines/components/machine-row.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index 489deac..d6bc4e5 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -13,7 +13,7 @@ import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH"; import type { User } from "~/types"; import cn from "~/utils/cn"; import * as hinfo from "~/utils/host-info"; -import type { PopulatedNode } from "~/utils/node-info"; +import { isNoExpiry, type PopulatedNode } from "~/utils/node-info"; import { formatTimeDelta } from "~/utils/time"; import toast from "~/utils/toast"; import { getUserDisplayName } from "~/utils/user"; @@ -156,7 +156,7 @@ export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) { uiTags.push("expired"); } - if (node.expiry === null) { + if (!node.expired && isNoExpiry(node.expiry)) { uiTags.push("no-expiry"); } From c164e073365a161b482f65f52de947ae7e941c40 Mon Sep 17 00:00:00 2001 From: eccgecko <36884529+eccgecko@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:50:26 +0100 Subject: [PATCH 3/3] fix: show 'Never' for Go zero-time expiry on detail page The 'Key expiry' attribute only checked node.expiry !== null, showing a garbled date for the '0001-01-01T00:00:00Z' zero-time that headscale may return for tagged nodes (juanfont/headscale#3170). --- app/routes/machines/machine.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index 3df43ac..3b6dd9e 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -12,7 +12,7 @@ import Tooltip from "~/components/tooltip"; import { nodesResource, usersResource } from "~/server/headscale/live-store"; import cn from "~/utils/cn"; import { getOSInfo, getTSVersion } from "~/utils/host-info"; -import { mapNodes, sortNodeTags } from "~/utils/node-info"; +import { isNoExpiry, mapNodes, sortNodeTags } from "~/utils/node-info"; import { getUserDisplayName } from "~/utils/user"; import type { Route } from "./+types/machine"; @@ -281,7 +281,7 @@ export default function Page({ /> {magic ? (