feat: add various machine operations

This commit is contained in:
Aarnav Tale
2024-05-01 02:47:45 -04:00
parent a63f4e4d52
commit 82f5e17207
6 changed files with 269 additions and 72 deletions
@@ -25,7 +25,7 @@ export default function Delete({ machine, fetcher, state }: DeleteProperties) {
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> <fetcher.Form method='POST'>
<input type='hidden' name='_method' value='delete'/> <input type='hidden' name='_method' value='delete'/>
<input type='hidden' name='id' value={machine.id}/> <input type='hidden' name='id' value={machine.id}/>
<div className='mt-6 flex justify-end gap-2 mt-6'> <div className='mt-6 flex justify-end gap-2 mt-6'>
@@ -39,7 +39,9 @@ export default function Delete({ machine, fetcher, state }: DeleteProperties) {
variant='confirm' variant='confirm'
className={cn( className={cn(
'bg-red-500 hover:border-red-700', 'bg-red-500 hover:border-red-700',
'pressed:bg-red-600' 'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white'
)} )}
onPress={close} onPress={close}
> >
@@ -0,0 +1,57 @@
import { type FetcherWithComponents } 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>>];
}
export default function Expire({ machine, fetcher, state }: DeleteProperties) {
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
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'>
<Dialog.Action
variant='cancel'
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
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'
)}
onPress={close}
>
Expire
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -5,7 +5,6 @@ import Code from '~/components/Code'
import Dialog from '~/components/Dialog' 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'
import { cn } from '~/utils/cn'
type RenameProperties = { type RenameProperties = {
readonly machine: Machine; readonly machine: Machine;
@@ -29,7 +28,7 @@ export default function Rename({ machine, fetcher, state, magic }: RenamePropert
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> <fetcher.Form method='POST'>
<input type='hidden' name='_method' value='rename'/> <input type='hidden' name='_method' value='rename'/>
<input type='hidden' name='id' value={machine.id}/> <input type='hidden' name='id' value={machine.id}/>
<TextField <TextField
@@ -0,0 +1,92 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog'
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>>];
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, fetcher, state }: RoutesProperties) {
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit route settings of {machine.givenName}
</Dialog.Title>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on
by advertising IP ranges as subnet routes.
</Dialog.Text>
<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'
)}
>
{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'
)}
>
<p>
{route.prefix}
</p>
<Switch
defaultSelected={route.enabled}
label='Enabled'
onChange={checked => {
const form = new FormData()
form.set('id', machine.id)
form.set('_method', 'routes')
form.set('route', route.id)
form.set('enabled', String(checked))
fetcher.submit(form, {
method: 'POST'
})
}}
/>
</div>
))}
</div>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>
Close
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
+86 -62
View File
@@ -2,27 +2,40 @@
import { ChevronDownIcon, ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline' import { ChevronDownIcon, ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
import { type FetcherWithComponents, Link } from '@remix-run/react' import { type FetcherWithComponents, Link } from '@remix-run/react'
import { useState } from 'react' import { useState } from 'react'
import toast from 'react-hot-toast/headless'
import Dialog from '~/components/Dialog'
import Menu from '~/components/Menu' import Menu from '~/components/Menu'
import StatusCircle from '~/components/StatusCircle' import StatusCircle from '~/components/StatusCircle'
import { type Machine } from '~/types' import { toast } from '~/components/Toaster'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import Delete from './dialogs/delete' import Delete from './dialogs/delete'
import Expire from './dialogs/expire'
import Rename from './dialogs/rename' import Rename from './dialogs/rename'
import Routes from './dialogs/routes'
type MachineProperties = { type MachineProperties = {
readonly machine: Machine; readonly machine: Machine;
readonly routes: Route[];
readonly fetcher: FetcherWithComponents<unknown>; readonly fetcher: FetcherWithComponents<unknown>;
readonly magic?: string; readonly magic?: string;
} }
export default function MachineRow({ machine, fetcher, magic }: MachineProperties) { export default function MachineRow({ machine, routes, fetcher, magic }: MachineProperties) {
const tags = [...machine.forcedTags, ...machine.validTags]
const renameState = useState(false) const renameState = useState(false)
const expireState = useState(false)
const removeState = useState(false) const removeState = useState(false)
const routesState = useState(false)
const expired = new Date(machine.expiry).getTime() < Date.now()
const tags = [
...machine.forcedTags,
...machine.validTags
]
if (expired) {
tags.unshift('Expired')
}
return ( return (
<tr <tr
@@ -70,56 +83,55 @@ export default function MachineRow({ machine, fetcher, magic }: MachinePropertie
</Menu.Button> </Menu.Button>
<Menu.Items> <Menu.Items>
{machine.ipAddresses.map(ip => ( {machine.ipAddresses.map(ip => (
<Menu.Item <Menu.ItemButton
key={ip} key={ip}
className='hover:bg-transparent' type='button'
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full'
)}
onPress={async () => {
await navigator.clipboard.writeText(ip)
toast('Copied IP address to clipboard')
}}
> >
<button {ip}
type='button' <ClipboardIcon className='w-3 h-3'/>
className={cn( </Menu.ItemButton>
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full'
)}
onClick={async () => {
await navigator.clipboard.writeText(ip)
toast('Copied IP address to clipboard')
}}
>
{ip}
<ClipboardIcon className='w-3 h-3'/>
</button>
</Menu.Item>
))} ))}
{magic ? ( {magic ? (
<Menu.Item className='hover:bg-transparent'> <Menu.ItemButton
<button 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 break-keep'
'justify-between w-full break-keep' )}
)} onPress={async () => {
onClick={async () => { const ip = `${machine.givenName}.${machine.user.name}.${magic}`
const ip = `${machine.givenName}.${machine.user.name}.${magic}` await navigator.clipboard.writeText(ip)
await navigator.clipboard.writeText(ip) toast('Copied hostname to clipboard')
toast('Copied hostname to clipboard') }}
}} >
> {machine.givenName}.{machine.user.name}.{magic}
{machine.givenName}.{machine.user.name}.{magic} <ClipboardIcon className='w-3 h-3'/>
<ClipboardIcon className='w-3 h-3'/> </Menu.ItemButton>
</button>
</Menu.Item>
) : undefined} ) : undefined}
</Menu.Items> </Menu.Items>
</Menu> </Menu>
</div> </div>
</td> </td>
<td className='py-2'> <td className='py-2'>
<span <span className={cn(
className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400' 'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400'
)}
> >
<StatusCircle isOnline={machine.online} className='w-4 h-4'/> <StatusCircle
isOnline={machine.online && !expired}
className='w-4 h-4'
/>
<p> <p>
{machine.online {machine.online && !expired
? 'Connected' ? 'Connected'
: new Date( : new Date(
machine.lastSeen machine.lastSeen
@@ -139,6 +151,20 @@ export default function MachineRow({ machine, fetcher, magic }: MachinePropertie
fetcher={fetcher} fetcher={fetcher}
state={removeState} state={removeState}
/> />
{expired ? undefined : (
<Expire
machine={machine}
fetcher={fetcher}
state={expireState}
/>
)}
<Routes
machine={machine}
routes={routes}
fetcher={fetcher}
state={routesState}
/>
<Menu> <Menu>
<Menu.Button <Menu.Button
className={cn( className={cn(
@@ -150,28 +176,26 @@ export default function MachineRow({ machine, fetcher, magic }: MachinePropertie
<EllipsisHorizontalIcon className='w-5'/> <EllipsisHorizontalIcon className='w-5'/>
</Menu.Button> </Menu.Button>
<Menu.Items> <Menu.Items>
<Menu.Item> <Menu.ItemButton control={renameState}>
<Dialog.Button Edit machine name
className='h-full w-full text-left' </Menu.ItemButton>
control={renameState} <Menu.ItemButton control={routesState}>
>
Edit machine name
</Dialog.Button>
</Menu.Item>
<Menu.Item>
Edit route settings Edit route settings
</Menu.Item> </Menu.ItemButton>
<Menu.Item> <Menu.Item className='opacity-50 hover:bg-transparent'>
Edit ACL tags Edit ACL tags
</Menu.Item> </Menu.Item>
<Menu.Item> {expired ? undefined : (
<Dialog.Button <Menu.ItemButton control={expireState}>
className='w-full h-full text-left text-red-500 dark:text-red-400' Expire
control={removeState} </Menu.ItemButton>
> )}
Remove <Menu.ItemButton
</Dialog.Button> className='text-red-500 dark:text-red-400'
</Menu.Item> control={removeState}
>
Remove
</Menu.ItemButton>
</Menu.Items> </Menu.Items>
</Menu> </Menu>
</td> </td>
+29 -6
View File
@@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { InformationCircleIcon } from '@heroicons/react/24/outline' import { InformationCircleIcon } from '@heroicons/react/24/outline'
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 { useFetcher, 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'
import { type Machine } from '~/types' import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import { getConfig, getContext } from '~/utils/config' import { getConfig, getContext } from '~/utils/config'
import { del, post, pull } from '~/utils/headscale' import { del, post, pull } from '~/utils/headscale'
@@ -16,8 +17,8 @@ import MachineRow from './machine'
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'))
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const machines = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!) const routes = await pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!)
const context = await getContext() const context = await getContext()
let magic: string | undefined let magic: string | undefined
@@ -29,7 +30,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
} }
return { return {
nodes: data.nodes, nodes: machines.nodes,
routes: routes.routes,
magic magic
} }
} }
@@ -54,11 +56,17 @@ export async function action({ request }: ActionFunctionArgs) {
switch (method) { switch (method) {
case 'delete': { case 'delete': {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await del(`v1/node/${id}`, session.get('hsApiKey')!) await del(`v1/node/${id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' }) return json({ message: 'Machine removed' })
} }
case 'expire': {
console.log('expire')
const data = await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
console.log(data)
return json({ message: 'Machine expired' })
}
case 'rename': { case 'rename': {
if (!data.has('name')) { if (!data.has('name')) {
return json({ message: 'No name provided' }, { return json({ message: 'No name provided' }, {
@@ -68,11 +76,25 @@ export async function action({ request }: ActionFunctionArgs) {
const name = String(data.get('name')) const name = String(data.get('name'))
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!) await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
return json({ message: 'Machine renamed' }) return json({ message: 'Machine renamed' })
} }
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, {
status: 400
})
}
const route = String(data.get('route'))
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
return json({ message: 'Route updated' })
}
default: { default: {
return json({ message: 'Invalid method' }, { return json({ message: 'Invalid method' }, {
status: 400 status: 400
@@ -131,6 +153,7 @@ export default function Page() {
key={machine.id} key={machine.id}
// Typescript isn't smart enough yet // Typescript isn't smart enough yet
machine={machine as unknown as Machine} machine={machine as unknown as Machine}
routes={data.routes.filter(route => route.node.id === machine.id) as unknown as Route[]}
fetcher={fetcher} fetcher={fetcher}
magic={data.magic} magic={data.magic}
/> />