mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f5ac6a9ef | |||
| 7f55ad826c | |||
| 2cc65e8783 | |||
| 5ff09e44d9 | |||
| c6cdcf35eb | |||
| 61ae161496 | |||
| 82f5e17207 | |||
| a63f4e4d52 | |||
| a57e777a6b | |||
| 8205f2b99b | |||
| c95218b8dd | |||
| 769556efda | |||
| ea47bb5fec | |||
| 3abd617a90 | |||
| cbecf85979 | |||
| 0ebadc26bc | |||
| 252e78d618 | |||
| c6930732ee |
@@ -0,0 +1,15 @@
|
||||
### 0.1.3 (May 4, 2024)
|
||||
|
||||
- Switched to a better icon set for the UI.
|
||||
- Support stable scrollbar gutter if supported by the browser.
|
||||
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
|
||||
|
||||
### 0.1.2 (May 1, 2024)
|
||||
|
||||
- Added support for renaming, expiring, removing, and managing the routes of a machine.
|
||||
- Implemented an expiry check for machines which now reflect on the machine table.
|
||||
- Fixed an issue where `HEADSCALE_CONTAINER` was needed to start even without the Docker integration.
|
||||
- Removed the requirement for the root `API_KEY` unless OIDC was being used for authentication.
|
||||
- Switched to [React Aria](https://react-spectrum.adobe.com/react-aria/) for better accessibility support.
|
||||
- Cleaned up various different UI inconsistencies and copied components that could've been abstracted.
|
||||
- Added a changelog for any new versions going forward.
|
||||
@@ -4,14 +4,13 @@
|
||||

|
||||
|
||||
Headscale is a self-hosted version of the Tailscale control server, however, it currently lacks a first-party web UI.
|
||||
This is a relatively tiny Remix app that aims to provide a usable GUI for the Headscale server.
|
||||
It's still very early in it's development, however these are some of the features that are planned.
|
||||
Headplane aims to solve this issue by providing a GUI that can deeply integrate with the Headscale server.
|
||||
It's able to replicate nearly all of the functions of the official Tailscale SaaS UI, including:
|
||||
|
||||
- [ ] Editable tags, machine names, users, etc
|
||||
- [x] ACL control through Docker integration
|
||||
- [x] OIDC based login for the web UI
|
||||
- [x] Automated API key regeneration
|
||||
- [x] Editable headscale configuration
|
||||
- Machine/Node expiry, network routing, name, and owner management
|
||||
- Access Control List (ACL) and tagging configuration
|
||||
- Support for OpenID Connect (OIDC) as a login provider
|
||||
- DNS and *safe* Headscale configuration management
|
||||
|
||||
## Deployment
|
||||
- If you run Headscale in a Docker container, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ClipboardIcon } from '@heroicons/react/24/outline'
|
||||
import toast from 'react-hot-toast/headless'
|
||||
import { CopyIcon } from '@primer/octicons-react'
|
||||
|
||||
import { toast } from './Toaster'
|
||||
|
||||
type Properties = {
|
||||
readonly name: string;
|
||||
@@ -27,7 +28,7 @@ export default function Attribute({ name, value, isCopyable }: Properties) {
|
||||
<dd className='min-w-0 truncate px-2 py-1'>
|
||||
{value}
|
||||
</dd>
|
||||
<ClipboardIcon className='text-gray-600 dark:text-gray-200 pr-2 w-max h-4'/>
|
||||
<CopyIcon className='text-gray-600 dark:text-gray-200 pr-2 w-max h-4'/>
|
||||
</button>
|
||||
) : (
|
||||
<dd className='min-w-0 truncate px-2 py-1'>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/* eslint-disable unicorn/no-keyword-prefix */
|
||||
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Dialog as AriaDialog,
|
||||
DialogTrigger,
|
||||
Heading as AriaHeading,
|
||||
Modal,
|
||||
ModalOverlay
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
}
|
||||
|
||||
function Button(properties: ButtonProperties) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
aria-label='Dialog'
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
'bg-main-700 dark:bg-main-800 text-white',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.className
|
||||
)}
|
||||
// If control is passed, set the state value
|
||||
onPress={properties.control ? () => {
|
||||
properties.control?.[1](true)
|
||||
} : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ActionProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly variant: 'cancel' | 'confirm';
|
||||
}
|
||||
|
||||
function Action(properties: ActionProperties) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
type={properties.variant === 'confirm' ? 'submit' : 'button'}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.variant === 'cancel'
|
||||
? 'text-ui-700 dark:text-ui-300'
|
||||
: 'text-ui-300 dark:text-ui-300',
|
||||
properties.variant === 'confirm'
|
||||
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800'
|
||||
: 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Title(properties: Parameters<typeof AriaHeading>[0]) {
|
||||
return (
|
||||
<AriaHeading
|
||||
{...properties}
|
||||
slot='title'
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-6 mb-5',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
{...properties}
|
||||
className={cn(
|
||||
'text-base leading-6 my-0',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type PanelProperties = {
|
||||
readonly children: (close: () => void) => ReactNode;
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
function Panel({ children, control, className }: PanelProperties) {
|
||||
return (
|
||||
<ModalOverlay
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'fixed inset-0 h-screen w-screen z-50 bg-black/30',
|
||||
'flex items-center justify-center dark:bg-black/70',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:duration-200 entering:ease-out',
|
||||
'exiting:fade-out exiting:duration-100 exiting:ease-in',
|
||||
className
|
||||
)}
|
||||
isOpen={control ? control[0] : undefined}
|
||||
onOpenChange={control ? control[1] : undefined}
|
||||
>
|
||||
<Modal
|
||||
className={cn(
|
||||
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
||||
'bg-ui-50 dark:bg-ui-900 shadow-lg',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'dark:border dark:border-ui-700',
|
||||
'entering:zoom-in-95 entering:ease-out entering:duration-200',
|
||||
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100'
|
||||
)}
|
||||
>
|
||||
<AriaDialog role='alertdialog' className='outline-none relative'>
|
||||
{({ close }) => children(close)}
|
||||
</AriaDialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogProperties = {
|
||||
readonly children: ReactNode;
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
}
|
||||
|
||||
function Dialog({ children, control }: DialogProperties) {
|
||||
if (control) {
|
||||
return children
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action })
|
||||
@@ -1,86 +0,0 @@
|
||||
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 })
|
||||
+35
-49
@@ -1,66 +1,52 @@
|
||||
import { Transition } from '@headlessui/react'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||
import { AlertIcon } from '@primer/octicons-react'
|
||||
import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
|
||||
import clsx from 'clsx'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Code from './Code'
|
||||
import Dialog from './Dialog'
|
||||
|
||||
type Properties = {
|
||||
readonly type?: 'full' | 'embedded';
|
||||
}
|
||||
|
||||
export function ErrorPopup({ type = 'full' }: Properties) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
// eslint-disable-next-line react/hook-use-state
|
||||
const open = useState(true)
|
||||
|
||||
const error = useRouteError()
|
||||
const routing = isRouteErrorResponse(error)
|
||||
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
|
||||
console.error(error)
|
||||
|
||||
// Debounce the error modal so it doesn't show up for a split second
|
||||
// when the user navigates to a new page.
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setIsOpen(true)
|
||||
}, 150)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Transition as={Fragment} show={isOpen}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter='ease-out duration-150'
|
||||
enterFrom='opacity-0 scale-95'
|
||||
enterTo='opacity-100 scale-100'
|
||||
>
|
||||
|
||||
<div className={clsx(
|
||||
'flex items-center justify-center overflow-clip',
|
||||
type === 'full' ? 'min-h-screen' : 'mt-24'
|
||||
<Dialog>
|
||||
<Dialog.Panel
|
||||
className={cn(
|
||||
type === 'embedded' ? 'pointer-events-none bg-transparent dark:bg-transparent' : ''
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
'flex flex-col items-center justify-center space-y-2 w-full sm:w-1/2 xl:w-1/3',
|
||||
'bg-white dark:bg-zinc-800 rounded-lg py-8 px-4 md:px-16',
|
||||
'border border-gray-200 dark:border-zinc-700 text-center'
|
||||
)}
|
||||
>
|
||||
<ExclamationTriangleIcon className='w-12 h-12 text-red-500'/>
|
||||
<h1 className='text-2xl font-semibold text-gray-800 dark:text-gray-100'>
|
||||
{routing ? error.status : 'Error'}
|
||||
</h1>
|
||||
{routing ? (
|
||||
<p className='text-gray-500 dark:text-gray-400'>
|
||||
{error.statusText}
|
||||
</p>
|
||||
) : (
|
||||
<Code className='text-sm'>
|
||||
{message}
|
||||
</Code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Transition>
|
||||
control={open}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Dialog.Title className='text-3xl mb-0'>
|
||||
{routing ? error.status : 'Error'}
|
||||
</Dialog.Title>
|
||||
<AlertIcon className='w-12 h-12 text-red-500'/>
|
||||
</div>
|
||||
<Dialog.Text className='mt-4 text-lg'>
|
||||
{routing ? (
|
||||
error.statusText
|
||||
) : (
|
||||
<Code>
|
||||
{message}
|
||||
</Code>
|
||||
)}
|
||||
</Dialog.Text>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIcon, ServerIcon } from '@primer/octicons-react'
|
||||
import { Form } from '@remix-run/react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
import { type Context } from '~/utils/config'
|
||||
import { type SessionData } from '~/utils/sessions'
|
||||
|
||||
import Menu from './Menu'
|
||||
import TabLink from './TabLink'
|
||||
|
||||
type Properties = {
|
||||
readonly data?: Context & { user?: SessionData['user'] };
|
||||
}
|
||||
|
||||
type LinkProperties = {
|
||||
readonly href: string;
|
||||
readonly text: string;
|
||||
readonly isMenu?: boolean;
|
||||
}
|
||||
|
||||
function Link({ href, text, isMenu }: LinkProperties) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={cn(
|
||||
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block'
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Header({ data }: Properties) {
|
||||
return (
|
||||
<header className='bg-main-700 dark:bg-main-800 text-ui-50'>
|
||||
<div className='container flex items-center justify-between py-4'>
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<PaperAirplaneIcon className='w-6 h-6'/>
|
||||
<h1 className='text-2xl'>Headplane</h1>
|
||||
</div>
|
||||
<div className='flex items-center gap-x-4'>
|
||||
<Link href='https://tailscale.com/download' text='Download'/>
|
||||
<Link href='https://github.com/tale/headplane' text='GitHub'/>
|
||||
<Link href='https://github.com/juanfont/headscale' text='Headscale'/>
|
||||
{data?.user ? (
|
||||
<Menu>
|
||||
<Menu.Button className={cn(
|
||||
'rounded-full h-9 w-9',
|
||||
'border border-main-600 dark:border-main-700',
|
||||
'hover:bg-main-600 dark:hover:bg-main-700'
|
||||
)}
|
||||
>
|
||||
<PersonIcon className='h-5 w-5 mt-0.5'/>
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.Item className='text-right'>
|
||||
<p className='font-bold'>{data.user.name}</p>
|
||||
<p>{data.user.email}</p>
|
||||
</Menu.Item>
|
||||
<Menu.Item className='text-right sm:hidden'>
|
||||
<Link
|
||||
isMenu
|
||||
href='https://tailscale.com/download'
|
||||
text='Download'
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className='text-right sm:hidden'>
|
||||
<Link
|
||||
isMenu
|
||||
href='https://github.com/tale/headplane'
|
||||
text='GitHub'
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className='text-right sm:hidden'>
|
||||
<Link
|
||||
isMenu
|
||||
href='https://github.com/juanfont/headscale'
|
||||
text='Headscale'
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className='text-red-500 dark:text-red-400'>
|
||||
<Form method='POST' action='/logout'>
|
||||
<button type='submit' className='w-full text-right'>
|
||||
Logout
|
||||
</button>
|
||||
</Form>
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
<nav className='container flex items-center gap-x-4 overflow-x-auto'>
|
||||
<TabLink to='/machines' name='Machines' icon={<ServerIcon className='w-4 h-4'/>}/>
|
||||
<TabLink to='/users' name='Users' icon={<PeopleIcon className='w-4 h-4'/>}/>
|
||||
{data?.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockIcon className='w-4 h-4'/>}/> : undefined}
|
||||
{data?.hasConfig ? (
|
||||
<>
|
||||
<TabLink to='/dns' name='DNS' icon={<GlobeIcon className='w-4 h-4'/>}/>
|
||||
<TabLink to='/settings' name='Settings' icon={<GearIcon className='w-4 h-4'/>}/>
|
||||
</>
|
||||
) : undefined}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { LinkExternalIcon } from '@primer/octicons-react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
/* eslint-disable unicorn/no-keyword-prefix */
|
||||
type Properties = {
|
||||
readonly to: string;
|
||||
readonly name: string;
|
||||
readonly children: string;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
export default function Link({ to, name: alt, children, className }: Properties) {
|
||||
return (
|
||||
<a
|
||||
href={to}
|
||||
aria-label={alt}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={cn(
|
||||
'inline-flex items-center gap-x-1',
|
||||
'text-blue-500 hover:text-blue-700',
|
||||
'dark:text-blue-400 dark:hover:text-blue-300',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<LinkExternalIcon className='h-3 w-3'/>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Menu as AriaMenu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
Popover
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
function Button(properties: Parameters<typeof AriaButton>[0]) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
className={cn(
|
||||
'outline-none',
|
||||
properties.className
|
||||
)}
|
||||
aria-label='Menu'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Items(properties: Parameters<typeof AriaMenu>[0]) {
|
||||
return (
|
||||
<Popover className={cn(
|
||||
'mt-2 rounded-md',
|
||||
'bg-ui-50 dark:bg-ui-800',
|
||||
'overflow-hidden z-50',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:zoom-in-95',
|
||||
'exiting:fade-out exiting:zoom-out-95',
|
||||
'fill-mode-forwards origin-left-right'
|
||||
)}
|
||||
>
|
||||
<AriaMenu
|
||||
{...properties}
|
||||
className={cn(
|
||||
'outline-none',
|
||||
'divide-y divide-ui-200 dark:divide-ui-600',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</AriaMenu>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
}
|
||||
|
||||
function ItemButton(properties: ButtonProperties) {
|
||||
return (
|
||||
<MenuItem className='outline-none'>
|
||||
<AriaButton
|
||||
{...properties}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none text-left',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
aria-label='Menu Dialog'
|
||||
// If control is passed, set the state value
|
||||
onPress={event => {
|
||||
properties.onPress?.(event)
|
||||
properties.control?.[1](true)
|
||||
}}
|
||||
/>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function Item(properties: Parameters<typeof MenuItem>[0]) {
|
||||
return (
|
||||
<MenuItem
|
||||
{...properties}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Menu({ children }: { readonly children: ReactNode }) {
|
||||
return (
|
||||
<MenuTrigger>
|
||||
{children}
|
||||
</MenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Menu, { Button, Item, ItemButton, Items })
|
||||
@@ -1,158 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/outline'
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
import clsx from 'clsx'
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function Notice({ children }: { readonly children: ReactNode }) {
|
||||
'bg-slate-400 dark:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
<InformationCircleIcon className='h-6 w-6 text-white'/>
|
||||
<InfoIcon className='h-6 w-6 text-white'/>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Switch as AriaSwitch } from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export default function Switch(properties: SwitchProperties) {
|
||||
return (
|
||||
<AriaSwitch
|
||||
{...properties}
|
||||
aria-label={properties.label}
|
||||
className='group flex gap-2 items-center'
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[26px] w-[44px] shrink-0 cursor-default',
|
||||
'rounded-full shadow-inner bg-clip-padding',
|
||||
'border border-solid border-white/30 p-[3px]',
|
||||
'box-border transition duration-100 ease-in-out',
|
||||
'outline-none group-focus-visible:ring-2 ring-black',
|
||||
|
||||
'bg-main-700 dark:bg-main-800',
|
||||
'group-pressed:bg-main-800 dark:group-pressed:bg-main-900',
|
||||
'group-selected:bg-main-900 group-selected:group-pressed:bg-main-900',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'h-[18px] w-[18px] transform rounded-full',
|
||||
'bg-white shadow transition duration-100',
|
||||
'ease-in-out translate-x-0 group-selected:translate-x-[100%]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AriaSwitch>
|
||||
)
|
||||
}
|
||||
@@ -13,8 +13,9 @@ export default function TabLink({ name, to, icon }: Properties) {
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive, isPending }) => clsx(
|
||||
'flex items-center gap-x-2 p-2 border-b-2 text-md',
|
||||
isActive ? 'border-white' : 'border-transparent'
|
||||
'flex items-center gap-x-2 p-2 border-b-2 text-md text-nowrap',
|
||||
isActive ? 'border-white' : 'border-transparent',
|
||||
isPending && 'animate-pulse'
|
||||
)}
|
||||
>
|
||||
{icon} {name}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
import {
|
||||
Input,
|
||||
TextField as AriaTextField
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
|
||||
readonly label: string;
|
||||
readonly placeholder: string;
|
||||
readonly state: [string, Dispatch<SetStateAction<string>>];
|
||||
}
|
||||
|
||||
export default function TextField(properties: TextFieldProperties) {
|
||||
return (
|
||||
<AriaTextField
|
||||
{...properties}
|
||||
aria-label={properties.label}
|
||||
className='w-full'
|
||||
>
|
||||
<Input
|
||||
placeholder={properties.placeholder}
|
||||
value={properties.state[0]}
|
||||
name={properties.name}
|
||||
className={cn(
|
||||
'block px-2.5 py-1.5 w-full rounded-lg my-1',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'dark:bg-ui-800 dark:text-ui-300',
|
||||
properties.className
|
||||
)}
|
||||
onChange={event => {
|
||||
properties.state[1](event.target.value)
|
||||
}}
|
||||
/>
|
||||
</AriaTextField>
|
||||
)
|
||||
}
|
||||
+71
-39
@@ -1,47 +1,79 @@
|
||||
import { useToaster } from 'react-hot-toast/headless'
|
||||
import { XIcon } from '@primer/octicons-react'
|
||||
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
|
||||
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast'
|
||||
import { type ReactNode, useRef } from 'react'
|
||||
import { Button } from 'react-aria-components'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
|
||||
export default function Toaster() {
|
||||
const { toasts, handlers } = useToaster()
|
||||
const { startPause, endPause, calculateOffset, updateHeight } = handlers
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type ToastProperties = AriaToastProps<ReactNode> & {
|
||||
readonly state: ToastState<ReactNode>;
|
||||
}
|
||||
|
||||
function Toast({ state, ...properties }: ToastProperties) {
|
||||
const reference = useRef(null)
|
||||
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
|
||||
|
||||
return (
|
||||
<div
|
||||
className='fixed bottom-0 right-0 p-4 w-80 h-1/2 overflow-hidden'
|
||||
onMouseEnter={startPause}
|
||||
onMouseLeave={endPause}
|
||||
{...toastProps}
|
||||
ref={reference}
|
||||
className={cn(
|
||||
'bg-main-700 dark:bg-main-800 rounded-lg',
|
||||
'text-main-100 dark:text-main-200 z-50',
|
||||
'border border-main-600 dark:border-main-700',
|
||||
'flex items-center justify-between p-3 pl-4 w-80'
|
||||
)}
|
||||
>
|
||||
{toasts.slice(0, 6).map(toast => {
|
||||
const offset = calculateOffset(toast, {
|
||||
reverseOrder: false,
|
||||
gutter: -8
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
const reference = (element: HTMLDivElement | null) => {
|
||||
if (element && typeof toast.height !== 'number') {
|
||||
const { height } = element.getBoundingClientRect()
|
||||
updateHeight(toast.id, -height)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
ref={reference}
|
||||
className='fixed bottom-4 right-4 p-4 bg-gray-800 rounded-lg text-white transition-all duration-300'
|
||||
{...toast.ariaProps}
|
||||
style={{
|
||||
transform: `translateY(${offset}px) translateX(${toast.visible ? 0 : 200}%)`
|
||||
}}
|
||||
>
|
||||
{typeof toast.message === 'function' ? (
|
||||
toast.message(toast)
|
||||
) : (
|
||||
toast.message
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div {...titleProps}>{properties.toast.content}</div>
|
||||
<Button
|
||||
{...closeButtonProps}
|
||||
className={cn(
|
||||
'outline-none rounded-full p-1',
|
||||
'hover:bg-main-600 dark:hover:bg-main-700'
|
||||
)}
|
||||
>
|
||||
<XIcon className='w-4 h-4'/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const toasts = new ToastQueue<ReactNode>({
|
||||
maxVisibleToasts: 5
|
||||
})
|
||||
|
||||
export function toast(text: string) {
|
||||
return toasts.add(text, { timeout: 5000 })
|
||||
}
|
||||
|
||||
export function Toaster() {
|
||||
const reference = useRef(null)
|
||||
const state = useToastQueue(toasts)
|
||||
const { regionProps } = useToastRegion({}, state, reference)
|
||||
|
||||
return (
|
||||
<ClientOnly>
|
||||
{() => createPortal(
|
||||
state.visibleToasts.length >= 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-4 right-4',
|
||||
'flex flex-col gap-4'
|
||||
)}
|
||||
{...regionProps}
|
||||
ref={reference}
|
||||
>
|
||||
{state.visibleToasts.map(toast => (
|
||||
<Toast key={toast.key} toast={toast} state={state}/>
|
||||
))}
|
||||
</div>
|
||||
) : undefined,
|
||||
document.body
|
||||
)}
|
||||
</ClientOnly>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-9
@@ -8,7 +8,7 @@ import {
|
||||
} from '@remix-run/react'
|
||||
|
||||
import { ErrorPopup } from '~/components/Error'
|
||||
import Toaster from '~/components/Toaster'
|
||||
import { Toaster } from '~/components/Toaster'
|
||||
import stylesheet from '~/tailwind.css?url'
|
||||
import { getContext, registerConfigWatcher } from '~/utils/config'
|
||||
|
||||
@@ -33,14 +33,6 @@ export async function loader() {
|
||||
throw new Error('The COOKIE_SECRET environment variable is required')
|
||||
}
|
||||
|
||||
if (!process.env.API_KEY) {
|
||||
throw new Error('The API_KEY environment variable is required')
|
||||
}
|
||||
|
||||
if (!process.env.HEADSCALE_CONTAINER) {
|
||||
throw new Error('The HEADSCALE_CONTAINER environment variable is required')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ import CodeMirror from '@uiw/react-codemirror'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import CodeMirrorMerge from 'react-codemirror-merge'
|
||||
import { toast } from 'react-hot-toast/headless'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { toast } from '~/components/Toaster'
|
||||
|
||||
import Fallback from './fallback'
|
||||
|
||||
@@ -48,28 +48,24 @@ export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
|
||||
<>
|
||||
<div className={clsx(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 overflow-hidden'
|
||||
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden'
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Fallback acl={acl} where='client'/>
|
||||
) : (
|
||||
mode === 'edit' ? (
|
||||
<CodeMirror
|
||||
value={acl}
|
||||
className='h-editor text-sm'
|
||||
theme={light ? githubLight : githubDark}
|
||||
extensions={[aclType]}
|
||||
readOnly={!data.hasAclWrite}
|
||||
onChange={value => {
|
||||
setAcl(value)
|
||||
}}
|
||||
/>
|
||||
<div className='overflow-y-scroll h-editor text-sm'>
|
||||
{loading ? (
|
||||
<Fallback acl={acl} where='client'/>
|
||||
) : (
|
||||
<div
|
||||
className='overflow-y-scroll'
|
||||
style={{ height: 'calc(100vh - 20rem)' }}
|
||||
>
|
||||
mode === 'edit' ? (
|
||||
<CodeMirror
|
||||
value={acl}
|
||||
theme={light ? githubLight : githubDark}
|
||||
extensions={[aclType]}
|
||||
readOnly={!data.hasAclWrite}
|
||||
onChange={value => {
|
||||
setAcl(value)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CodeMirrorMerge
|
||||
theme={light ? githubLight : githubDark}
|
||||
orientation='a-b'
|
||||
@@ -85,9 +81,9 @@ export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
|
||||
extensions={[aclType]}
|
||||
/>
|
||||
</CodeMirrorMerge>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Tab } from '@headlessui/react'
|
||||
import { BeakerIcon, CubeTransparentIcon, EyeIcon, PencilSquareIcon } from '@heroicons/react/24/outline'
|
||||
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, json } from '@remix-run/node'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import { Fragment } from 'react/jsx-runtime'
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
|
||||
import Link from '~/components/Link'
|
||||
import Notice from '~/components/Notice'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { getAcl, getContext, patchAcl } from '~/utils/config'
|
||||
import { sighupHeadscale } from '~/utils/docker'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
@@ -59,7 +59,7 @@ export default function Page() {
|
||||
const [acl, setAcl] = useState(data.currentAcl)
|
||||
|
||||
return (
|
||||
<div className='mx-16'>
|
||||
<div>
|
||||
{data.hasAclWrite ? undefined : (
|
||||
<div className='mb-4'>
|
||||
<Notice>
|
||||
@@ -75,112 +75,100 @@ export default function Page() {
|
||||
|
||||
<p className='mb-4 max-w-prose'>
|
||||
The ACL file is used to define the access control rules for your network.
|
||||
You can find more information about the ACL file in the Tailscale documentation.
|
||||
You can find more information about the ACL file in the
|
||||
{' '}
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
href='https://tailscale.com/kb/1018/acls'
|
||||
className='text-blue-500 dark:text-blue-400 hover:underline'
|
||||
<Link
|
||||
to='https://tailscale.com/kb/1018/acls'
|
||||
name='Tailscale ACL documentation'
|
||||
>
|
||||
More information
|
||||
</a>
|
||||
Tailscale ACL guide
|
||||
</Link>
|
||||
{' '}
|
||||
and the
|
||||
{' '}
|
||||
<Link
|
||||
to='https://headscale.net/acls'
|
||||
name='Headscale ACL documentation'
|
||||
>
|
||||
Headscale docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
<Tab.Group>
|
||||
<Tab.List className={clsx(
|
||||
<Tabs>
|
||||
<TabList className={cn(
|
||||
'flex border-t border-gray-200 dark:border-gray-700',
|
||||
'w-fit rounded-t-lg overflow-hidden',
|
||||
'text-gray-400 dark:text-gray-500'
|
||||
)}
|
||||
>
|
||||
<Tab as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
type='button'
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-tl-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-l border-gray-200 dark:border-gray-700',
|
||||
selected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
)}
|
||||
>
|
||||
<PencilSquareIcon className='w-5 h-5'/>
|
||||
<p>
|
||||
Edit file
|
||||
</p>
|
||||
</button>
|
||||
<Tab
|
||||
id='edit'
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tl-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
)}
|
||||
>
|
||||
<PencilIcon className='w-5 h-5'/>
|
||||
<p>Edit file</p>
|
||||
</Tab>
|
||||
<Tab as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
type='button'
|
||||
className={clsx(
|
||||
'px-4 py-2',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
selected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
)}
|
||||
>
|
||||
<EyeIcon className='w-5 h-5'/>
|
||||
<p>
|
||||
Preview changes
|
||||
</p>
|
||||
</button>
|
||||
<Tab
|
||||
id='diff'
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
)}
|
||||
>
|
||||
<EyeIcon className='w-5 h-5'/>
|
||||
<p>Preview changes</p>
|
||||
</Tab>
|
||||
<Tab as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
type='button'
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-tr-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-r border-gray-200 dark:border-gray-700',
|
||||
selected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
)}
|
||||
>
|
||||
<BeakerIcon className='w-5 h-5'/>
|
||||
<p>
|
||||
Preview rules
|
||||
</p>
|
||||
</button>
|
||||
<Tab
|
||||
id='preview'
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tr-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
)}
|
||||
>
|
||||
<BeakerIcon className='w-5 h-5'/>
|
||||
<p>Preview rules</p>
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode='diff'/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<div
|
||||
className={clsx(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
||||
'p-16 flex flex-col items-center justify-center'
|
||||
)}
|
||||
>
|
||||
<CubeTransparentIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
|
||||
<p className='w-1/2 text-center mt-4'>
|
||||
The Preview rules is very much still a work in progress.
|
||||
It is a bit complicated to implement right now but hopefully it will be available soon.
|
||||
</p>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</TabList>
|
||||
<TabPanel id='edit'>
|
||||
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</TabPanel>
|
||||
<TabPanel id='diff'>
|
||||
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode='diff'/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</TabPanel>
|
||||
<TabPanel id='preview'>
|
||||
<div
|
||||
className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
||||
'p-16 flex flex-col items-center justify-center'
|
||||
)}
|
||||
>
|
||||
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
|
||||
<p className='w-1/2 text-center mt-4'>
|
||||
The Preview rules is very much still a work in progress.
|
||||
It is a bit complicated to implement right now but hopefully it will be available soon.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Bars3Icon, LockClosedIcon } from '@heroicons/react/24/outline'
|
||||
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react'
|
||||
import { type FetcherWithComponents, useFetcher } from '@remix-run/react'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -83,7 +83,7 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
|
||||
{baseDomain ? (
|
||||
<TableList.Item key='magic-dns-sd'>
|
||||
<p className='font-mono text-sm'>{baseDomain}</p>
|
||||
<LockClosedIcon className='h-4 w-4'/>
|
||||
<LockIcon className='h-4 w-4'/>
|
||||
</TableList.Item>
|
||||
) : undefined}
|
||||
<SortableContext
|
||||
@@ -190,7 +190,7 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
|
||||
>
|
||||
<p className='font-mono text-sm flex items-center gap-4'>
|
||||
{disabled ? undefined : (
|
||||
<Bars3Icon
|
||||
<ThreeBarsIcon
|
||||
className='h-4 w-4 text-gray-400 focus:outline-none'
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import useModal from '~/components/Modal'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type Properties = {
|
||||
readonly isEnabled: boolean;
|
||||
@@ -12,38 +12,59 @@ type Properties = {
|
||||
|
||||
export default function Modal({ isEnabled, disabled }: Properties) {
|
||||
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 (
|
||||
<>
|
||||
<Button
|
||||
variant='emphasized'
|
||||
className='w-fit text-sm'
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
open()
|
||||
}}
|
||||
<Dialog>
|
||||
<Dialog.Button
|
||||
isDisabled={disabled}
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
'bg-main-700 dark:bg-main-800 text-white',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className='w-3 h-3'/>
|
||||
)}
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Button>
|
||||
{Modal}
|
||||
</>
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Devices will no longer be accessible via your tailnet domain.
|
||||
The search domain will also be disabled.
|
||||
</Dialog.Text>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns_config.magic_dns': !isEnabled
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
/* eslint-disable unicorn/no-keyword-prefix */
|
||||
import { Dialog } from '@headlessui/react'
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Input from '~/components/Input'
|
||||
import useModal from '~/components/Modal'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import TextField from '~/components/TextField'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type Properties = {
|
||||
readonly name: string;
|
||||
@@ -19,29 +19,6 @@ type Properties = {
|
||||
export default function Modal({ name, disabled }: Properties) {
|
||||
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'>
|
||||
@@ -65,20 +42,62 @@ export default function Modal({ name, disabled }: Properties) {
|
||||
event.target.select()
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant='emphasized'
|
||||
className='text-sm w-fit'
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
open()
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className='w-3 h-3'/>
|
||||
)}
|
||||
Rename Tailnet...
|
||||
</Button>
|
||||
{Modal}
|
||||
<Dialog>
|
||||
<Dialog.Button
|
||||
isDisabled={disabled}
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
'bg-main-700 dark:bg-main-800 text-white',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className='w-3 h-3'/>
|
||||
)}
|
||||
Rename Tailnet
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Rename Tailnet
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.
|
||||
</Dialog.Text>
|
||||
<TextField
|
||||
label='Tailnet name'
|
||||
placeholder='ts.net'
|
||||
state={[newName, setNewName]}
|
||||
className='my-2'
|
||||
/>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
'dns_config.base_domain': newName
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
close()
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Switch } from '@headlessui/react'
|
||||
import { type ActionFunctionArgs } from '@remix-run/node'
|
||||
import { json, useFetcher, useLoaderData } from '@remix-run/react'
|
||||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
@@ -9,6 +7,7 @@ import Code from '~/components/Code'
|
||||
import Input from '~/components/Input'
|
||||
import Notice from '~/components/Notice'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import Switch from '~/components/Switch'
|
||||
import TableList from '~/components/TableList'
|
||||
import { getConfig, getContext, patchConfig } from '~/utils/config'
|
||||
import { restartHeadscale } from '~/utils/docker'
|
||||
@@ -97,12 +96,9 @@ export default function Page() {
|
||||
Override local DNS
|
||||
</span>
|
||||
<Switch
|
||||
checked={localOverride}
|
||||
disabled={!data.hasConfigWrite}
|
||||
className={clsx(
|
||||
localOverride ? 'bg-gray-800 dark:bg-gray-600' : 'bg-gray-200 dark:bg-gray-400',
|
||||
'relative inline-flex h-4 w-9 items-center rounded-full'
|
||||
)}
|
||||
label='Override local DNS'
|
||||
defaultSelected={localOverride}
|
||||
isDisabled={!data.hasConfigWrite}
|
||||
onChange={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -114,15 +110,7 @@ export default function Page() {
|
||||
|
||||
setLocalOverride(!localOverride)
|
||||
}}
|
||||
>
|
||||
<span className='sr-only'>Override local DNS</span>
|
||||
<span
|
||||
className={clsx(
|
||||
localOverride ? 'translate-x-6' : 'translate-x-1',
|
||||
'inline-block h-2 w-2 transform rounded-full bg-white transition'
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TableList>
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
/* eslint-disable unicorn/filename-case */
|
||||
/* 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 { 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'))
|
||||
|
||||
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() {
|
||||
useLiveData({ interval: 3000 })
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const fetcher = useFetcher()
|
||||
|
||||
const { Modal, open } = useModal()
|
||||
|
||||
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'>
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 Delete({ machine, fetcher, state }: DeleteProperties) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Remove {machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This machine will be permanently removed from
|
||||
your network. To re-add it, you will need to
|
||||
reauthenticate to your tailnet from the device.
|
||||
</Dialog.Text>
|
||||
<fetcher.Form method='POST'>
|
||||
<input type='hidden' name='_method' value='delete'/>
|
||||
<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}
|
||||
>
|
||||
Remove
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { type FetcherWithComponents } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import { type Machine } from '~/types'
|
||||
|
||||
type RenameProperties = {
|
||||
readonly machine: Machine;
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
readonly magic?: string;
|
||||
}
|
||||
|
||||
export default function Rename({ machine, fetcher, state, magic }: RenameProperties) {
|
||||
const [name, setName] = useState(machine.givenName)
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit machine name for {machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This name is shown in the admin panel, in Tailscale clients,
|
||||
and used when generating MagicDNS names.
|
||||
</Dialog.Text>
|
||||
<fetcher.Form method='POST'>
|
||||
<input type='hidden' name='_method' value='rename'/>
|
||||
<input type='hidden' name='id' value={machine.id}/>
|
||||
<TextField
|
||||
label='Machine name'
|
||||
placeholder='Machine name'
|
||||
name='name'
|
||||
state={[name, setName]}
|
||||
className='my-2'
|
||||
/>
|
||||
{magic ? (
|
||||
name.length > 0 && name !== machine.givenName ? (
|
||||
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
|
||||
This machine will be accessible by the hostname
|
||||
{' '}
|
||||
<Code className='text-sm'>
|
||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
||||
</Code>
|
||||
{'. '}
|
||||
The hostname
|
||||
{' '}
|
||||
<Code className='text-sm'>
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
{' '}
|
||||
will no longer point to this machine.
|
||||
</p>
|
||||
) : (
|
||||
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
|
||||
This machine is accessible by the hostname
|
||||
{' '}
|
||||
<Code className='text-sm'>
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
) : undefined}
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
onPress={close}
|
||||
>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/* eslint-disable react/hook-use-state */
|
||||
import { ChevronDownIcon, CopyIcon, KebabHorizontalIcon } from '@primer/octicons-react'
|
||||
import { type FetcherWithComponents, Link } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Menu from '~/components/Menu'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { type Machine, type Route } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Delete from './dialogs/delete'
|
||||
import Expire from './dialogs/expire'
|
||||
import Rename from './dialogs/rename'
|
||||
import Routes from './dialogs/routes'
|
||||
|
||||
type MachineProperties = {
|
||||
readonly machine: Machine;
|
||||
readonly routes: Route[];
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
readonly magic?: string;
|
||||
}
|
||||
|
||||
export default function MachineRow({ machine, routes, fetcher, magic }: MachineProperties) {
|
||||
const renameState = useState(false)
|
||||
const expireState = 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 (
|
||||
<tr
|
||||
key={machine.id}
|
||||
className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'
|
||||
>
|
||||
<td className='pl-0.5 py-2'>
|
||||
<Link
|
||||
to={`/machines/${machine.id}`}
|
||||
className='group/link h-full'
|
||||
>
|
||||
<p className={cn(
|
||||
'font-semibold leading-snug',
|
||||
'group-hover/link:text-blue-600',
|
||||
'group-hover/link:dark:text-blue-400'
|
||||
)}
|
||||
>
|
||||
{machine.givenName}
|
||||
</p>
|
||||
<p className='text-sm text-gray-500 dark:text-gray-300 font-mono'>
|
||||
{machine.name}
|
||||
</p>
|
||||
<div className='flex gap-1 mt-1'>
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className={cn(
|
||||
'text-xs rounded-sm px-1 py-0.5',
|
||||
'bg-gray-100 dark:bg-zinc-700',
|
||||
'text-gray-600 dark:text-gray-300'
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className='py-2'>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
{machine.ipAddresses[0]}
|
||||
<Menu>
|
||||
<Menu.Button>
|
||||
<ChevronDownIcon className='w-4 h-4'/>
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
{machine.ipAddresses.map(ip => (
|
||||
<Menu.ItemButton
|
||||
key={ip}
|
||||
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')
|
||||
}}
|
||||
>
|
||||
{ip}
|
||||
<CopyIcon className='w-3 h-3'/>
|
||||
</Menu.ItemButton>
|
||||
))}
|
||||
{magic ? (
|
||||
<Menu.ItemButton
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex items-center gap-x-1.5 text-sm',
|
||||
'justify-between w-full break-keep'
|
||||
)}
|
||||
onPress={async () => {
|
||||
const ip = `${machine.givenName}.${machine.user.name}.${magic}`
|
||||
await navigator.clipboard.writeText(ip)
|
||||
toast('Copied hostname to clipboard')
|
||||
}}
|
||||
>
|
||||
{machine.givenName}.{machine.user.name}.{magic}
|
||||
<CopyIcon className='w-3 h-3'/>
|
||||
</Menu.ItemButton>
|
||||
) : undefined}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
<td className='py-2'>
|
||||
<span className={cn(
|
||||
'flex items-center gap-x-1 text-sm',
|
||||
'text-gray-500 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
<StatusCircle
|
||||
isOnline={machine.online && !expired}
|
||||
className='w-4 h-4'
|
||||
/>
|
||||
<p>
|
||||
{machine.online && !expired
|
||||
? 'Connected'
|
||||
: new Date(
|
||||
machine.lastSeen
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className='py-2 pr-0.5'>
|
||||
<Rename
|
||||
machine={machine}
|
||||
fetcher={fetcher}
|
||||
state={renameState}
|
||||
magic={magic}
|
||||
/>
|
||||
<Delete
|
||||
machine={machine}
|
||||
fetcher={fetcher}
|
||||
state={removeState}
|
||||
/>
|
||||
{expired ? undefined : (
|
||||
<Expire
|
||||
machine={machine}
|
||||
fetcher={fetcher}
|
||||
state={expireState}
|
||||
/>
|
||||
)}
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
fetcher={fetcher}
|
||||
state={routesState}
|
||||
/>
|
||||
|
||||
<Menu>
|
||||
<Menu.Button
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'border border-transparent rounded-lg py-0.5 w-10',
|
||||
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
|
||||
)}
|
||||
>
|
||||
<KebabHorizontalIcon className='w-5'/>
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.ItemButton control={renameState}>
|
||||
Edit machine name
|
||||
</Menu.ItemButton>
|
||||
<Menu.ItemButton control={routesState}>
|
||||
Edit route settings
|
||||
</Menu.ItemButton>
|
||||
<Menu.Item className='opacity-50 hover:bg-transparent'>
|
||||
Edit ACL tags
|
||||
</Menu.Item>
|
||||
{expired ? undefined : (
|
||||
<Menu.ItemButton control={expireState}>
|
||||
Expire
|
||||
</Menu.ItemButton>
|
||||
)}
|
||||
<Menu.ItemButton
|
||||
className='text-red-500 dark:text-red-400'
|
||||
control={removeState}
|
||||
>
|
||||
Remove
|
||||
</Menu.ItemButton>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useFetcher, useLoaderData } from '@remix-run/react'
|
||||
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import { type Machine, type Route } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { getConfig, getContext } from '~/utils/config'
|
||||
import { del, post, pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import MachineRow from './machine'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
|
||||
const machines = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
|
||||
const routes = await pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!)
|
||||
const context = await getContext()
|
||||
|
||||
let magic: string | undefined
|
||||
if (context.hasConfig) {
|
||||
const config = await getConfig()
|
||||
if (config.dns_config.magic_dns) {
|
||||
magic = config.dns_config.base_domain
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: machines.nodes,
|
||||
routes: routes.routes,
|
||||
magic
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ message: 'Unauthorized' }, {
|
||||
status: 401
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.formData()
|
||||
if (!data.has('_method') || !data.has('id')) {
|
||||
return json({ message: 'No method or ID provided' }, {
|
||||
status: 400
|
||||
})
|
||||
}
|
||||
|
||||
const id = String(data.get('id'))
|
||||
const method = String(data.get('_method'))
|
||||
|
||||
switch (method) {
|
||||
case 'delete': {
|
||||
await del(`v1/node/${id}`, session.get('hsApiKey')!)
|
||||
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': {
|
||||
if (!data.has('name')) {
|
||||
return json({ message: 'No name provided' }, {
|
||||
status: 400
|
||||
})
|
||||
}
|
||||
|
||||
const name = String(data.get('name'))
|
||||
|
||||
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
|
||||
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: {
|
||||
return json({ message: 'Invalid method' }, {
|
||||
status: 400
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 3000 })
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
|
||||
<table className='table-auto w-full rounded-lg'>
|
||||
<thead className='text-gray-500 dark:text-gray-400'>
|
||||
<tr className='text-left uppercase text-xs font-bold px-0.5'>
|
||||
<th className='pb-2'>Name</th>
|
||||
<th className='pb-2'>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
Addresses
|
||||
{data.magic ? (
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button>
|
||||
<InfoIcon className='w-4 h-4'/>
|
||||
</Button>
|
||||
<Tooltip className={cn(
|
||||
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||
'bg-white dark:bg-zinc-800',
|
||||
'border border-gray-200 dark:border-zinc-700'
|
||||
)}
|
||||
>
|
||||
Since MagicDNS is enabled, you can access devices
|
||||
based on their name and also at
|
||||
{' '}
|
||||
<Code>
|
||||
[name].[user].{data.magic}
|
||||
</Code>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
) : undefined}
|
||||
</div>
|
||||
</th>
|
||||
<th className='pb-2'>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={cn(
|
||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||
'border-t border-zinc-200 dark:border-zinc-700'
|
||||
)}
|
||||
>
|
||||
{data.nodes.map(machine => (
|
||||
<MachineRow
|
||||
key={machine.id}
|
||||
// Typescript isn't smart enough yet
|
||||
machine={machine as unknown as Machine}
|
||||
routes={data.routes.filter(route => route.node.id === machine.id) as unknown as Route[]}
|
||||
fetcher={fetcher}
|
||||
magic={data.magic}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CubeTransparentIcon } from '@heroicons/react/24/outline'
|
||||
import { IssueDraftIcon } from '@primer/octicons-react'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center'>
|
||||
<CubeTransparentIcon className='w-32 h-32 text-gray-500'/>
|
||||
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center my-8'>
|
||||
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
|
||||
<p className='text-lg mt-8'>
|
||||
The settings page is currently unavailable.
|
||||
It will be available in a future release.
|
||||
|
||||
+6
-78
@@ -1,10 +1,8 @@
|
||||
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 { Outlet, useLoaderData } from '@remix-run/react'
|
||||
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import { ErrorPopup } from '~/components/Error'
|
||||
import TabLink from '~/components/TabLink'
|
||||
import Header from '~/components/Header'
|
||||
import { getContext } from '~/utils/config'
|
||||
import { HeadscaleError, pull } from '~/utils/headscale'
|
||||
import { destroySession, getSession } from '~/utils/sessions'
|
||||
@@ -20,7 +18,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
await pull('v1/apikey', session.get('hsApiKey')!)
|
||||
} catch (error) {
|
||||
if (error instanceof HeadscaleError) {
|
||||
console.error(error)
|
||||
// Safest to just redirect to login if we can't pull
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
@@ -43,57 +40,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
export default function Layout() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className='mb-16 bg-gray-800 text-white dark:bg-gray-700'>
|
||||
<nav className='container mx-auto'>
|
||||
<div className='flex items-center justify-between mb-8 pt-4'>
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<CpuChipIcon className='w-8 h-8'/>
|
||||
<h1 className='text-2xl'>Headplane</h1>
|
||||
</div>
|
||||
<div className='flex items-center gap-x-4'>
|
||||
<a href='https://tailscale.com/download' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
|
||||
Download
|
||||
</a>
|
||||
<a href='https://github.com/tale/headplane' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
|
||||
GitHub
|
||||
</a>
|
||||
<a href='https://github.com/juanfont/headscale' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
|
||||
Headscale
|
||||
</a>
|
||||
<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'>
|
||||
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
|
||||
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
|
||||
{data.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/> : undefined}
|
||||
{data.hasConfig ? (
|
||||
<>
|
||||
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
|
||||
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<Header data={data}/>
|
||||
|
||||
<main className='container mx-auto overscroll-contain mb-24'>
|
||||
<main className='container mx-auto overscroll-contain mt-4 mb-24'>
|
||||
<Outlet/>
|
||||
</main>
|
||||
</>
|
||||
@@ -101,33 +53,9 @@ export default function Layout() {
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const error = useRouteError()
|
||||
if (!data) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className='mb-16 bg-gray-800 text-white dark:bg-gray-700'>
|
||||
<nav className='container mx-auto'>
|
||||
<div className='flex items-center gap-x-2 mb-8 pt-4'>
|
||||
<CpuChipIcon className='w-8 h-8'/>
|
||||
<h1 className='text-2xl'>Headplane</h1>
|
||||
</div>
|
||||
<div className='flex items-center gap-x-4'>
|
||||
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
|
||||
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
|
||||
{data.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/> : undefined}
|
||||
{data.hasConfig ? (
|
||||
<>
|
||||
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
|
||||
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<Header/>
|
||||
<ErrorPopup type='embedded'/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
/* eslint-disable unicorn/filename-case */
|
||||
import { ClipboardIcon, UserIcon } from '@heroicons/react/24/outline'
|
||||
import { PersonIcon } from '@primer/octicons-react'
|
||||
import { type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import { toast } from 'react-hot-toast/headless'
|
||||
|
||||
import Attribute from '~/components/Attribute'
|
||||
import Card from '~/components/Card'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { type Machine, type User } from '~/types'
|
||||
import { type Machine } from '~/types'
|
||||
import { pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
@@ -47,7 +46,7 @@ export default function Page() {
|
||||
{data.map(user => (
|
||||
<Card key={user.id}>
|
||||
<div className='flex items-center gap-4'>
|
||||
<UserIcon className='w-6 h-6'/>
|
||||
<PersonIcon className='w-6 h-6'/>
|
||||
<span className='text-lg font-mono'>
|
||||
{user.name}
|
||||
</span>
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@supports (scrollbar-gutter: stable) {
|
||||
html {
|
||||
scrollbar-gutter: stable
|
||||
}
|
||||
|
||||
html body {
|
||||
margin-right: 0!important;
|
||||
--removed-body-scroll-bar-size: 0 !important
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
|
||||
+8
-3
@@ -196,7 +196,7 @@ export function registerConfigWatcher() {
|
||||
})
|
||||
}
|
||||
|
||||
type Context = {
|
||||
export type Context = {
|
||||
hasDockerSock: boolean;
|
||||
hasConfig: boolean;
|
||||
hasConfigWrite: boolean;
|
||||
@@ -231,8 +231,9 @@ export async function getContext() {
|
||||
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
|
||||
let client = process.env.OIDC_CLIENT_ID
|
||||
let secret = process.env.OIDC_CLIENT_SECRET
|
||||
const rootKey = process.env.API_KEY
|
||||
|
||||
if (!issuer || !client || !secret) {
|
||||
const config = await getConfig()
|
||||
@@ -250,6 +251,10 @@ async function getOidcConfig() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!rootKey) {
|
||||
throw new Error('Cannot use OIDC without the root API_KEY variable set')
|
||||
}
|
||||
|
||||
return { issuer, client, secret }
|
||||
}
|
||||
|
||||
|
||||
+11
-4
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
/* eslint-disable no-constant-condition */
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
@@ -6,7 +6,7 @@ import { setTimeout } from 'node:timers/promises'
|
||||
import { Client } from 'undici'
|
||||
|
||||
import { getContext } from './config'
|
||||
import { pull } from './headscale'
|
||||
import { HeadscaleError, pull } from './headscale'
|
||||
|
||||
export async function sighupHeadscale() {
|
||||
const context = await getContext()
|
||||
@@ -61,9 +61,16 @@ export async function restartHeadscale() {
|
||||
let attempts = 0
|
||||
while (true) {
|
||||
try {
|
||||
await pull('v1/apikey', process.env.API_KEY!)
|
||||
// Acceptable blank because API_KEY is not required
|
||||
await pull('v1/apikey', process.env.API_KEY ?? '')
|
||||
return
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// This means the server is up but the API key is invalid
|
||||
// This can happen if the user only uses API_KEY via cookies
|
||||
if (error instanceof HeadscaleError && error.status === 401) {
|
||||
break
|
||||
}
|
||||
|
||||
if (attempts > 10) {
|
||||
throw new Error('Headscale did not restart in time')
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export class HeadscaleError extends Error {
|
||||
|
||||
export class FatalError extends Error {
|
||||
constructor() {
|
||||
super('The Headscale server is not accessible or the API_KEY is invalid.')
|
||||
super('The Headscale server is not accessible or the supplied API key is invalid')
|
||||
this.name = 'FatalError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createCookieSessionStorage } from '@remix-run/node' // Or cloudflare/deno
|
||||
|
||||
type SessionData = {
|
||||
export type SessionData = {
|
||||
hsApiKey: string;
|
||||
authState: string;
|
||||
authNonce: string;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 142 KiB |
@@ -18,6 +18,8 @@ By default this is set to `/etc/headscale/config.yaml`.
|
||||
|
||||
### Access Control Lists (ACLs)
|
||||
|
||||

|
||||
|
||||
Headplane will enable the `Access Controls` tab if it is able to read an ACL file from Headscale.<br>
|
||||
The ACL file path is read from the following sources in order of priority:
|
||||
|
||||
@@ -66,9 +68,8 @@ services:
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
# These are always required for Headplane to work
|
||||
# This is always required for Headplane to work
|
||||
COOKIE_SECRET: 'abcdefghijklmnopqrstuvwxyz'
|
||||
API_KEY: 'abcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
HEADSCALE_CONTAINER: 'headscale'
|
||||
DISABLE_API_KEY_LOGIN: 'true'
|
||||
@@ -80,6 +81,11 @@ services:
|
||||
OIDC_CLIENT_ID: 'headscale'
|
||||
OIDC_ISSUER: 'https://sso.example.com'
|
||||
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
|
||||
|
||||
# This NEEDS to be set with OIDC, regardless of what's in the config
|
||||
# This needs to be a very long-lived (999 day) API key used to create
|
||||
# shorter ones for OIDC and allow the OIDC functionality to work
|
||||
API_KEY: 'abcdefghijklmnopqrstuvwxyz'
|
||||
```
|
||||
|
||||
> For a breakdown of each configuration variable, please refer to the [Configuration](/docs/Configuration.md) guide.
|
||||
|
||||
@@ -28,13 +28,17 @@ services:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
HEADSCALE_URL: 'http://headscale:8080'
|
||||
API_KEY: 'abcdefghijklmnopqrstuvwxyz'
|
||||
COOKIE_SECRET: 'abcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
# These are all optional!
|
||||
HEADSCALE_CONTAINER: 'headscale'
|
||||
API_KEY: 'abcdefghijklmnopqrstuvwxyz'
|
||||
OIDC_CLIENT_ID: 'headscale'
|
||||
OIDC_ISSUER: 'https://sso.example.com'
|
||||
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
|
||||
DISABLE_API_KEY_LOGIN: 'true'
|
||||
|
||||
# These are the default values
|
||||
HOST: '0.0.0.0'
|
||||
PORT: '3000'
|
||||
```
|
||||
|
||||
@@ -4,9 +4,8 @@ You can configure Headplane using environment variables.
|
||||
|
||||
#### Required Variables
|
||||
|
||||
- **`HEADSCALE_URL`**: The public URL of your Headscale server.
|
||||
- **`API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
|
||||
- **`COOKIE_SECRET`**: A secret used to sign cookies (use a relatively long and random string).
|
||||
- **`HEADSCALE_URL`**: The public URL of your Headscale server (not required if using the configuration file).
|
||||
|
||||
#### Optional Variables
|
||||
|
||||
@@ -24,6 +23,7 @@ If you use the Headscale configuration integration, these are not required.
|
||||
- **`OIDC_ISSUER`**: The issuer URL of your OIDC provider.
|
||||
- **`OIDC_CLIENT_ID`**: The client ID of your OIDC provider.
|
||||
- **`OIDC_CLIENT_SECRET`**: The client secret of your OIDC provider.
|
||||
- **`API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
|
||||
- **`DISABLE_API_KEY_LOGIN`**: If you want to disable API key login, set this to `true`.
|
||||
|
||||
Here's what an example with Authelia would look like if you used the same client for both Headscale and Headplane.
|
||||
|
||||
+27
-23
@@ -17,36 +17,40 @@
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@remix-run/node": "^2.8.1",
|
||||
"@remix-run/react": "^2.8.1",
|
||||
"@remix-run/serve": "^2.8.1",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"clsx": "^2.1.0",
|
||||
"isbot": "^4.1.0",
|
||||
"oauth4webapi": "^2.10.3",
|
||||
"react": "^18.2.0",
|
||||
"react-codemirror-merge": "^4.21.25",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"@primer/octicons-react": "^19.9.0",
|
||||
"@react-aria/toast": "3.0.0-beta.11",
|
||||
"@react-stately/toast": "3.0.0-beta.2",
|
||||
"@remix-run/node": "^2.9.1",
|
||||
"@remix-run/react": "^2.9.1",
|
||||
"@remix-run/serve": "^2.9.1",
|
||||
"@uiw/codemirror-theme-github": "^4.22.0",
|
||||
"@uiw/react-codemirror": "^4.22.0",
|
||||
"clsx": "^2.1.1",
|
||||
"isbot": "^5.1.6",
|
||||
"oauth4webapi": "^2.10.4",
|
||||
"react": "^18.3.1",
|
||||
"react-aria-components": "^1.2.0",
|
||||
"react-codemirror-merge": "^4.22.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"remix-utils": "^7.6.0",
|
||||
"undici": "^6.10.2",
|
||||
"usehooks-ts": "^3.0.2",
|
||||
"yaml": "^2.4.1"
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-react-aria-components": "^1.1.2",
|
||||
"undici": "^6.15.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "^2.8.1",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@remix-run/dev": "^2.9.1",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-tale": "^1.0.16",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^5.1.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
Generated
+2109
-590
File diff suppressed because it is too large
Load Diff
+19
-1
@@ -1,14 +1,32 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import type { Config } from 'tailwindcss'
|
||||
import colors from 'tailwindcss/colors'
|
||||
import animate from 'tailwindcss-animate'
|
||||
import aria from 'tailwindcss-react-aria-components'
|
||||
|
||||
export default {
|
||||
content: ['./app/**/*.{js,jsx,ts,tsx}'],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: {
|
||||
DEFAULT: '1rem',
|
||||
sm: '2rem',
|
||||
lg: '4rem',
|
||||
xl: '5rem',
|
||||
'2xl': '6rem'
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
height: {
|
||||
editor: 'calc(100vh - 20rem)'
|
||||
},
|
||||
colors: {
|
||||
main: colors.slate,
|
||||
ui: colors.neutral
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
plugins: [animate, aria]
|
||||
} satisfies Config
|
||||
|
||||
|
||||
+1
-1
@@ -264,7 +264,7 @@ unix_socket_permission: "0777"
|
||||
# help us test it.
|
||||
# OpenID Connect
|
||||
oidc:
|
||||
only_start_if_oidc_is_available: true
|
||||
only_start_if_oidc_is_available: false
|
||||
issuer: "https://sso.example.com"
|
||||
client_id: "headscale"
|
||||
client_secret: "super_secret_client_secret"
|
||||
|
||||
Reference in New Issue
Block a user