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)).
- 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)).
- 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)
- Fix an issue where other preferences to skip onboarding affected every user.

View File

@ -17,10 +17,10 @@ export default function Chip({
return (
<span
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',
'bg-headplane-100 dark:bg-headplane-700',
leftIcon || rightIcon ? 'inline-flex items-center gap-x-1' : '',
'inline-flex items-center gap-x-1',
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 Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle';
import type { HostInfo, Machine, Route, User } from '~/types';
import type { User } from '~/types';
import cn from '~/utils/cn';
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 MenuOptions from './menu';
interface Props {
machine: Machine;
routes: Route[];
node: PopulatedNode;
users: User[];
isAgent?: boolean;
magic?: string;
stats?: HostInfo;
isDisabled?: boolean;
}
export default function MachineRow({
machine,
routes,
node,
users,
isAgent,
magic,
stats,
isDisabled,
}: Props) {
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 = [...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 uiTags = useMemo(() => {
const tags = uiTagsForNode(node, isAgent);
return tags;
}, [node, isAgent]);
const ipOptions = useMemo(() => {
if (magic) {
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
return [...node.ipAddresses, `${node.givenName}.${magic}`];
}
return machine.ipAddresses;
}, [magic, machine.ipAddresses]);
return node.ipAddresses;
}, [magic, node.ipAddresses]);
return (
<tr
key={machine.id}
key={node.id}
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
>
<td className="pl-0.5 py-2 focus-within:ring">
<Link
to={`/machines/${machine.id}`}
to={`/machines/${node.id}`}
className={cn('group/link h-full focus:outline-none')}
>
<p
@ -112,11 +61,12 @@ export default function MachineRow({
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
{node.givenName}
</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">
{tags.map((tag) => (
{mapTagsToComponents(node, uiTags)}
{node.validTags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
@ -124,7 +74,7 @@ export default function MachineRow({
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
{node.ipAddresses[0]}
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<ChevronDownIcon className="w-4 h-4" />
@ -157,11 +107,13 @@ export default function MachineRow({
{/* We pass undefined when agents are not enabled */}
{isAgent !== undefined ? (
<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">
{hinfo.getOSInfo(stats)}
{hinfo.getOSInfo(node.hostInfo)}
</p>
</>
) : (
@ -177,20 +129,19 @@ export default function MachineRow({
)}
>
<StatusCircle
isOnline={machine.online && !expired}
isOnline={node.online && !node.expired}
className="w-4 h-4"
/>
<p suppressHydrationWarning>
{machine.online && !expired
{node.online && !node.expired
? 'Connected'
: new Date(machine.lastSeen).toLocaleString()}
: new Date(node.lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
machine={machine}
routes={routes}
node={node}
users={users}
magic={magic}
isDisabled={isDisabled}
@ -199,3 +150,64 @@ export default function MachineRow({
</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 { useState } from 'react';
import Menu from '~/components/Menu';
import type { Machine, Route, User } from '~/types';
import type { User } from '~/types';
import cn from '~/utils/cn';
import { PopulatedNode } from '~/utils/node-info';
import Delete from '../dialogs/delete';
import Expire from '../dialogs/expire';
import Move from '../dialogs/move';
@ -11,8 +12,7 @@ import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags';
interface MenuProps {
machine: Machine;
routes: Route[];
node: PopulatedNode;
users: User[];
magic?: string;
isFullButton?: boolean;
@ -22,27 +22,18 @@ interface MenuProps {
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
export default function MachineMenu({
machine,
routes,
node,
magic,
users,
isFullButton,
isDisabled,
}: MenuProps) {
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 (
<>
{modal === 'remove' && (
<Delete
machine={machine}
machine={node}
isOpen={modal === 'remove'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
@ -51,7 +42,7 @@ export default function MachineMenu({
)}
{modal === 'move' && (
<Move
machine={machine}
machine={node}
users={users}
isOpen={modal === 'move'}
setIsOpen={(isOpen) => {
@ -61,7 +52,7 @@ export default function MachineMenu({
)}
{modal === 'rename' && (
<Rename
machine={machine}
machine={node}
magic={magic}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
@ -71,8 +62,7 @@ export default function MachineMenu({
)}
{modal === 'routes' && (
<Routes
machine={machine}
routes={routes}
node={node}
isOpen={modal === 'routes'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
@ -81,16 +71,16 @@ export default function MachineMenu({
)}
{modal === 'tags' && (
<Tags
machine={machine}
machine={node}
isOpen={modal === 'tags'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{expired && modal === 'expire' ? undefined : (
{node.expired && modal === 'expire' ? undefined : (
<Expire
machine={machine}
machine={node}
isOpen={modal === 'expire'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
@ -116,7 +106,10 @@ export default function MachineMenu({
<Ellipsis className="h-5" />
</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.Item key="rename">Edit machine name</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.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">
<p className="text-red-500 dark:text-red-400">Remove</p>
</Menu.Item>

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ export interface Machine {
user: User;
lastSeen: string;
expiry: string;
expiry: string | null;
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(),
};
});
}