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
No known key found for this signature in database
12 changed files with 458 additions and 289 deletions

View File

@ -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<typeof loader>()
const { machine, magic, routes } = useLoaderData<typeof loader>()
useLiveData({ interval: 1000 })
const expired = new Date(machine.expiry).getTime() < Date.now()
const tags = [
...machine.forcedTags,
...machine.validTags,
]
if (expired) {
tags.unshift('Expired')
}
return (
<div>
<p className='mb-4 text-gray-500 dark:text-gray-400 text-sm'>
<p className="mb-8 text-md">
<Link
to='/machines'
className='font-bold text-gray-700 dark:text-gray-300 hover:underline'
to="/machines"
className="font-medium"
>
All Machines
</Link>
{' / '}
{data.givenName}
<span className="mx-2">
/
</span>
{machine.givenName}
</p>
<span className='flex items-baseline gap-x-4 text-sm mb-4'>
<h1 className='text-2xl font-bold'>
{data.givenName}
</h1>
<StatusCircle isOnline={data.online} className='w-4 h-4'/>
</span>
<Card variant='flat'>
<Attribute name='Creator' value={data.user.name}/>
<Attribute name='Node ID' value={data.id}/>
<Attribute name='Node Name' value={data.givenName}/>
<Attribute name='Hostname' value={data.name}/>
<div className="flex justify-between items-center">
<span className="flex items-baseline gap-x-4 text-sm mb-4">
<h1 className="text-2xl font-medium">
{machine.givenName}
</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
</span>
<MenuOptions
machine={machine}
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
isCopyable
name='Node Key'
value={data.nodeKey}
name="Node Key"
value={machine.nodeKey}
/>
<Attribute
name='Created'
value={new Date(data.createdAt).toLocaleString()}
name="Created"
value={new Date(machine.createdAt).toLocaleString()}
/>
<Attribute
name='Last Seen'
value={new Date(data.lastSeen).toLocaleString()}
name="Last Seen"
value={new Date(machine.lastSeen).toLocaleString()}
/>
<Attribute
name='Expiry'
value={new Date(data.expiry).toLocaleString()}
name="Expiry"
value={new Date(machine.expiry).toLocaleString()}
/>
<Attribute
isCopyable
name='Domain'
value={`${data.givenName}.${data.user.name}.ts.net`}
name="Domain"
value={`${machine.givenName}.${machine.user.name}.ts.net`}
/>
</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>
)
}

View File

@ -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<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
interface DeleteProps {
readonly machine: Machine
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 (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Remove {machine.givenName}
Remove
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from
your network. To re-add it, you will need to
reauthenticate to your tailnet from the device.
</Dialog.Text>
<fetcher.Form method='POST'>
<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'>
<Form
method="POST"
onSubmit={(e) => {
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
variant='cancel'
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
variant="confirm"
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white'
'text-white dark:text-white',
)}
onPress={close}
>
Remove
</Dialog.Action>
</div>
</fetcher.Form>
</Form>
</>
)}
</Dialog.Panel>

View File

@ -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<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
interface ExpireProps {
readonly machine: Machine
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 (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Expire {machine.givenName}
Expire
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet.
In order to reconnect, you will need to re-authenticate
from the device.
</Dialog.Text>
<fetcher.Form method='POST'>
<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'>
<Form
method="POST"
onSubmit={(e) => {
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
variant='cancel'
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
variant="confirm"
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white'
'text-white dark:text-white',
)}
onPress={close}
>
Expire
</Dialog.Action>
</div>
</fetcher.Form>
</Form>
</>
)}
</Dialog.Panel>

View File

@ -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<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
interface RenameProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
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 (
<Dialog>
@ -22,65 +22,76 @@ export default function Rename({ machine, fetcher, state, magic }: RenamePropert
{close => (
<>
<Dialog.Title>
Edit machine name for {machine.givenName}
Edit machine name for
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
This name is shown in the admin panel, in Tailscale clients,
and used when generating MagicDNS names.
</Dialog.Text>
<fetcher.Form method='POST'>
<input type='hidden' name='_method' value='rename'/>
<input type='hidden' name='id' value={machine.id}/>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="id" value={machine.id} />
<TextField
label='Machine name'
placeholder='Machine name'
name='name'
label="Machine name"
placeholder="Machine name"
name="name"
state={[name, setName]}
className='my-2'
className="my-2"
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
This machine will be accessible by the hostname
{' '}
<Code className='text-sm'>
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname
{' '}
<Code className='text-sm'>
{machine.givenName}
</Code>
{' '}
will no longer point to this machine.
</p>
) : (
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
This machine is accessible by the hostname
{' '}
<Code className='text-sm'>
{machine.givenName}
</Code>
.
</p>
)
) : undefined}
<div className='mt-6 flex justify-end gap-2 mt-6'>
{magic
? (
name.length > 0 && name !== machine.givenName
? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine will be accessible by the hostname
{' '}
<Code className="text-sm">
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname
{' '}
<Code className="text-sm">
{machine.givenName}
</Code>
{' '}
will no longer point to this machine.
</p>
)
: (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname
{' '}
<Code className="text-sm">
{machine.givenName}
</Code>
.
</p>
)
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant='cancel'
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
variant="confirm"
onPress={close}
>
Rename
</Dialog.Action>
</div>
</fetcher.Form>
</Form>
</>
)}
</Dialog.Panel>

