mirror of
https://github.com/tale/headplane.git
synced 2026-08-31 17:28:14 +00:00
feat: add various machine operations
This commit is contained in:
@@ -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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user