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

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