import { ChevronDown, ChevronUp, Info, X } from "lucide-react"; import { useMemo, useState } from "react"; import Code from "~/components/Code"; import Input from "~/components/Input"; import Link from "~/components/link"; import PageError from "~/components/page-error"; import Tooltip from "~/components/Tooltip"; import { Capabilities } from "~/server/web/roles"; import cn from "~/utils/cn"; import { mapNodes, sortNodeTags } from "~/utils/node-info"; import type { Route } from "./+types/overview"; import MachineRow from "./components/machine-row"; import NewMachine from "./dialogs/new"; import { machineAction } from "./machine-actions"; export async function loader({ request, context }: Route.LoaderArgs) { const principal = await context.auth.require(request); if (!context.auth.can(principal, Capabilities.read_machines)) { throw new Error( "You do not have permission to view this page. Please contact your administrator.", ); } const writablePermission = context.auth.can(principal, Capabilities.write_machines); const api = context.hsApi.getRuntimeClient( context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey), ); const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]); let magic: string | undefined; if (context.hs.readable()) { if (context.hs.c?.dns.magic_dns) { magic = context.hs.c.dns.base_domain; } } const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey)); const populatedNodes = mapNodes(nodes, stats); const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1"); return { agent: context.agents?.agentID(), headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined, magic, nodes, populatedNodes, preAuth: context.auth.can(principal, Capabilities.generate_authkeys), publicServer: context.config.headscale.public_url, server: context.config.headscale.url, supportsNodeOwnerChange: supportsNodeOwnerChange, users, writable: writablePermission, }; } export const action = machineAction; type SortField = "name" | "ip" | "version" | "lastSeen"; export default function Page({ loaderData }: Route.ComponentProps) { const [searchQuery, setSearchQuery] = useState(""); const [sortField, setSortField] = useState("name"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const filteredAndSortedNodes = useMemo(() => { const query = searchQuery.toLowerCase().trim(); let nodes = loaderData.populatedNodes.filter((node) => { if (!query) { return true; } if (node.givenName.toLowerCase().includes(query)) { return true; } if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) { return true; } return false; }); nodes = [...nodes].toSorted((a, b) => { let comparison = 0; switch (sortField) { case "name": { comparison = a.givenName.localeCompare(b.givenName); break; } case "ip": { const getIPv4 = (addresses: string[]) => addresses.find((ip) => !ip.includes(":")) || addresses[0] || ""; const ipA = getIPv4(a.ipAddresses); const ipB = getIPv4(b.ipAddresses); if (!ipA.includes(":") && !ipB.includes(":")) { const octetsA = ipA.split(".").map(Number); const octetsB = ipB.split(".").map(Number); for (let i = 0; i < 4; i++) { if (octetsA[i] !== octetsB[i]) { comparison = octetsA[i] - octetsB[i]; break; } } } else { comparison = ipA.localeCompare(ipB); } break; } case "version": { const versionA = a.hostInfo?.IPNVersion?.split("-")[0] || "0"; const versionB = b.hostInfo?.IPNVersion?.split("-")[0] || "0"; const partsA = versionA.split(".").map(Number); const partsB = versionB.split(".").map(Number); const maxLen = Math.max(partsA.length, partsB.length); for (let i = 0; i < maxLen; i++) { const segA = partsA[i] || 0; const segB = partsB[i] || 0; if (segA !== segB) { comparison = segA - segB; break; } } break; } case "lastSeen": { if (a.online !== b.online) { comparison = a.online ? 1 : -1; break; } comparison = new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime(); break; } } return sortDirection === "asc" ? comparison : -comparison; }); return nodes; }, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]); const handleSort = (field: SortField) => { if (sortField === field) { setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); } else { setSortField(field); setSortDirection("asc"); } }; return ( <>

Machines

Manage the devices connected to your Tailnet.{" "} Learn more

setSearchQuery(value.slice(0, 100))} placeholder="Search by name or IP address..." value={searchQuery} /> {searchQuery && ( )}
{searchQuery ? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines` : `${loaderData.populatedNodes.length} machines`}
{/* We only want to show the version column if there are agents */} {loaderData.agent !== undefined ? ( ) : undefined} {filteredAndSortedNodes.length === 0 ? ( ) : ( filteredAndSortedNodes.map((node) => ( )) )}
{loaderData.magic ? ( Since MagicDNS is enabled, you can access devices based on their name and also at{" "} [name]. {loaderData.magic} ) : undefined}
Actions
No machines found matching "{searchQuery}"
); } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { return ; }