Compare commits

...

23 Commits

Author SHA1 Message Date
Aarnav Tale 9f5ac6a9ef chore: v0.1.3 2024-05-04 15:07:27 -04:00
Aarnav Tale 7f55ad826c chore: add link to headscale acl faq on acl page 2024-05-04 15:04:55 -04:00
Aarnav Tale 2cc65e8783 chore: do a feature check for scrollbar-gutter-stable 2024-05-04 15:04:40 -04:00
Aarnav Tale 5ff09e44d9 feat: cleanup header and extract to component 2024-05-04 15:04:22 -04:00
Aarnav Tale c6cdcf35eb chore: switch from heroicons to primer icons 2024-05-04 15:03:21 -04:00
Aarnav Tale 61ae161496 chore: update readme 2024-05-01 02:51:03 -04:00
Aarnav Tale 82f5e17207 feat: add various machine operations 2024-05-01 02:47:45 -04:00
Aarnav Tale a63f4e4d52 fix: API_KEY is no longer required for login to work 2024-05-01 02:47:36 -04:00
Aarnav Tale a57e777a6b feat: completely nuke headlessui and consolidate to aria 2024-05-01 02:21:54 -04:00
Aarnav Tale 8205f2b99b feat: replace the old modal completely 2024-04-30 11:03:53 -04:00
Aarnav Tale c95218b8dd fix: prune an image for readme 2024-04-29 20:33:51 -04:00
Aarnav Tale 769556efda fix: make the toaster click-throughable 2024-04-29 20:32:14 -04:00
Aarnav Tale ea47bb5fec chore: cleanup and use aria components in places 2024-04-29 20:32:00 -04:00
Aarnav Tale 3abd617a90 feat: switch to react aria and replace many components 2024-04-29 20:31:29 -04:00
Aarnav Tale cbecf85979 chore: update readme images 2024-04-23 01:15:31 -04:00
Aarnav Tale 0ebadc26bc fix: oidc will fail in the dev config 2024-04-22 14:34:03 -04:00
Aarnav Tale 252e78d618 feat: facelift overall machines page and refactor 2024-04-22 14:33:51 -04:00
Aarnav Tale c6930732ee fix: oidc config variables were named incorrectly 2024-04-22 13:21:48 -04:00
Aarnav Tale f04b17109b fix: only sighup if we have docker 2024-04-17 17:44:12 -04:00
Aarnav Tale 0ff9e6fdc3 feat: implement better ssr fallbacks 2024-04-17 17:42:44 -04:00
Aarnav Tale 94174ebcce feat: add dumb yaml detection 2024-04-17 17:20:35 -04:00
Aarnav Tale c2fe69ec17 chore: add acl file instructions 2024-04-15 04:06:18 -04:00
Aarnav Tale b285753b24 fix: omg why do i forget these things here 2024-04-15 04:02:23 -04:00
49 changed files with 3834 additions and 1508 deletions
+15
View File
@@ -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.
+7 -7
View File
@@ -4,18 +4,18 @@
![Preview](/assets/main-preview.png)
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
- [ ] 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.
- If you run Headscale natively, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
- For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
## Contributing
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
+4 -3
View File
@@ -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'>
+142
View File
@@ -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 })
-86
View File
@@ -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
View File
@@ -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>
)
}
+109
View File
@@ -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>
)
}
+31
View File
@@ -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>
)
}
+98
View File
@@ -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 })
-158
View File
@@ -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>
)
}
+2 -2
View File
@@ -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>
)
+40
View File
@@ -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>
)
}
+3 -2
View File
@@ -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}
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+44 -35
View File
@@ -1,14 +1,17 @@
import { json } from '@codemirror/lang-json'
import { yaml } from '@codemirror/lang-yaml'
import { useFetcher } from '@remix-run/react'
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
import CodeMirror from '@uiw/react-codemirror'
import clsx from 'clsx'
import { useEffect, useState } from 'react'
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'
type EditorProperties = {
readonly acl: string;
@@ -18,12 +21,16 @@ type EditorProperties = {
readonly data: {
hasAclWrite: boolean;
currentAcl: string;
aclType: string;
};
}
export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
const [light, setLight] = useState(false)
const [loading, setLoading] = useState(true)
const fetcher = useFetcher()
const aclType = useMemo(() => data.aclType === 'json' ? json() : yaml(), [data.aclType])
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)')
@@ -32,49 +39,51 @@ export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
theme.addEventListener('change', theme => {
setLight(theme.matches)
})
// Prevents the FOUC
setLoading(false)
}, [])
return (
<>
<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'
)}
>
{mode === 'edit' ? (
<CodeMirror
value={acl}
maxHeight='calc(100vh - 20rem)'
theme={light ? githubLight : githubDark}
extensions={[json()]}
readOnly={!data.hasAclWrite}
onChange={value => {
setAcl(value)
}}
/>
) : (
<div
className='overflow-y-scroll'
style={{ height: 'calc(100vh - 20rem)' }}
>
<CodeMirrorMerge
theme={light ? githubLight : githubDark}
orientation='a-b'
>
<CodeMirrorMerge.Original
readOnly
value={data.currentAcl}
extensions={[json()]}
/>
<CodeMirrorMerge.Modified
readOnly
<div className='overflow-y-scroll h-editor text-sm'>
{loading ? (
<Fallback acl={acl} where='client'/>
) : (
mode === 'edit' ? (
<CodeMirror
value={acl}
extensions={[json()]}
theme={light ? githubLight : githubDark}
extensions={[aclType]}
readOnly={!data.hasAclWrite}
onChange={value => {
setAcl(value)
}}
/>
</CodeMirrorMerge>
</div>
)}
) : (
<CodeMirrorMerge
theme={light ? githubLight : githubDark}
orientation='a-b'
>
<CodeMirrorMerge.Original
readOnly
value={data.currentAcl}
extensions={[aclType]}
/>
<CodeMirrorMerge.Modified
readOnly
value={acl}
extensions={[aclType]}
/>
</CodeMirrorMerge>
)
)}
</div>
</div>
<Button
+51
View File
@@ -0,0 +1,51 @@
import clsx from 'clsx'
import Button from '~/components/Button'
type FallbackProperties = {
readonly acl: string;
readonly where: 'client' | 'server';
}
export default function Fallback({ acl, where }: FallbackProperties) {
return (
<>
<div className={clsx(
where === 'server' ? 'mb-2 overflow-hidden rounded-tr-lg rounded-b-lg' : '',
where === 'server' ? 'border border-gray-200 dark:border-gray-700' : ''
)}
>
<textarea
readOnly
className={clsx(
'w-full h-editor font-mono resize-none',
'text-sm text-gray-600 dark:text-gray-300',
'pl-10 pt-1 leading-snug'
)}
value={acl}
/>
</div>
{where === 'server' ? (
<>
<Button
disabled
variant='emphasized'
className='text-sm w-fit mr-2'
>
Save
</Button>
<Button
disabled
variant='emphasized'
className={clsx(
'text-sm w-fit bg-gray-100 dark:bg-transparent',
'border border-gray-200 dark:border-gray-700'
)}
>
Discard Changes
</Button>
</>
) : undefined}
</>
)
}
+94 -100
View File
@@ -1,18 +1,19 @@
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'
import Editor from './editor'
import Fallback from './fallback'
export async function loader() {
const context = await getContext()
@@ -20,10 +21,11 @@ export async function loader() {
throw new Error('No ACL configuration is available')
}
const acl = await getAcl()
const { data, type } = await getAcl()
return {
hasAclWrite: context.hasAclWrite,
currentAcl: acl
currentAcl: data,
aclType: type
}
}
@@ -44,7 +46,11 @@ export async function action({ request }: ActionFunctionArgs) {
const data = await request.json() as { acl: string }
await patchAcl(data.acl)
await sighupHeadscale()
if (context.hasDockerSock) {
await sighupHeadscale()
}
return json({ success: true })
}
@@ -53,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>
@@ -69,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-300 dark:text-gray-500'
'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>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/>
)}
</ClientOnly>
</Tab.Panel>
<Tab.Panel>
<ClientOnly>
{() => (
<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's 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>
)
}
+3 -3
View File
@@ -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}
+49 -28
View File
@@ -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>
)
}
+59 -40
View File
@@ -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>
)
}
+5 -17
View File
@@ -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>
-208
View File
@@ -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&apos;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>
)
}
+165
View File
@@ -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>
</>
)
}
+3 -3
View File
@@ -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
View File
@@ -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'/>
</>
)
+3 -4
View File
@@ -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>
+11
View File
@@ -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
}
}
+4
View File
@@ -0,0 +1,4 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
+23 -9
View File
@@ -2,7 +2,7 @@ import { type FSWatcher, watch } from 'node:fs'
import { access, constants, readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { type Document, parseDocument } from 'yaml'
import { type Document, parse, parseDocument } from 'yaml'
type Duration = `${string}s` | `${string}h` | `${string}m` | `${string}d` | `${string}y`
@@ -141,11 +141,19 @@ export async function getAcl() {
}
if (!path) {
return ''
return { data: '', type: 'json' }
}
const data = await readFile(path, 'utf8')
return data
// Naive check for YAML over JSON
// This is because JSON.parse doesn't support comments
try {
parse(data)
return { data, type: 'yaml' }
} catch {
return { data, type: 'json' }
}
}
// This is so obscenely dangerous, please have a check around it
@@ -188,7 +196,7 @@ export function registerConfigWatcher() {
})
}
type Context = {
export type Context = {
hasDockerSock: boolean;
hasConfig: boolean;
hasConfigWrite: boolean;
@@ -223,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()
@@ -242,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 }
}
@@ -323,8 +336,6 @@ async function hasAclW() {
const config = await getConfig()
path = config.acl_policy_path
} catch {}
return false
}
if (!path) {
@@ -335,7 +346,10 @@ async function hasAclW() {
path = resolve(path)
await access(path, constants.W_OK)
return true
} catch {}
} catch (error) {
console.log('Cannot acquire read access to ACL file', error)
}
return false
}
+11 -4
View File
@@ -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')
}
+1 -1
View File
@@ -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 -1
View File
@@ -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

