feat: implement node removal

This commit is contained in:
Aarnav Tale 2024-03-30 18:56:21 -04:00
parent 1d9f4553eb
commit a47fb61549
No known key found for this signature in database
3 changed files with 166 additions and 78 deletions

View File

@ -11,7 +11,7 @@ type Properties = {
function Dropdown(properties: Properties) {
return (
<div className='relative'>
<div className={clsx('relative', properties.className)}>
<Menu>
<Button className='flex flex-col items-center'>
{properties.button}
@ -26,10 +26,10 @@ function Dropdown(properties: Properties) {
leaveTo='transform opacity-0 scale-95'
>
<Menu.Items className={clsx(
'absolute right-0 w-36 mt-2 rounded-md',
'absolute right-0 w-fit max-w-36 mt-2 rounded-md',
'text-gray-700 dark:text-gray-300',
'bg-white dark:bg-zinc-800 text-right',
'overflow-hidden',
'overflow-hidden z-50',
'border border-gray-200 dark:border-zinc-700',
'divide-y divide-gray-200 dark:divide-zinc-700'
)}

View File

@ -1,29 +1,77 @@
/* eslint-disable unicorn/filename-case */
import { ClipboardIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs } from '@remix-run/node'
import { Link, useLoaderData } from '@remix-run/react'
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
import { Link, useFetcher, useLoaderData } from '@remix-run/react'
import clsx from 'clsx'
import { useState } from 'react'
import { toast } from 'react-hot-toast/headless'
import Dropdown from '~/components/Dropdown'
import useModal from '~/components/Modal'
import StatusCircle from '~/components/StatusCircle'
import { type Machine } from '~/types'
import { pull } from '~/utils/headscale'
import { del, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
return data.nodes
}
export async function action({ request }: ActionFunctionArgs) {
const data = await request.json() as { id?: string }
if (!data.id) {
return json({ message: 'No ID provided' }, {
status: 400
})
}
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401
})
}
await del(`v1/node/${data.id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
}
export default function Page() {
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>()
const [activeId, setActiveId] = useState<string | undefined>(undefined)
const fetcher = useFetcher()
const { Modal, open } = useModal({
title: 'Remove Machine',
description: [
'This action is irreversible and will disconnect the machine from the Headscale server.',
'All data associated with this machine including ACLs and tags will be lost.'
].join('\n'),
variant: 'danger',
buttonText: 'Remove',
onConfirm: () => {
fetcher.submit(
{
id: activeId!
},
{
method: 'DELETE',
encType: 'application/json'
}
)
}
})
return (
<>
{Modal}
<table className='table-auto w-full rounded-lg'>
<thead className='text-gray-500 dark:text-gray-400'>
<tr className='text-left uppercase text-sm font-bold'>
@ -39,9 +87,8 @@ export default function Page() {
>
{data.map(machine => {
const tags = [...machine.forcedTags, ...machine.validTags]
return (
<tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800'>
<tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'>
<td className='py-2 pl-4'>
<Link to={`/machines/${machine.id}`}>
<h1>{machine.givenName}</h1>
@ -89,10 +136,36 @@ export default function Page() {
</p>
</span>
</td>
<td className='py-2 pr-4'>
<div className={clsx(
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
)}
>
<Dropdown
className='left-1/4 w-min'
button={(
<EllipsisHorizontalIcon className='w-5 h-5'/>
)}
>
<Dropdown.Item className='text-red-700'>
<button
type='button' onClick={() => {
setActiveId(machine.id)
open()
}}
>
Remove
</button>
</Dropdown.Item>
</Dropdown>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</>
)
}

View File

@ -48,3 +48,18 @@ export async function post<T>(url: string, key: string, body?: unknown) {
return (response.json() as Promise<T>)
}
export async function del<T>(url: string, key: string) {
const prefix = process.env.HEADSCALE_URL!
const response = await fetch(`${prefix}/api/${url}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${key}`
}
})
if (!response.ok) {
throw new HeadscaleError(await response.text(), response.status)
}
return (response.json() as Promise<T>)
}