feat: implement node removal
This commit is contained in:
parent
1d9f4553eb
commit
a47fb61549
@ -11,7 +11,7 @@ type Properties = {
|
|||||||
|
|
||||||
function Dropdown(properties: Properties) {
|
function Dropdown(properties: Properties) {
|
||||||
return (
|
return (
|
||||||
<div className='relative'>
|
<div className={clsx('relative', properties.className)}>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Button className='flex flex-col items-center'>
|
<Button className='flex flex-col items-center'>
|
||||||
{properties.button}
|
{properties.button}
|
||||||
@ -26,10 +26,10 @@ function Dropdown(properties: Properties) {
|
|||||||
leaveTo='transform opacity-0 scale-95'
|
leaveTo='transform opacity-0 scale-95'
|
||||||
>
|
>
|
||||||
<Menu.Items className={clsx(
|
<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',
|
'text-gray-700 dark:text-gray-300',
|
||||||
'bg-white dark:bg-zinc-800 text-right',
|
'bg-white dark:bg-zinc-800 text-right',
|
||||||
'overflow-hidden',
|
'overflow-hidden z-50',
|
||||||
'border border-gray-200 dark:border-zinc-700',
|
'border border-gray-200 dark:border-zinc-700',
|
||||||
'divide-y divide-gray-200 dark:divide-zinc-700'
|
'divide-y divide-gray-200 dark:divide-zinc-700'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,98 +1,171 @@
|
|||||||
/* eslint-disable unicorn/filename-case */
|
/* eslint-disable unicorn/filename-case */
|
||||||
import { ClipboardIcon } from '@heroicons/react/24/outline'
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { type LoaderFunctionArgs } from '@remix-run/node'
|
|
||||||
import { Link, useLoaderData } from '@remix-run/react'
|
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 clsx from 'clsx'
|
||||||
|
import { useState } from 'react'
|
||||||
import { toast } from 'react-hot-toast/headless'
|
import { toast } from 'react-hot-toast/headless'
|
||||||
|
|
||||||
|
import Dropdown from '~/components/Dropdown'
|
||||||
|
import useModal from '~/components/Modal'
|
||||||
import StatusCircle from '~/components/StatusCircle'
|
import StatusCircle from '~/components/StatusCircle'
|
||||||
import { type Machine } from '~/types'
|
import { type Machine } from '~/types'
|
||||||
import { pull } from '~/utils/headscale'
|
import { del, pull } from '~/utils/headscale'
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
import { useLiveData } from '~/utils/useLiveData'
|
import { useLiveData } from '~/utils/useLiveData'
|
||||||
|
|
||||||
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 data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
|
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
|
||||||
return data.nodes
|
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() {
|
export default function Page() {
|
||||||
const data = useLoaderData<typeof loader>()
|
|
||||||
useLiveData({ interval: 3000 })
|
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 (
|
return (
|
||||||
<table className='table-auto w-full rounded-lg'>
|
<>
|
||||||
<thead className='text-gray-500 dark:text-gray-400'>
|
{Modal}
|
||||||
<tr className='text-left uppercase text-sm font-bold'>
|
<table className='table-auto w-full rounded-lg'>
|
||||||
<th className='pl-4'>Name</th>
|
<thead className='text-gray-500 dark:text-gray-400'>
|
||||||
<th>IP Addresses</th>
|
<tr className='text-left uppercase text-sm font-bold'>
|
||||||
<th>Last Seen</th>
|
<th className='pl-4'>Name</th>
|
||||||
</tr>
|
<th>IP Addresses</th>
|
||||||
</thead>
|
<th>Last Seen</th>
|
||||||
<tbody className={clsx(
|
</tr>
|
||||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
</thead>
|
||||||
'border-t border-zinc-200 dark:border-zinc-700'
|
<tbody className={clsx(
|
||||||
)}
|
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||||
>
|
'border-t border-zinc-200 dark:border-zinc-700'
|
||||||
{data.map(machine => {
|
)}
|
||||||
const tags = [...machine.forcedTags, ...machine.validTags]
|
>
|
||||||
|
{data.map(machine => {
|
||||||
return (
|
const tags = [...machine.forcedTags, ...machine.validTags]
|
||||||
<tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800'>
|
return (
|
||||||
<td className='py-2 pl-4'>
|
<tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'>
|
||||||
<Link to={`/machines/${machine.id}`}>
|
<td className='py-2 pl-4'>
|
||||||
<h1>{machine.givenName}</h1>
|
<Link to={`/machines/${machine.id}`}>
|
||||||
<span className='text-sm font-mono text-gray-500 dark:text-gray-400'>
|
<h1>{machine.givenName}</h1>
|
||||||
{machine.name}
|
<span className='text-sm font-mono text-gray-500 dark:text-gray-400'>
|
||||||
</span>
|
{machine.name}
|
||||||
<div className='flex gap-1 mt-1'>
|
|
||||||
{tags.map(tag => (
|
|
||||||
<span key={tag} className='text-xs bg-gray-200 text-gray-600 rounded-sm px-1 py-0.5'>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className='pt-2 pb-4 font-mono text-gray-600 dark:text-gray-300'>
|
|
||||||
{machine.ipAddresses.map((ip, index) => (
|
|
||||||
<button
|
|
||||||
key={ip}
|
|
||||||
type='button'
|
|
||||||
className='flex items-center gap-x-1 w-full'
|
|
||||||
onClick={async () => {
|
|
||||||
await navigator.clipboard.writeText(ip)
|
|
||||||
toast('Copied IP address to clipboard')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className={clsx(index === 0 ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500')}>
|
|
||||||
{ip}
|
|
||||||
</span>
|
</span>
|
||||||
<ClipboardIcon className='text-gray-400 dark:text-gray-500 w-4 h-4'/>
|
<div className='flex gap-1 mt-1'>
|
||||||
</button>
|
{tags.map(tag => (
|
||||||
))}
|
<span key={tag} className='text-xs bg-gray-200 text-gray-600 rounded-sm px-1 py-0.5'>
|
||||||
</td>
|
{tag}
|
||||||
<td className='py-2'>
|
</span>
|
||||||
<span
|
))}
|
||||||
className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400'
|
</div>
|
||||||
>
|
</Link>
|
||||||
<StatusCircle isOnline={machine.online} className='w-4 h-4'/>
|
</td>
|
||||||
<p>
|
<td className='pt-2 pb-4 font-mono text-gray-600 dark:text-gray-300'>
|
||||||
{machine.online
|
{machine.ipAddresses.map((ip, index) => (
|
||||||
? 'Connected'
|
<button
|
||||||
: new Date(
|
key={ip}
|
||||||
machine.lastSeen
|
type='button'
|
||||||
).toLocaleString()}
|
className='flex items-center gap-x-1 w-full'
|
||||||
</p>
|
onClick={async () => {
|
||||||
</span>
|
await navigator.clipboard.writeText(ip)
|
||||||
</td>
|
toast('Copied IP address to clipboard')
|
||||||
</tr>
|
}}
|
||||||
)
|
>
|
||||||
})}
|
<span className={clsx(index === 0 ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500')}>
|
||||||
</tbody>
|
{ip}
|
||||||
</table>
|
</span>
|
||||||
|
<ClipboardIcon className='text-gray-400 dark:text-gray-500 w-4 h-4'/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
<td className='py-2'>
|
||||||
|
<span
|
||||||
|
className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400'
|
||||||
|
>
|
||||||
|
<StatusCircle isOnline={machine.online} className='w-4 h-4'/>
|
||||||
|
<p>
|
||||||
|
{machine.online
|
||||||
|
? 'Connected'
|
||||||
|
: new Date(
|
||||||
|
machine.lastSeen
|
||||||
|
).toLocaleString()}
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,3 +48,18 @@ export async function post<T>(url: string, key: string, body?: unknown) {
|
|||||||
return (response.json() as Promise<T>)
|
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>)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user