headplane/app/routes/machines/machine.tsx

399 lines
11 KiB
TypeScript
Raw Normal View History

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';
2024-12-31 10:30:14 +05:30
import Attribute from '~/components/Attribute';
import Button from '~/components/Button';
import Card from '~/components/Card';
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';
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';
import { machineAction } from './machine-actions';
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;
}
}
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
]);
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
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,
magic,
2025-04-18 14:40:56 -04:00
agent: context.agents?.agentID(),
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) {
return machineAction(request);
}
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>();
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-03-26 09:28:52 -04:00
return (
<div>
<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
</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" />
</span>
2025-04-18 14:40:56 -04:00
<MenuOptions isFullButton node={node} users={users} magic={magic} />
</div>
<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">
Managed by
<Tooltip>
2025-02-04 17:21:03 -05:00
<Info className="p-1" />
<Tooltip.Body>
By default, a machines permissions match its creators.
</Tooltip.Body>
</Tooltip>
</span>
<div className="flex items-center gap-x-2.5 mt-1">
2025-02-04 17:21:03 -05:00
<UserCircle />
{node.user.name || node.user.email}
</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} />
))}
</div>
2025-04-18 14:40:56 -04:00
</div>
</div>
2025-04-18 14:40:56 -04:00
<Routes node={node} isOpen={showRouting} setIsOpen={setShowRouting} />
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
<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.{' '}
<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>
</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">
Approved
<Tooltip>
2025-02-04 17:21:03 -05:00
<Info className="w-3.5 h-3.5" />
<Tooltip.Body>
2024-12-31 10:30:14 +05:30
Traffic to these routes are being routed through this machine.
</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>
) : (
<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>
))}
</ul>
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'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">
Awaiting Approval
<Tooltip>
2025-02-04 17:21:03 -05:00
<Info className="w-3.5 h-3.5" />
<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.
</Tooltip.Body>
</Tooltip>
</span>
<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>
) : (
<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>
))}
</ul>
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'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">
Exit Node
<Tooltip>
2025-02-04 17:21:03 -05:00
<Info className="w-3.5 h-3.5" />
<Tooltip.Body>
2024-12-31 10:30:14 +05:30
Whether this machine can act as an exit node for your tailnet.
</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 ? (
<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" />
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" />
Awaiting Approval
</span>
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
>
Edit
</Button>
</div>
</Card>
<h2 className="text-xl font-medium">Machine Details</h2>
<p className="mb-4">
Information about this machines 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">
<Attribute name="Creator" value={node.user.name || node.user.email} />
<Attribute name="Machine name" value={node.givenName} />
<Attribute
tooltip="OS hostname is published by the machines 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
tooltip="This machines IPv4 address within your tailnet (your private Tailscale network)."
name="Tailscale IPv4"
value={getIpv4Address(node.ipAddresses)}
2024-12-31 10:30:14 +05:30
/>
<Attribute
isCopyable
tooltip="This machines 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 machines 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>
</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
}
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 '—';
}