From 3ffbabd7fc812d1a2d1db36a30824744f4f9b934 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sun, 2 Jun 2024 01:33:40 -0400 Subject: [PATCH] feat: add functional machine overview page and fix types --- app/routes/_data.machines.$id.tsx | 173 ++++++++++++++---- .../_data.machines._index/dialogs/delete.tsx | 38 ++-- .../_data.machines._index/dialogs/expire.tsx | 38 ++-- .../_data.machines._index/dialogs/rename.tsx | 105 ++++++----- .../_data.machines._index/dialogs/routes.tsx | 61 +++--- app/routes/_data.machines._index/machine.tsx | 167 ++++++----------- app/routes/_data.machines._index/menu.tsx | 89 +++++++++ app/routes/_data.machines._index/route.tsx | 9 +- app/routes/_data.users._index/route.tsx | 3 - app/types/Machine.ts | 36 ++-- app/types/Route.ts | 20 +- app/types/User.ts | 8 +- 12 files changed, 458 insertions(+), 289 deletions(-) create mode 100644 app/routes/_data.machines._index/menu.tsx diff --git a/app/routes/_data.machines.$id.tsx b/app/routes/_data.machines.$id.tsx index d156db6..05a0089 100644 --- a/app/routes/_data.machines.$id.tsx +++ b/app/routes/_data.machines.$id.tsx @@ -4,72 +4,183 @@ import { Link, useLoaderData } from '@remix-run/react' import Attribute from '~/components/Attribute' import Card from '~/components/Card' import StatusCircle from '~/components/StatusCircle' -import { type Machine } from '~/types' +import { type Machine, Route } from '~/types' +import { cn } from '~/utils/cn' +import { loadContext } from '~/utils/config/headplane' +import { loadConfig } from '~/utils/config/headscale' import { pull } from '~/utils/headscale' import { getSession } from '~/utils/sessions' import { useLiveData } from '~/utils/useLiveData' +import MenuOptions from './_data.machines._index/menu' + export async function loader({ request, params }: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')) if (!params.id) { throw new Error('No machine ID provided') } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const data = await pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!) - return data.node + const context = await loadContext() + let magic: string | undefined + + if (context.config.read) { + const config = await loadConfig() + if (config.dns_config.magic_dns) { + magic = config.dns_config.base_domain + } + } + + const [machine, routes] = await Promise.all([ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!), + ]) + + return { + machine: machine.node, + routes: routes.routes.filter(route => route.node.id === params.id), + magic, + } } export default function Page() { - const data = useLoaderData() + const { machine, magic, routes } = useLoaderData() useLiveData({ interval: 1000 }) + const expired = new Date(machine.expiry).getTime() < Date.now() + const tags = [ + ...machine.forcedTags, + ...machine.validTags, + ] + + if (expired) { + tags.unshift('Expired') + } + return (
-

+

All Machines - {' / '} - {data.givenName} + + / + + {machine.givenName}

- -

- {data.givenName} -

- -
- - - - - +
+ +

+ {machine.givenName} +

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

+ Machine Details +

+ + + + + +

+ Machine Routes +

+ + {routes.length === 0 + ? ( +
+

+ No routes are advertised on this machine. +

+
+ ) + : [...routes, ...routes].map((route, i) => ( +
+
+

+ {route.prefix} +

+

+ {' '} + (Created: + {' '} + {new Date(route.createdAt).toLocaleString()} + ) +

+
+
+

+ {route.enabled ? 'Enabled' : 'Disabled'} +

+

+ {route.isPrimary ? 'Primary' : 'Secondary'} +

+
+
+ ))} +
) } diff --git a/app/routes/_data.machines._index/dialogs/delete.tsx b/app/routes/_data.machines._index/dialogs/delete.tsx index 5044fbf..dfb03f6 100644 --- a/app/routes/_data.machines._index/dialogs/delete.tsx +++ b/app/routes/_data.machines._index/dialogs/delete.tsx @@ -1,54 +1,62 @@ -import { type FetcherWithComponents } from '@remix-run/react' +import { Form, useSubmit } from '@remix-run/react' import { type Dispatch, type SetStateAction } from 'react' import Dialog from '~/components/Dialog' import { type Machine } from '~/types' import { cn } from '~/utils/cn' -type DeleteProperties = { - readonly machine: Machine; - readonly fetcher: FetcherWithComponents; - readonly state: [boolean, Dispatch>]; +interface DeleteProps { + readonly machine: Machine + readonly state: [boolean, Dispatch>] } -export default function Delete({ machine, fetcher, state }: DeleteProperties) { +export default function Delete({ machine, state }: DeleteProps) { + const submit = useSubmit() + return ( {close => ( <> - Remove {machine.givenName} + Remove + {' '} + {machine.givenName} This machine will be permanently removed from your network. To re-add it, you will need to reauthenticate to your tailnet from the device. - - - -
+
{ + submit(e.currentTarget) + }} + > + + +
Cancel Remove
- +
)} diff --git a/app/routes/_data.machines._index/dialogs/expire.tsx b/app/routes/_data.machines._index/dialogs/expire.tsx index 559ab44..7af827d 100644 --- a/app/routes/_data.machines._index/dialogs/expire.tsx +++ b/app/routes/_data.machines._index/dialogs/expire.tsx @@ -1,54 +1,62 @@ -import { type FetcherWithComponents } from '@remix-run/react' +import { Form, useSubmit } from '@remix-run/react' import { type Dispatch, type SetStateAction } from 'react' import Dialog from '~/components/Dialog' import { type Machine } from '~/types' import { cn } from '~/utils/cn' -type DeleteProperties = { - readonly machine: Machine; - readonly fetcher: FetcherWithComponents; - readonly state: [boolean, Dispatch>]; +interface ExpireProps { + readonly machine: Machine + readonly state: [boolean, Dispatch>] } -export default function Expire({ machine, fetcher, state }: DeleteProperties) { +export default function Expire({ machine, state }: ExpireProps) { + const submit = useSubmit() + return ( {close => ( <> - Expire {machine.givenName} + Expire + {' '} + {machine.givenName} This will disconnect the machine from your Tailnet. In order to reconnect, you will need to re-authenticate from the device. - - - -
+
{ + submit(e.currentTarget) + }} + > + + +
Cancel Expire
- +
)} diff --git a/app/routes/_data.machines._index/dialogs/rename.tsx b/app/routes/_data.machines._index/dialogs/rename.tsx index 50a4b37..36bc65c 100644 --- a/app/routes/_data.machines._index/dialogs/rename.tsx +++ b/app/routes/_data.machines._index/dialogs/rename.tsx @@ -1,4 +1,4 @@ -import { type FetcherWithComponents } from '@remix-run/react' +import { Form, useSubmit } from '@remix-run/react' import { type Dispatch, type SetStateAction, useState } from 'react' import Code from '~/components/Code' @@ -6,15 +6,15 @@ import Dialog from '~/components/Dialog' import TextField from '~/components/TextField' import { type Machine } from '~/types' -type RenameProperties = { - readonly machine: Machine; - readonly fetcher: FetcherWithComponents; - readonly state: [boolean, Dispatch>]; - readonly magic?: string; +interface RenameProps { + readonly machine: Machine + readonly state: [boolean, Dispatch>] + readonly magic?: string } -export default function Rename({ machine, fetcher, state, magic }: RenameProperties) { +export default function Rename({ machine, state, magic }: RenameProps) { const [name, setName] = useState(machine.givenName) + const submit = useSubmit() return ( @@ -22,65 +22,76 @@ export default function Rename({ machine, fetcher, state, magic }: RenamePropert {close => ( <> - Edit machine name for {machine.givenName} + Edit machine name for + {' '} + {machine.givenName} This name is shown in the admin panel, in Tailscale clients, and used when generating MagicDNS names. - - - +
{ + submit(e.currentTarget) + }} + > + + - {magic ? ( - name.length > 0 && name !== machine.givenName ? ( -

- This machine will be accessible by the hostname - {' '} - - {name.toLowerCase().replaceAll(/\s+/g, '-')} - - {'. '} - The hostname - {' '} - - {machine.givenName} - - {' '} - will no longer point to this machine. -

- ) : ( -

- This machine is accessible by the hostname - {' '} - - {machine.givenName} - - . -

- ) - ) : undefined} -
+ {magic + ? ( + name.length > 0 && name !== machine.givenName + ? ( +

+ This machine will be accessible by the hostname + {' '} + + {name.toLowerCase().replaceAll(/\s+/g, '-')} + + {'. '} + The hostname + {' '} + + {machine.givenName} + + {' '} + will no longer point to this machine. +

+ ) + : ( +

+ This machine is accessible by the hostname + {' '} + + {machine.givenName} + + . +

+ ) + ) + : undefined} +
Cancel Rename
- + )} diff --git a/app/routes/_data.machines._index/dialogs/routes.tsx b/app/routes/_data.machines._index/dialogs/routes.tsx index 5348250..08ecdf2 100644 --- a/app/routes/_data.machines._index/dialogs/routes.tsx +++ b/app/routes/_data.machines._index/dialogs/routes.tsx @@ -1,4 +1,4 @@ -import { type FetcherWithComponents } from '@remix-run/react' +import { useFetcher } from '@remix-run/react' import { type Dispatch, type SetStateAction } from 'react' import Dialog from '~/components/Dialog' @@ -6,22 +6,25 @@ import Switch from '~/components/Switch' import { type Machine, type Route } from '~/types' import { cn } from '~/utils/cn' -type RoutesProperties = { - readonly machine: Machine; - readonly routes: Route[]; - readonly fetcher: FetcherWithComponents; - readonly state: [boolean, Dispatch>]; +interface RoutesProps { + readonly machine: Machine + readonly routes: Route[] + readonly state: [boolean, Dispatch>] } // TODO: Support deleting routes -export default function Routes({ machine, routes, fetcher, state }: RoutesProperties) { +export default function Routes({ machine, routes, state }: RoutesProps) { + const fetcher = useFetcher() + return ( {close => ( <> - Edit route settings of {machine.givenName} + Edit route settings of + {' '} + {machine.givenName} Connect to devices you can't install Tailscale on @@ -30,28 +33,30 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
- {routes.length === 0 ? ( -
-

- No routes are advertised on this machine. -

-
- ) : undefined} + {routes.length === 0 + ? ( +
+

+ No routes are advertised on this machine. +

+
+ ) + : undefined} {routes.map(route => (

@@ -59,8 +64,8 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper

{ + label="Enabled" + onChange={(checked) => { const form = new FormData() form.set('id', machine.id) form.set('_method', 'routes') @@ -68,16 +73,16 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper form.set('enabled', String(checked)) fetcher.submit(form, { - method: 'POST' + method: 'POST', }) }} />
))}
-
+
diff --git a/app/routes/_data.machines._index/machine.tsx b/app/routes/_data.machines._index/machine.tsx index c010666..5f13e93 100644 --- a/app/routes/_data.machines._index/machine.tsx +++ b/app/routes/_data.machines._index/machine.tsx @@ -1,7 +1,5 @@ -/* eslint-disable react/hook-use-state */ -import { ChevronDownIcon, CopyIcon, KebabHorizontalIcon } from '@primer/octicons-react' -import { type FetcherWithComponents, Link } from '@remix-run/react' -import { useState } from 'react' +import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react' +import { Link } from '@remix-run/react' import Menu from '~/components/Menu' import StatusCircle from '~/components/StatusCircle' @@ -9,28 +7,20 @@ import { toast } from '~/components/Toaster' import { type Machine, type Route } from '~/types' import { cn } from '~/utils/cn' -import Delete from './dialogs/delete' -import Expire from './dialogs/expire' -import Rename from './dialogs/rename' -import Routes from './dialogs/routes' +import MenuOptions from './menu' -type MachineProperties = { - readonly machine: Machine; - readonly routes: Route[]; - readonly fetcher: FetcherWithComponents; - readonly magic?: string; +interface Props { + readonly machine: Machine + readonly routes: Route[] + readonly magic?: string } -export default function MachineRow({ machine, routes, fetcher, magic }: MachineProperties) { - const renameState = useState(false) - const expireState = useState(false) - const removeState = useState(false) - const routesState = useState(false) - +export default function MachineRow({ machine, routes, magic }: Props) { const expired = new Date(machine.expiry).getTime() < Date.now() + const tags = [ ...machine.forcedTags, - ...machine.validTags + ...machine.validTags, ] if (expired) { @@ -40,32 +30,32 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP return ( - +

{machine.givenName}

-

+

{machine.name}

-
+
{tags.map(tag => ( {tag} @@ -74,21 +64,21 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
- -
+ +
{machine.ipAddresses[0]} - + {machine.ipAddresses.map(ip => ( { await navigator.clipboard.writeText(ip) @@ -96,108 +86,61 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP }} > {ip} - + ))} - {magic ? ( - { - const ip = `${machine.givenName}.${machine.user.name}.${magic}` - await navigator.clipboard.writeText(ip) - toast('Copied hostname to clipboard') - }} - > - {machine.givenName}.{machine.user.name}.{magic} - - - ) : undefined} + {magic + ? ( + { + const ip = `${machine.givenName}.${machine.user.name}.${magic}` + await navigator.clipboard.writeText(ip) + toast('Copied hostname to clipboard') + }} + > + {machine.givenName} + . + {machine.user.name} + . + {magic} + + + ) + : undefined}
- +

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

- - - - {expired ? undefined : ( - - )} - + - - - - - - - - Edit machine name - - - Edit route settings - - - Edit ACL tags - - {expired ? undefined : ( - - Expire - - )} - - Remove - - - ) diff --git a/app/routes/_data.machines._index/menu.tsx b/app/routes/_data.machines._index/menu.tsx new file mode 100644 index 0000000..d98ca5a --- /dev/null +++ b/app/routes/_data.machines._index/menu.tsx @@ -0,0 +1,89 @@ +import { KebabHorizontalIcon } from '@primer/octicons-react' +import { useState } from 'react' + +import MenuComponent from '~/components/Menu' +import { Machine, Route } from '~/types' +import { cn } from '~/utils/cn' + +import Delete from './dialogs/delete' +import Expire from './dialogs/expire' +import Rename from './dialogs/rename' +import Routes from './dialogs/routes' + +interface MenuProps { + machine: Machine + routes: Route[] + magic?: string +} + +export default function Menu({ machine, routes, magic }: MenuProps) { + const renameState = useState(false) + const expireState = useState(false) + const removeState = useState(false) + const routesState = useState(false) + + const expired = new Date(machine.expiry).getTime() < Date.now() + + return ( + <> + + + {expired + ? undefined + : ( + + )} + + + + + + + + + Edit machine name + + + Edit route settings + + + Edit ACL tags + + {expired + ? undefined + : ( + + Expire + + )} + + Remove + + + + + ) +} diff --git a/app/routes/_data.machines._index/route.tsx b/app/routes/_data.machines._index/route.tsx index 0583f44..77adbb1 100644 --- a/app/routes/_data.machines._index/route.tsx +++ b/app/routes/_data.machines._index/route.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { InfoIcon } from '@primer/octicons-react' import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node' -import { useFetcher, useLoaderData } from '@remix-run/react' +import { useLoaderData } from '@remix-run/react' import { Button, Tooltip, TooltipTrigger } from 'react-aria-components' import Code from '~/components/Code' @@ -107,7 +107,6 @@ export async function action({ request }: ActionFunctionArgs) { export default function Page() { useLiveData({ interval: 3000 }) const data = useLoaderData() - const fetcher = useFetcher() return ( <> @@ -155,10 +154,8 @@ export default function Page() { {data.nodes.map(machine => ( route.node.id === machine.id) as unknown as Route[]} - fetcher={fetcher} + machine={machine} + routes={data.routes.filter(route => route.node.id === machine.id)} magic={data.magic} /> ))} diff --git a/app/routes/_data.users._index/route.tsx b/app/routes/_data.users._index/route.tsx index 899f93e..8163e9e 100644 --- a/app/routes/_data.users._index/route.tsx +++ b/app/routes/_data.users._index/route.tsx @@ -182,15 +182,12 @@ export default function Page() { )} } > {() => ( diff --git a/app/types/Machine.ts b/app/types/Machine.ts index d0a954e..5dc17aa 100644 --- a/app/types/Machine.ts +++ b/app/types/Machine.ts @@ -1,28 +1,28 @@ import type { User } from './User' -export type Machine = { - id: string; - machineKey: string; - nodeKey: string; - discoKey: string; - ipAddresses: string[]; - name: string; +export interface Machine { + id: string + machineKey: string + nodeKey: string + discoKey: string + ipAddresses: string[] + name: string - user: User; - lastSeen: Date; - expiry: Date; + user: User + lastSeen: string + expiry: string - preAuthKey?: unknown; // TODO + preAuthKey?: unknown // TODO - createdAt: Date; + createdAt: string registerMethod: 'REGISTER_METHOD_UNSPECIFIED' | 'REGISTER_METHOD_AUTH_KEY' | 'REGISTER_METHOD_CLI' - | 'REGISTER_METHOD_OIDC'; + | 'REGISTER_METHOD_OIDC' - forcedTags: string[]; - invalidTags: string[]; - validTags: string[]; - givenName: string; - online: boolean; + forcedTags: string[] + invalidTags: string[] + validTags: string[] + givenName: string + online: boolean } diff --git a/app/types/Route.ts b/app/types/Route.ts index 3d0a039..fa1c03b 100644 --- a/app/types/Route.ts +++ b/app/types/Route.ts @@ -1,13 +1,13 @@ import type { Machine } from './Machine' -export type Route = { - id: string; - node: Machine; - prefix: string; - advertised: boolean; - enabled: boolean; - isPrimary: boolean; - createdAt: Date; - updatedAt: Date; - deletedAt: Date; +export interface Route { + id: string + node: Machine + prefix: string + advertised: boolean + enabled: boolean + isPrimary: boolean + createdAt: string + updatedAt: string + deletedAt: string } diff --git a/app/types/User.ts b/app/types/User.ts index 2160627..b8e3495 100644 --- a/app/types/User.ts +++ b/app/types/User.ts @@ -1,5 +1,5 @@ -export type User = { - id: string; - name: string; - createdAt: Date; +export interface User { + id: string + name: string + createdAt: string }