import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import { Link as RemixLink, useLoaderData } from 'react-router'; import { InfoIcon, GearIcon, CheckCircleIcon, SkipIcon, PersonIcon, } from '@primer/octicons-react'; import { useMemo, useState } from 'react'; import Attribute from '~/components/Attribute'; import Button from '~/components/Button'; import Card from '~/components/Card'; import Menu from '~/components/Menu'; import Tooltip from '~/components/Tooltip'; import StatusCircle from '~/components/StatusCircle'; import type { Machine, Route, User } from '~/types'; import { cn } from '~/utils/cn'; import { loadContext } from '~/utils/config/headplane'; import { loadConfig } from '~/utils/config/headscale'; import { pull } from '~/utils/headscale'; import { getSession } from '~/utils/sessions.server'; import { useLiveData } from '~/utils/useLiveData'; import Link from '~/components/Link'; import { menuAction } from './action'; import MenuOptions from './components/menu'; import Routes from './dialogs/routes'; export async function loader({ request, params }: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')); if (!params.id) { throw new Error('No machine ID provided'); } const context = await loadContext(); let magic: string | undefined; if (context.config.read) { const config = await loadConfig(); if (config.dns.magic_dns) { magic = config.dns.base_domain; } } const [machine, routes, users] = await Promise.all([ pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!), pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!), pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!), ]); return { machine: machine.node, routes: routes.routes.filter((route) => route.node.id === params.id), users: users.users, magic, }; } export async function action({ request }: ActionFunctionArgs) { return menuAction(request); } export default function Page() { const { machine, magic, routes, users } = useLoaderData(); const routesState = useState(false); useLiveData({ interval: 1000 }); const expired = machine.expiry === '0001-01-01 00:00:00' || machine.expiry === '0001-01-01T00:00:00Z' || machine.expiry === null ? false : new Date(machine.expiry).getTime() < Date.now(); const tags = [...machine.forcedTags, ...machine.validTags]; if (expired) { tags.unshift('Expired'); } // This is much easier with Object.groupBy but it's too new for us const { exit, subnet, subnetApproved } = routes.reduce<{ exit: Route[]; subnet: Route[]; subnetApproved: Route[]; }>( (acc, route) => { if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') { acc.exit.push(route); return acc; } if (route.enabled) { acc.subnetApproved.push(route); return acc; } acc.subnet.push(route); return acc; }, { exit: [], subnetApproved: [], subnet: [] }, ); const exitEnabled = useMemo(() => { if (exit.length !== 2) return false; return exit[0].enabled && exit[1].enabled; }, [exit]); if (exitEnabled) { tags.unshift('Exit Node'); } if (subnetApproved.length > 0) { tags.unshift('Subnets'); } return (

All Machines / {machine.givenName}

{machine.givenName}

Machine Settings } />
Managed by By default, a machine’s permissions match its creator’s.
{machine.user.name}

Status

{tags.map((tag) => ( {tag} ))}

Subnets & Routing

Subnets let you expose physical network routes onto Tailscale.{' '} Learn More

Approved Traffic to these routes are being routed through this machine.
{subnetApproved.length === 0 ? ( ) : (
    {subnetApproved.map((route) => (
  • {route.prefix}
  • ))}
)}
Awaiting Approval This machine is advertising these routes, but they must be approved before traffic will be routed to them.
{subnet.length === 0 ? ( ) : (
    {subnet.map((route) => (
  • {route.prefix}
  • ))}
)}
Exit Node Whether this machine can act as an exit node for your tailnet.
{exit.length === 0 ? ( ) : exitEnabled ? ( Allowed ) : ( Awaiting Approval )}

Machine Details

{magic ? ( ) : undefined}
); }