View File

@ -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<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
interface RoutesProps {
readonly machine: Machine
readonly routes: Route[]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
}
// 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 (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit route settings of {machine.givenName}
Edit route settings of
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
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(
'rounded-lg overflow-y-auto my-2',
'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 ? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300'
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
) : undefined}
{routes.length === 0
? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
)
: undefined}
{routes.map(route => (
<div
key={route.node.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between'
'items-center justify-between',
)}
>
<p>
@ -59,8 +64,8 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
</p>
<Switch
defaultSelected={route.enabled}
label='Enabled'
onChange={checked => {
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',
})
}}
/>
</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
variant='cancel'
variant="cancel"
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>

View File

@ -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<unknown>;
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 (
<tr
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
to={`/machines/${machine.id}`}
className='group/link h-full'
className="group/link h-full"
>
<p className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400'
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
</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}
</p>
<div className='flex gap-1 mt-1'>
<div className="flex gap-1 mt-1">
{tags.map(tag => (
<span
key={tag}
className={cn(
'text-xs rounded-sm px-1 py-0.5',
'bg-gray-100 dark:bg-zinc-700',
'text-gray-600 dark:text-gray-300'
'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}
@ -74,21 +64,21 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
</div>
</Link>
</td>
<td className='py-2'>
<div className='flex items-center gap-x-1'>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
<Menu>
<Menu.Button>
<ChevronDownIcon className='w-4 h-4'/>
<ChevronDownIcon className="w-4 h-4" />
</Menu.Button>
<Menu.Items>
{machine.ipAddresses.map(ip => (
<Menu.ItemButton
key={ip}
type='button'
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full'
'justify-between w-full',
)}
onPress={async () => {
await navigator.clipboard.writeText(ip)
@ -96,108 +86,61 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
}}
>
{ip}
<CopyIcon className='w-3 h-3'/>
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
))}
{magic ? (
<Menu.ItemButton
type='button'
className={cn(
'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}`
await navigator.clipboard.writeText(ip)
toast('Copied hostname to clipboard')
}}
>
{machine.givenName}.{machine.user.name}.{magic}
<CopyIcon className='w-3 h-3'/>
</Menu.ItemButton>
) : undefined}
{magic
? (
<Menu.ItemButton
type="button"
className={cn(
'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}`
await navigator.clipboard.writeText(ip)
toast('Copied hostname to clipboard')
}}
>
{machine.givenName}
.
{machine.user.name}
.
{magic}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
)
: undefined}
</Menu.Items>
</Menu>
</div>
</td>
<td className='py-2'>
<td className="py-2">
<span className={cn(
'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400'
'text-gray-500 dark:text-gray-400',
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className='w-4 h-4'
className="w-4 h-4"
/>
<p>
{machine.online && !expired
? 'Connected'
: new Date(
machine.lastSeen
machine.lastSeen,
).toLocaleString()}
</p>
</span>
</td>
<td className='py-2 pr-0.5'>
<Rename
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
<td className="py-2 pr-0.5">
<MenuOptions
machine={machine}
routes={routes}
fetcher={fetcher}
state={routesState}
magic={magic}
/>
<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>
</tr>
)

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>
</>
)
}

View File

@ -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<typeof loader>()
const fetcher = useFetcher()
return (
<>
@ -155,10 +154,8 @@ export default function Page() {
{data.nodes.map(machine => (
<MachineRow
key={machine.id}
// Typescript isn't smart enough yet
machine={machine as unknown as Machine}
routes={data.routes.filter(route => 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}
/>
))}

View File

@ -182,15 +182,12 @@ export default function Page() {
<Auth magic={data.magic} />
)}
<ClientOnly fallback={
// @ts-expect-error: JsonifyObject is weird
<Users users={users} />
}
>
{() => (
<InteractiveUsers
// @ts-expect-error: JsonifyObject is weird
users={users}
// @ts-expect-error: JsonifyObject is weird
setUsers={setUsers}
magic={data.magic}
/>

View File

@ -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
}

View File

@ -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
}

View File

@ -1,5 +1,5 @@
export type User = {
id: string;
name: string;
createdAt: Date;
export interface User {
id: string
name: string
createdAt: string
}