feat: redo machine tagging system

This commit is contained in:
Aarnav Tale 2025-04-18 14:40:56 -04:00
parent 6ace244401
commit 0c7e2e49f5
No known key found for this signature in database
13 changed files with 379 additions and 274 deletions

View File

@ -11,6 +11,8 @@
- Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)). - Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)).
- The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)). - The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)).
- The machine actions backend has been reworked to better handle errors and provide more information to the user (closes [#185](https://github.com/tale/headplane/issues/185)). - The machine actions backend has been reworked to better handle errors and provide more information to the user (closes [#185](https://github.com/tale/headplane/issues/185)).
- Machine tags now show states when waiting for subnet or exit node approval and when expiry is disabled.
- Expiry status on the UI was incorrectly showing as never due to changes in the Headscale API.
### 0.5.10 (April 4, 2025) ### 0.5.10 (April 4, 2025)
- Fix an issue where other preferences to skip onboarding affected every user. - Fix an issue where other preferences to skip onboarding affected every user.

View File

@ -17,10 +17,10 @@ export default function Chip({
return ( return (
<span <span
className={cn( className={cn(
'text-xs px-2 py-0.5 rounded-full', 'h-5 text-xs py-0.5 px-1 rounded-md',
'text-headplane-700 dark:text-headplane-100', 'text-headplane-700 dark:text-headplane-100',
'bg-headplane-100 dark:bg-headplane-700', 'bg-headplane-100 dark:bg-headplane-700',
leftIcon || rightIcon ? 'inline-flex items-center gap-x-1' : '', 'inline-flex items-center gap-x-1',
className, className,
)} )}
> >

View File

@ -0,0 +1,32 @@
import { Info } from 'lucide-react';
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export interface ExitNodeTagProps {
isEnabled?: boolean;
}
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
return (
<Tooltip>
<Chip
text="Exit Node"
className={cn(
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
)}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine is acting as an exit node.</>
) : (
<>
This machine is requesting to be used as an exit node. Review this
from the "Edit route settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}

View File

@ -0,0 +1,42 @@
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export interface ExpiryTagProps {
variant: 'expired' | 'no-expiry';
expiry?: string;
}
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return (
<Tooltip>
<Chip
text={
variant === 'expired'
? `Expired ${formatter.format(new Date(expiry!))}`
: 'No expiry'
}
className="bg-headplane-200 text-headplane-800 dark:bg-headplane-800 dark:text-headplane-200"
/>
<Tooltip.Body>
{variant === 'expired' ? (
<>
This machine is expired and will not be able to connect to the
network. Re-authenticate with Tailscale on the machine to re-enable
it.
</>
) : (
<>
This machine has key expiry disabled and will never need to
re-authenticate.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}

View File

@ -0,0 +1,20 @@
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export function HeadplaneAgentTag() {
return (
<Tooltip>
<Chip
text="Headplane Agent"
className={cn(
'bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300',
)}
/>
<Tooltip.Body>
This machine is running the Headplane agent, which allows it to provide
host information in the web UI.
</Tooltip.Body>
</Tooltip>
);
}

View File

@ -0,0 +1,32 @@
import { Info } from 'lucide-react';
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export interface SubnetTagProps {
isEnabled?: boolean;
}
export function SubnetTag({ isEnabled }: SubnetTagProps) {
return (
<Tooltip>
<Chip
text="Subnets"
className={cn(
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
)}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine advertises subnet routes.</>
) : (
<>
This machine has unadvertised subnet routes. Review this from the
"Edit route settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}

View File

@ -4,105 +4,54 @@ import { Link } from 'react-router';
import Chip from '~/components/Chip'; import Chip from '~/components/Chip';
import Menu from '~/components/Menu'; import Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle'; import StatusCircle from '~/components/StatusCircle';
import type { HostInfo, Machine, Route, User } from '~/types'; import type { User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import * as hinfo from '~/utils/host-info'; import * as hinfo from '~/utils/host-info';
import { ExitNodeTag } from '~/components/tags/ExitNode';
import { ExpiryTag } from '~/components/tags/Expiry';
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
import { SubnetTag } from '~/components/tags/Subnet';
import { PopulatedNode } from '~/utils/node-info';
import toast from '~/utils/toast'; import toast from '~/utils/toast';
import MenuOptions from './menu'; import MenuOptions from './menu';
interface Props { interface Props {
machine: Machine; node: PopulatedNode;
routes: Route[];
users: User[]; users: User[];
isAgent?: boolean; isAgent?: boolean;
magic?: string; magic?: string;
stats?: HostInfo;
isDisabled?: boolean; isDisabled?: boolean;
} }
export default function MachineRow({ export default function MachineRow({
machine, node,
routes,
users, users,
isAgent, isAgent,
magic, magic,
stats,
isDisabled, isDisabled,
}: Props) { }: Props) {
const expired = const uiTags = useMemo(() => {
machine.expiry === '0001-01-01 00:00:00' || const tags = uiTagsForNode(node, isAgent);
machine.expiry === '0001-01-01T00:00:00Z' || return tags;
machine.expiry === null }, [node, isAgent]);
? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
if (expired) {
tags.unshift('Expired');
}
const prefix = magic?.startsWith('[user]')
? magic.replace('[user]', machine.user.name)
: magic;
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce<{
exit: Route[];
subnetApproved: Route[];
subnet: 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');
}
if (isAgent) {
tags.unshift('Headplane Agent');
}
const ipOptions = useMemo(() => { const ipOptions = useMemo(() => {
if (magic) { if (magic) {
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`]; return [...node.ipAddresses, `${node.givenName}.${magic}`];
} }
return machine.ipAddresses; return node.ipAddresses;
}, [magic, machine.ipAddresses]); }, [magic, node.ipAddresses]);
return ( return (
<tr <tr
key={machine.id} key={node.id}
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
> >
<td className="pl-0.5 py-2 focus-within:ring"> <td className="pl-0.5 py-2 focus-within:ring">
<Link <Link
to={`/machines/${machine.id}`} to={`/machines/${node.id}`}
className={cn('group/link h-full focus:outline-none')} className={cn('group/link h-full focus:outline-none')}
> >
<p <p
@ -112,11 +61,12 @@ export default function MachineRow({
'group-hover/link:dark:text-blue-400', 'group-hover/link:dark:text-blue-400',
)} )}
> >
{machine.givenName} {node.givenName}
</p> </p>
<p className="text-sm font-mono opacity-50">{machine.name}</p> <p className="text-sm font-mono opacity-50">{node.name}</p>
<div className="flex gap-1 mt-1"> <div className="flex gap-1 mt-1">
{tags.map((tag) => ( {mapTagsToComponents(node, uiTags)}
{node.validTags.map((tag) => (
<Chip key={tag} text={tag} /> <Chip key={tag} text={tag} />
))} ))}
</div> </div>
@ -124,7 +74,7 @@ export default function MachineRow({
</td> </td>
<td className="py-2"> <td className="py-2">
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
{machine.ipAddresses[0]} {node.ipAddresses[0]}
<Menu placement="bottom end"> <Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses"> <Menu.IconButton className="bg-transparent" label="IP Addresses">
<ChevronDownIcon className="w-4 h-4" /> <ChevronDownIcon className="w-4 h-4" />
@ -157,11 +107,13 @@ export default function MachineRow({
{/* We pass undefined when agents are not enabled */} {/* We pass undefined when agents are not enabled */}
{isAgent !== undefined ? ( {isAgent !== undefined ? (
<td className="py-2"> <td className="py-2">
{stats !== undefined ? ( {node.hostInfo !== undefined ? (
<> <>
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p> <p className="leading-snug">
{hinfo.getTSVersion(node.hostInfo)}
</p>
<p className="text-sm opacity-50 max-w-48 truncate"> <p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(stats)} {hinfo.getOSInfo(node.hostInfo)}
</p> </p>
</> </>
) : ( ) : (
@ -177,20 +129,19 @@ export default function MachineRow({
)} )}
> >
<StatusCircle <StatusCircle
isOnline={machine.online && !expired} isOnline={node.online && !node.expired}
className="w-4 h-4" className="w-4 h-4"
/> />
<p suppressHydrationWarning> <p suppressHydrationWarning>
{machine.online && !expired {node.online && !node.expired
? 'Connected' ? 'Connected'
: new Date(machine.lastSeen).toLocaleString()} : new Date(node.lastSeen).toLocaleString()}
</p> </p>
</span> </span>
</td> </td>
<td className="py-2 pr-0.5"> <td className="py-2 pr-0.5">
<MenuOptions <MenuOptions
machine={machine} node={node}
routes={routes}
users={users} users={users}
magic={magic} magic={magic}
isDisabled={isDisabled} isDisabled={isDisabled}
@ -199,3 +150,64 @@ export default function MachineRow({
</tr> </tr>
); );
} }
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
const uiTags: string[] = [];
if (node.expired) {
uiTags.push('expired');
}
if (node.expiry === null) {
uiTags.push('no-expiry');
}
if (node.customRouting.exitRoutes.length > 0) {
if (node.customRouting.exitApproved) {
uiTags.push('exit-approved');
} else {
uiTags.push('exit-waiting');
}
}
if (node.customRouting.subnetWaitingRoutes.length > 0) {
uiTags.push('subnet-waiting');
} else if (node.customRouting.subnetApprovedRoutes.length > 0) {
uiTags.push('subnet-approved');
}
if (isAgent === true) {
uiTags.push('headplane-agent');
}
return uiTags;
}
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
return uiTags.map((tag) => {
switch (tag) {
case 'exit-approved':
case 'exit-waiting':
return <ExitNodeTag key={tag} isEnabled={tag === 'exit-approved'} />;
case 'subnet-approved':
case 'subnet-waiting':
return <SubnetTag key={tag} isEnabled={tag === 'subnet-approved'} />;
case 'expired':
case 'no-expiry':
return (
<ExpiryTag
key={tag}
variant={tag}
expiry={node.expiry ?? undefined}
/>
);
case 'headplane-agent':
return <HeadplaneAgentTag />;
default:
return;
}
});
}

View File

@ -1,8 +1,9 @@
import { Cog, Ellipsis } from 'lucide-react'; import { Cog, Ellipsis } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import Menu from '~/components/Menu'; import Menu from '~/components/Menu';
import type { Machine, Route, User } from '~/types'; import type { User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import { PopulatedNode } from '~/utils/node-info';
import Delete from '../dialogs/delete'; import Delete from '../dialogs/delete';
import Expire from '../dialogs/expire'; import Expire from '../dialogs/expire';
import Move from '../dialogs/move'; import Move from '../dialogs/move';
@ -11,8 +12,7 @@ import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags'; import Tags from '../dialogs/tags';
interface MenuProps { interface MenuProps {
machine: Machine; node: PopulatedNode;
routes: Route[];
users: User[]; users: User[];
magic?: string; magic?: string;
isFullButton?: boolean; isFullButton?: boolean;
@ -22,27 +22,18 @@ interface MenuProps {
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null; type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
export default function MachineMenu({ export default function MachineMenu({
machine, node,
routes,
magic, magic,
users, users,
isFullButton, isFullButton,
isDisabled, isDisabled,
}: MenuProps) { }: MenuProps) {
const [modal, setModal] = useState<Modal>(null); const [modal, setModal] = useState<Modal>(null);
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();
return ( return (
<> <>
{modal === 'remove' && ( {modal === 'remove' && (
<Delete <Delete
machine={machine} machine={node}
isOpen={modal === 'remove'} isOpen={modal === 'remove'}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
@ -51,7 +42,7 @@ export default function MachineMenu({
)} )}
{modal === 'move' && ( {modal === 'move' && (
<Move <Move
machine={machine} machine={node}
users={users} users={users}
isOpen={modal === 'move'} isOpen={modal === 'move'}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
@ -61,7 +52,7 @@ export default function MachineMenu({
)} )}
{modal === 'rename' && ( {modal === 'rename' && (
<Rename <Rename
machine={machine} machine={node}
magic={magic} magic={magic}
isOpen={modal === 'rename'} isOpen={modal === 'rename'}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
@ -71,8 +62,7 @@ export default function MachineMenu({
)} )}
{modal === 'routes' && ( {modal === 'routes' && (
<Routes <Routes
machine={machine} node={node}
routes={routes}
isOpen={modal === 'routes'} isOpen={modal === 'routes'}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
@ -81,16 +71,16 @@ export default function MachineMenu({
)} )}
{modal === 'tags' && ( {modal === 'tags' && (
<Tags <Tags
machine={machine} machine={node}
isOpen={modal === 'tags'} isOpen={modal === 'tags'}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
}} }}
/> />
)} )}
{expired && modal === 'expire' ? undefined : ( {node.expired && modal === 'expire' ? undefined : (
<Expire <Expire
machine={machine} machine={node}
isOpen={modal === 'expire'} isOpen={modal === 'expire'}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
@ -116,7 +106,10 @@ export default function MachineMenu({
<Ellipsis className="h-5" /> <Ellipsis className="h-5" />
</Menu.IconButton> </Menu.IconButton>
)} )}
<Menu.Panel onAction={(key) => setModal(key as Modal)}> <Menu.Panel
onAction={(key) => setModal(key as Modal)}
disabledKeys={node.expired ? ['expire'] : []}
>
<Menu.Section> <Menu.Section>
<Menu.Item key="rename">Edit machine name</Menu.Item> <Menu.Item key="rename">Edit machine name</Menu.Item>
<Menu.Item key="routes">Edit route settings</Menu.Item> <Menu.Item key="routes">Edit route settings</Menu.Item>
@ -124,13 +117,9 @@ export default function MachineMenu({
<Menu.Item key="move">Change owner</Menu.Item> <Menu.Item key="move">Change owner</Menu.Item>
</Menu.Section> </Menu.Section>
<Menu.Section> <Menu.Section>
{expired ? ( <Menu.Item key="expire" textValue="Expire">
<></> <p className="text-red-500 dark:text-red-400">Expire</p>
) : ( </Menu.Item>
<Menu.Item key="expire" textValue="Expire">
<p className="text-red-500 dark:text-red-400">Expire</p>
</Menu.Item>
)}
<Menu.Item key="remove" textValue="Remove"> <Menu.Item key="remove" textValue="Remove">
<p className="text-red-500 dark:text-red-400">Remove</p> <p className="text-red-500 dark:text-red-400">Remove</p>
</Menu.Item> </Menu.Item>

View File

@ -1,55 +1,30 @@
import { GlobeLock, RouteOff } from 'lucide-react'; import { GlobeLock, RouteOff } from 'lucide-react';
import { useMemo } from 'react';
import { useFetcher } from 'react-router'; import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog'; import Dialog from '~/components/Dialog';
import Link from '~/components/Link'; import Link from '~/components/Link';
import Switch from '~/components/Switch'; import Switch from '~/components/Switch';
import TableList from '~/components/TableList'; import TableList from '~/components/TableList';
import type { Machine, Route } from '~/types'; import { PopulatedNode } from '~/utils/node-info';
import cn from '~/utils/cn';
interface RoutesProps { interface RoutesProps {
machine: Machine; node: PopulatedNode;
routes: Route[];
isOpen: boolean; isOpen: boolean;
setIsOpen: (isOpen: boolean) => void; setIsOpen: (isOpen: boolean) => void;
} }
// TODO: Support deleting routes // TODO: Support deleting routes
export default function Routes({ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
machine,
routes,
isOpen,
setIsOpen,
}: RoutesProps) {
const fetcher = useFetcher(); const fetcher = useFetcher();
// This is much easier with Object.groupBy but it's too new for us const subnets = [
const { exit, subnet } = routes.reduce<{ ...node.customRouting.subnetApprovedRoutes,
exit: Route[]; ...node.customRouting.subnetWaitingRoutes,
subnet: Route[]; ];
}>(
(acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route);
return acc;
}
acc.subnet.push(route);
return acc;
},
{ exit: [], subnet: [] },
);
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled;
}, [exit]);
return ( return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}> <Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="unactionable"> <Dialog.Panel variant="unactionable">
<Dialog.Title>Edit route settings of {machine.givenName}</Dialog.Title> <Dialog.Title>Edit route settings of {node.givenName}</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text> <Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text> <Dialog.Text>
Connect to devices you can&apos;t install Tailscale on by advertising Connect to devices you can&apos;t install Tailscale on by advertising
@ -62,7 +37,7 @@ export default function Routes({
</Link> </Link>
</Dialog.Text> </Dialog.Text>
<TableList className="mt-4"> <TableList className="mt-4">
{subnet.length === 0 ? ( {subnets.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70"> <TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<RouteOff /> <RouteOff />
<p className="font-semibold"> <p className="font-semibold">
@ -70,7 +45,7 @@ export default function Routes({
</p> </p>
</TableList.Item> </TableList.Item>
) : undefined} ) : undefined}
{subnet.map((route) => ( {subnets.map((route) => (
<TableList.Item key={route.id}> <TableList.Item key={route.id}>
<p>{route.prefix}</p> <p>{route.prefix}</p>
<Switch <Switch
@ -79,7 +54,7 @@ export default function Routes({
onChange={(checked) => { onChange={(checked) => {
const form = new FormData(); const form = new FormData();
form.set('action_id', 'update_routes'); form.set('action_id', 'update_routes');
form.set('node_id', machine.id); form.set('node_id', node.id);
form.set('routes', [route.id].join(',')); form.set('routes', [route.id].join(','));
form.set('enabled', String(checked)); form.set('enabled', String(checked));
@ -102,7 +77,7 @@ export default function Routes({
</Link> </Link>
</Dialog.Text> </Dialog.Text>
<TableList className="mt-4"> <TableList className="mt-4">
{exit.length === 0 ? ( {node.customRouting.exitRoutes.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70"> <TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<GlobeLock /> <GlobeLock />
<p className="font-semibold">This machine is not an exit node</p> <p className="font-semibold">This machine is not an exit node</p>
@ -111,13 +86,18 @@ export default function Routes({
<TableList.Item> <TableList.Item>
<p>Use as exit node</p> <p>Use as exit node</p>
<Switch <Switch
defaultSelected={exitEnabled} defaultSelected={node.customRouting.exitApproved}
label="Enabled" label="Enabled"
onChange={(checked) => { onChange={(checked) => {
const form = new FormData(); const form = new FormData();
form.set('action_id', 'update_routes'); form.set('action_id', 'update_routes');
form.set('node_id', machine.id); form.set('node_id', node.id);
form.set('routes', exit.map((route) => route.id).join(',')); form.set(
'routes',
node.customRouting.exitRoutes
.map((route) => route.id)
.join(','),
);
form.set('enabled', String(checked)); form.set('enabled', String(checked));
fetcher.submit(form, { fetcher.submit(form, {

View File

@ -2,6 +2,7 @@ import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { Link as RemixLink, useLoaderData } from 'react-router'; import { Link as RemixLink, useLoaderData } from 'react-router';
import { mapTag } from 'yaml/util';
import Attribute from '~/components/Attribute'; import Attribute from '~/components/Attribute';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Card from '~/components/Card'; import Card from '~/components/Card';
@ -12,6 +13,8 @@ import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import type { Machine, Route, User } from '~/types'; import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import { mapNodes } from '~/utils/node-info';
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
import MenuOptions from './components/menu'; import MenuOptions from './components/menu';
import Routes from './dialogs/routes'; import Routes from './dialogs/routes';
import { machineAction } from './machine-actions'; import { machineAction } from './machine-actions';
@ -33,7 +36,7 @@ export async function loader({
} }
} }
const [machine, routes, users] = await Promise.all([ const [machine, { routes }, { users }] = await Promise.all([
context.client.get<{ node: Machine }>( context.client.get<{ node: Machine }>(
`v1/node/${params.id}`, `v1/node/${params.id}`,
session.get('api_key')!, session.get('api_key')!,
@ -45,16 +48,13 @@ export async function loader({
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!), context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]); ]);
const [node] = mapNodes([machine.node], routes);
return { return {
machine: machine.node, node,
routes: routes.routes.filter((route) => route.node.id === params.id), users,
users: users.users,
magic, magic,
// TODO: Fix agent agent: context.agents?.agentID(),
agent: false,
// agent: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()].includes(
// machine.node.id,
// ),
}; };
} }
@ -63,62 +63,13 @@ export async function action(request: ActionFunctionArgs) {
} }
export default function Page() { export default function Page() {
const { machine, magic, routes, users, agent } = const { node, magic, users, agent } = useLoaderData<typeof loader>();
useLoaderData<typeof loader>();
const [showRouting, setShowRouting] = useState(false); const [showRouting, setShowRouting] = useState(false);
const expired = const uiTags = useMemo(() => {
machine.expiry === '0001-01-01 00:00:00' || const tags = uiTagsForNode(node, agent === node.nodeKey);
machine.expiry === '0001-01-01T00:00:00Z' || return tags;
machine.expiry === null }, [node, agent]);
? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
if (expired) {
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[];
}>(
(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 ( return (
<div> <div>
@ -127,7 +78,7 @@ export default function Page() {
All Machines All Machines
</RemixLink> </RemixLink>
<span className="mx-2">/</span> <span className="mx-2">/</span>
{machine.givenName} {node.givenName}
</p> </p>
<div <div
className={cn( className={cn(
@ -136,17 +87,10 @@ export default function Page() {
)} )}
> >
<span className="flex items-baseline gap-x-4 text-sm"> <span className="flex items-baseline gap-x-4 text-sm">
<h1 className="text-2xl font-medium">{machine.givenName}</h1> <h1 className="text-2xl font-medium">{node.givenName}</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" /> <StatusCircle isOnline={node.online} className="w-4 h-4" />
</span> </span>
<MenuOptions isFullButton node={node} users={users} magic={magic} />
<MenuOptions
isFullButton
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
</div> </div>
<div className="flex gap-1 mb-4"> <div className="flex gap-1 mb-4">
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4"> <div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
@ -161,29 +105,23 @@ export default function Page() {
</span> </span>
<div className="flex items-center gap-x-2.5 mt-1"> <div className="flex items-center gap-x-2.5 mt-1">
<UserCircle /> <UserCircle />
{machine.user.name} {node.user.name}
</div> </div>
</div> </div>
{tags.length > 0 ? ( <div className="p-2 pl-4">
<div className="p-2 pl-4"> <p className="text-sm text-headplane-600 dark:text-headplane-300">
<p className="text-sm text-headplane-600 dark:text-headplane-300"> Status
Status </p>
</p> <div className="flex gap-1 mt-1 mb-8">
<div className="flex gap-1 mt-1 mb-8"> {mapTagsToComponents(node, uiTags)}
{tags.map((tag) => ( {node.validTags.map((tag) => (
<Chip key={tag} text={tag} /> <Chip key={tag} text={tag} />
))} ))}
</div>
</div> </div>
) : undefined} </div>
</div> </div>
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2> <h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
<Routes <Routes node={node} isOpen={showRouting} setIsOpen={setShowRouting} />
machine={machine}
routes={routes}
isOpen={showRouting}
setIsOpen={setShowRouting}
/>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<p> <p>
Subnets let you expose physical network routes onto Tailscale.{' '} Subnets let you expose physical network routes onto Tailscale.{' '}
@ -214,11 +152,11 @@ export default function Page() {
</Tooltip> </Tooltip>
</span> </span>
<div className="mt-1"> <div className="mt-1">
{subnetApproved.length === 0 ? ( {node.customRouting.subnetApprovedRoutes.length === 0 ? (
<span className="opacity-50"></span> <span className="opacity-50"></span>
) : ( ) : (
<ul className="leading-normal"> <ul className="leading-normal">
{subnetApproved.map((route) => ( {node.customRouting.subnetApprovedRoutes.map((route) => (
<li key={route.id}>{route.prefix}</li> <li key={route.id}>{route.prefix}</li>
))} ))}
</ul> </ul>
@ -246,11 +184,11 @@ export default function Page() {
</Tooltip> </Tooltip>
</span> </span>
<div className="mt-1"> <div className="mt-1">
{subnet.length === 0 ? ( {node.customRouting.subnetWaitingRoutes.length === 0 ? (
<span className="opacity-50"></span> <span className="opacity-50"></span>
) : ( ) : (
<ul className="leading-normal"> <ul className="leading-normal">
{subnet.map((route) => ( {node.customRouting.subnetWaitingRoutes.map((route) => (
<li key={route.id}>{route.prefix}</li> <li key={route.id}>{route.prefix}</li>
))} ))}
</ul> </ul>
@ -277,9 +215,9 @@ export default function Page() {
</Tooltip> </Tooltip>
</span> </span>
<div className="mt-1"> <div className="mt-1">
{exit.length === 0 ? ( {node.customRouting.exitRoutes.length === 0 ? (
<span className="opacity-50"></span> <span className="opacity-50"></span>
) : exitEnabled ? ( ) : node.customRouting.exitApproved ? (
<span className="flex items-center gap-x-1"> <span className="flex items-center gap-x-1">
<CheckCircle className="w-3.5 h-3.5 text-green-700" /> <CheckCircle className="w-3.5 h-3.5 text-green-700" />
Allowed Allowed
@ -304,31 +242,35 @@ export default function Page() {
</Card> </Card>
<h2 className="text-xl font-medium mb-4">Machine Details</h2> <h2 className="text-xl font-medium mb-4">Machine Details</h2>
<Card variant="flat" className="w-full max-w-full"> <Card variant="flat" className="w-full max-w-full">
<Attribute name="Creator" value={machine.user.name} /> <Attribute name="Creator" value={node.user.name} />
<Attribute name="Node ID" value={machine.id} /> <Attribute name="Node ID" value={node.id} />
<Attribute name="Node Name" value={machine.givenName} /> <Attribute name="Node Name" value={node.givenName} />
<Attribute name="Hostname" value={machine.name} /> <Attribute name="Hostname" value={node.name} />
<Attribute isCopyable name="Node Key" value={machine.nodeKey} /> <Attribute isCopyable name="Node Key" value={node.nodeKey} />
<Attribute <Attribute
suppressHydrationWarning suppressHydrationWarning
name="Created" name="Created"
value={new Date(machine.createdAt).toLocaleString()} value={new Date(node.createdAt).toLocaleString()}
/> />
<Attribute <Attribute
suppressHydrationWarning suppressHydrationWarning
name="Last Seen" name="Last Seen"
value={new Date(machine.lastSeen).toLocaleString()} value={new Date(node.lastSeen).toLocaleString()}
/> />
<Attribute <Attribute
suppressHydrationWarning suppressHydrationWarning
name="Expiry" name="Expiry"
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'} value={
node.expiry !== null
? new Date(node.expiry).toLocaleString()
: 'Never'
}
/> />
{magic ? ( {magic ? (
<Attribute <Attribute
isCopyable isCopyable
name="Domain" name="Domain"
value={`${machine.givenName}.${magic}`} value={`${node.givenName}.${magic}`}
/> />
) : undefined} ) : undefined}
</Card> </Card>

View File

@ -9,6 +9,7 @@ import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import type { Machine, Route, User } from '~/types'; import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import { mapNodes } from '~/utils/node-info';
import MachineRow from './components/machine-row'; import MachineRow from './components/machine-row';
import NewMachine from './dialogs/new'; import NewMachine from './dialogs/new';
import { machineAction } from './machine-actions'; import { machineAction } from './machine-actions';
@ -40,7 +41,7 @@ export async function loader({
Capabilities.write_machines, Capabilities.write_machines,
); );
const [machines, routes, users] = await Promise.all([ const [{ nodes }, { routes }, { users }] = await Promise.all([
context.client.get<{ nodes: Machine[] }>( context.client.get<{ nodes: Machine[] }>(
'v1/node', 'v1/node',
session.get('api_key')!, session.get('api_key')!,
@ -59,17 +60,18 @@ export async function loader({
} }
} }
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, routes, stats);
return { return {
nodes: machines.nodes, populatedNodes,
routes: routes.routes, nodes,
users: users.users, routes,
users,
magic, magic,
server: context.config.headscale.url, server: context.config.headscale.url,
publicServer: context.config.headscale.public_url, publicServer: context.config.headscale.public_url,
agent: context.agents?.agentID(), agent: context.agents?.agentID(),
stats: await context.agents?.lookup(
machines.nodes.map((node) => node.nodeKey),
),
writable: writablePermission, writable: writablePermission,
preAuth: await context.sessions.check( preAuth: await context.sessions.check(
request, request,
@ -143,19 +145,13 @@ export default function Page() {
'border-t border-headplane-100 dark:border-headplane-800', 'border-t border-headplane-100 dark:border-headplane-800',
)} )}
> >
{data.nodes.map((machine) => ( {data.populatedNodes.map((machine) => (
<MachineRow <MachineRow
key={machine.id} key={machine.id}
machine={machine} node={machine}
routes={data.routes.filter(
(route) => route.node.id === machine.id,
)}
users={data.users} users={data.users}
magic={data.magic} magic={data.magic}
// If we pass undefined, the column will not be rendered
// This is useful for when there are no agents configured
isAgent={data.agent === machine.nodeKey} isAgent={data.agent === machine.nodeKey}
stats={data.stats?.[machine.nodeKey]}
isDisabled={ isDisabled={
data.writable data.writable
? false // If the user has write permissions, they can edit all machines ? false // If the user has write permissions, they can edit all machines

View File

@ -10,7 +10,7 @@ export interface Machine {
user: User; user: User;
lastSeen: string; lastSeen: string;
expiry: string; expiry: string | null;
preAuthKey?: unknown; // TODO preAuthKey?: unknown; // TODO

58
app/utils/node-info.ts Normal file
View File

@ -0,0 +1,58 @@
import { HostInfo, Machine, Route } from '~/types';
export interface PopulatedNode extends Machine {
routes: Route[];
hostInfo?: HostInfo;
expired: boolean;
customRouting: {
exitRoutes: Route[];
exitApproved: boolean;
subnetApprovedRoutes: Route[];
subnetWaitingRoutes: Route[];
};
}
export function mapNodes(
nodes: Machine[],
routes: Route[],
stats?: Record<string, HostInfo> | undefined,
): PopulatedNode[] {
return nodes.map((node) => {
const nodeRoutes = routes.filter((route) => route.node.id === node.id);
const customRouting = nodeRoutes.reduce<PopulatedNode['customRouting']>(
(acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exitRoutes.push(route);
if (route.enabled) {
acc.exitApproved = true;
}
} else {
if (route.enabled) {
acc.subnetApprovedRoutes.push(route);
} else {
acc.subnetWaitingRoutes.push(route);
}
}
return acc;
},
{
exitRoutes: [],
exitApproved: false,
subnetApprovedRoutes: [],
subnetWaitingRoutes: [],
},
);
return {
...node,
routes: nodeRoutes,
hostInfo: stats?.[node.nodeKey],
customRouting,
expired:
node.expiry === null
? false
: new Date(node.expiry).getTime() < Date.now(),
};
});
}