From 0c7e2e49f5538efd682c74ff1829e63681442283 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Fri, 18 Apr 2025 14:40:56 -0400 Subject: [PATCH] feat: redo machine tagging system --- CHANGELOG.md | 2 + app/components/Chip.tsx | 4 +- app/components/tags/ExitNode.tsx | 32 ++++ app/components/tags/Expiry.tsx | 42 +++++ app/components/tags/HeadplaneAgent.tsx | 20 ++ app/components/tags/Subnet.tsx | 32 ++++ .../machines/components/machine-row.tsx | 172 ++++++++++-------- app/routes/machines/components/menu.tsx | 47 ++--- app/routes/machines/dialogs/routes.tsx | 60 ++---- app/routes/machines/machine.tsx | 156 +++++----------- app/routes/machines/overview.tsx | 26 ++- app/types/Machine.ts | 2 +- app/utils/node-info.ts | 58 ++++++ 13 files changed, 379 insertions(+), 274 deletions(-) create mode 100644 app/components/tags/ExitNode.tsx create mode 100644 app/components/tags/Expiry.tsx create mode 100644 app/components/tags/HeadplaneAgent.tsx create mode 100644 app/components/tags/Subnet.tsx create mode 100644 app/utils/node-info.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e33ba5..239aa42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/components/Chip.tsx b/app/components/Chip.tsx index 591f6c2..55903ee 100644 --- a/app/components/Chip.tsx +++ b/app/components/Chip.tsx @@ -17,10 +17,10 @@ export default function Chip({ return ( diff --git a/app/components/tags/ExitNode.tsx b/app/components/tags/ExitNode.tsx new file mode 100644 index 0000000..0456c43 --- /dev/null +++ b/app/components/tags/ExitNode.tsx @@ -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 ( + + } + /> + + {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. + + )} + + + ); +} diff --git a/app/components/tags/Expiry.tsx b/app/components/tags/Expiry.tsx new file mode 100644 index 0000000..4b030de --- /dev/null +++ b/app/components/tags/Expiry.tsx @@ -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 ( + + + + {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. + + )} + + + ); +} diff --git a/app/components/tags/HeadplaneAgent.tsx b/app/components/tags/HeadplaneAgent.tsx new file mode 100644 index 0000000..575788c --- /dev/null +++ b/app/components/tags/HeadplaneAgent.tsx @@ -0,0 +1,20 @@ +import cn from '~/utils/cn'; +import Chip from '../Chip'; +import Tooltip from '../Tooltip'; + +export function HeadplaneAgentTag() { + return ( + + + + This machine is running the Headplane agent, which allows it to provide + host information in the web UI. + + + ); +} diff --git a/app/components/tags/Subnet.tsx b/app/components/tags/Subnet.tsx new file mode 100644 index 0000000..7bfb46d --- /dev/null +++ b/app/components/tags/Subnet.tsx @@ -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 ( + + } + /> + + {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. + + )} + + + ); +} diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index 7597b55..a5a01c3 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -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 (

- {machine.givenName} + {node.givenName}

-

{machine.name}

+

{node.name}

- {tags.map((tag) => ( + {mapTagsToComponents(node, uiTags)} + {node.validTags.map((tag) => ( ))}
@@ -124,7 +74,7 @@ export default function MachineRow({
- {machine.ipAddresses[0]} + {node.ipAddresses[0]} @@ -157,11 +107,13 @@ export default function MachineRow({ {/* We pass undefined when agents are not enabled */} {isAgent !== undefined ? ( - {stats !== undefined ? ( + {node.hostInfo !== undefined ? ( <> -

{hinfo.getTSVersion(stats)}

+

+ {hinfo.getTSVersion(node.hostInfo)} +

- {hinfo.getOSInfo(stats)} + {hinfo.getOSInfo(node.hostInfo)}

) : ( @@ -177,20 +129,19 @@ export default function MachineRow({ )} >

- {machine.online && !expired + {node.online && !node.expired ? 'Connected' - : new Date(machine.lastSeen).toLocaleString()} + : new Date(node.lastSeen).toLocaleString()}

); } + +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 ; + + case 'subnet-approved': + case 'subnet-waiting': + return ; + + case 'expired': + case 'no-expiry': + return ( + + ); + + case 'headplane-agent': + return ; + + default: + return; + } + }); +} diff --git a/app/routes/machines/components/menu.tsx b/app/routes/machines/components/menu.tsx index 01a7e71..167f92d 100644 --- a/app/routes/machines/components/menu.tsx +++ b/app/routes/machines/components/menu.tsx @@ -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(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' && ( { if (!isOpen) setModal(null); @@ -51,7 +42,7 @@ export default function MachineMenu({ )} {modal === 'move' && ( { @@ -61,7 +52,7 @@ export default function MachineMenu({ )} {modal === 'rename' && ( { @@ -71,8 +62,7 @@ export default function MachineMenu({ )} {modal === 'routes' && ( { if (!isOpen) setModal(null); @@ -81,16 +71,16 @@ export default function MachineMenu({ )} {modal === 'tags' && ( { if (!isOpen) setModal(null); }} /> )} - {expired && modal === 'expire' ? undefined : ( + {node.expired && modal === 'expire' ? undefined : ( { if (!isOpen) setModal(null); @@ -116,7 +106,10 @@ export default function MachineMenu({
)} - setModal(key as Modal)}> + setModal(key as Modal)} + disabledKeys={node.expired ? ['expire'] : []} + > Edit machine name Edit route settings @@ -124,13 +117,9 @@ export default function MachineMenu({ Change owner - {expired ? ( - <> - ) : ( - -

Expire

-
- )} + +

Expire

+

Remove

diff --git a/app/routes/machines/dialogs/routes.tsx b/app/routes/machines/dialogs/routes.tsx index 4c69c76..7376475 100644 --- a/app/routes/machines/dialogs/routes.tsx +++ b/app/routes/machines/dialogs/routes.tsx @@ -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 ( - Edit route settings of {machine.givenName} + Edit route settings of {node.givenName} Subnet routes Connect to devices you can't install Tailscale on by advertising @@ -62,7 +37,7 @@ export default function Routes({ - {subnet.length === 0 ? ( + {subnets.length === 0 ? (

@@ -70,7 +45,7 @@ export default function Routes({

) : undefined} - {subnet.map((route) => ( + {subnets.map((route) => (

{route.prefix}

{ 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({ - {exit.length === 0 ? ( + {node.customRouting.exitRoutes.length === 0 ? (

This machine is not an exit node

@@ -111,13 +86,18 @@ export default function Routes({

Use as exit node

{ 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, { diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index 0d6eaeb..927a522 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -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(); + const { node, magic, users, agent } = useLoaderData(); 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 (
@@ -127,7 +78,7 @@ export default function Page() { All Machines / - {machine.givenName} + {node.givenName}

-

{machine.givenName}

- +

{node.givenName}

+
- - +
@@ -161,29 +105,23 @@ export default function Page() {
- {machine.user.name} + {node.user.name}
- {tags.length > 0 ? ( -
-

- Status -

-
- {tags.map((tag) => ( - - ))} -
+
+

+ Status +

+
+ {mapTagsToComponents(node, uiTags)} + {node.validTags.map((tag) => ( + + ))}
- ) : undefined} +

Subnets & Routing

- +

Subnets let you expose physical network routes onto Tailscale.{' '} @@ -214,11 +152,11 @@ export default function Page() {

- {subnetApproved.length === 0 ? ( + {node.customRouting.subnetApprovedRoutes.length === 0 ? ( ) : (
    - {subnetApproved.map((route) => ( + {node.customRouting.subnetApprovedRoutes.map((route) => (
  • {route.prefix}
  • ))}
@@ -246,11 +184,11 @@ export default function Page() {
- {subnet.length === 0 ? ( + {node.customRouting.subnetWaitingRoutes.length === 0 ? ( ) : (
    - {subnet.map((route) => ( + {node.customRouting.subnetWaitingRoutes.map((route) => (
  • {route.prefix}
  • ))}
@@ -277,9 +215,9 @@ export default function Page() {
- {exit.length === 0 ? ( + {node.customRouting.exitRoutes.length === 0 ? ( - ) : exitEnabled ? ( + ) : node.customRouting.exitApproved ? ( Allowed @@ -304,31 +242,35 @@ export default function Page() {

Machine Details

- - - - - + + + + + {magic ? ( ) : undefined} diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index e7a56b5..65d0c1f 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -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) => ( 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 diff --git a/app/types/Machine.ts b/app/types/Machine.ts index 6fbee75..e1d05eb 100644 --- a/app/types/Machine.ts +++ b/app/types/Machine.ts @@ -10,7 +10,7 @@ export interface Machine { user: User; lastSeen: string; - expiry: string; + expiry: string | null; preAuthKey?: unknown; // TODO diff --git a/app/utils/node-info.ts b/app/utils/node-info.ts new file mode 100644 index 0000000..2e60279 --- /dev/null +++ b/app/utils/node-info.ts @@ -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 | undefined, +): PopulatedNode[] { + return nodes.map((node) => { + const nodeRoutes = routes.filter((route) => route.node.id === node.id); + const customRouting = nodeRoutes.reduce( + (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(), + }; + }); +}