399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
|
||
import { useMemo, useState } from 'react';
|
||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||
import { Link as RemixLink, useLoaderData } from 'react-router';
|
||
import { mapTag } from 'yaml/util';
|
||
import Attribute from '~/components/Attribute';
|
||
import Button from '~/components/Button';
|
||
import Card from '~/components/Card';
|
||
import Chip from '~/components/Chip';
|
||
import Link from '~/components/Link';
|
||
import StatusCircle from '~/components/StatusCircle';
|
||
import Tooltip from '~/components/Tooltip';
|
||
import type { LoadContext } from '~/server';
|
||
import type { Machine, Route, User } from '~/types';
|
||
import cn from '~/utils/cn';
|
||
import { getOSInfo, getTSVersion } from '~/utils/host-info';
|
||
import { mapNodes } from '~/utils/node-info';
|
||
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
|
||
import MenuOptions from './components/menu';
|
||
import Routes from './dialogs/routes';
|
||
import { machineAction } from './machine-actions';
|
||
|
||
export async function loader({
|
||
request,
|
||
params,
|
||
context,
|
||
}: LoaderFunctionArgs<LoadContext>) {
|
||
const session = await context.sessions.auth(request);
|
||
if (!params.id) {
|
||
throw new Error('No machine ID provided');
|
||
}
|
||
|
||
let magic: string | undefined;
|
||
if (context.hs.readable()) {
|
||
if (context.hs.c?.dns.magic_dns) {
|
||
magic = context.hs.c.dns.base_domain;
|
||
}
|
||
}
|
||
|
||
const [machine, { routes }, { users }] = await Promise.all([
|
||
context.client.get<{ node: Machine }>(
|
||
`v1/node/${params.id}`,
|
||
session.get('api_key')!,
|
||
),
|
||
context.client.get<{ routes: Route[] }>(
|
||
'v1/routes',
|
||
session.get('api_key')!,
|
||
),
|
||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||
]);
|
||
|
||
const [node] = mapNodes([machine.node], routes);
|
||
const lookup = await context.agents?.lookup([node.nodeKey]);
|
||
|
||
return {
|
||
node,
|
||
users,
|
||
magic,
|
||
agent: context.agents?.agentID(),
|
||
stats: lookup?.[node.nodeKey],
|
||
};
|
||
}
|
||
|
||
export async function action(request: ActionFunctionArgs) {
|
||
return machineAction(request);
|
||
}
|
||
|
||
export default function Page() {
|
||
const { node, magic, users, agent, stats } = useLoaderData<typeof loader>();
|
||
const [showRouting, setShowRouting] = useState(false);
|
||
|
||
const uiTags = useMemo(() => {
|
||
const tags = uiTagsForNode(node, agent === node.nodeKey);
|
||
return tags;
|
||
}, [node, agent]);
|
||
|
||
return (
|
||
<div>
|
||
<p className="mb-8 text-md">
|
||
<RemixLink to="/machines" className="font-medium">
|
||
All Machines
|
||
</RemixLink>
|
||
<span className="mx-2">/</span>
|
||
{node.givenName}
|
||
</p>
|
||
<div
|
||
className={cn(
|
||
'flex justify-between items-center pb-2',
|
||
'border-b border-headplane-100 dark:border-headplane-800',
|
||
)}
|
||
>
|
||
<span className="flex items-baseline gap-x-4 text-sm">
|
||
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||
<StatusCircle isOnline={node.online} className="w-4 h-4" />
|
||
</span>
|
||
<MenuOptions isFullButton node={node} users={users} magic={magic} />
|
||
</div>
|
||
<div className="flex gap-1 mb-4">
|
||
<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>
|
||
<Info className="p-1" />
|
||
<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">
|
||
<UserCircle />
|
||
{node.user.name}
|
||
</div>
|
||
</div>
|
||
<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)}
|
||
{node.validTags.map((tag) => (
|
||
<Chip key={tag} text={tag} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<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>
|
||
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>
|
||
<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>
|
||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||
Approved
|
||
<Tooltip>
|
||
<Info className="w-3.5 h-3.5" />
|
||
<Tooltip.Body>
|
||
Traffic to these routes are being routed through this machine.
|
||
</Tooltip.Body>
|
||
</Tooltip>
|
||
</span>
|
||
<div className="mt-1">
|
||
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
|
||
<span className="opacity-50">—</span>
|
||
) : (
|
||
<ul className="leading-normal">
|
||
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
||
<li key={route.id}>{route.prefix}</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>
|
||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||
Awaiting Approval
|
||
<Tooltip>
|
||
<Info className="w-3.5 h-3.5" />
|
||
<Tooltip.Body>
|
||
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">
|
||
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
|
||
<span className="opacity-50">—</span>
|
||
) : (
|
||
<ul className="leading-normal">
|
||
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
||
<li key={route.id}>{route.prefix}</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>
|
||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||
Exit Node
|
||
<Tooltip>
|
||
<Info className="w-3.5 h-3.5" />
|
||
<Tooltip.Body>
|
||
Whether this machine can act as an exit node for your tailnet.
|
||
</Tooltip.Body>
|
||
</Tooltip>
|
||
</span>
|
||
<div className="mt-1">
|
||
{node.customRouting.exitRoutes.length === 0 ? (
|
||
<span className="opacity-50">—</span>
|
||
) : node.customRouting.exitApproved ? (
|
||
<span className="flex items-center gap-x-1">
|
||
<CheckCircle className="w-3.5 h-3.5 text-green-700" />
|
||
Allowed
|
||
</span>
|
||
) : (
|
||
<span className="flex items-center gap-x-1">
|
||
<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 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">
|
||
<Attribute name="Creator" value={node.user.name} />
|
||
<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>
|
||
<Attribute
|
||
isCopyable
|
||
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
||
name="Tailscale IPv4"
|
||
value={getIpv4Address(node.ipAddresses)}
|
||
/>
|
||
<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>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 '—';
|
||
}
|