headplane/app/routes/machines/machine.tsx

326 lines
9 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';
2024-12-31 10:31:50 +05:30
import type { Machine, Route, User } from '~/types';
2025-01-28 17:46:16 -05:00
import cn from '~/utils/cn';
2025-02-19 18:09:42 -05:00
import { hs_getConfig } from '~/utils/config/loader';
2024-12-31 10:30:14 +05:30
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { hp_getSingleton } from '~server/context/global';
2024-12-31 10:30:14 +05:30
import { menuAction } from './action';
import MenuOptions from './components/menu';
import Routes from './dialogs/routes';
export async function loader({ request, params }: LoaderFunctionArgs) {
2024-12-31 10:30:14 +05:30
const session = await getSession(request.headers.get('Cookie'));
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
}
2025-02-13 12:35:12 -05:00
const { mode, config } = hs_getConfig();
2024-12-31 10:30:14 +05:30
let magic: string | undefined;
2025-02-13 12:35:12 -05:00
if (mode !== 'no') {
if (config.dns.magic_dns) {
2024-12-31 10:30:14 +05:30
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')!),
2024-12-31 10:30:14 +05:30
]);
return {
machine: machine.node,
2024-12-31 10:30:14 +05:30
routes: routes.routes.filter((route) => route.node.id === params.id),
users: users.users,
magic,
agent: [...hp_getSingleton('ws_agents').keys()].includes(machine.node.id),
2024-12-31 10:30:14 +05:30
};
2024-03-26 09:28:52 -04:00
}
export async function action({ request }: ActionFunctionArgs) {
2024-12-31 10:30:14 +05:30
return menuAction(request);
}
2024-03-26 09:28:52 -04:00
export default function Page() {
const { machine, magic, routes, users, agent } =
useLoaderData<typeof loader>();
const [showRouting, setShowRouting] = useState(false);
2024-03-26 09:28:52 -04:00
2024-12-31 10:30:14 +05:30
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();
2025-01-23 11:16:56 -05:00
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
if (expired) {
2024-12-31 10:30:14 +05:30
tags.unshift('Expired');
}
if (agent) {
tags.unshift('Headplane Agent');
}
// 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[];
}>(
2024-12-31 10:30:14 +05:30
(acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route);
return acc;
}
2024-12-31 10:30:14 +05:30
if (route.enabled) {
acc.subnetApproved.push(route);
return acc;
}
2024-12-31 10:30:14 +05:30
acc.subnet.push(route);
return acc;
},
{ exit: [], subnetApproved: [], subnet: [] },
);
const exitEnabled = useMemo(() => {
2024-12-31 10:30:14 +05:30
if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled;
}, [exit]);
if (exitEnabled) {
2024-12-31 10:30:14 +05:30
tags.unshift('Exit Node');
}
if (subnetApproved.length > 0) {
2024-12-31 10:30:14 +05:30
tags.unshift('Subnets');
}
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>
{machine.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">
2024-12-31 10:30:14 +05:30
<h1 className="text-2xl font-medium">{machine.givenName}</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
</span>
<MenuOptions
2025-01-28 16:02:47 -05:00
isFullButton
machine={machine}
routes={routes}
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 />
{machine.user.name}
</div>
</div>
{tags.length > 0 ? (
<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">
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</div>
) : undefined}
</div>
2024-12-31 10:30:14 +05:30
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
<Routes
machine={machine}
routes={routes}
2025-01-28 16:02:47 -05:00
isOpen={showRouting}
setIsOpen={setShowRouting}
/>
<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">
{subnetApproved.length === 0 ? (
2025-02-04 17:21:03 -05:00
<span className="opacity-50"></span>
) : (
<ul className="leading-normal">
2024-12-31 10:30:14 +05:30
{subnetApproved.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>
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">
{subnet.length === 0 ? (
2025-02-04 17:21:03 -05:00
<span className="opacity-50"></span>
) : (
<ul className="leading-normal">
2024-12-31 10:30:14 +05:30
{subnet.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>
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">
{exit.length === 0 ? (
2025-02-04 17:21:03 -05:00
<span className="opacity-50"></span>
) : exitEnabled ? (
<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>
2024-12-31 10:30:14 +05:30
<h2 className="text-xl font-medium mb-4">Machine Details</h2>
<Card variant="flat" className="w-full max-w-full">
<Attribute name="Creator" value={machine.user.name} />
<Attribute name="Node ID" value={machine.id} />
<Attribute name="Node Name" value={machine.givenName} />
<Attribute name="Hostname" value={machine.name} />
2024-12-31 10:30:14 +05:30
<Attribute isCopyable name="Node Key" value={machine.nodeKey} />
2024-03-26 09:28:52 -04:00
<Attribute
name="Created"
value={new Date(machine.createdAt).toLocaleString()}
2024-03-26 09:28:52 -04:00
/>
<Attribute
name="Last Seen"
value={new Date(machine.lastSeen).toLocaleString()}
2024-03-26 09:28:52 -04:00
/>
<Attribute
name="Expiry"
2024-12-31 10:30:14 +05:30
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
2024-03-26 09:28:52 -04:00
/>
2024-12-31 10:30:14 +05:30
{magic ? (
<Attribute
isCopyable
name="Domain"
value={`${machine.givenName}.${magic}`}
/>
) : undefined}
</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
}