2025-02-04 17:21:03 -05:00
import { CheckCircle , CircleSlash , Info , UserCircle } from 'lucide-react' ;
2024-12-31 10:30:14 +05:30
import { useMemo , useState } from 'react' ;
2025-01-23 11:16:56 -05:00
import type { ActionFunctionArgs , LoaderFunctionArgs } from 'react-router' ;
import { Link as RemixLink , useLoaderData } from 'react-router' ;
2025-04-18 14:40:56 -04:00
import { mapTag } from 'yaml/util' ;
2024-12-31 10:30:14 +05:30
import Attribute from '~/components/Attribute' ;
import Button from '~/components/Button' ;
import Card from '~/components/Card' ;
2025-01-23 11:28:42 -05:00
import Chip from '~/components/Chip' ;
2025-01-23 11:16:56 -05:00
import Link from '~/components/Link' ;
2024-12-31 10:30:14 +05:30
import StatusCircle from '~/components/StatusCircle' ;
2025-01-23 11:16:56 -05:00
import Tooltip from '~/components/Tooltip' ;
2025-03-22 01:36:27 -04:00
import type { LoadContext } from '~/server' ;
2025-05-16 11:05:20 -04:00
import type { Machine , User } from '~/types' ;
2025-01-28 17:46:16 -05:00
import cn from '~/utils/cn' ;
2025-04-25 14:32:35 -04:00
import { getOSInfo , getTSVersion } from '~/utils/host-info' ;
2025-04-18 14:40:56 -04:00
import { mapNodes } from '~/utils/node-info' ;
import { mapTagsToComponents , uiTagsForNode } from './components/machine-row' ;
2024-12-31 10:30:14 +05:30
import MenuOptions from './components/menu' ;
import Routes from './dialogs/routes' ;
2025-04-03 00:14:51 -04:00
import { machineAction } from './machine-actions' ;
2024-06-02 01:33:40 -04:00
2025-03-22 01:36:27 -04:00
export async function loader ( {
request ,
params ,
context ,
} : LoaderFunctionArgs < LoadContext > ) {
const session = await context . sessions . auth ( request ) ;
2024-03-26 09:28:52 -04:00
if ( ! params . id ) {
2024-12-31 10:30:14 +05:30
throw new Error ( 'No machine ID provided' ) ;
2024-03-26 09:28:52 -04:00
}
2024-12-31 10:30:14 +05:30
let magic : string | undefined ;
2025-03-22 01:36:27 -04:00
if ( context . hs . readable ( ) ) {
if ( context . hs . c ? . dns . magic_dns ) {
magic = context . hs . c . dns . base_domain ;
2024-06-02 01:33:40 -04:00
}
}
2025-05-16 11:05:20 -04:00
const [ machine , { users } ] = await Promise . all ( [
2025-03-22 01:36:27 -04:00
context . client . get < { node : Machine } > (
` v1/node/ ${ params . id } ` ,
session . get ( 'api_key' ) ! ,
) ,
context . client . get < { users : User [ ] } > ( 'v1/user' , session . get ( 'api_key' ) ! ) ,
2024-12-31 10:30:14 +05:30
] ) ;
2024-06-02 01:33:40 -04:00
2025-05-16 11:05:20 -04:00
const lookup = await context . agents ? . lookup ( [ machine . node . nodeKey ] ) ;
const [ node ] = mapNodes ( [ machine . node ] , lookup ) ;
2025-05-25 10:26:50 -04:00
const tags = Array . from (
new Set ( [ . . . node . validTags , . . . node . forcedTags ] ) ,
) . sort ( ) ;
2025-04-18 14:40:56 -04:00
2024-06-02 01:33:40 -04:00
return {
2025-04-18 14:40:56 -04:00
node ,
2025-05-25 10:26:50 -04:00
tags ,
2025-04-18 14:40:56 -04:00
users ,
2024-06-02 01:33:40 -04:00
magic ,
2025-04-18 14:40:56 -04:00
agent : context.agents?.agentID ( ) ,
2025-04-25 14:32:35 -04:00
stats : lookup?. [ node . nodeKey ] ,
2024-12-31 10:30:14 +05:30
} ;
2024-03-26 09:28:52 -04:00
}
2025-03-22 01:36:27 -04:00
export async function action ( request : ActionFunctionArgs ) {
2025-04-03 00:14:51 -04:00
return machineAction ( request ) ;
2024-06-23 01:05:03 -04:00
}
2024-03-26 09:28:52 -04:00
export default function Page() {
2025-05-25 10:26:50 -04:00
const { node , tags , magic , users , agent , stats } =
useLoaderData < typeof loader > ( ) ;
2025-01-19 17:31:46 +00:00
const [ showRouting , setShowRouting ] = useState ( false ) ;
2024-03-26 09:28:52 -04:00
2025-04-18 14:40:56 -04:00
const uiTags = useMemo ( ( ) = > {
const tags = uiTagsForNode ( node , agent === node . nodeKey ) ;
return tags ;
} , [ node , agent ] ) ;
2024-11-07 13:04:53 -05:00
2024-03-26 09:28:52 -04:00
return (
< div >
2024-06-02 01:33:40 -04:00
< p className = "mb-8 text-md" >
2024-12-31 10:30:14 +05:30
< RemixLink to = "/machines" className = "font-medium" >
2024-03-26 09:28:52 -04:00
All Machines
2024-11-07 13:04:53 -05:00
< / RemixLink >
2024-12-31 10:30:14 +05:30
< span className = "mx-2" > / < / span >
2025-04-18 14:40:56 -04:00
{ node . givenName }
2024-03-26 09:28:52 -04:00
< / p >
2024-12-31 10:30:14 +05:30
< div
className = { cn (
2025-02-04 17:21:03 -05:00
'flex justify-between items-center pb-2' ,
'border-b border-headplane-100 dark:border-headplane-800' ,
2024-12-31 10:30:14 +05:30
) }
>
2025-02-04 17:21:03 -05:00
< span className = "flex items-baseline gap-x-4 text-sm" >
2025-04-18 14:40:56 -04:00
< h1 className = "text-2xl font-medium" > { node . givenName } < / h1 >
< StatusCircle isOnline = { node . online } className = "w-4 h-4" / >
2024-06-02 01:33:40 -04:00
< / span >
2025-04-18 14:40:56 -04:00
< MenuOptions isFullButton node = { node } users = { users } magic = { magic } / >
2024-06-02 01:33:40 -04:00
< / div >
2024-11-07 13:04:53 -05:00
< div className = "flex gap-1 mb-4" >
2025-02-04 17:21:03 -05:00
< div className = "border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4" >
< span className = "text-sm text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1" >
2024-11-07 13:04:53 -05:00
Managed by
< Tooltip >
2025-02-04 17:21:03 -05:00
< Info className = "p-1" / >
2024-11-07 13:04:53 -05:00
< Tooltip.Body >
By default , a machine ’ s permissions match its creator ’ s .
< / Tooltip.Body >
< / Tooltip >
< / span >
< div className = "flex items-center gap-x-2.5 mt-1" >
2025-02-04 17:21:03 -05:00
< UserCircle / >
2025-06-05 22:06:38 -05:00
{ node . user . name ? ? node . user . email }
2024-11-07 13:04:53 -05:00
< / div >
< / div >
2025-04-18 14:40:56 -04:00
< div className = "p-2 pl-4" >
< p className = "text-sm text-headplane-600 dark:text-headplane-300" >
Status
< / p >
< div className = "flex gap-1 mt-1 mb-8" >
{ mapTagsToComponents ( node , uiTags ) }
2025-05-25 10:26:50 -04:00
{ tags . map ( ( tag ) = > (
2025-04-18 14:40:56 -04:00
< Chip key = { tag } text = { tag } / >
) ) }
2024-11-07 13:04:53 -05:00
< / div >
2025-04-18 14:40:56 -04:00
< / div >
2024-11-07 13:04:53 -05:00
< / div >
2025-04-18 14:40:56 -04:00
< Routes node = { node } isOpen = { showRouting } setIsOpen = { setShowRouting } / >
2025-04-25 14:32:35 -04:00
< h2 className = "text-xl font-medium mt-8" > Subnets & Routing < / h2 >
2024-11-07 13:04:53 -05:00
< div className = "flex items-center justify-between mb-4" >
< p >
2024-12-31 10:30:14 +05:30
Subnets let you expose physical network routes onto Tailscale . { ' ' }
2024-11-07 13:04:53 -05:00
< Link
to = "https://tailscale.com/kb/1019/subnets"
name = "Tailscale Subnets Documentation"
>
Learn More
< / Link >
< / p >
2025-01-23 11:16:56 -05:00
< Button onPress = { ( ) = > setShowRouting ( true ) } > Review < / Button >
2024-11-07 13:04:53 -05:00
< / div >
< Card
variant = "flat"
className = { cn (
'w-full max-w-full grid sm:grid-cols-2' ,
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8' ,
) }
>
< div >
2025-02-04 17:21:03 -05:00
< span className = "text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1" >
2024-11-07 13:04:53 -05:00
Approved
< Tooltip >
2025-02-04 17:21:03 -05:00
< Info className = "w-3.5 h-3.5" / >
2024-11-07 13:04:53 -05:00
< Tooltip.Body >
2024-12-31 10:30:14 +05:30
Traffic to these routes are being routed through this machine .
2024-11-07 13:04:53 -05:00
< / Tooltip.Body >
< / Tooltip >
< / span >
< div className = "mt-1" >
2025-04-18 14:40:56 -04:00
{ node . customRouting . subnetApprovedRoutes . length === 0 ? (
2025-02-04 17:21:03 -05:00
< span className = "opacity-50" > — < / span >
2024-11-07 13:04:53 -05:00
) : (
< ul className = "leading-normal" >
2025-04-18 14:40:56 -04:00
{ node . customRouting . subnetApprovedRoutes . map ( ( route ) = > (
2025-05-16 11:05:20 -04:00
< li key = { route } > { route } < / li >
2024-11-07 13:04:53 -05:00
) ) }
< / ul >
) }
< / div >
< Button
2025-01-19 17:31:46 +00:00
onPress = { ( ) = > setShowRouting ( true ) }
2024-06-02 01:33:40 -04:00
className = { cn (
2025-01-19 17:31:46 +00:00
'px-1.5 py-0.5 rounded-md mt-1.5' ,
2024-11-07 13:04:53 -05:00
'text-blue-500 dark:text-blue-400' ,
2024-06-02 01:33:40 -04:00
) }
>
2024-11-07 13:04:53 -05:00
Edit
< / Button >
< / div >
< div >
2025-02-04 17:21:03 -05:00
< span className = "text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1" >
2024-11-07 13:04:53 -05:00
Awaiting Approval
< Tooltip >
2025-02-04 17:21:03 -05:00
< Info className = "w-3.5 h-3.5" / >
2024-11-07 13:04:53 -05:00
< Tooltip.Body >
2024-12-31 10:30:14 +05:30
This machine is advertising these routes , but they must be
approved before traffic will be routed to them .
2024-11-07 13:04:53 -05:00
< / Tooltip.Body >
< / Tooltip >
2024-06-02 01:33:40 -04:00
< / span >
2024-11-07 13:04:53 -05:00
< div className = "mt-1" >
2025-04-18 14:40:56 -04:00
{ node . customRouting . subnetWaitingRoutes . length === 0 ? (
2025-02-04 17:21:03 -05:00
< span className = "opacity-50" > — < / span >
2024-11-07 13:04:53 -05:00
) : (
< ul className = "leading-normal" >
2025-04-18 14:40:56 -04:00
{ node . customRouting . subnetWaitingRoutes . map ( ( route ) = > (
2025-05-16 11:05:20 -04:00
< li key = { route } > { route } < / li >
2024-11-07 13:04:53 -05:00
) ) }
< / ul >
) }
< / div >
< Button
2025-01-19 17:31:46 +00:00
onPress = { ( ) = > setShowRouting ( true ) }
2024-11-07 13:04:53 -05:00
className = { cn (
2025-01-19 17:31:46 +00:00
'px-1.5 py-0.5 rounded-md mt-1.5' ,
2024-11-07 13:04:53 -05:00
'text-blue-500 dark:text-blue-400' ,
) }
>
Edit
< / Button >
< / div >
< div >
2025-02-04 17:21:03 -05:00
< span className = "text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1" >
2024-11-07 13:04:53 -05:00
Exit Node
< Tooltip >
2025-02-04 17:21:03 -05:00
< Info className = "w-3.5 h-3.5" / >
2024-11-07 13:04:53 -05:00
< Tooltip.Body >
2024-12-31 10:30:14 +05:30
Whether this machine can act as an exit node for your tailnet .
2024-11-07 13:04:53 -05:00
< / Tooltip.Body >
< / Tooltip >
< / span >
< div className = "mt-1" >
2025-04-18 14:40:56 -04:00
{ node . customRouting . exitRoutes . length === 0 ? (
2025-02-04 17:21:03 -05:00
< span className = "opacity-50" > — < / span >
2025-04-18 14:40:56 -04:00
) : node . customRouting . exitApproved ? (
2024-11-07 13:04:53 -05:00
< span className = "flex items-center gap-x-1" >
2025-02-04 17:21:03 -05:00
< CheckCircle className = "w-3.5 h-3.5 text-green-700" / >
2024-11-07 13:04:53 -05:00
Allowed
< / span >
) : (
< span className = "flex items-center gap-x-1" >
2025-02-04 17:21:03 -05:00
< CircleSlash className = "w-3.5 h-3.5 text-red-700" / >
2024-11-07 13:04:53 -05:00
Awaiting Approval
< / span >
) }
< / div >
< Button
2025-01-19 17:31:46 +00:00
onPress = { ( ) = > setShowRouting ( true ) }
2024-11-07 13:04:53 -05:00
className = { cn (
2025-01-19 17:31:46 +00:00
'px-1.5 py-0.5 rounded-md mt-1.5' ,
2024-11-07 13:04:53 -05:00
'text-blue-500 dark:text-blue-400' ,
) }
>
Edit
< / Button >
< / div >
< / Card >
2025-04-25 14:32:35 -04:00
< h2 className = "text-xl font-medium" > Machine Details < / h2 >
< p className = "mb-4" >
Information about this machine ’ s network . Used to debug connection
issues .
< / p >
< Card
variant = "flat"
className = "w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12"
>
< div className = "flex flex-col gap-1" >
2025-06-05 22:06:38 -05:00
< Attribute name = "Creator" value = { node . user . name ? ? node . user . email } / >
2025-04-25 14:32:35 -04:00
< Attribute name = "Machine name" value = { node . givenName } / >
< Attribute
tooltip = "OS hostname is published by the machine’ s operating system and is used as the default name for the machine."
name = "OS hostname"
value = { node . name }
/ >
{ stats ? (
< >
< Attribute name = "OS" value = { getOSInfo ( stats ) } / >
< Attribute name = "Tailscale version" value = { getTSVersion ( stats ) } / >
< / >
) : undefined }
< Attribute
tooltip = "ID for this machine. Used in the Headscale API."
name = "ID"
value = { node . id }
/ >
< Attribute
isCopyable
tooltip = "Public key which uniquely identifies this machine."
name = "Node key"
value = { node . nodeKey }
/ >
< Attribute
name = "Created"
value = { new Date ( node . createdAt ) . toLocaleString ( ) }
/ >
< Attribute
name = "Last Seen"
value = {
node . online
? 'Connected'
: new Date ( node . lastSeen ) . toLocaleString ( )
}
/ >
< Attribute
name = "Key expiry"
value = {
node . expiry !== null
? new Date ( node . expiry ) . toLocaleString ( )
: 'Never'
}
/ >
{ magic ? (
< Attribute
isCopyable
name = "Domain"
value = { ` ${ node . givenName } . ${ magic } ` }
/ >
) : undefined }
< / div >
< div className = "flex flex-col gap-1" >
< p className = "uppercase text-sm font-semibold opacity-75" >
Addresses
< / p >
2024-12-31 10:30:14 +05:30
< Attribute
isCopyable
2025-04-25 14:32:35 -04:00
tooltip = "This machine’ s IPv4 address within your tailnet (your private Tailscale network)."
name = "Tailscale IPv4"
value = { getIpv4Address ( node . ipAddresses ) }
2024-12-31 10:30:14 +05:30
/ >
2025-04-25 14:32:35 -04:00
< Attribute
isCopyable
tooltip = "This machine’ s IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
name = "Tailscale IPv6"
value = { getIpv6Address ( node . ipAddresses ) }
/ >
< Attribute
isCopyable
tooltip = "Users of your tailnet can use this DNS short name to access this machine."
name = "Short domain"
value = { node . givenName }
/ >
{ magic ? (
< Attribute
isCopyable
tooltip = "Users of your tailnet can use this DNS name to access this machine."
name = "Full domain"
value = { ` ${ node . givenName } . ${ magic } ` }
/ >
) : undefined }
{ stats ? (
< >
< p className = "uppercase text-sm font-semibold opacity-75 mt-4" >
Client Connectivity
< / p >
< Attribute
tooltip = "Whether the machine is behind a difficult NAT that varies the machine’ s IP address depending on the destination."
name = "Varies"
value = { stats . NetInfo ? . MappingVariesByDestIP ? 'Yes' : 'No' }
/ >
< Attribute
tooltip = "Whether the machine needs to traverse NATs with hairpinning."
name = "Hairpinning"
value = { stats . NetInfo ? . HairPinning ? 'Yes' : 'No' }
/ >
< Attribute
name = "IPv6"
value = { stats . NetInfo ? . WorkingIPv6 ? 'Yes' : 'No' }
/ >
< Attribute
name = "UDP"
value = { stats . NetInfo ? . WorkingUDP ? 'Yes' : 'No' }
/ >
< Attribute
name = "UPnP"
value = { stats . NetInfo ? . UPnP ? 'Yes' : 'No' }
/ >
< Attribute name = "PCP" value = { stats . NetInfo ? . PCP ? 'Yes' : 'No' } / >
< Attribute
name = "NAT-PMP"
value = { stats . NetInfo ? . PMP ? 'Yes' : 'No' }
/ >
< / >
) : undefined }
< / div >
2024-03-29 15:41:10 -04:00
< / Card >
2024-03-26 09:28:52 -04:00
< / div >
2024-12-31 10:30:14 +05:30
) ;
2024-03-26 09:28:52 -04:00
}
2025-04-25 14:32:35 -04:00
function getIpv4Address ( addresses : string [ ] ) {
for ( const address of addresses ) {
if ( address . startsWith ( '100.' ) ) {
// Return the first CGNAT address
return address ;
}
}
return '—' ;
}
function getIpv6Address ( addresses : string [ ] ) {
for ( const address of addresses ) {
if ( address . startsWith ( 'fd' ) ) {
// Return the first IPv6 address
return address ;
}
}
return '—' ;
}