From 3f16acafd36fa5eefe430759cff55608f5abc55d Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Wed, 14 Oct 2026 15:25:57 -0700 Subject: [PATCH] feat(ui): redesign machines list and detail pages --- CHANGELOG.md | 4 + app/components/attribute.tsx | 26 +- app/components/chip.tsx | 22 +- app/components/status-circle.tsx | 22 +- app/components/tags/ExitNode.tsx | 14 +- app/components/tags/Expiry.tsx | 65 +-- app/components/tags/HeadplaneAgent.tsx | 4 +- app/components/tags/Subnet.tsx | 16 +- app/components/tags/TailscaleSSH.tsx | 4 +- .../machines/components/machine-filters.tsx | 34 +- .../machines/components/machine-row.tsx | 180 +++++--- app/routes/machines/components/menu.tsx | 16 +- app/routes/machines/dialogs/new.tsx | 7 +- app/routes/machines/machine.tsx | 403 ++++++++---------- app/routes/machines/overview.tsx | 19 +- app/routes/settings/agent.tsx | 2 +- .../settings/auth-keys/auth-key-row.tsx | 16 +- .../users/components/headplane-user-row.tsx | 4 +- .../users/components/headscale-user-row.tsx | 4 +- app/server/tailscale-release.ts | 40 ++ app/utils/host-info.ts | 17 + app/utils/time.ts | 65 ++- app/utils/user.ts | 7 +- 23 files changed, 522 insertions(+), 469 deletions(-) create mode 100644 app/server/tailscale-release.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d2cde0d6..0a98a377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ - Headplane has a new color palette built from its logo: warm greys, black primary buttons, and a muted blue that marks links, focus, and the current page. Dark mode now separates the page, menus, and dialogs by shade so overlays stand out, and secondary text is easier to read in both themes (via [#651](https://github.com/tale/headplane/issues/651)). - Dialogs can now be closed by clicking outside them, except the ones that confirm a destructive action like removing a machine. +- The navigation bar is slimmer, and on small screens the pages now live in a menu instead of a second row of tabs. +- The machines list is easier to scan: each machine shows its status next to its name, its tags and badges on one line, and both of its addresses. Badges only use color when something needs attention, like an expired key, a key expiring within a week, or routes waiting for approval. Click an address to copy it (via [#654](https://github.com/tale/headplane/issues/654)). +- With the Headplane agent enabled, the machines list marks clients running an older Tailscale release than the latest stable one. Headplane checks pkgs.tailscale.com for the latest version once a day in the background. +- The machine page has a simpler layout, with routing and network details shown as plain lists instead of boxed cards. # 0.8.0 (October 13, 2026) diff --git a/app/components/attribute.tsx b/app/components/attribute.tsx index e5377c98..ddfee80e 100644 --- a/app/components/attribute.tsx +++ b/app/components/attribute.tsx @@ -1,6 +1,5 @@ import { Check, Copy, Info } from "lucide-react"; -import cn from "~/utils/cn"; import { copyToClipboard } from "~/utils/copy"; import toast from "~/utils/toast"; @@ -15,14 +14,8 @@ export interface AttributeProps { export default function Attribute({ name, value, tooltip, isCopyable }: AttributeProps) { return ( -
-
+
+
{name} {tooltip ? ( @@ -30,18 +23,11 @@ export default function Attribute({ name, value, tooltip, isCopyable }: Attribut ) : undefined}
-
+
{isCopyable ? (
-
+ ); } diff --git a/app/components/chip.tsx b/app/components/chip.tsx index 3f2c081f..2b238025 100644 --- a/app/components/chip.tsx +++ b/app/components/chip.tsx @@ -2,21 +2,33 @@ import React from "react"; import cn from "~/utils/cn"; +const variants = { + neutral: "bg-subtle text-muted border-line", + warn: "bg-warn-subtle text-warn border-transparent", + bad: "bg-bad-subtle text-bad border-transparent", +}; + export interface ChipProps { text: string; + variant?: keyof typeof variants; className?: string; leftIcon?: React.ReactNode; rightIcon?: React.ReactNode; } -export default function Chip({ text, className, leftIcon, rightIcon }: ChipProps) { +export default function Chip({ + text, + variant = "neutral", + className, + leftIcon, + rightIcon, +}: ChipProps) { return ( diff --git a/app/components/status-circle.tsx b/app/components/status-circle.tsx index 2e0defc8..58eb3c37 100644 --- a/app/components/status-circle.tsx +++ b/app/components/status-circle.tsx @@ -2,18 +2,22 @@ import cn from "~/utils/cn"; export interface StatusCircleProps { isOnline: boolean; + isExpired?: boolean; className?: string; } -export default function StatusCircle({ isOnline, className }: StatusCircleProps) { +export default function StatusCircle({ isOnline, isExpired, className }: StatusCircleProps) { + const label = isExpired ? "Expired" : isOnline ? "Online" : "Offline"; + return ( - - {isOnline ? "Online" : "Offline"} - - + ); } diff --git a/app/components/tags/ExitNode.tsx b/app/components/tags/ExitNode.tsx index d14ad200..a997ceae 100644 --- a/app/components/tags/ExitNode.tsx +++ b/app/components/tags/ExitNode.tsx @@ -1,7 +1,3 @@ -import { Info } from "lucide-react"; - -import cn from "~/utils/cn"; - import Chip from "../chip"; import Tooltip from "../tooltip"; @@ -17,17 +13,13 @@ export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) { <>This machine is acting as an exit node. ) : ( <> - This machine is requesting to be used as an exit node. Review this from the "Edit route - settings..." option in the machine's menu. + This machine is requesting to be used as an exit node. Review it from "Edit route + settings" in the machine's menu. ) } > - } - /> + ); } diff --git a/app/components/tags/Expiry.tsx b/app/components/tags/Expiry.tsx index b7f55652..1eb62e36 100644 --- a/app/components/tags/Expiry.tsx +++ b/app/components/tags/Expiry.tsx @@ -1,37 +1,46 @@ +import { formatTimeUntil } from "~/utils/time"; + import Chip from "../chip"; import Tooltip from "../tooltip"; export interface ExpiryTagProps { - variant: "expired" | "no-expiry"; + variant: "expired" | "expiring" | "no-expiry"; expiry?: string; } -export function ExpiryTag({ variant, expiry }: ExpiryTagProps) { - const formatter = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); +const formatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", +}); - return ( - - This machine is expired and will not be able to connect to the network. Re-authenticate - with Tailscale on the machine to re-enable it. - - ) : ( - <>This machine has key expiry disabled and will never need to re-authenticate. - ) - } - > - - - ); +export function ExpiryTag({ variant, expiry }: ExpiryTagProps) { + const date = expiry ? formatter.format(new Date(expiry)) : undefined; + + switch (variant) { + case "expired": + return ( + + + + ); + + case "expiring": + return ( + + + + ); + + case "no-expiry": + return ( + + + + ); + } } diff --git a/app/components/tags/HeadplaneAgent.tsx b/app/components/tags/HeadplaneAgent.tsx index ab6d326f..41c22bdc 100644 --- a/app/components/tags/HeadplaneAgent.tsx +++ b/app/components/tags/HeadplaneAgent.tsx @@ -1,12 +1,10 @@ -import cn from "~/utils/cn"; - import Chip from "../chip"; import Tooltip from "../tooltip"; export function HeadplaneAgentTag() { return ( - + ); } diff --git a/app/components/tags/Subnet.tsx b/app/components/tags/Subnet.tsx index c91fc5e2..138c18d0 100644 --- a/app/components/tags/Subnet.tsx +++ b/app/components/tags/Subnet.tsx @@ -1,15 +1,12 @@ -import { Info } from "lucide-react"; - -import cn from "~/utils/cn"; - import Chip from "../chip"; import Tooltip from "../tooltip"; export interface SubnetTagProps { isEnabled?: boolean; + count: number; } -export function SubnetTag({ isEnabled }: SubnetTagProps) { +export function SubnetTag({ isEnabled, count }: SubnetTagProps) { return ( This machine advertises subnet routes. ) : ( <> - This machine has unadvertised subnet routes. Review this from the "Edit route - settings..." option in the machine's menu. + This machine has subnet routes waiting for approval. Review them from "Edit route + settings" in the machine's menu. ) } > } + text={`${count} ${count === 1 ? "subnet" : "subnets"}`} + variant={isEnabled ? "neutral" : "warn"} /> ); diff --git a/app/components/tags/TailscaleSSH.tsx b/app/components/tags/TailscaleSSH.tsx index 0e6cc8f2..26487093 100644 --- a/app/components/tags/TailscaleSSH.tsx +++ b/app/components/tags/TailscaleSSH.tsx @@ -1,12 +1,10 @@ -import cn from "~/utils/cn"; - import Chip from "../chip"; import Tooltip from "../tooltip"; export function TailscaleSSHTag() { return ( - + ); } diff --git a/app/routes/machines/components/machine-filters.tsx b/app/routes/machines/components/machine-filters.tsx index c4704755..43a33695 100644 --- a/app/routes/machines/components/machine-filters.tsx +++ b/app/routes/machines/components/machine-filters.tsx @@ -1,6 +1,7 @@ import { ChevronDown, X } from "lucide-react"; import type { JSX } from "react"; +import Button from "~/components/button"; import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu"; import type { User } from "~/types/User"; import cn from "~/utils/cn"; @@ -38,16 +39,20 @@ function FilterDropdown({ - {activeOption?.label ?? label} - + {isActive ? ( + <> + {label}: {activeOption.label} + + ) : ( + label + )} + {options.map((option) => ( @@ -131,19 +136,10 @@ export function MachineFilters({ users, populatedNodes }: MachineFiltersProps): value={filterRoute} /> {hasActiveFilters && ( - + + )} ); diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index 5f3d0c09..5e119935 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -1,7 +1,6 @@ -import { ChevronDown, Copy } from "lucide-react"; +import { ArrowUp, Copy } from "lucide-react"; import { useMemo } from "react"; -import Chip from "~/components/chip"; import Link from "~/components/link"; import { Menu, MenuContent, MenuItem, MenuTrigger } from "~/components/menu"; import StatusCircle from "~/components/status-circle"; @@ -11,14 +10,14 @@ import { ExpiryTag } from "~/components/tags/Expiry"; import { HeadplaneAgentTag } from "~/components/tags/HeadplaneAgent"; import { SubnetTag } from "~/components/tags/Subnet"; import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH"; +import Tooltip from "~/components/tooltip"; import type { User } from "~/types"; -import cn from "~/utils/cn"; import { copyToClipboard } from "~/utils/copy"; import * as hinfo from "~/utils/host-info"; import { isNoExpiry, type PopulatedNode } from "~/utils/node-info"; import { formatTimeDelta } from "~/utils/time"; import toast from "~/utils/toast"; -import { getUserDisplayName } from "~/utils/user"; +import { getUserDisplayName, isTaggedDevicesUser } from "~/utils/user"; import MenuOptions from "./menu"; @@ -26,6 +25,7 @@ interface Props { node: PopulatedNode; users: User[]; isAgent?: boolean; + latestVersion?: string; magic?: string; isDisabled?: boolean; existingTags?: string[]; @@ -38,6 +38,7 @@ export default function MachineRow({ node, users, isAgent, + latestVersion, magic, isDisabled, existingTags, @@ -47,66 +48,98 @@ export default function MachineRow({ }: Props) { const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]); - const ipOptions = useMemo(() => { + const addresses = useMemo(() => { + const list = node.ipAddresses.map((ip) => ({ + label: ip.includes(":") ? "IPv6" : "IPv4", + value: ip, + })); + + list.sort((a, b) => a.label.localeCompare(b.label)); if (magic) { - return [...node.ipAddresses, `${node.givenName}.${magic}`]; + list.push({ label: "MagicDNS", value: `${node.givenName}.${magic}` }); } - return node.ipAddresses; - }, [magic, node.ipAddresses]); + return list; + }, [magic, node.givenName, node.ipAddresses]); + + const isConnected = node.online && !node.expired; + const [ipv4, ipv6] = [ + addresses.find((a) => a.label === "IPv4"), + addresses.find((a) => a.label === "IPv6"), + ]; return ( - + -

{node.givenName}

-

{node.user ? getUserDisplayName(node.user) : "Tag-owned"}

-
- {mapTagsToComponents(node, uiTags)} - {node.tags?.map((tag) => ( - - ))} -
+ + + {node.givenName} + +
+ {node.user && !(isTaggedDevicesUser(node.user) && node.tags?.length) ? ( + {getUserDisplayName(node.user)} + ) : undefined} + {node.tags?.map((tag) => ( + + {tag} + + ))} + {mapTagsToComponents(node, uiTags)} +
-
- {node.ipAddresses[0]} - - - - - - {ipOptions.map((ip) => ( - { - const isCopied = await copyToClipboard(ip); - toast( - isCopied - ? "Copied IP address to clipboard" - : "Copy failed. Please copy the address manually.", - ); - }} - > -
- {ip} - + + + {ipv4 ? ( + + {ipv4.value} + + + ) : undefined} + {ipv6 ? {ipv6.value} : undefined} + + + {addresses.map(({ label, value }) => ( + { + const isCopied = await copyToClipboard(value); + toast(isCopied ? `Copied ${value}` : "Copy failed. Copy the address manually."); + }} + > +
+
+

{label}

+

{value}

- - ))} - -
-
+ +
+ + ))} +
+
{/* We pass undefined when agents are not enabled */} {isAgent !== undefined ? ( - + {node.hostInfo !== undefined ? ( <> -

{hinfo.getTSVersion(node.hostInfo)}

+

+ {hinfo.getTSVersion(node.hostInfo)} + {latestVersion && hinfo.isUpdateAvailable(node.hostInfo, latestVersion) ? ( + + + + + + ) : undefined} +

{hinfo.getOSInfo(node.hostInfo)}

) : ( @@ -114,24 +147,20 @@ export default function MachineRow({ )}
) : undefined} - -
- -
-

- {node.online && !node.expired - ? "Connected" - : new Date(node.lastSeen).toLocaleString()} -

- {!(node.online && !node.expired) && ( -

- {formatTimeDelta(new Date(node.lastSeen))} -

- )} -
-
+ + {isConnected ? ( + Connected + ) : ( + + {formatTimeDelta(new Date(node.lastSeen))} + + )} - + 0) { if (node.customRouting.exitApproved) { uiTags.push("exit-approved"); @@ -192,10 +230,20 @@ export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) { case "subnet-approved": case "subnet-waiting": { - return ; + return ( + + ); } case "expired": + case "expiring": case "no-expiry": { return ; } diff --git a/app/routes/machines/components/menu.tsx b/app/routes/machines/components/menu.tsx index 19431aa9..0cccb18f 100644 --- a/app/routes/machines/components/menu.tsx +++ b/app/routes/machines/components/menu.tsx @@ -1,4 +1,4 @@ -import { Cog, Ellipsis, SquareTerminal } from "lucide-react"; +import { ChevronDown, Ellipsis, SquareTerminal } from "lucide-react"; import { useState } from "react"; import Button from "~/components/button"; @@ -44,7 +44,7 @@ export default function MachineMenu({ const supportsTailscaleSSH = node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0; return ( -
+
{modal === "remove" && ( - + SSH ) : ( @@ -155,17 +155,17 @@ export default function MachineMenu({ {isFullButton ? ( <> - -

Machine Settings

+ Manage + ) : ( - + )}
diff --git a/app/routes/machines/dialogs/new.tsx b/app/routes/machines/dialogs/new.tsx index 95331a7b..deb78eb4 100644 --- a/app/routes/machines/dialogs/new.tsx +++ b/app/routes/machines/dialogs/new.tsx @@ -1,4 +1,4 @@ -import { Computer, FileKey2 } from "lucide-react"; +import { Computer, FileKey2, Plus } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router"; @@ -59,8 +59,9 @@ export default function NewMachine(data: NewMachineProps) { /> - - Add Device + + + Add machine -

- - All Machines + <> +

+ + } + description={ + + {node.user && !(isTaggedDevicesUser(node.user) && tags.length) ? ( + {getUserDisplayName(node.user)} + ) : undefined} {tags.map((tag) => ( - + + {tag} + ))} -
-
- + {mapTagsToComponents(node, uiTags)} +
+ } + title={ + + + {node.givenName} + + } + /> -

Subnets & Routing

-
-

- Subnets let you expose physical network routes onto Tailscale.{" "} - - Learn More - -

- -
- -
- - Approved - - - - -
- {node.customRouting.subnetApprovedRoutes.length === 0 ? ( - — - ) : ( -
    - {node.customRouting.subnetApprovedRoutes.map((route) => ( -
  • {route}
  • - ))} -
- )} +
+
+
+

Routing

+

+ Expose subnets or route all traffic through this machine.{" "} + + Docs + +

- +
-
- - Awaiting Approval - - - - -
- {node.customRouting.subnetWaitingRoutes.length === 0 ? ( - — - ) : ( -
    - {node.customRouting.subnetWaitingRoutes.map((route) => ( -
  • {route}
  • - ))} -
- )} -
- -
-
- - Exit Node - - - - -
- {node.customRouting.exitRoutes.length === 0 ? ( - — - ) : node.customRouting.exitApproved ? ( - - - Allowed - - ) : ( - - - Awaiting Approval - - )} -
- -
- -

Machine Details

-

- Information about this machine’s network. Used to debug connection issues. -

- -
+
0 ? subnetApprovedRoutes.join("\n") : "None"} /> - - - {stats ? ( - <> - - - - ) : undefined} - - - - - - {magic ? ( - - ) : undefined} -
-
-

Addresses

- - - - {magic ? ( + {subnetWaitingRoutes.length > 0 ? ( ) : undefined} - {stats?.Endpoints ? ( - - ) : undefined} - {stats ? ( - <> -

Client Connectivity

- - - - - - - - - ) : undefined} + + +
+
+
+

Details

+

+ Information about this machine's network. Useful when debugging connections. +

- -
+
+
+ + + + {stats ? ( + <> + + + + ) : undefined} + + + + + +
+
+
+ + + + {magic ? ( + + ) : undefined} + {stats?.Endpoints ? ( + + ) : undefined} +
+ {stats ? ( + <> +

Connectivity

+
+ + + + + + + +
+ + ) : undefined} +
+
+ + ); } diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index a0803005..1e0e1e07 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -1,4 +1,4 @@ -import { Info, X } from "lucide-react"; +import { Info, Search, X } from "lucide-react"; import { useMemo, useState } from "react"; import { useSearchParams } from "react-router"; @@ -26,6 +26,7 @@ import { requestApiContext, } from "~/server/context"; import { nodesResource, usersResource } from "~/server/headscale/live-store"; +import { latestTailscaleVersion } from "~/server/tailscale-release"; import { isUserPrincipal } from "~/server/web/auth"; import { Capabilities } from "~/server/web/roles"; import cn from "~/utils/cn"; @@ -89,6 +90,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { : undefined, headscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined, existingTags: sortAssignableTags(nodes, policy), + latestVersion: agents ? latestTailscaleVersion() : undefined, // `undefined` keeps the tag dialog from flagging every tag as undeclared. policyTags: extractTagOwnerTags(policy), magic, @@ -259,22 +261,24 @@ export default function Page({ loaderData }: Route.ComponentProps) { } description={ <> - Manage the devices connected to your Tailnet.{" "} + Devices connected to your tailnet.{" "} - Learn more + Docs } title="Machines" /> -
-
+
+
+ {searchQuery && ( @@ -294,7 +298,7 @@ export default function Page({ loaderData }: Route.ComponentProps) { )}
- + {searchQuery || hasActiveFilters ? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines` : `${loaderData.populatedNodes.length} machines`} @@ -368,6 +372,7 @@ export default function Page({ loaderData }: Route.ComponentProps) { : node.user?.id !== loaderData.headscaleUserId } key={node.id} + latestVersion={loaderData.latestVersion} magic={loaderData.magic} node={node} users={loaderData.users} diff --git a/app/routes/settings/agent.tsx b/app/routes/settings/agent.tsx index 39b017ac..76e3d770 100644 --- a/app/routes/settings/agent.tsx +++ b/app/routes/settings/agent.tsx @@ -79,7 +79,7 @@ export default function Page({ loaderData }: Route.ComponentProps) { />
- + {hasError ? "Error" : isPending ? "Waiting for approval" : "Healthy"} diff --git a/app/routes/settings/auth-keys/auth-key-row.tsx b/app/routes/settings/auth-keys/auth-key-row.tsx index aa8b1624..13bc2679 100644 --- a/app/routes/settings/auth-keys/auth-key-row.tsx +++ b/app/routes/settings/auth-keys/auth-key-row.tsx @@ -18,13 +18,15 @@ export default function AuthKeyRow({ authKey, user }: Props) { return (
- - - - - - - +
+ + + + + + + +
{!isExpired && user && (
diff --git a/app/routes/users/components/headplane-user-row.tsx b/app/routes/users/components/headplane-user-row.tsx index f21ff642..3cddd3cd 100644 --- a/app/routes/users/components/headplane-user-row.tsx +++ b/app/routes/users/components/headplane-user-row.tsx @@ -76,8 +76,8 @@ export default function HeadplaneUserRow({ {user.machines.length > 0 ? ( - - + +

{isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}

diff --git a/app/routes/users/components/headscale-user-row.tsx b/app/routes/users/components/headscale-user-row.tsx index 874cb2dd..dae149a1 100644 --- a/app/routes/users/components/headscale-user-row.tsx +++ b/app/routes/users/components/headscale-user-row.tsx @@ -62,8 +62,8 @@ export default function HeadscaleUserRow({
{user.machines.length > 0 ? ( - - + +

{isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}

diff --git a/app/server/tailscale-release.ts b/app/server/tailscale-release.ts new file mode 100644 index 00000000..062d31a1 --- /dev/null +++ b/app/server/tailscale-release.ts @@ -0,0 +1,40 @@ +import log from "~/utils/log"; + +const RELEASE_URL = "https://pkgs.tailscale.com/stable/?mode=json"; +const REFRESH_MS = 24 * 60 * 60 * 1000; +const RETRY_MS = 60 * 60 * 1000; + +let latest: string | undefined; +let nextFetchAt = 0; + +/** + * Returns the latest stable Tailscale version, if known. The lookup runs in + * the background so loaders never wait on pkgs.tailscale.com. + */ +export function latestTailscaleVersion() { + if (Date.now() >= nextFetchAt) { + nextFetchAt = Date.now() + RETRY_MS; + void refresh(); + } + + return latest; +} + +async function refresh() { + try { + const res = await fetch(RELEASE_URL, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const body = (await res.json()) as { TarballsVersion?: unknown }; + if (typeof body.TarballsVersion !== "string") { + throw new Error("Missing TarballsVersion"); + } + + latest = body.TarballsVersion; + nextFetchAt = Date.now() + REFRESH_MS; + } catch (error) { + log.debug("server", "Unable to fetch latest Tailscale version: %s", String(error)); + } +} diff --git a/app/utils/host-info.ts b/app/utils/host-info.ts index 46afa215..fc45696a 100644 --- a/app/utils/host-info.ts +++ b/app/utils/host-info.ts @@ -10,6 +10,23 @@ export function getTSVersion(host: HostInfo) { return IPNVersion.split("-")[0]; } +// Only major.minor is compared since platforms ship patch releases at +// different times (e.g. the macOS app often trails the Linux tarballs). +export function isUpdateAvailable(host: HostInfo, latest: string) { + const current = getTSVersion(host); + if (current === "Unknown") { + return false; + } + + const [curMajor = 0, curMinor = 0] = current.split(".").map(Number); + const [newMajor = 0, newMinor = 0] = latest.split(".").map(Number); + if (curMajor !== newMajor) { + return curMajor < newMajor; + } + + return curMinor < newMinor; +} + export function getOSInfo(host: HostInfo) { const { OS, OSVersion } = host; // OS follows runtime.GOOS but uses iOS and macOS instead of darwin diff --git a/app/utils/time.ts b/app/utils/time.ts index 6a352f76..7e5806ae 100644 --- a/app/utils/time.ts +++ b/app/utils/time.ts @@ -1,42 +1,37 @@ +const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ + ["year", 365 * 24 * 60 * 60], + ["month", 30 * 24 * 60 * 60], + ["day", 24 * 60 * 60], + ["hour", 60 * 60], + ["minute", 60], +]; + +const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); + /** - * Formats the time delta since a given date into a human-readable string. - * - Under 1 hour: "X minutes ago" - * - Under 1 day: "X hours, Y minutes ago" - * - Under 1 month: "X days, Y hours ago" - * - Over 1 month: "X months, Y days ago" + * Formats the time since a date in its largest unit, e.g. "3 hours ago". */ export function formatTimeDelta(date: Date): string { - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - - const minutes = Math.floor(diffMs / (1000 * 60)); - const hours = Math.floor(diffMs / (1000 * 60 * 60)); - const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - const months = Math.floor(days / 30); - - if (minutes < 60) { - return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`; - } - - if (hours < 24) { - const remainingMinutes = minutes % 60; - if (remainingMinutes === 0) { - return `${hours} hour${hours !== 1 ? "s" : ""} ago`; + const seconds = Math.round((Date.now() - date.getTime()) / 1000); + for (const [unit, size] of UNITS) { + if (seconds >= size) { + return relative.format(-Math.floor(seconds / size), unit); } - return `${hours} hour${hours !== 1 ? "s" : ""}, ${remainingMinutes} minute${remainingMinutes !== 1 ? "s" : ""} ago`; } - if (days < 30) { - const remainingHours = hours % 24; - if (remainingHours === 0) { - return `${days} day${days !== 1 ? "s" : ""} ago`; - } - return `${days} day${days !== 1 ? "s" : ""}, ${remainingHours} hour${remainingHours !== 1 ? "s" : ""} ago`; - } - - const remainingDays = days % 30; - if (remainingDays === 0) { - return `${months} month${months !== 1 ? "s" : ""} ago`; - } - return `${months} month${months !== 1 ? "s" : ""}, ${remainingDays} day${remainingDays !== 1 ? "s" : ""} ago`; + return "just now"; +} + +/** + * Formats the time until a date in its largest unit, e.g. "in 4 days". + */ +export function formatTimeUntil(date: Date): string { + const seconds = Math.round((date.getTime() - Date.now()) / 1000); + for (const [unit, size] of UNITS) { + if (seconds >= size) { + return relative.format(Math.floor(seconds / size), unit); + } + } + + return "now"; } diff --git a/app/utils/user.ts b/app/utils/user.ts index b53d1b95..d81aca09 100644 --- a/app/utils/user.ts +++ b/app/utils/user.ts @@ -1,7 +1,12 @@ import type { User } from "~/types/User"; +// Headscale 0.28+ assigns tagged nodes to this placeholder user. +export function isTaggedDevicesUser(user: User): boolean { + return user.name === "tagged-devices"; +} + export function getUserDisplayName(user: User): string { - if (user.name === "tagged-devices") { + if (isTaggedDevicesUser(user)) { return "Tag-owned"; }