+8 -2
View File
@@ -18,6 +18,8 @@ By default this is set to `/etc/headscale/config.yaml`.
### Access Control Lists (ACLs)
![ACL Preview](/assets/acl-preview.png)
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.
+9 -1
View File
@@ -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'
```
@@ -44,3 +48,7 @@ services:
You may also choose to run it natively with the distributed binaries on the releases page.
You'll need to manage running this yourself, and I would recommend making a `systemd` unit.
## ACL Configuration
If you would like to get the web ACL configuration working, you'll need to pass the `ACL_FILE` environment variable.
This should point to the path of the ACL file on the Headscale server (ie. `ACL_FILE=/etc/headscale/acl_policy.json`).
+3 -3
View File
@@ -4,16 +4,15 @@ 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
- **`HOST`**: The host to bind the server to (default: `0.0.0.0`).
- **`PORT`**: The port to bind the server to (default: `3000`).
- **`CONFIG_FILE`**: The path to the Headscale `config.yaml` (default: `/etc/headscale/config.yaml`).
- **`ACL_FILE`**: The path to the ACL file (default: `/etc/headscale/acl_policy.json`).
- **`ACL_FILE`**: The path to the ACL file (default: `/etc/headscale/acl_policy.json`, not needed if you have `acl_policy_path` in your config).
- **`HEADSCALE_CONTAINER`**: The name of the Headscale container (required for Docker integration).
### SSO/OpenID Connect
@@ -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.
+28 -23
View File
@@ -12,40 +12,45 @@
},
"dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.1",
"@dnd-kit/core": "^6.1.0",
"@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": {
+2133 -590
View File
File diff suppressed because it is too large Load Diff
+24 -2
View File
@@ -1,10 +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: {
extend: {}
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
View File
@@ -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"