feat: add functional machine overview page and fix types

This commit is contained in:
Aarnav Tale
2024-06-02 01:33:40 -04:00
parent c08203cc76
commit 3ffbabd7fc
12 changed files with 458 additions and 289 deletions
+142 -31
View File
@@ -4,72 +4,183 @@ import { Link, useLoaderData } from '@remix-run/react'
import Attribute from '~/components/Attribute' import Attribute from '~/components/Attribute'
import Card from '~/components/Card' import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle' 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 { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData' import { useLiveData } from '~/utils/useLiveData'
import MenuOptions from './_data.machines._index/menu'
export async function loader({ request, params }: LoaderFunctionArgs) { export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'))
if (!params.id) { if (!params.id) {
throw new Error('No machine ID provided') throw new Error('No machine ID provided')
} }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const context = await loadContext()
const data = await pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!) let magic: string | undefined
return data.node
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() { export default function Page() {
const data = useLoaderData<typeof loader>() const { machine, magic, routes } = useLoaderData<typeof loader>()
useLiveData({ interval: 1000 }) useLiveData({ interval: 1000 })
const expired = new Date(machine.expiry).getTime() < Date.now()
const tags = [
...machine.forcedTags,
...machine.validTags,
]
if (expired) {
tags.unshift('Expired')
}
return ( return (
<div> <div>
<p className='mb-4 text-gray-500 dark:text-gray-400 text-sm'> <p className="mb-8 text-md">
<Link <Link
to='/machines' to="/machines"
className='font-bold text-gray-700 dark:text-gray-300 hover:underline' className="font-medium"
> >
All Machines All Machines
</Link> </Link>
{' / '} <span className="mx-2">
{data.givenName} /
</span>
{machine.givenName}
</p> </p>
<span className='flex items-baseline gap-x-4 text-sm mb-4'> <div className="flex justify-between items-center">
<h1 className='text-2xl font-bold'> <span className="flex items-baseline gap-x-4 text-sm mb-4">
{data.givenName} <h1 className="text-2xl font-medium">
</h1> {machine.givenName}
<StatusCircle isOnline={data.online} className='w-4 h-4'/> </h1>
</span> <StatusCircle isOnline={machine.online} className="w-4 h-4" />
<Card variant='flat'> </span>
<Attribute name='Creator' value={data.user.name}/>
<Attribute name='Node ID' value={data.id}/> <MenuOptions
<Attribute name='Node Name' value={data.givenName}/> machine={machine}
<Attribute name='Hostname' value={data.name}/> routes={routes}
magic={magic}
/>
</div>
<div className="flex gap-1 mt-1 mb-8">
{tags.map(tag => (
<span
key={tag}
className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
{tag}
</span>
))}
</div>
<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 <Attribute
isCopyable isCopyable
name='Node Key' name="Node Key"
value={data.nodeKey} value={machine.nodeKey}
/> />
<Attribute <Attribute
name='Created' name="Created"
value={new Date(data.createdAt).toLocaleString()} value={new Date(machine.createdAt).toLocaleString()}
/> />
<Attribute <Attribute
name='Last Seen' name="Last Seen"
value={new Date(data.lastSeen).toLocaleString()} value={new Date(machine.lastSeen).toLocaleString()}
/> />
<Attribute <Attribute
name='Expiry' name="Expiry"
value={new Date(data.expiry).toLocaleString()} value={new Date(machine.expiry).toLocaleString()}
/> />
<Attribute <Attribute
isCopyable isCopyable
name='Domain' name="Domain"
value={`${data.givenName}.${data.user.name}.ts.net`} value={`${machine.givenName}.${machine.user.name}.ts.net`}
/> />
</Card> </Card>
<h2 className="text-xl font-medium mb-4 mt-8">
Machine Routes
</h2>
<Card variant="flat" className="w-full max-w-full">
{routes.length === 0
? (
<div
className={cn(
'flex py-4 px-4',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
)
: [...routes, ...routes].map((route, i) => (
<div
key={route.node.id}
className={cn(
'flex items-center justify-between',
routes.length - 1 === i ? 'border-b pb-3 mb-2' : '',
'border-ui-100 dark:border-ui-800',
)}
>
<div>
<p className="font-mono mb-1">
{route.prefix}
</p>
<p className="text-sm text-ui-600 dark:text-ui-300">
{' '}
(Created:
{' '}
{new Date(route.createdAt).toLocaleString()}
)
</p>
</div>
<div className="text-right">
<p className="mb-1">
{route.enabled ? 'Enabled' : 'Disabled'}
</p>
<p className="text-sm text-ui-600 dark:text-ui-300">
{route.isPrimary ? 'Primary' : 'Secondary'}
</p>
</div>
</div>
))}
</Card>
</div> </div>
) )
} }
@@ -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 { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog'
import { type Machine } from '~/types' import { type Machine } from '~/types'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
type DeleteProperties = { interface DeleteProps {
readonly machine: Machine; readonly machine: Machine
readonly fetcher: FetcherWithComponents<unknown>; readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
} }
export default function Delete({ machine, fetcher, state }: DeleteProperties) { export default function Delete({ machine, state }: DeleteProps) {
const submit = useSubmit()
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {close => (
<> <>
<Dialog.Title> <Dialog.Title>
Remove {machine.givenName} Remove
{' '}
{machine.givenName}
</Dialog.Title> </Dialog.Title>
<Dialog.Text> <Dialog.Text>
This machine will be permanently removed from This machine will be permanently removed from
your network. To re-add it, you will need to your network. To re-add it, you will need to
reauthenticate to your tailnet from the device. reauthenticate to your tailnet from the device.
</Dialog.Text> </Dialog.Text>
<fetcher.Form method='POST'> <Form
<input type='hidden' name='_method' value='delete'/> method="POST"
<input type='hidden' name='id' value={machine.id}/> onSubmit={(e) => {
<div className='mt-6 flex justify-end gap-2 mt-6'> submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action
variant='cancel' variant="cancel"
onPress={close} onPress={close}
> >
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
variant='confirm' variant="confirm"
className={cn( className={cn(
'bg-red-500 hover:border-red-700', 'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700', 'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600', 'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white' 'text-white dark:text-white',
)} )}
onPress={close} onPress={close}
> >
Remove Remove
</Dialog.Action> </Dialog.Action>
</div> </div>
</fetcher.Form> </Form>
</> </>
)} )}
</Dialog.Panel> </Dialog.Panel>
@@ -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 { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog'
import { type Machine } from '~/types' import { type Machine } from '~/types'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
type DeleteProperties = { interface ExpireProps {
readonly machine: Machine; readonly machine: Machine
readonly fetcher: FetcherWithComponents<unknown>; readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
} }
export default function Expire({ machine, fetcher, state }: DeleteProperties) { export default function Expire({ machine, state }: ExpireProps) {
const submit = useSubmit()
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {close => (
<> <>
<Dialog.Title> <Dialog.Title>
Expire {machine.givenName} Expire
{' '}
{machine.givenName}
</Dialog.Title> </Dialog.Title>
<Dialog.Text> <Dialog.Text>
This will disconnect the machine from your Tailnet. This will disconnect the machine from your Tailnet.
In order to reconnect, you will need to re-authenticate In order to reconnect, you will need to re-authenticate
from the device. from the device.
</Dialog.Text> </Dialog.Text>
<fetcher.Form method='POST'> <Form
<input type='hidden' name='_method' value='expire'/> method="POST"
<input type='hidden' name='id' value={machine.id}/> onSubmit={(e) => {
<div className='mt-6 flex justify-end gap-2 mt-6'> submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action
variant='cancel' variant="cancel"
onPress={close} onPress={close}
> >
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
variant='confirm' variant="confirm"
className={cn( className={cn(
'bg-red-500 hover:border-red-700', 'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700', 'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600', 'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white' 'text-white dark:text-white',
)} )}
onPress={close} onPress={close}
> >
Expire Expire
</Dialog.Action> </Dialog.Action>
</div> </div>
</fetcher.Form> </Form>
</> </>
)} )}
</Dialog.Panel> </Dialog.Panel>
@@ -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 { type Dispatch, type SetStateAction, useState } from 'react'
import Code from '~/components/Code' import Code from '~/components/Code'
@@ -6,15 +6,15 @@ import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField' import TextField from '~/components/TextField'
import { type Machine } from '~/types' import { type Machine } from '~/types'
type RenameProperties = { interface RenameProps {
readonly machine: Machine; readonly machine: Machine
readonly fetcher: FetcherWithComponents<unknown>; readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]; readonly magic?: string
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 [name, setName] = useState(machine.givenName)
const submit = useSubmit()
return ( return (
<Dialog> <Dialog>
@@ -22,65 +22,76 @@ export default function Rename({ machine, fetcher, state, magic }: RenamePropert
{close => ( {close => (
<> <>
<Dialog.Title> <Dialog.Title>
Edit machine name for {machine.givenName} Edit machine name for
{' '}
{machine.givenName}
</Dialog.Title> </Dialog.Title>
<Dialog.Text> <Dialog.Text>
This name is shown in the admin panel, in Tailscale clients, This name is shown in the admin panel, in Tailscale clients,
and used when generating MagicDNS names. and used when generating MagicDNS names.
</Dialog.Text> </Dialog.Text>
<fetcher.Form method='POST'> <Form
<input type='hidden' name='_method' value='rename'/> method="POST"
<input type='hidden' name='id' value={machine.id}/> onSubmit={(e) => {
submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="id" value={machine.id} />
<TextField <TextField
label='Machine name' label="Machine name"
placeholder='Machine name' placeholder="Machine name"
name='name' name="name"
state={[name, setName]} state={[name, setName]}
className='my-2' className="my-2"
/> />
{magic ? ( {magic
name.length > 0 && name !== machine.givenName ? ( ? (
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'> name.length > 0 && name !== machine.givenName
This machine will be accessible by the hostname ? (
{' '} <p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
<Code className='text-sm'> This machine will be accessible by the hostname
{name.toLowerCase().replaceAll(/\s+/g, '-')} {' '}
</Code> <Code className="text-sm">
{'. '} {name.toLowerCase().replaceAll(/\s+/g, '-')}
The hostname </Code>
{' '} {'. '}
<Code className='text-sm'> The hostname
{machine.givenName} {' '}
</Code> <Code className="text-sm">
{' '} {machine.givenName}
will no longer point to this machine. </Code>
</p> {' '}
) : ( will no longer point to this machine.
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'> </p>
This machine is accessible by the hostname )
{' '} : (
<Code className='text-sm'> <p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
{machine.givenName} This machine is accessible by the hostname
</Code> {' '}
. <Code className="text-sm">
</p> {machine.givenName}
) </Code>
) : undefined} .
<div className='mt-6 flex justify-end gap-2 mt-6'> </p>
)
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action
variant='cancel' variant="cancel"
onPress={close} onPress={close}
> >
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
variant='confirm' variant="confirm"
onPress={close} onPress={close}
> >
Rename Rename
</Dialog.Action> </Dialog.Action>
</div> </div>
</fetcher.Form> </Form>
</> </>
)} )}
</Dialog.Panel> </Dialog.Panel>
@@ -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 { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog'
@@ -6,22 +6,25 @@ import Switch from '~/components/Switch'
import { type Machine, type Route } from '~/types' import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
type RoutesProperties = { interface RoutesProps {
readonly machine: Machine; readonly machine: Machine
readonly routes: Route[]; readonly routes: Route[]
readonly fetcher: FetcherWithComponents<unknown>; readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
} }
// TODO: Support deleting routes // 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 ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {close => (
<> <>
<Dialog.Title> <Dialog.Title>
Edit route settings of {machine.givenName} Edit route settings of
{' '}
{machine.givenName}
</Dialog.Title> </Dialog.Title>
<Dialog.Text> <Dialog.Text>
Connect to devices you can&apos;t install Tailscale on Connect to devices you can&apos;t install Tailscale on
@@ -30,28 +33,30 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
<div className={cn( <div className={cn(
'rounded-lg overflow-y-auto my-2', 'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top', 'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700' 'border border-zinc-200 dark:border-zinc-700',
)} )}
> >
{routes.length === 0 ? ( {routes.length === 0
<div ? (
className={cn( <div
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800', className={cn(
'items-center justify-center', 'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300' 'items-center justify-center',
)} 'text-ui-600 dark:text-ui-300',
> )}
<p> >
No routes are advertised on this machine. <p>
</p> No routes are advertised on this machine.
</div> </p>
) : undefined} </div>
)
: undefined}
{routes.map(route => ( {routes.map(route => (
<div <div
key={route.node.id} key={route.node.id}
className={cn( className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800', 'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between' 'items-center justify-between',
)} )}
> >
<p> <p>
@@ -59,8 +64,8 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
</p> </p>
<Switch <Switch
defaultSelected={route.enabled} defaultSelected={route.enabled}
label='Enabled' label="Enabled"
onChange={checked => { onChange={(checked) => {
const form = new FormData() const form = new FormData()
form.set('id', machine.id) form.set('id', machine.id)
form.set('_method', 'routes') form.set('_method', 'routes')
@@ -68,16 +73,16 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
form.set('enabled', String(checked)) form.set('enabled', String(checked))
fetcher.submit(form, { fetcher.submit(form, {
method: 'POST' method: 'POST',
}) })
}} }}
/> />
</div> </div>
))} ))}
</div> </div>
<div className='mt-6 flex justify-end gap-2 mt-6'> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action
variant='cancel' variant="cancel"
isDisabled={fetcher.state === 'submitting'} isDisabled={fetcher.state === 'submitting'}
onPress={close} onPress={close}
> >
+55 -112
View File
@@ -1,7 +1,5 @@
/* eslint-disable react/hook-use-state */ import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react'
import { ChevronDownIcon, CopyIcon, KebabHorizontalIcon } from '@primer/octicons-react' import { Link } from '@remix-run/react'
import { type FetcherWithComponents, Link } from '@remix-run/react'
import { useState } from 'react'
import Menu from '~/components/Menu' import Menu from '~/components/Menu'
import StatusCircle from '~/components/StatusCircle' import StatusCircle from '~/components/StatusCircle'
@@ -9,28 +7,20 @@ import { toast } from '~/components/Toaster'
import { type Machine, type Route } from '~/types' import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import Delete from './dialogs/delete' import MenuOptions from './menu'
import Expire from './dialogs/expire'
import Rename from './dialogs/rename'
import Routes from './dialogs/routes'
type MachineProperties = { interface Props {
readonly machine: Machine; readonly machine: Machine
readonly routes: Route[]; readonly routes: Route[]
readonly fetcher: FetcherWithComponents<unknown>; readonly magic?: string
readonly magic?: string;
} }
export default function MachineRow({ machine, routes, fetcher, magic }: MachineProperties) { export default function MachineRow({ machine, routes, magic }: Props) {
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() const expired = new Date(machine.expiry).getTime() < Date.now()
const tags = [ const tags = [
...machine.forcedTags, ...machine.forcedTags,
...machine.validTags ...machine.validTags,
] ]
if (expired) { if (expired) {
@@ -40,32 +30,32 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
return ( return (
<tr <tr
key={machine.id} key={machine.id}
className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group' className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
> >
<td className='pl-0.5 py-2'> <td className="pl-0.5 py-2">
<Link <Link
to={`/machines/${machine.id}`} to={`/machines/${machine.id}`}
className='group/link h-full' className="group/link h-full"
> >
<p className={cn( <p className={cn(
'font-semibold leading-snug', 'font-semibold leading-snug',
'group-hover/link:text-blue-600', 'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400' 'group-hover/link:dark:text-blue-400',
)} )}
> >
{machine.givenName} {machine.givenName}
</p> </p>
<p className='text-sm text-gray-500 dark:text-gray-300 font-mono'> <p className="text-sm text-gray-500 dark:text-gray-300 font-mono">
{machine.name} {machine.name}
</p> </p>
<div className='flex gap-1 mt-1'> <div className="flex gap-1 mt-1">
{tags.map(tag => ( {tags.map(tag => (
<span <span
key={tag} key={tag}
className={cn( className={cn(
'text-xs rounded-sm px-1 py-0.5', 'text-xs rounded-md px-1.5 py-0.5',
'bg-gray-100 dark:bg-zinc-700', 'bg-ui-200 dark:bg-ui-800',
'text-gray-600 dark:text-gray-300' 'text-ui-600 dark:text-ui-300',
)} )}
> >
{tag} {tag}
@@ -74,21 +64,21 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
</div> </div>
</Link> </Link>
</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]} {machine.ipAddresses[0]}
<Menu> <Menu>
<Menu.Button> <Menu.Button>
<ChevronDownIcon className='w-4 h-4'/> <ChevronDownIcon className="w-4 h-4" />
</Menu.Button> </Menu.Button>
<Menu.Items> <Menu.Items>
{machine.ipAddresses.map(ip => ( {machine.ipAddresses.map(ip => (
<Menu.ItemButton <Menu.ItemButton
key={ip} key={ip}
type='button' type="button"
className={cn( className={cn(
'flex items-center gap-x-1.5 text-sm', 'flex items-center gap-x-1.5 text-sm',
'justify-between w-full' 'justify-between w-full',
)} )}
onPress={async () => { onPress={async () => {
await navigator.clipboard.writeText(ip) await navigator.clipboard.writeText(ip)
@@ -96,108 +86,61 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
}} }}
> >
{ip} {ip}
<CopyIcon className='w-3 h-3'/> <CopyIcon className="w-3 h-3" />
</Menu.ItemButton> </Menu.ItemButton>
))} ))}
{magic ? ( {magic
<Menu.ItemButton ? (
type='button' <Menu.ItemButton
className={cn( type="button"
'flex items-center gap-x-1.5 text-sm', className={cn(
'justify-between w-full break-keep' 'flex items-center gap-x-1.5 text-sm',
)} 'justify-between w-full break-keep',
onPress={async () => { )}
const ip = `${machine.givenName}.${machine.user.name}.${magic}` onPress={async () => {
await navigator.clipboard.writeText(ip) const ip = `${machine.givenName}.${machine.user.name}.${magic}`
toast('Copied hostname to clipboard') await navigator.clipboard.writeText(ip)
}} toast('Copied hostname to clipboard')
> }}
{machine.givenName}.{machine.user.name}.{magic} >
<CopyIcon className='w-3 h-3'/> {machine.givenName}
</Menu.ItemButton> .
) : undefined} {machine.user.name}
.
{magic}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
)
: undefined}
</Menu.Items> </Menu.Items>
</Menu> </Menu>
</div> </div>
</td> </td>
<td className='py-2'> <td className="py-2">
<span className={cn( <span className={cn(
'flex items-center gap-x-1 text-sm', 'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400' 'text-gray-500 dark:text-gray-400',
)} )}
> >
<StatusCircle <StatusCircle
isOnline={machine.online && !expired} isOnline={machine.online && !expired}
className='w-4 h-4' className="w-4 h-4"
/> />
<p> <p>
{machine.online && !expired {machine.online && !expired
? 'Connected' ? 'Connected'
: new Date( : new Date(
machine.lastSeen machine.lastSeen,
).toLocaleString()} ).toLocaleString()}
</p> </p>
</span> </span>
</td> </td>
<td className='py-2 pr-0.5'> <td className="py-2 pr-0.5">
<Rename <MenuOptions
machine={machine}
fetcher={fetcher}
state={renameState}
magic={magic}
/>
<Delete
machine={machine}
fetcher={fetcher}
state={removeState}
/>
{expired ? undefined : (
<Expire
machine={machine}
fetcher={fetcher}
state={expireState}
/>
)}
<Routes
machine={machine} machine={machine}
routes={routes} routes={routes}
fetcher={fetcher} magic={magic}
state={routesState}
/> />
<Menu>
<Menu.Button
className={cn(
'flex items-center justify-center',
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
)}
>
<KebabHorizontalIcon className='w-5'/>
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={renameState}>
Edit machine name
</Menu.ItemButton>
<Menu.ItemButton control={routesState}>
Edit route settings
</Menu.ItemButton>
<Menu.Item className='opacity-50 hover:bg-transparent'>
Edit ACL tags
</Menu.Item>
{expired ? undefined : (
<Menu.ItemButton control={expireState}>
Expire
</Menu.ItemButton>
)}
<Menu.ItemButton
className='text-red-500 dark:text-red-400'
control={removeState}
>
Remove
</Menu.ItemButton>
</Menu.Items>
</Menu>
</td> </td>
</tr> </tr>
) )
+89
View File
@@ -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 (
<>
<Rename
machine={machine}
state={renameState}
magic={magic}
/>
<Delete
machine={machine}
state={removeState}
/>
{expired
? undefined
: (
<Expire
machine={machine}
state={expireState}
/>
)}
<Routes
machine={machine}
routes={routes}
state={routesState}
/>
<MenuComponent>
<MenuComponent.Button
className={cn(
'flex items-center justify-center',
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700',
)}
>
<KebabHorizontalIcon className="w-5" />
</MenuComponent.Button>
<MenuComponent.Items>
<MenuComponent.ItemButton control={renameState}>
Edit machine name
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={routesState}>
Edit route settings
</MenuComponent.ItemButton>
<MenuComponent.Item className="opacity-50 hover:bg-transparent">
Edit ACL tags
</MenuComponent.Item>
{expired
? undefined
: (
<MenuComponent.ItemButton control={expireState}>
Expire
</MenuComponent.ItemButton>
)}
<MenuComponent.ItemButton
className="text-red-500 dark:text-red-400"
control={removeState}
>
Remove
</MenuComponent.ItemButton>
</MenuComponent.Items>
</MenuComponent>
</>
)
}
+3 -6
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { InfoIcon } from '@primer/octicons-react' import { InfoIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node' 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 { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
import Code from '~/components/Code' import Code from '~/components/Code'
@@ -107,7 +107,6 @@ export async function action({ request }: ActionFunctionArgs) {
export default function Page() { export default function Page() {
useLiveData({ interval: 3000 }) useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>()
const fetcher = useFetcher()
return ( return (
<> <>
@@ -155,10 +154,8 @@ export default function Page() {
{data.nodes.map(machine => ( {data.nodes.map(machine => (
<MachineRow <MachineRow
key={machine.id} key={machine.id}
// Typescript isn't smart enough yet machine={machine}
machine={machine as unknown as Machine} routes={data.routes.filter(route => route.node.id === machine.id)}
routes={data.routes.filter(route => route.node.id === machine.id) as unknown as Route[]}
fetcher={fetcher}
magic={data.magic} magic={data.magic}
/> />
))} ))}
-3
View File
@@ -182,15 +182,12 @@ export default function Page() {
<Auth magic={data.magic} /> <Auth magic={data.magic} />
)} )}
<ClientOnly fallback={ <ClientOnly fallback={
// @ts-expect-error: JsonifyObject is weird
<Users users={users} /> <Users users={users} />
} }
> >
{() => ( {() => (
<InteractiveUsers <InteractiveUsers
// @ts-expect-error: JsonifyObject is weird
users={users} users={users}
// @ts-expect-error: JsonifyObject is weird
setUsers={setUsers} setUsers={setUsers}
magic={data.magic} magic={data.magic}
/> />
+18 -18
View File
@@ -1,28 +1,28 @@
import type { User } from './User' import type { User } from './User'
export type Machine = { export interface Machine {
id: string; id: string
machineKey: string; machineKey: string
nodeKey: string; nodeKey: string
discoKey: string; discoKey: string
ipAddresses: string[]; ipAddresses: string[]
name: string; name: string
user: User; user: User
lastSeen: Date; lastSeen: string
expiry: Date; expiry: string
preAuthKey?: unknown; // TODO preAuthKey?: unknown // TODO
createdAt: Date; createdAt: string
registerMethod: 'REGISTER_METHOD_UNSPECIFIED' registerMethod: 'REGISTER_METHOD_UNSPECIFIED'
| 'REGISTER_METHOD_AUTH_KEY' | 'REGISTER_METHOD_AUTH_KEY'
| 'REGISTER_METHOD_CLI' | 'REGISTER_METHOD_CLI'
| 'REGISTER_METHOD_OIDC'; | 'REGISTER_METHOD_OIDC'
forcedTags: string[]; forcedTags: string[]
invalidTags: string[]; invalidTags: string[]
validTags: string[]; validTags: string[]
givenName: string; givenName: string
online: boolean; online: boolean
} }
+10 -10
View File
@@ -1,13 +1,13 @@
import type { Machine } from './Machine' import type { Machine } from './Machine'
export type Route = { export interface Route {
id: string; id: string
node: Machine; node: Machine
prefix: string; prefix: string
advertised: boolean; advertised: boolean
enabled: boolean; enabled: boolean
isPrimary: boolean; isPrimary: boolean
createdAt: Date; createdAt: string
updatedAt: Date; updatedAt: string
deletedAt: Date; deletedAt: string
} }
+4 -4
View File
@@ -1,5 +1,5 @@
export type User = { export interface User {
id: string; id: string
name: string; name: string
createdAt: Date; createdAt: string
} }