import { ChevronDown, ChevronUp, Info, X } from "lucide-react"; import { useMemo, useState } from "react"; import { useSearchParams } from "react-router"; 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 { nodesResource, usersResource } from "~/server/headscale/live-store"; import { Capabilities } from "~/server/web/roles"; import cn from "~/utils/cn"; import { mapNodes, sortNodeTags, type PopulatedNode } from "~/utils/node-info"; import type { Route } from "./+types/overview"; import { MachineFilters } from "./components/machine-filters"; import MachineRow from "./components/machine-row"; import NewMachine from "./dialogs/new"; import { useMachineFilterParams } from "./hooks/use-machine-filter-params"; 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 [nodesSnap, usersSnap] = await Promise.all([ context.hsLive.get(nodesResource, api), context.hsLive.get(usersResource, api), ]); const nodes = nodesSnap.data; const users = usersSnap.data; 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"); 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"; const STATUS_MATCH: Record boolean> = { online: (n) => n.online && !n.expired, offline: (n) => !n.online && !n.expired, expired: (n) => n.expired, }; const ROUTE_MATCH: Record boolean> = { "exit-node": (n) => n.customRouting.exitRoutes.length > 0, subnet: (n) => n.customRouting.subnetApprovedRoutes.length > 0 || n.customRouting.subnetWaitingRoutes.length > 0, }; export default function Page({ loaderData }: Route.ComponentProps) { const [searchParams, setSearchParams] = useSearchParams(); const [sortField, setSortField] = useState("name"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const searchQuery = searchParams.get("q") ?? ""; const { filterUser, filterTag, filterStatus, filterRoute, hasActiveFilters } = useMachineFilterParams(); const setSearchQuery = (value: string) => { setSearchParams((prev) => { const next = new URLSearchParams(prev); const v = value.slice(0, 100); if (v) next.set("q", v); else next.delete("q"); return next; }); }; const clearSearch = () => { setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete("q"); return next; }); }; const filteredAndSortedNodes = useMemo(() => { const query = searchQuery.toLowerCase().trim(); let nodes = loaderData.populatedNodes.filter( (node) => (!query || node.givenName.toLowerCase().includes(query) || node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) && (filterUser === null || (filterUser === "tag-owned" ? !node.user : node.user?.id === filterUser)) && (filterTag === null || (node.tags?.includes(filterTag) ?? false)) && (filterStatus === null || STATUS_MATCH[filterStatus](node)) && (filterRoute === null || ROUTE_MATCH[filterRoute](node)), ); 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, filterUser, filterTag, filterStatus, filterRoute, 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

{searchQuery && ( )}
{searchQuery || hasActiveFilters ? `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 match the current filters
); } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { return ; }