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 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 session = await context.sessions.auth(request); const user = session.user; if (!user) { throw new Error('Missing user session. Please log in again.'); } const check = await context.sessions.check( request, Capabilities.read_machines, ); if (!check) { // Not authorized to view this page throw new Error( 'You do not have permission to view this page. Please contact your administrator.', ); } const writablePermission = await context.sessions.check( request, Capabilities.write_machines, ); const api = context.hsApi.getRuntimeClient(session.api_key); 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); return { populatedNodes, nodes, users, magic, server: context.config.headscale.url, publicServer: context.config.headscale.public_url, agent: context.agents?.agentID(), writable: writablePermission, preAuth: await context.sessions.check( request, Capabilities.generate_authkeys, ), subject: user.subject, }; } 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].sort((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}
No machines found matching "{searchQuery}"
); }