- {node.customRouting.subnetApprovedRoutes.length === 0 ? (
-
—
- ) : (
-
- {node.customRouting.subnetApprovedRoutes.map((route) => (
- {route}
- ))}
-
- )}
+
+
+
+
Routing
+
+ Expose subnets or route all traffic through this machine.{" "}
+
+ Docs
+
+
-
setShowRouting(true)} size="sm" variant="ghost">
- Edit
-
+
setShowRouting(true)}>Edit routes
-
-
- Awaiting Approval
-
-
-
-
-
- {node.customRouting.subnetWaitingRoutes.length === 0 ? (
-
—
- ) : (
-
- {node.customRouting.subnetWaitingRoutes.map((route) => (
- {route}
- ))}
-
- )}
-
-
setShowRouting(true)} size="sm" variant="ghost">
- Edit
-
-
-
-
- Exit Node
-
-
-
-
-
- {node.customRouting.exitRoutes.length === 0 ? (
- —
- ) : node.customRouting.exitApproved ? (
-
-
- Allowed
-
- ) : (
-
-
- Awaiting Approval
-
- )}
-
-
setShowRouting(true)} size="sm" variant="ghost">
- Edit
-
-
-
- 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";
}