mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 00:08:14 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca32590e54 | |||
| a846249be1 | |||
| bcf00beb75 | |||
| 6dae5d647a | |||
| d787b8517e | |||
| bdb00b6cd7 | |||
| f1347803a4 | |||
| 6fa27e5d28 | |||
| 78140927ad | |||
| a47fb61549 | |||
| 1d9f4553eb | |||
| b146e4c3a8 | |||
| 381c3d6df4 |
@@ -39,3 +39,4 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Menu, type MenuButtonProps, Transition } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import { Fragment, type HTMLProps, type ReactNode } from 'react'
|
||||
|
||||
type Properties = {
|
||||
readonly children: ReactNode;
|
||||
readonly button: ReactNode;
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix
|
||||
readonly className?: string;
|
||||
readonly width?: string;
|
||||
}
|
||||
|
||||
function Dropdown(properties: Properties) {
|
||||
return (
|
||||
<div className={clsx('relative', properties.className)}>
|
||||
<Menu>
|
||||
<Button className='flex flex-col items-center'>
|
||||
{properties.button}
|
||||
</Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter='transition ease-out duration-100'
|
||||
enterFrom='transform opacity-0 scale-95'
|
||||
enterTo='transform opacity-100 scale-100'
|
||||
leave='transition ease-in duration-75'
|
||||
leaveFrom='transform opacity-100 scale-100'
|
||||
leaveTo='transform opacity-0 scale-95'
|
||||
>
|
||||
<Menu.Items className={clsx(
|
||||
'absolute right-0 mt-2 rounded-md',
|
||||
'text-gray-700 dark:text-gray-300',
|
||||
'bg-white dark:bg-zinc-800',
|
||||
'overflow-hidden z-50',
|
||||
'border border-gray-200 dark:border-zinc-700',
|
||||
'divide-y divide-gray-200 dark:divide-zinc-700',
|
||||
properties.width ?? 'w-36'
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Button(properties: MenuButtonProps<'button'>) {
|
||||
return (
|
||||
<Menu.Button
|
||||
{...properties}
|
||||
className={clsx(
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</Menu.Button>
|
||||
)
|
||||
}
|
||||
|
||||
type ItemProperties = HTMLProps<HTMLDivElement> & {
|
||||
variant?: 'static' | 'normal';
|
||||
}
|
||||
|
||||
function Item(properties: ItemProperties) {
|
||||
return (
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<div
|
||||
{...properties}
|
||||
className={clsx(
|
||||
'px-4 py-2 w-full',
|
||||
'focus:outline-none focus:ring',
|
||||
'focus:ring-gray-300 dark:focus:ring-zinc-700',
|
||||
properties.className,
|
||||
properties.variant !== 'static' && active
|
||||
? 'bg-gray-100 dark:bg-zinc-500' : ''
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</div>
|
||||
)}
|
||||
</Menu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Dropdown, { Item })
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Fragment, type ReactNode, type SetStateAction, useState } from 'react'
|
||||
|
||||
import Button from './Button'
|
||||
|
||||
type HookParameters = {
|
||||
title: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
variant?: 'danger' | 'confirm';
|
||||
children?: ReactNode;
|
||||
|
||||
// Optional because the button submits
|
||||
onConfirm?: () => void | Promise<void>;
|
||||
onOpen?: () => void | Promise<void>;
|
||||
onClose?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
type Overrides = Omit<HookParameters, 'onOpen' | 'onClose'>
|
||||
|
||||
type Properties = {
|
||||
readonly isOpen: boolean;
|
||||
readonly setIsOpen: (value: SetStateAction<boolean>) => void;
|
||||
readonly parameters?: HookParameters;
|
||||
}
|
||||
|
||||
export default function useModal(properties?: HookParameters) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [liveProperties, setLiveProperties] = useState(properties)
|
||||
|
||||
return {
|
||||
Modal: (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
parameters={liveProperties}
|
||||
/>
|
||||
),
|
||||
|
||||
open: (overrides?: Overrides) => {
|
||||
if (!overrides && !properties) {
|
||||
throw new Error('No properties provided')
|
||||
}
|
||||
|
||||
setIsOpen(true)
|
||||
if (properties?.onOpen) {
|
||||
void properties.onOpen()
|
||||
}
|
||||
|
||||
if (overrides) {
|
||||
setLiveProperties(overrides)
|
||||
}
|
||||
},
|
||||
|
||||
close: () => {
|
||||
setIsOpen(false)
|
||||
if (properties?.onClose) {
|
||||
void properties.onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Modal({ parameters, isOpen, setIsOpen }: Properties) {
|
||||
if (!parameters) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition
|
||||
show={isOpen}
|
||||
as={Fragment}
|
||||
>
|
||||
<Dialog
|
||||
as='div'
|
||||
className='relative z-50'
|
||||
onClose={() => {
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
<Transition.Child
|
||||
enter='ease-out duration-100'
|
||||
enterFrom='opacity-0'
|
||||
enterTo='opacity-100'
|
||||
leave='ease-in duration-75'
|
||||
leaveFrom='opacity-100'
|
||||
leaveTo='opacity-0'
|
||||
as={Fragment}
|
||||
>
|
||||
<div className='fixed inset-0 bg-black/30' aria-hidden='true'/>
|
||||
</Transition.Child>
|
||||
<div className='fixed inset-0 flex w-screen items-center justify-center'>
|
||||
<Transition.Child
|
||||
enter='transition ease-out duration-100'
|
||||
enterFrom='transform opacity-0 scale-95'
|
||||
enterTo='transform opacity-100 scale-100'
|
||||
leave='transition ease-in duration-75'
|
||||
leaveFrom='transform opacity-100 scale-100'
|
||||
leaveTo='transform opacity-0 scale-95'
|
||||
as={Fragment}
|
||||
>
|
||||
<Dialog.Panel className={clsx(
|
||||
'rounded-lg p-4 w-full max-w-md',
|
||||
'bg-white dark:bg-black relative',
|
||||
'border border-gray-200 dark:border-zinc-800'
|
||||
)}
|
||||
>
|
||||
<XMarkIcon
|
||||
className={clsx(
|
||||
'absolute top-3 right-3 rounded-lg p-1.5',
|
||||
'w-8 h-8 text-gray-500 dark:text-gray-400',
|
||||
'hover:bg-gray-100 dark:hover:bg-zinc-800'
|
||||
)}
|
||||
onClick={() => {
|
||||
setIsOpen(false)
|
||||
}}
|
||||
/>
|
||||
<Dialog.Title className='text-xl font-bold'>
|
||||
{parameters.title}
|
||||
</Dialog.Title>
|
||||
{parameters.description ? (
|
||||
<Dialog.Description className='text-gray-500 dark:text-gray-400 mt-1'>
|
||||
{parameters.description}
|
||||
</Dialog.Description>
|
||||
) : undefined}
|
||||
{parameters.children ? (
|
||||
<div className='w-full mt-4'>
|
||||
{parameters.children}
|
||||
</div>
|
||||
) : undefined}
|
||||
<Button
|
||||
variant='emphasized'
|
||||
type='submit'
|
||||
className={clsx(
|
||||
'w-full mt-12',
|
||||
parameters.variant === 'danger'
|
||||
? 'bg-red-800 dark:bg-red-500 focus:ring-red-500 dark:focus:ring-red-500'
|
||||
: ''
|
||||
)}
|
||||
onClick={async () => {
|
||||
if (parameters.onConfirm) {
|
||||
await parameters.onConfirm()
|
||||
}
|
||||
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
{parameters.buttonText ?? 'Confirm'}
|
||||
</Button>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -22,11 +22,11 @@ export const links: LinksFunction = () => [
|
||||
]
|
||||
|
||||
export async function loader() {
|
||||
await getContext()
|
||||
const context = await getContext()
|
||||
registerConfigWatcher()
|
||||
|
||||
if (!process.env.HEADSCALE_URL) {
|
||||
throw new Error('The HEADSCALE_URL environment variable is required')
|
||||
if (context.headscaleUrl.length === 0) {
|
||||
throw new Error('No headscale URL was provided either by the HEADSCALE_URL environment variable or the config file')
|
||||
}
|
||||
|
||||
if (!process.env.COOKIE_SECRET) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Dialog } from '@headlessui/react'
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import useModal from '~/components/Modal'
|
||||
import Spinner from '~/components/Spinner'
|
||||
|
||||
type Properties = {
|
||||
@@ -13,8 +11,22 @@ type Properties = {
|
||||
}
|
||||
|
||||
export default function Modal({ isEnabled, disabled }: Properties) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const fetcher = useFetcher()
|
||||
const { Modal, open } = useModal({
|
||||
title: `${isEnabled ? 'Disable' : 'Enable'} Magic DNS`,
|
||||
variant: isEnabled ? 'danger' : 'confirm',
|
||||
buttonText: `${isEnabled ? 'Disable' : 'Enable'} Magic DNS`,
|
||||
description: 'Devices will no longer be accessible via your tailnet domain. The search domain will also be disabled.',
|
||||
onConfirm: () => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns_config.magic_dns': !isEnabled
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -23,7 +35,7 @@ export default function Modal({ isEnabled, disabled }: Properties) {
|
||||
className='w-fit text-sm'
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setIsOpen(true)
|
||||
open()
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
@@ -31,46 +43,7 @@ export default function Modal({ isEnabled, disabled }: Properties) {
|
||||
)}
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Button>
|
||||
<Dialog
|
||||
className='relative z-50'
|
||||
open={isOpen} onClose={() => {
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className='fixed inset-0 bg-black/30' aria-hidden='true'/>
|
||||
<div className='fixed inset-0 flex w-screen items-center justify-center'>
|
||||
<Dialog.Panel className='bg-white rounded-lg p-4 w-full max-w-md'>
|
||||
<Dialog.Title className='text-lg font-bold'>
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className='text-gray-500 dark:text-gray-400'>
|
||||
Devices will no longer be accessible via your tailnet domain.
|
||||
The search domain will also be disabled.
|
||||
</Dialog.Description>
|
||||
<Button
|
||||
variant='emphasized'
|
||||
type='submit'
|
||||
className={clsx(
|
||||
'w-full mt-12',
|
||||
isEnabled ? 'bg-red-800 dark:bg-red-500' : ''
|
||||
)}
|
||||
onClick={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns_config.magic_dns': !isEnabled
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
{isEnabled ? 'Disable' : 'Enable'}
|
||||
</Button>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Dialog>
|
||||
{Modal}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useState } from 'react'
|
||||
import Button from '~/components/Button'
|
||||
import Code from '~/components/Code'
|
||||
import Input from '~/components/Input'
|
||||
import useModal from '~/components/Modal'
|
||||
import Spinner from '~/components/Spinner'
|
||||
|
||||
type Properties = {
|
||||
@@ -16,9 +17,31 @@ type Properties = {
|
||||
}
|
||||
|
||||
export default function Modal({ name, disabled }: Properties) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [newName, setNewName] = useState(name)
|
||||
const fetcher = useFetcher()
|
||||
const { Modal, open } = useModal({
|
||||
title: 'Rename Tailnet',
|
||||
description: 'Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.',
|
||||
buttonText: 'Rename',
|
||||
children: (
|
||||
<Input
|
||||
type='text'
|
||||
className='font-mono mt-4'
|
||||
value={newName}
|
||||
onChange={event => {
|
||||
setNewName(event.target.value)
|
||||
}}
|
||||
/>
|
||||
),
|
||||
onConfirm: () => {
|
||||
fetcher.submit({
|
||||
'dns_config.base_domain': newName
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='flex flex-col w-2/3'>
|
||||
@@ -47,7 +70,7 @@ export default function Modal({ name, disabled }: Properties) {
|
||||
className='text-sm w-fit'
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setIsOpen(true)
|
||||
open()
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
@@ -55,50 +78,7 @@ export default function Modal({ name, disabled }: Properties) {
|
||||
)}
|
||||
Rename Tailnet...
|
||||
</Button>
|
||||
<Dialog
|
||||
className='relative z-50'
|
||||
open={isOpen} onClose={() => {
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className='fixed inset-0 bg-black/30' aria-hidden='true'/>
|
||||
<div className='fixed inset-0 flex w-screen items-center justify-center'>
|
||||
<Dialog.Panel className='bg-white rounded-lg p-4 w-full max-w-md dark:bg-zinc-800'>
|
||||
<Dialog.Title className='text-lg font-bold'>
|
||||
Rename {name}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className='text-gray-500 dark:text-gray-400'>
|
||||
Keep in mind that changing this can lead to all sorts
|
||||
of unexpected behavior and may break existing devices
|
||||
in your tailnet.
|
||||
</Dialog.Description>
|
||||
<Input
|
||||
type='text'
|
||||
className='font-mono mt-4'
|
||||
value={newName}
|
||||
onChange={event => {
|
||||
setNewName(event.target.value)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type='submit'
|
||||
className='rounded-lg py-2 bg-gray-800 text-white w-full mt-2'
|
||||
onClick={() => {
|
||||
fetcher.submit({
|
||||
'dns_config.base_domain': newName
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Dialog>
|
||||
{Modal}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import Spinner from '~/components/Spinner'
|
||||
import TableList from '~/components/TableList'
|
||||
import { getConfig, getContext, patchConfig } from '~/utils/config'
|
||||
import { restartHeadscale } from '~/utils/docker'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import Domains from './domains'
|
||||
@@ -45,9 +46,18 @@ export async function loader() {
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ success: false }, {
|
||||
status: 401
|
||||
})
|
||||
}
|
||||
|
||||
const context = await getContext()
|
||||
if (!context.hasConfigWrite) {
|
||||
return json({ success: false })
|
||||
return json({ success: false }, {
|
||||
status: 403
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.json() as Record<string, unknown>
|
||||
|
||||
@@ -1,98 +1,208 @@
|
||||
/* 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 fetcher = useFetcher()
|
||||
|
||||
const { Modal, open } = useModal()
|
||||
|
||||
return (
|
||||
<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'>
|
||||
<th className='pl-4'>Name</th>
|
||||
<th>IP Addresses</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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]
|
||||
|
||||
return (
|
||||
<tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800'>
|
||||
<td className='py-2 pl-4'>
|
||||
<Link to={`/machines/${machine.id}`}>
|
||||
<h1>{machine.givenName}</h1>
|
||||
<span className='text-sm font-mono text-gray-500 dark:text-gray-400'>
|
||||
{machine.name}
|
||||
</span>
|
||||
<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}
|
||||
<>
|
||||
{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'>
|
||||
<th className='pl-4'>Name</th>
|
||||
<th>IP Addresses</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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]
|
||||
return (
|
||||
<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>
|
||||
<span className='text-sm font-mono text-gray-500 dark:text-gray-400'>
|
||||
{machine.name}
|
||||
</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>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
<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'
|
||||
width='w-48'
|
||||
button={(
|
||||
<EllipsisHorizontalIcon className='w-5 h-5'/>
|
||||
)}
|
||||
>
|
||||
<Dropdown.Item variant='static'>
|
||||
<button
|
||||
disabled
|
||||
type='button'
|
||||
className='text-left w-full opacity-50'
|
||||
onClick={() => {
|
||||
open()
|
||||
}}
|
||||
>
|
||||
Edit machine name
|
||||
</button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item variant='static'>
|
||||
<button
|
||||
disabled
|
||||
type='button'
|
||||
className='text-left w-full opacity-50'
|
||||
onClick={() => {
|
||||
open()
|
||||
}}
|
||||
>
|
||||
Edit route settings
|
||||
</button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item variant='static'>
|
||||
<button
|
||||
disabled
|
||||
type='button'
|
||||
className='text-left w-full opacity-50'
|
||||
onClick={() => {
|
||||
open()
|
||||
}}
|
||||
>
|
||||
Edit ACL tags
|
||||
</button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<button
|
||||
type='button'
|
||||
className='text-left text-red-700 w-full'
|
||||
onClick={() => {
|
||||
open({
|
||||
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: machine.id
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
encType: 'application/json'
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Dropdown.Item>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+16
-53
@@ -1,10 +1,8 @@
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { Cog8ToothIcon, CpuChipIcon, GlobeAltIcon, LockClosedIcon, ServerStackIcon, UserCircleIcon, UsersIcon } from '@heroicons/react/24/outline'
|
||||
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
|
||||
import { Form, Outlet, useLoaderData, useRouteError } from '@remix-run/react'
|
||||
import clsx from 'clsx'
|
||||
import { Fragment } from 'react/jsx-runtime'
|
||||
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import { ErrorPopup } from '~/components/Error'
|
||||
import TabLink from '~/components/TabLink'
|
||||
import { getContext } from '~/utils/config'
|
||||
@@ -64,56 +62,21 @@ export default function Layout() {
|
||||
<a href='https://github.com/juanfont/headscale' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
|
||||
Headscale
|
||||
</a>
|
||||
<div className='relative'>
|
||||
<Menu>
|
||||
<Menu.Button>
|
||||
<UserCircleIcon className='w-8 h-8'/>
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter='transition ease-out duration-100'
|
||||
enterFrom='transform opacity-0 scale-95'
|
||||
enterTo='transform opacity-100 scale-100'
|
||||
leave='transition ease-in duration-75'
|
||||
leaveFrom='transform opacity-100 scale-100'
|
||||
leaveTo='transform opacity-0 scale-95'
|
||||
>
|
||||
<Menu.Items className={clsx(
|
||||
'absolute right-0 w-36 mt-2 rounded-md',
|
||||
'text-gray-700 dark:text-gray-300',
|
||||
'bg-white dark:bg-zinc-800 text-right',
|
||||
'overflow-hidden',
|
||||
'border border-gray-200 dark:border-zinc-500',
|
||||
'divide-y divide-gray-200 dark:divide-zinc-500'
|
||||
)}
|
||||
>
|
||||
<Menu.Item>
|
||||
{() => (
|
||||
<div className='px-4 py-2'>
|
||||
<p>{data.user?.name}</p>
|
||||
<p>{data.user?.email}</p>
|
||||
</div>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<Form method='POST' action='/logout'>
|
||||
<button
|
||||
type='submit'
|
||||
className={clsx(
|
||||
'px-4 py-2 w-full text-right',
|
||||
active ? 'bg-gray-200 dark:bg-zinc-500' : ''
|
||||
)}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</Form>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
<Dropdown
|
||||
button={<UserCircleIcon className='w-8 h-8'/>}
|
||||
>
|
||||
<Dropdown.Item variant='static'>
|
||||
<p className='font-bold'>{data.user?.name}</p>
|
||||
<p>{data.user?.email}</p>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item className='text-red-700 cursor-pointer'>
|
||||
<Form method='POST' action='/logout'>
|
||||
<button type='submit' className='w-full'>
|
||||
Logout
|
||||
</button>
|
||||
</Form>
|
||||
</Dropdown.Item>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-x-4'>
|
||||
|
||||
+12
-6
@@ -7,6 +7,7 @@ import Card from '~/components/Card'
|
||||
import Code from '~/components/Code'
|
||||
import Input from '~/components/Input'
|
||||
import { type Key } from '~/types'
|
||||
import { getContext } from '~/utils/config'
|
||||
import { pull } from '~/utils/headscale'
|
||||
import { startOidc } from '~/utils/oidc'
|
||||
import { commitSession, getSession } from '~/utils/sessions'
|
||||
@@ -22,9 +23,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
})
|
||||
}
|
||||
|
||||
const issuer = process.env.OIDC_ISSUER
|
||||
const id = process.env.OIDC_CLIENT_ID
|
||||
const secret = process.env.OIDC_CLIENT_SECRET
|
||||
const context = await getContext()
|
||||
const issuer = context.oidcConfig?.issuer
|
||||
const id = context.oidcConfig?.client
|
||||
const secret = context.oidcConfig?.secret
|
||||
const normal = process.env.DISABLE_API_KEY_LOGIN
|
||||
|
||||
if (issuer && (!id || !secret)) {
|
||||
@@ -50,10 +52,14 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData()
|
||||
const oidcStart = String(formData.get('oidc-start'))
|
||||
const oidcStart = formData.get('oidc-start')
|
||||
|
||||
if (oidcStart) {
|
||||
const issuer = process.env.OIDC_ISSUER
|
||||
const id = process.env.OIDC_CLIENT_ID
|
||||
const context = await getContext()
|
||||
const issuer = context.oidcConfig?.issuer
|
||||
const id = context.oidcConfig?.client
|
||||
|
||||
// We know it exists here because this action only happens on OIDC
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return startOidc(issuer!, id!, request)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { type LoaderFunctionArgs } from '@remix-run/node'
|
||||
|
||||
import { getContext } from '~/utils/config'
|
||||
import { finishOidc } from '~/utils/oidc'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const issuer = process.env.OIDC_ISSUER
|
||||
const id = process.env.OIDC_CLIENT_ID
|
||||
const secret = process.env.OIDC_CLIENT_SECRET
|
||||
const context = await getContext()
|
||||
const oidc = context.oidcConfig
|
||||
|
||||
if (!issuer || !id || !secret) {
|
||||
if (!oidc) {
|
||||
throw new Error('An invalid OIDC configuration was provided')
|
||||
}
|
||||
|
||||
return finishOidc(issuer, id, secret, request)
|
||||
return finishOidc(oidc.issuer, oidc.client, oidc.secret, request)
|
||||
}
|
||||
|
||||
+49
-1
@@ -161,6 +161,12 @@ type Context = {
|
||||
hasConfigWrite: boolean;
|
||||
hasAcl: boolean;
|
||||
hasAclWrite: boolean;
|
||||
headscaleUrl: string;
|
||||
oidcConfig?: {
|
||||
issuer: string;
|
||||
client: string;
|
||||
secret: string;
|
||||
};
|
||||
}
|
||||
|
||||
export let context: Context
|
||||
@@ -172,13 +178,55 @@ export async function getContext() {
|
||||
hasConfig: await hasConfig(),
|
||||
hasConfigWrite: await hasConfigW(),
|
||||
hasAcl: await hasAcl(),
|
||||
hasAclWrite: await hasAclW()
|
||||
hasAclWrite: await hasAclW(),
|
||||
headscaleUrl: await getHeadscaleUrl(),
|
||||
oidcConfig: await getOidcConfig()
|
||||
}
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
async function getOidcConfig() {
|
||||
// Check for the OIDC environment variables first
|
||||
let issuer = process.env.OIDC_ISSUER
|
||||
let client = process.env.OIDC_CLIENT
|
||||
let secret = process.env.OIDC_SECRET
|
||||
|
||||
if (!issuer || !client || !secret) {
|
||||
const config = await getConfig()
|
||||
issuer = config.oidc?.issuer
|
||||
client = config.oidc?.client_id
|
||||
secret = config.oidc?.client_secret
|
||||
}
|
||||
|
||||
// If atleast one is defined but not all 3, throw an error
|
||||
if ((issuer || client || secret) && !(issuer && client && secret)) {
|
||||
throw new Error('OIDC configuration is incomplete')
|
||||
}
|
||||
|
||||
if (!issuer || !client || !secret) {
|
||||
return
|
||||
}
|
||||
|
||||
return { issuer, client, secret }
|
||||
}
|
||||
|
||||
async function getHeadscaleUrl() {
|
||||
if (process.env.HEADSCALE_URL) {
|
||||
return process.env.HEADSCALE_URL
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getConfig()
|
||||
if (config.server_url) {
|
||||
return config.server_url
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
async function checkSock() {
|
||||
try {
|
||||
await access('/var/run/docker.sock', constants.R_OK)
|
||||
|
||||
+22
-3
@@ -1,3 +1,5 @@
|
||||
import { getContext } from './config'
|
||||
|
||||
export class HeadscaleError extends Error {
|
||||
status: number
|
||||
|
||||
@@ -15,9 +17,9 @@ export class FatalError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
export async function pull<T>(url: string, key: string) {
|
||||
const prefix = process.env.HEADSCALE_URL!
|
||||
const context = await getContext()
|
||||
const prefix = context.headscaleUrl
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`
|
||||
@@ -32,7 +34,8 @@ export async function pull<T>(url: string, key: string) {
|
||||
}
|
||||
|
||||
export async function post<T>(url: string, key: string, body?: unknown) {
|
||||
const prefix = process.env.HEADSCALE_URL!
|
||||
const context = await getContext()
|
||||
const prefix = context.headscaleUrl
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
@@ -48,3 +51,19 @@ 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 context = await getContext()
|
||||
const prefix = context.headscaleUrl
|
||||
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>)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# THIS IS A DEVELOPER CONFIGURATION FILE
|
||||
# IT IS NOT AN EXAMPLE OF SOMETHING YOU DEPLOY
|
||||
# I ONLY USE IT FOR DEVELOPING HEADPLANE
|
||||
|
||||
version: '3.9'
|
||||
networks:
|
||||
headplane-dev:
|
||||
|
||||
Reference in New Issue
Block a user