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 { agentsContext, appConfigContext, authContext, headscaleConfigContext, headscaleContext, headscaleLiveStoreContext, requestApiContext, } from "~/server/context"; import { nodesResource, usersResource } from "~/server/headscale/live-store"; import { isUserPrincipal } from "~/server/web/auth"; import { Capabilities } from "~/server/web/roles"; import cn from "~/utils/cn"; import { mapNodes, sortAssignableTags, 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 agentsFeature = context.get(agentsContext); const auth = context.get(authContext); const config = context.get(appConfigContext); const getRequestApi = context.get(requestApiContext); const headscale = context.get(headscaleContext); const headscaleConfig = context.get(headscaleConfigContext); const headscaleLiveStore = context.get(headscaleLiveStoreContext); const principal = await auth.require(request); if (!auth.can(principal, Capabilities.read_machines)) { throw new Error( "You do not have permission to view this page. Please contact your administrator.", ); } const writablePermission = auth.can(principal, Capabilities.write_machines); const { api } = await getRequestApi(request); const [nodesSnap, usersSnap] = await Promise.all([ headscaleLiveStore.get(nodesResource, api), headscaleLiveStore.get(usersResource, api), ]); const nodes = nodesSnap.data; const users = usersSnap.data; const magic = headscaleConfig.getMagicDNSBaseDomain(); const agents = agentsFeature.state === "enabled" ? agentsFeature.value : undefined; const [statsResult, policyResult] = await Promise.allSettled([ agents?.lookup(nodes.map((node) => node.nodeKey)), api.policy.get(), ]); const stats = statsResult.status === "fulfilled" ? statsResult.value : undefined; const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined; const populatedNodes = mapNodes(nodes, stats); const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable; const agentSync = agents?.lastSync(); return { agent: agentSync ? { syncedAt: agentSync.syncedAt?.toISOString() ?? null, nodeCount: agentSync.nodeCount, nodeKey: agents?.agentNodeKey(), } : undefined, headscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined, existingTags: sortAssignableTags(nodes, policy), magic, nodes, populatedNodes, preAuth: auth.can(principal, Capabilities.generate_authkeys), publicServer: config.headscale.public_url, server: 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?.name === 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 ; }