chore: switch to react-router v7

This commit is contained in:
Aarnav Tale 2024-12-31 10:30:14 +05:30
parent 39504e2487
commit aa9872a45b
No known key found for this signature in database
101 changed files with 3825 additions and 6796 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
node_modules node_modules
/.react-router
/.cache /.cache
/build /build
.env .env

View File

@ -1,16 +1,15 @@
import { CopyIcon } from '@primer/octicons-react' import { CopyIcon } from '@primer/octicons-react';
import { toast } from './Toaster';
import { toast } from './Toaster'
interface Props { interface Props {
name: string name: string;
value: string value: string;
isCopyable?: boolean isCopyable?: boolean;
link?: string link?: string;
} }
export default function Attribute({ name, value, link, isCopyable }: Props) { export default function Attribute({ name, value, link, isCopyable }: Props) {
const canCopy = isCopyable ?? false const canCopy = isCopyable ?? false;
return ( return (
<dl className="flex gap-1 text-sm w-full"> <dl className="flex gap-1 text-sm w-full">
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1"> <dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
@ -18,31 +17,26 @@ export default function Attribute({ name, value, link, isCopyable }: Props) {
<a className="hover:underline" href={link}> <a className="hover:underline" href={link}>
{name} {name}
</a> </a>
) : name} ) : (
name
)}
</dt> </dt>
{canCopy {canCopy ? (
? ( <button
<button type="button"
type="button" className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md" onClick={async () => {
// eslint-disable-next-line @typescript-eslint/no-misused-promises await navigator.clipboard.writeText(value);
onClick={async () => { toast(`Copied ${name}`);
await navigator.clipboard.writeText(value) }}
toast(`Copied ${name}`) >
}} <dd className="min-w-0 truncate px-2 py-1">{value}</dd>
> <CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
<dd className="min-w-0 truncate px-2 py-1"> </button>
{value} ) : (
</dd> <dd className="min-w-0 truncate px-2 py-1">{value}</dd>
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" /> )}
</button>
)
: (
<dd className="min-w-0 truncate px-2 py-1">
{value}
</dd>
)}
</dl> </dl>
) );
} }

View File

@ -1,37 +1,38 @@
import { type Dispatch, type SetStateAction } from 'react' import { Dispatch, SetStateAction } from 'react';
import { Button as AriaButton } from 'react-aria-components' import { Button as AriaButton } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' type Props = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly variant?: 'heavy' | 'light';
};
type ButtonProperties = Parameters<typeof AriaButton>[0] & { export default function Button(props: Props) {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]
readonly variant?: 'heavy' | 'light'
}
export default function Button(properties: ButtonProperties) {
return ( return (
<AriaButton <AriaButton
{...properties} {...props}
className={cn( className={cn(
'w-fit text-sm rounded-lg px-4 py-2', 'w-fit text-sm rounded-lg px-4 py-2',
properties.variant === 'heavy' props.variant === 'heavy'
? 'bg-main-700 dark:bg-main-800' ? 'bg-main-700 dark:bg-main-800'
: 'bg-main-200 dark:bg-main-700/30', : 'bg-main-200 dark:bg-main-700/30',
properties.variant === 'heavy' props.variant === 'heavy'
? 'hover:bg-main-800 dark:hover:bg-main-700' ? 'hover:bg-main-800 dark:hover:bg-main-700'
: 'hover:bg-main-300 dark:hover:bg-main-600/30', : 'hover:bg-main-300 dark:hover:bg-main-600/30',
properties.variant === 'heavy' props.variant === 'heavy'
? 'text-white' ? 'text-white'
: 'text-ui-700 dark:text-ui-300', : 'text-ui-700 dark:text-ui-300',
properties.isDisabled && 'opacity-50 cursor-not-allowed', props.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className, props.className,
)} )}
// If control is passed, set the state value // If control is passed, set the state value
onPress={properties.control onPress={
? () => { props.control
properties.control?.[1](true) ? () => {
} props.control?.[1](true);
: properties.onPress} }
: props.onPress
}
/> />
) );
} }

View File

@ -1,53 +1,43 @@
import { type HTMLProps } from 'react' import { HTMLProps } from 'react';
import { Heading as AriaHeading } from 'react-aria-components' import { Heading as AriaHeading } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' function Title(props: Parameters<typeof AriaHeading>[0]) {
function Title(properties: Parameters<typeof AriaHeading>[0]) {
return ( return (
<AriaHeading <AriaHeading
{...properties} {...props}
slot='title' slot="title"
className={cn( className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
/> />
) );
} }
function Text(properties: React.HTMLProps<HTMLParagraphElement>) { function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return ( return (
<p <p {...props} className={cn('text-base leading-6 my-0', props.className)} />
{...properties} );
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
} }
type Properties = HTMLProps<HTMLDivElement> & { type Props = HTMLProps<HTMLDivElement> & {
variant?: 'raised' | 'flat'; variant?: 'raised' | 'flat';
} };
function Card(properties: Properties) { function Card(props: Props) {
return ( return (
<div <div
{...properties} {...props}
className={cn( className={cn(
'w-full max-w-md overflow-hidden rounded-xl p-4', 'w-full max-w-md overflow-hidden rounded-xl p-4',
properties.variant === 'flat' props.variant === 'flat'
? 'bg-transparent shadow-none' ? 'bg-transparent shadow-none'
: 'bg-ui-50 dark:bg-ui-900 shadow-sm', : 'bg-ui-50 dark:bg-ui-900 shadow-sm',
'border border-ui-200 dark:border-ui-700', 'border border-ui-200 dark:border-ui-700',
properties.className props.className,
)} )}
> >
{properties.children} {props.children}
</div> </div>
) );
} }
export default Object.assign(Card, { Title, Text }) export default Object.assign(Card, { Title, Text });

View File

@ -1,45 +1,47 @@
import { useState, HTMLProps } from 'react' import { useState, HTMLProps } from 'react';
import { CopyIcon, CheckIcon } from '@primer/octicons-react' import { CopyIcon, CheckIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' import { toast } from '~/components/Toaster';
import { toast } from '~/components/Toaster'
interface Props extends HTMLProps<HTMLSpanElement> { interface Props extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean isCopyable?: boolean;
} }
export default function Code(props: Props) { export default function Code(props: Props) {
const [isCopied, setIsCopied] = useState(false) const [isCopied, setIsCopied] = useState(false);
return ( return (
<> <>
<code className={cn( <code
'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md', className={cn(
props.className 'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
)}> props.className,
)}
>
{props.children} {props.children}
</code> </code>
{props.isCopyable && ( {props.isCopyable && props.children ? (
<button <button
className={cn( className={cn(
'ml-1 p-1 rounded-md', 'ml-1 p-1 rounded-md',
'bg-ui-100 dark:bg-ui-800', 'bg-ui-100 dark:bg-ui-800',
'text-ui-500 dark:text-ui-400', 'text-ui-500 dark:text-ui-400',
'inline-flex items-center justify-center' 'inline-flex items-center justify-center',
)} )}
onClick={() => { onClick={() => {
navigator.clipboard.writeText(props.children.join('')) navigator.clipboard.writeText(props.children.join(''));
toast('Copied to clipboard') toast('Copied to clipboard');
setIsCopied(true) setIsCopied(true);
setTimeout(() => setIsCopied(false), 1000) setTimeout(() => setIsCopied(false), 1000);
}} }}
> >
{isCopied ? {isCopied ? (
<CheckIcon className="h-3 w-3" /> : <CheckIcon className="h-3 w-3" />
) : (
<CopyIcon className="h-3 w-3" /> <CopyIcon className="h-3 w-3" />
} )}
</button> </button>
)} ) : undefined}
</> </>
) );
} }

View File

@ -1,106 +1,99 @@
/* eslint-disable unicorn/no-keyword-prefix */ import { Dispatch, ReactNode, SetStateAction } from 'react';
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import { import {
Button as AriaButton, Button as AriaButton,
Dialog as AriaDialog, Dialog as AriaDialog,
DialogTrigger, DialogTrigger,
Heading as AriaHeading, Heading as AriaHeading,
Modal, Modal,
ModalOverlay ModalOverlay,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' type ButtonProps = Parameters<typeof AriaButton>[0] & {
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
} };
function Button(properties: ButtonProperties) { function Button(props: ButtonProps) {
return ( return (
<AriaButton <AriaButton
{...properties} {...props}
aria-label='Dialog' aria-label="Dialog"
className={cn( className={cn(
'w-fit text-sm rounded-lg px-4 py-2', 'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white', 'bg-main-700 dark:bg-main-800 text-white',
'hover:bg-main-800 dark:hover:bg-main-700', 'hover:bg-main-800 dark:hover:bg-main-700',
properties.isDisabled && 'opacity-50 cursor-not-allowed', props.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className props.className,
)} )}
// If control is passed, set the state value // If control is passed, set the state value
onPress={properties.control ? () => { onPress={
properties.control?.[1](true) props.control
} : undefined} ? () => {
props.control?.[1](true);
}
: undefined
}
/> />
) );
} }
type ActionProperties = Parameters<typeof AriaButton>[0] & { type ActionProps = Parameters<typeof AriaButton>[0] & {
readonly variant: 'cancel' | 'confirm'; readonly variant: 'cancel' | 'confirm';
} };
function Action(properties: ActionProperties) { function Action(props: ActionProps) {
return ( return (
<AriaButton <AriaButton
{...properties} {...props}
type={properties.variant === 'confirm' ? 'submit' : 'button'} type={props.variant === 'confirm' ? 'submit' : 'button'}
className={cn( className={cn(
'px-4 py-2 rounded-lg', 'px-4 py-2 rounded-lg',
properties.isDisabled && 'opacity-50 cursor-not-allowed', props.isDisabled && 'opacity-50 cursor-not-allowed',
properties.variant === 'cancel' props.variant === 'cancel'
? 'text-ui-700 dark:text-ui-300' ? 'text-ui-700 dark:text-ui-300'
: 'text-ui-300 dark:text-ui-300', : 'text-ui-300 dark:text-ui-300',
properties.variant === 'confirm' props.variant === 'confirm'
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800' ? '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', : 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
properties.className props.className,
)} )}
/> />
) );
} }
function Title(properties: Parameters<typeof AriaHeading>[0]) { function Title(props: Parameters<typeof AriaHeading>[0]) {
return ( return (
<AriaHeading <AriaHeading
{...properties} {...props}
slot='title' slot="title"
className={cn( className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
/> />
) );
} }
function Text(properties: React.HTMLProps<HTMLParagraphElement>) { function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return ( return (
<p <p {...props} className={cn('text-base leading-6 my-0', props.className)} />
{...properties} );
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
} }
type PanelProperties = { interface PanelProps {
readonly children: (close: () => void) => ReactNode; children: (close: () => void) => ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly className?: string; className?: string;
} }
function Panel({ children, control, className }: PanelProperties) { function Panel({ children, control, className }: PanelProps) {
return ( return (
<ModalOverlay <ModalOverlay
aria-hidden='true' aria-hidden="true"
className={cn( className={cn(
'fixed inset-0 h-screen w-screen z-50 bg-black/30', 'fixed inset-0 h-screen w-screen z-50 bg-black/30',
'flex items-center justify-center dark:bg-black/70', 'flex items-center justify-center dark:bg-black/70',
'entering:animate-in exiting:animate-out', 'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-200 entering:ease-out', 'entering:fade-in entering:duration-200 entering:ease-out',
'exiting:fade-out exiting:duration-100 exiting:ease-in', 'exiting:fade-out exiting:duration-100 exiting:ease-in',
className className,
)} )}
isOpen={control ? control[0] : undefined} isOpen={control ? control[0] : undefined}
onOpenChange={control ? control[1] : undefined} onOpenChange={control ? control[1] : undefined}
@ -112,32 +105,28 @@ function Panel({ children, control, className }: PanelProperties) {
'entering:animate-in exiting:animate-out', 'entering:animate-in exiting:animate-out',
'dark:border dark:border-ui-700', 'dark:border dark:border-ui-700',
'entering:zoom-in-95 entering:ease-out entering:duration-200', 'entering:zoom-in-95 entering:ease-out entering:duration-200',
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100' 'exiting:zoom-out-95 exiting:ease-in exiting:duration-100',
)} )}
> >
<AriaDialog role='alertdialog' className='outline-none relative'> <AriaDialog role="alertdialog" className="outline-none relative">
{({ close }) => children(close)} {({ close }) => children(close)}
</AriaDialog> </AriaDialog>
</Modal> </Modal>
</ModalOverlay> </ModalOverlay>
) );
} }
type DialogProperties = { interface DialogProps {
readonly children: ReactNode; children: ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; control?: [boolean, Dispatch<SetStateAction<boolean>>];
} }
function Dialog({ children, control }: DialogProperties) { function Dialog({ children, control }: DialogProps) {
if (control) { if (control) {
return children return children;
} }
return ( return <DialogTrigger>{children}</DialogTrigger>;
<DialogTrigger>
{children}
</DialogTrigger>
)
} }
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action }) export default Object.assign(Dialog, { Button, Title, Text, Panel, Action });

View File

@ -1,19 +1,18 @@
import { AlertIcon } from '@primer/octicons-react' import { AlertIcon } from '@primer/octicons-react';
import { isRouteErrorResponse, useRouteError } from '@remix-run/react' import { isRouteErrorResponse, useRouteError } from 'react-router';
import { cn } from '~/utils/cn';
import Card from './Card';
import Code from './Code';
import { cn } from '~/utils/cn' interface Props {
type?: 'full' | 'embedded';
import Card from './Card'
import Code from './Code'
type Properties = {
readonly type?: 'full' | 'embedded';
} }
export function ErrorPopup({ type = 'full' }: Properties) { export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError() const error = useRouteError();
const routing = isRouteErrorResponse(error) const routing = isRouteErrorResponse(error);
const message = (error instanceof Error ? error.message : 'An unexpected error occurred') const message =
error instanceof Error ? error.message : 'An unexpected error occurred';
return ( return (
<div <div
@ -21,26 +20,20 @@ export function ErrorPopup({ type = 'full' }: Properties) {
'flex items-center justify-center', 'flex items-center justify-center',
type === 'embedded' type === 'embedded'
? 'pointer-events-none mt-24' ? 'pointer-events-none mt-24'
: 'fixed inset-0 h-screen w-screen z-50' : 'fixed inset-0 h-screen w-screen z-50',
)} )}
> >
<Card> <Card>
<div className='flex items-center justify-between'> <div className="flex items-center justify-between">
<Card.Title className='text-3xl mb-0'> <Card.Title className="text-3xl mb-0">
{routing ? error.status : 'Error'} {routing ? error.status : 'Error'}
</Card.Title> </Card.Title>
<AlertIcon className='w-12 h-12 text-red-500'/> <AlertIcon className="w-12 h-12 text-red-500" />
</div> </div>
<Card.Text className='mt-4 text-lg'> <Card.Text className="mt-4 text-lg">
{routing ? ( {routing ? error.statusText : <Code>{message}</Code>}
error.statusText
) : (
<Code>
{message}
</Code>
)}
</Card.Text> </Card.Text>
</Card> </Card>
</div> </div>
) );
} }

View File

@ -1,44 +1,36 @@
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import Link from '~/components/Link' import Link from '~/components/Link';
interface FooterProps { interface FooterProps {
url: string url: string;
debug: boolean debug: boolean;
} }
export default function Footer({ url, debug, integration }: FooterProps) { export default function Footer({ url, debug, integration }: FooterProps) {
return ( return (
<footer className={cn( <footer
'fixed bottom-0 left-0 z-50 w-full h-14', className={cn(
'bg-ui-100 dark:bg-ui-900 text-ui-500', 'fixed bottom-0 left-0 z-50 w-full h-14',
'flex flex-col justify-center gap-1', 'bg-ui-100 dark:bg-ui-900 text-ui-500',
'border-t border-ui-200 dark:border-ui-800', 'flex flex-col justify-center gap-1',
)}> 'border-t border-ui-200 dark:border-ui-800',
)}
>
<p className="container text-xs"> <p className="container text-xs">
Headplane is entirely free to use. Headplane is entirely free to use. If you find it useful, consider{' '}
{' '}
If you find it useful, consider
{' '}
<Link <Link
to="https://github.com/sponsors/tale" to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors" name="Aarnav's GitHub Sponsors"
> >
donating donating
</Link> </Link>{' '}
{' '} to support development.{' '}
to support development.
{' '}
</p> </p>
<p className="container text-xs opacity-75"> <p className="container text-xs opacity-75">
Version: {__VERSION__} Version: {__VERSION__}
{' | '} {' | '}
Connecting to Connecting to <strong>{url}</strong> {debug && '(Debug mode enabled)'}
{' '}
<strong>{url}</strong>
{' '}
{debug && '(Debug mode enabled)'}
</p> </p>
</footer> </footer>
) );
} }

View File

@ -1,42 +1,51 @@
import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIcon, ServerIcon } from '@primer/octicons-react' import {
import { Form } from '@remix-run/react' GearIcon,
GlobeIcon,
LockIcon,
PaperAirplaneIcon,
PeopleIcon,
PersonIcon,
ServerIcon,
} from '@primer/octicons-react';
import { Form } from 'react-router';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import { HeadplaneContext } from '~/utils/config/headplane' import { HeadplaneContext } from '~/utils/config/headplane';
import { type SessionData } from '~/utils/sessions' import { SessionData } from '~/utils/sessions';
import Menu from './Menu' import Menu from './Menu';
import TabLink from './TabLink' import TabLink from './TabLink';
interface Properties { interface Props {
readonly data?: { data?: {
config: HeadplaneContext['config'] config: HeadplaneContext['config'];
user?: SessionData['user'] user?: SessionData['user'];
} };
} }
interface LinkProperties { interface LinkProps {
readonly href: string href: string;
readonly text: string text: string;
readonly isMenu?: boolean isMenu?: boolean;
} }
function Link({ href, text, isMenu }: LinkProperties) { function Link({ href, text, isMenu }: LinkProps) {
return ( return (
<a <a
href={href} href={href}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className={cn( className={cn(
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block', !isMenu &&
'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
)} )}
> >
{text} {text}
</a> </a>
) );
} }
export default function Header({ data }: Properties) { export default function Header({ data }: Props) {
return ( return (
<header className="bg-main-700 dark:bg-main-800 text-ui-50"> <header className="bg-main-700 dark:bg-main-800 text-ui-50">
<div className="container flex items-center justify-between py-4"> <div className="container flex items-center justify-between py-4">
@ -48,69 +57,86 @@ export default function Header({ data }: Properties) {
<Link href="https://tailscale.com/download" text="Download" /> <Link href="https://tailscale.com/download" text="Download" />
<Link href="https://github.com/tale/headplane" text="GitHub" /> <Link href="https://github.com/tale/headplane" text="GitHub" />
<Link href="https://github.com/juanfont/headscale" text="Headscale" /> <Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data?.user {data?.user ? (
? ( <Menu>
<Menu> <Menu.Button
<Menu.Button className={cn( className={cn(
'rounded-full h-9 w-9', 'rounded-full h-9 w-9',
'border border-main-600 dark:border-main-700', 'border border-main-600 dark:border-main-700',
'hover:bg-main-600 dark:hover:bg-main-700', 'hover:bg-main-600 dark:hover:bg-main-700',
)} )}
> >
<PersonIcon className="h-5 w-5 mt-0.5" /> <PersonIcon className="h-5 w-5 mt-0.5" />
</Menu.Button> </Menu.Button>
<Menu.Items> <Menu.Items>
<Menu.Item className="text-right"> <Menu.Item className="text-right">
<p className="font-bold">{data.user.name}</p> <p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p> <p>{data.user.email}</p>
</Menu.Item> </Menu.Item>
<Menu.Item className="text-right sm:hidden"> <Menu.Item className="text-right sm:hidden">
<Link <Link
isMenu isMenu
href="https://tailscale.com/download" href="https://tailscale.com/download"
text="Download" text="Download"
/> />
</Menu.Item> </Menu.Item>
<Menu.Item className="text-right sm:hidden"> <Menu.Item className="text-right sm:hidden">
<Link <Link
isMenu isMenu
href="https://github.com/tale/headplane" href="https://github.com/tale/headplane"
text="GitHub" text="GitHub"
/> />
</Menu.Item> </Menu.Item>
<Menu.Item className="text-right sm:hidden"> <Menu.Item className="text-right sm:hidden">
<Link <Link
isMenu isMenu
href="https://github.com/juanfont/headscale" href="https://github.com/juanfont/headscale"
text="Headscale" text="Headscale"
/> />
</Menu.Item> </Menu.Item>
<Menu.Item className="text-red-500 dark:text-red-400"> <Menu.Item className="text-red-500 dark:text-red-400">
<Form method="POST" action="/logout"> <Form method="POST" action="/logout">
<button type="submit" className="w-full text-right"> <button type="submit" className="w-full text-right">
Logout Logout
</button> </button>
</Form> </Form>
</Menu.Item> </Menu.Item>
</Menu.Items> </Menu.Items>
</Menu> </Menu>
) ) : undefined}
: undefined}
</div> </div>
</div> </div>
<nav className="container flex items-center gap-x-4 overflow-x-auto"> <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
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} /> to="/machines"
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} /> name="Machines"
{data?.config.read icon={<ServerIcon className="w-4 h-4" />}
? ( />
<> <TabLink
<TabLink to="/dns" name="DNS" icon={<GlobeIcon className="w-4 h-4" />} /> to="/users"
<TabLink to="/settings" name="Settings" icon={<GearIcon className="w-4 h-4" />} /> name="Users"
</> icon={<PeopleIcon className="w-4 h-4" />}
) />
: undefined} <TabLink
to="/acls"
name="Access Control"
icon={<LockIcon className="w-4 h-4" />}
/>
{data?.config.read ? (
<>
<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> </nav>
</header> </header>
) );
} }

View File

@ -1,12 +1,11 @@
import { LinkExternalIcon } from '@primer/octicons-react' import { LinkExternalIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
interface Props { interface Props {
to: string to: string;
name: string name: string;
children: string children: string;
className?: string className?: string;
} }
export default function Link({ to, name: alt, children, className }: Props) { export default function Link({ to, name: alt, children, className }: Props) {
@ -26,5 +25,5 @@ export default function Link({ to, name: alt, children, className }: Props) {
{children} {children}
<LinkExternalIcon className="h-3 w-3" /> <LinkExternalIcon className="h-3 w-3" />
</a> </a>
) );
} }

View File

@ -1,98 +1,91 @@
import { type Dispatch, type ReactNode, type SetStateAction } from 'react' import { Dispatch, ReactNode, SetStateAction } from 'react';
import { import {
Button as AriaButton, Button as AriaButton,
Menu as AriaMenu, Menu as AriaMenu,
MenuItem, MenuItem,
MenuTrigger, MenuTrigger,
Popover Popover,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' function Button(props: Parameters<typeof AriaButton>[0]) {
function Button(properties: Parameters<typeof AriaButton>[0]) {
return ( return (
<AriaButton <AriaButton
{...properties} {...props}
className={cn( className={cn('outline-none', props.className)}
'outline-none', aria-label="Menu"
properties.className
)}
aria-label='Menu'
/> />
) );
} }
function Items(properties: Parameters<typeof AriaMenu>[0]) { function Items(props: Parameters<typeof AriaMenu>[0]) {
return ( return (
<Popover className={cn( <Popover
'mt-2 rounded-md', className={cn(
'bg-ui-50 dark:bg-ui-800', 'mt-2 rounded-md',
'overflow-hidden z-50', 'bg-ui-50 dark:bg-ui-800',
'border border-ui-200 dark:border-ui-600', 'overflow-hidden z-50',
'entering:animate-in exiting:animate-out', 'border border-ui-200 dark:border-ui-600',
'entering:fade-in entering:zoom-in-95', 'entering:animate-in exiting:animate-out',
'exiting:fade-out exiting:zoom-out-95', 'entering:fade-in entering:zoom-in-95',
'fill-mode-forwards origin-left-right' 'exiting:fade-out exiting:zoom-out-95',
)} 'fill-mode-forwards origin-left-right',
)}
> >
<AriaMenu <AriaMenu
{...properties} {...props}
className={cn( className={cn(
'outline-none', 'outline-none',
'divide-y divide-ui-200 dark:divide-ui-600', 'divide-y divide-ui-200 dark:divide-ui-600',
properties.className props.className,
)} )}
> >
{properties.children} {props.children}
</AriaMenu> </AriaMenu>
</Popover> </Popover>
) );
} }
type ButtonProperties = Parameters<typeof AriaButton>[0] & { type ButtonProps = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]; readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
} };
function ItemButton(properties: ButtonProperties) { function ItemButton(props: ButtonProps) {
return ( return (
<MenuItem className='outline-none'> <MenuItem className="outline-none">
<AriaButton <AriaButton
{...properties} {...props}
className={cn( className={cn(
'px-4 py-2 w-full outline-none text-left', 'px-4 py-2 w-full outline-none text-left',
'hover:bg-ui-200 dark:hover:bg-ui-700', 'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className props.className,
)} )}
aria-label='Menu Dialog' aria-label="Menu Dialog"
// If control is passed, set the state value // If control is passed, set the state value
onPress={event => { onPress={(event) => {
properties.onPress?.(event) props.onPress?.(event);
properties.control?.[1](true) props.control?.[1](true);
}} }}
/> />
</MenuItem> </MenuItem>
) );
} }
function Item(properties: Parameters<typeof MenuItem>[0]) { function Item(props: Parameters<typeof MenuItem>[0]) {
return ( return (
<MenuItem <MenuItem
{...properties} {...props}
className={cn( className={cn(
'px-4 py-2 w-full outline-none', 'px-4 py-2 w-full outline-none',
'hover:bg-ui-200 dark:hover:bg-ui-700', 'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className props.className,
)} )}
/> />
) );
} }
function Menu({ children }: { readonly children: ReactNode }) { function Menu({ children }: { children: ReactNode }) {
return ( return <MenuTrigger>{children}</MenuTrigger>;
<MenuTrigger>
{children}
</MenuTrigger>
)
} }
export default Object.assign(Menu, { Button, Item, ItemButton, Items }) export default Object.assign(Menu, { Button, Item, ItemButton, Items });

View File

@ -1,23 +1,23 @@
import { InfoIcon } from '@primer/octicons-react' import { InfoIcon } from '@primer/octicons-react';
import type { ReactNode } from 'react' import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
interface Props { interface Props {
className?: string className?: string;
children: ReactNode children: ReactNode;
} }
export default function Notice({ children, className }: Props) { export default function Notice({ children, className }: Props) {
return ( return (
<div className={cn( <div
'p-4 rounded-md w-full flex items-center gap-3', className={cn(
'bg-ui-200 dark:bg-ui-800', 'p-4 rounded-md w-full flex items-center gap-3',
className, 'bg-ui-200 dark:bg-ui-800',
)} className,
)}
> >
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" /> <InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
{children} {children}
</div> </div>
) );
} }

View File

@ -1,18 +1,17 @@
import { PlusIcon, DashIcon } from '@primer/octicons-react' import { PlusIcon, DashIcon } from '@primer/octicons-react';
import { Dispatch, SetStateAction } from 'react' import { Dispatch, SetStateAction } from 'react';
import { import {
Button, Button,
Group, Group,
Input, Input,
NumberField as AriaNumberField NumberField as AriaNumberField,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & { type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
label: string; label: string;
state?: [number, Dispatch<SetStateAction<number>>]; state?: [number, Dispatch<SetStateAction<number>>];
} };
export default function NumberField(props: NumberFieldProps) { export default function NumberField(props: NumberFieldProps) {
return ( return (
@ -21,20 +20,20 @@ export default function NumberField(props: NumberFieldProps) {
aria-label={props.label} aria-label={props.label}
className="w-full" className="w-full"
value={props.state?.[0]} value={props.state?.[0]}
onChange={value => { onChange={(value) => {
props.state?.[1](value) props.state?.[1](value);
}} }}
> >
<Group className={cn( <Group
'flex px-2.5 py-1.5 w-full rounded-lg my-1', className={cn(
'border border-ui-200 dark:border-ui-600', 'flex px-2.5 py-1.5 w-full rounded-lg my-1',
'dark:bg-ui-800 dark:text-ui-300 gap-2', 'border border-ui-200 dark:border-ui-600',
'focus-within:ring-2 focus-within:ring-blue-600', 'dark:bg-ui-800 dark:text-ui-300 gap-2',
props.className 'focus-within:ring-2 focus-within:ring-blue-600',
)}> props.className,
<Input )}
className="w-full bg-transparent focus:outline-none" >
/> <Input className="w-full bg-transparent focus:outline-none" />
<Button slot="decrement"> <Button slot="decrement">
<DashIcon className="w-4 h-4" /> <DashIcon className="w-4 h-4" />
</Button> </Button>
@ -43,5 +42,5 @@ export default function NumberField(props: NumberFieldProps) {
</Button> </Button>
</Group> </Group>
</AriaNumberField> </AriaNumberField>
) );
} }

View File

@ -1,5 +1,5 @@
import { ChevronDownIcon } from '@primer/octicons-react' import { ChevronDownIcon } from '@primer/octicons-react';
import { Dispatch, ReactNode, SetStateAction } from 'react' import { Dispatch, ReactNode, SetStateAction } from 'react';
import { import {
Button, Button,
ListBox, ListBox,
@ -7,15 +7,14 @@ import {
Popover, Popover,
Select as AriaSelect, Select as AriaSelect,
SelectValue, SelectValue,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type SelectProps = Parameters<typeof AriaSelect>[0] & { type SelectProps = Parameters<typeof AriaSelect>[0] & {
readonly label: string readonly label: string;
readonly state?: [string, Dispatch<SetStateAction<string>>] readonly state?: [string, Dispatch<SetStateAction<string>>];
readonly children: ReactNode readonly children: ReactNode;
} };
function Select(props: SelectProps) { function Select(props: SelectProps) {
return ( return (
@ -24,7 +23,7 @@ function Select(props: SelectProps) {
aria-label={props.label} aria-label={props.label}
selectedKey={props.state?.[0]} selectedKey={props.state?.[0]}
onSelectionChange={(key) => { onSelectionChange={(key) => {
props.state?.[1](key.toString()) props.state?.[1](key.toString());
}} }}
className={cn( className={cn(
'block w-full rounded-lg my-1', 'block w-full rounded-lg my-1',
@ -34,10 +33,11 @@ function Select(props: SelectProps) {
props.className, props.className,
)} )}
> >
<Button className={cn( <Button
'w-full flex items-center justify-between', className={cn(
'px-2.5 py-1.5 rounded-lg', 'w-full flex items-center justify-between',
)} 'px-2.5 py-1.5 rounded-lg',
)}
> >
<SelectValue /> <SelectValue />
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" /> <ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
@ -54,15 +54,13 @@ function Select(props: SelectProps) {
'fill-mode-forwards origin-left-right', 'fill-mode-forwards origin-left-right',
)} )}
> >
<ListBox orientation="vertical"> <ListBox orientation="vertical">{props.children}</ListBox>
{props.children}
</ListBox>
</Popover> </Popover>
</AriaSelect> </AriaSelect>
) );
} }
type ItemProps = Parameters<typeof ListBoxItem>[0] type ItemProps = Parameters<typeof ListBoxItem>[0];
function Item(props: ItemProps) { function Item(props: ItemProps) {
return ( return (
@ -76,7 +74,7 @@ function Item(props: ItemProps) {
> >
{props.children} {props.children}
</ListBoxItem> </ListBoxItem>
) );
} }
export default Object.assign(Select, { Item }) export default Object.assign(Select, { Item });

View File

@ -1,23 +1,22 @@
import clsx from 'clsx' import clsx from 'clsx';
type Properties = { interface Props {
// eslint-disable-next-line unicorn/no-keyword-prefix
className?: string; className?: string;
} }
export default function Spinner(properties: Properties) { export default function Spinner({ className }: Props) {
return ( return (
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', properties.className)}> <div className={clsx('mr-1.5 inline-block align-middle mb-0.5', className)}>
<div <div
className={clsx( className={clsx(
'animate-spin rounded-full w-full h-full', 'animate-spin rounded-full w-full h-full',
'border-2 border-current border-t-transparent', 'border-2 border-current border-t-transparent',
properties.className className,
)} )}
role='status' role="status"
> >
<span className='sr-only'>Loading...</span> <span className="sr-only">Loading...</span>
</div> </div>
</div> </div>
) );
} }

View File

@ -1,24 +1,24 @@
import clsx from 'clsx' import clsx from 'clsx';
import { type HTMLProps } from 'react' import { HTMLProps } from 'react';
type Properties = HTMLProps<SVGElement> & { type Props = HTMLProps<SVGElement> & {
readonly isOnline: boolean; readonly isOnline: boolean;
} };
// eslint-disable-next-line unicorn/no-keyword-prefix // eslint-disable-next-line unicorn/no-keyword-prefix
export default function StatusCircle({ isOnline, className }: Properties) { export default function StatusCircle({ isOnline, className }: Props) {
return ( return (
<svg <svg
className={clsx( className={clsx(
className, className,
isOnline isOnline
? 'text-green-700 dark:text-green-400' ? 'text-green-700 dark:text-green-400'
: 'text-gray-300 dark:text-gray-500' : 'text-gray-300 dark:text-gray-500',
)} )}
viewBox='0 0 24 24' viewBox="0 0 24 24"
fill='currentColor' fill="currentColor"
> >
<circle cx='12' cy='12' r='8'/> <circle cx="12" cy="12" r="8" />
</svg> </svg>
) );
} }

View File

@ -1,34 +1,34 @@
import { Switch as AriaSwitch } from 'react-aria-components' import { Switch as AriaSwitch } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' type SwitchProps = Parameters<typeof AriaSwitch>[0] & {
type SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
readonly label: string; readonly label: string;
} };
export default function Switch(properties: SwitchProperties) { export default function Switch(props: SwitchProps) {
return ( return (
<AriaSwitch <AriaSwitch
{...properties} {...props}
aria-label={properties.label} aria-label={props.label}
className='group flex gap-2 items-center' className="group flex gap-2 items-center"
> >
<div <div
className={cn( className={cn(
'flex h-[26px] w-[44px] p-[4px] shrink-0', 'flex h-[26px] w-[44px] p-[4px] shrink-0',
'rounded-full outline-none group-focus-visible:ring-2', 'rounded-full outline-none group-focus-visible:ring-2',
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700', 'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
properties.isDisabled && 'opacity-50 cursor-not-allowed', props.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className props.className,
)} )}
> >
<span className={cn( <span
'h-[18px] w-[18px] transform rounded-full', className={cn(
'bg-white transition duration-100 ease-in-out', 'h-[18px] w-[18px] transform rounded-full',
'translate-x-0 group-selected:translate-x-[100%]' 'bg-white transition duration-100 ease-in-out',
)} 'translate-x-0 group-selected:translate-x-[100%]',
)}
/> />
</div> </div>
</AriaSwitch> </AriaSwitch>
) );
} }

View File

@ -1,32 +1,33 @@
import { NavLink } from '@remix-run/react' import { NavLink } from 'react-router';
import type { ReactNode } from 'react' import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' interface Props {
name: string;
type Properties = { to: string;
readonly name: string; icon: ReactNode;
readonly to: string;
readonly icon: ReactNode;
} }
export default function TabLink({ name, to, icon }: Properties) { export default function TabLink({ name, to, icon }: Props) {
return ( return (
<NavLink <NavLink
to={to} to={to}
prefetch='intent' prefetch="intent"
className={({ isActive }) => cn( className={({ isActive }) =>
'border-b-2 py-1.5', cn(
isActive ? 'border-white' : 'border-transparent' 'border-b-2 py-1.5',
)} isActive ? 'border-white' : 'border-transparent',
)
}
> >
<div <div
className={cn( className={cn(
'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap', 'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap',
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md' 'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md',
)} )}
> >
{icon} {name} {icon} {name}
</div> </div>
</NavLink> </NavLink>
) );
} }

View File

@ -1,37 +1,36 @@
import clsx from 'clsx' import clsx from 'clsx';
import { type HTMLProps } from 'react' import { HTMLProps } from 'react';
function TableList(properties: HTMLProps<HTMLDivElement>) { function TableList(props: HTMLProps<HTMLDivElement>) {
return ( return (
<div <div
{...properties} {...props}
className={clsx( className={clsx(
'border border-gray-300 rounded-lg overflow-clip', 'border border-gray-300 rounded-lg overflow-clip',
'dark:border-zinc-700 dark:text-gray-300', 'dark:border-zinc-700 dark:text-gray-300',
// 'dark:bg-zinc-800', // 'dark:bg-zinc-800',
properties.className props.className,
)} )}
> >
{properties.children} {props.children}
</div> </div>
) );
} }
function Item(properties: HTMLProps<HTMLDivElement>) { function Item(props: HTMLProps<HTMLDivElement>) {
return ( return (
<div <div
{...properties} {...props}
className={clsx( className={clsx(
'flex items-center justify-between px-3 py-2', 'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0', 'border-b border-gray-200 last:border-b-0',
'dark:border-zinc-800', 'dark:border-zinc-800',
properties.className props.className,
)} )}
> >
{properties.children} {props.children}
</div> </div>
) );
} }
export default Object.assign(TableList, { Item }) export default Object.assign(TableList, { Item });

View File

@ -1,38 +1,30 @@
import { type Dispatch, type SetStateAction } from 'react' import { Dispatch, SetStateAction } from 'react';
import { import { Input, TextField as AriaTextField } from 'react-aria-components';
Input, import { cn } from '~/utils/cn';
TextField as AriaTextField
} from 'react-aria-components'
import { cn } from '~/utils/cn' type TextFieldProps = Parameters<typeof AriaTextField>[0] & {
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
readonly label: string; readonly label: string;
readonly placeholder: string; readonly placeholder: string;
readonly state?: [string, Dispatch<SetStateAction<string>>]; readonly state?: [string, Dispatch<SetStateAction<string>>];
} };
export default function TextField(properties: TextFieldProperties) { export default function TextField(props: TextFieldProps) {
return ( return (
<AriaTextField <AriaTextField {...props} aria-label={props.label} className="w-full">
{...properties}
aria-label={properties.label}
className='w-full'
>
<Input <Input
placeholder={properties.placeholder} placeholder={props.placeholder}
value={properties.state?.[0]} value={props.state?.[0]}
name={properties.name} name={props.name}
className={cn( className={cn(
'block px-2.5 py-1.5 w-full rounded-lg my-1', 'block px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600', 'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300', 'dark:bg-ui-800 dark:text-ui-300',
properties.className props.className,
)} )}
onChange={event => { onChange={(event) => {
properties.state?.[1](event.target.value) props.state?.[1](event.target.value);
}} }}
/> />
</AriaTextField> </AriaTextField>
) );
} }

View File

@ -1,22 +1,25 @@
import { XIcon } from '@primer/octicons-react' import { XIcon } from '@primer/octicons-react';
import { AriaToastProps, useToast, useToastRegion } from '@react-aria/toast' import { AriaToastProps, useToast, useToastRegion } from '@react-aria/toast';
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast' import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast';
import { ReactNode, useRef } from 'react' import { ReactNode, useRef } from 'react';
import { Button } from 'react-aria-components' import { Button } from 'react-aria-components';
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom';
import { ClientOnly } from 'remix-utils/client-only' import { ClientOnly } from 'remix-utils/client-only';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn' type ToastProps = AriaToastProps<ReactNode> & {
type ToastProperties = AriaToastProps<ReactNode> & {
readonly state: ToastState<ReactNode>; readonly state: ToastState<ReactNode>;
} };
function Toast({ state, ...properties }: ToastProperties) { function Toast({ state, ...properties }: ToastProps) {
const reference = useRef(null) const reference = useRef(null);
// @ts-expect-error: RefObject doesn't map to FocusableElement? // @ts-expect-error: RefObject doesn't map to FocusableElement?
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference) const { toastProps, titleProps, closeButtonProps } = useToast(
properties,
state,
reference,
);
return ( return (
<div <div
@ -26,7 +29,7 @@ function Toast({ state, ...properties }: ToastProperties) {
'bg-main-700 dark:bg-main-800 rounded-lg', 'bg-main-700 dark:bg-main-800 rounded-lg',
'text-main-100 dark:text-main-200 z-50', 'text-main-100 dark:text-main-200 z-50',
'border border-main-600 dark:border-main-700', 'border border-main-600 dark:border-main-700',
'flex items-center justify-between p-3 pl-4 w-80' 'flex items-center justify-between p-3 pl-4 w-80',
)} )}
> >
<div {...titleProps}>{properties.toast.content}</div> <div {...titleProps}>{properties.toast.content}</div>
@ -34,52 +37,50 @@ function Toast({ state, ...properties }: ToastProperties) {
{...closeButtonProps} {...closeButtonProps}
className={cn( className={cn(
'outline-none rounded-full p-1', 'outline-none rounded-full p-1',
'hover:bg-main-600 dark:hover:bg-main-700' 'hover:bg-main-600 dark:hover:bg-main-700',
)} )}
> >
<XIcon className='w-4 h-4'/> <XIcon className="w-4 h-4" />
</Button> </Button>
</div> </div>
) );
} }
const toasts = new ToastQueue<ReactNode>({ const toasts = new ToastQueue<ReactNode>({
maxVisibleToasts: 5 maxVisibleToasts: 5,
}) });
export function toast(text: string) { export function toast(text: string) {
return toasts.add(text, { timeout: 5000 }) return toasts.add(text, { timeout: 5000 });
} }
export function Toaster() { export function Toaster() {
const reference = useRef(null) const reference = useRef(null);
const state = useToastQueue(toasts) const state = useToastQueue(toasts);
// @ts-expect-error: React 19 has weird types for Portal vs Node // @ts-expect-error: React 19 has weird types for Portal vs Node
const { regionProps } = useToastRegion({}, state, reference) const { regionProps } = useToastRegion({}, state, reference);
return ( return (
<ClientOnly> <ClientOnly>
{ {
// @ts-expect-error: Portal doesn't match Node in React 19 yet // @ts-expect-error: Portal doesn't match Node in React 19 yet
() => createPortal( () =>
state.visibleToasts.length >= 0 ? ( createPortal(
<div state.visibleToasts.length >= 0 ? (
className={cn( <div
'fixed bottom-4 right-4', className={cn('fixed bottom-4 right-4', 'flex flex-col gap-4')}
'flex flex-col gap-4' {...regionProps}
)} ref={reference}
{...regionProps} >
ref={reference} {state.visibleToasts.map((toast) => (
> <Toast key={toast.key} toast={toast} state={state} />
{state.visibleToasts.map(toast => ( ))}
<Toast key={toast.key} toast={toast} state={state}/> </div>
))} ) : undefined,
</div> document.body,
) : undefined, )
document.body }
)}
</ClientOnly> </ClientOnly>
) );
} }

View File

@ -1,43 +1,37 @@
import { ReactNode } from 'react' import { ReactNode } from 'react';
import { import {
Button as AriaButton, Button as AriaButton,
Tooltip as AriaTooltip, Tooltip as AriaTooltip,
TooltipTrigger, TooltipTrigger,
} from 'react-aria-components' } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
interface Props { interface Props {
children: ReactNode children: ReactNode;
className?: string className?: string;
} }
function Tooltip({ children }: Props) { function Tooltip({ children }: Props) {
return ( return <TooltipTrigger delay={0}>{children}</TooltipTrigger>;
<TooltipTrigger delay={0}>
{children}
</TooltipTrigger>
)
} }
function Button(props: Parameters<typeof AriaButton>[0]) { function Button(props: Parameters<typeof AriaButton>[0]) {
return ( return <AriaButton {...props} />;
<AriaButton {...props} />
)
} }
function Body({ children, className }: Props) { function Body({ children, className }: Props) {
return ( return (
<AriaTooltip className={cn( <AriaTooltip
'text-sm max-w-xs p-2 rounded-lg mb-2', className={cn(
'bg-white dark:bg-ui-900 drop-shadow-sm', 'text-sm max-w-xs p-2 rounded-lg mb-2',
'border border-gray-200 dark:border-zinc-700', 'bg-white dark:bg-ui-900 drop-shadow-sm',
className, 'border border-gray-200 dark:border-zinc-700',
)} className,
)}
> >
{children} {children}
</AriaTooltip> </AriaTooltip>
) );
} }
export default Object.assign(Tooltip, { Button, Body }) export default Object.assign(Tooltip, { Button, Body });

View File

@ -1,62 +1,59 @@
import { PassThrough } from 'node:stream' import { PassThrough } from 'node:stream';
import type { AppLoadContext, EntryContext } from '@remix-run/node' import type { AppLoadContext, EntryContext } from 'react-router';
import { createReadableStreamFromReadable } from '@remix-run/node' import { createReadableStreamFromReadable } from '@react-router/node';
import { RemixServer } from '@remix-run/react' import { ServerRouter } from 'react-router';
import { isbot } from 'isbot' import { isbot } from 'isbot';
import { renderToPipeableStream } from 'react-dom/server' import { renderToPipeableStream } from 'react-dom/server';
import { loadContext } from './utils/config/headplane' import { loadContext } from './utils/config/headplane';
await loadContext() await loadContext();
export const streamTimeout = 5000 export const streamTimeout = 5000;
export default function handleRequest( export default function handleRequest(
request: Request, request: Request,
responseStatusCode: number, responseStatusCode: number,
responseHeaders: Headers, responseHeaders: Headers,
remixContext: EntryContext, reactRouterContext: EntryContext,
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
_loadContext: AppLoadContext, _loadContext: AppLoadContext,
) { ) {
const ua = request.headers.get('user-agent') const ua = request.headers.get('user-agent');
const isBot = ua ? isbot(ua) : false const isBot = ua ? isbot(ua) : false;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let shellRendered = false let shellRendered = false;
const { pipe, abort } = renderToPipeableStream( const { pipe, abort } = renderToPipeableStream(
<RemixServer <ServerRouter context={reactRouterContext} url={request.url} />,
context={remixContext}
url={request.url}
/>,
{ {
[isBot ? 'onAllReady' : 'onShellReady']() { [isBot ? 'onAllReady' : 'onShellReady']() {
shellRendered = true shellRendered = true;
const body = new PassThrough() const body = new PassThrough();
const stream = createReadableStreamFromReadable(body) const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html') responseHeaders.set('Content-Type', 'text/html');
resolve( resolve(
new Response(stream, { new Response(stream, {
headers: responseHeaders, headers: responseHeaders,
status: responseStatusCode, status: responseStatusCode,
}), }),
) );
pipe(body) pipe(body);
}, },
onShellError(error: unknown) { onShellError(error: unknown) {
reject(error as Error) reject(error as Error);
}, },
onError(error: unknown) { onError(error: unknown) {
responseStatusCode = 500 responseStatusCode = 500;
if (shellRendered) { if (shellRendered) {
console.error(error) console.error(error);
} }
}, },
}, },
) );
setTimeout(abort, streamTimeout + 1000) setTimeout(abort, streamTimeout + 1000);
}) });
} }

View File

@ -1,17 +1,17 @@
import { access, constants } from 'node:fs/promises' import { access, constants } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises' import { setTimeout } from 'node:timers/promises';
import { Client } from 'undici' import { Client } from 'undici';
import { HeadscaleError, pull } from '~/utils/headscale' import { HeadscaleError, pull } from '~/utils/headscale';
import log from '~/utils/log' import log from '~/utils/log';
import { createIntegration } from './integration' import { createIntegration } from './integration';
interface Context { interface Context {
client: Client | undefined client: Client | undefined;
container: string | undefined container: string | undefined;
maxAttempts: number maxAttempts: number;
} }
export default createIntegration<Context>({ export default createIntegration<Context>({
@ -24,133 +24,126 @@ export default createIntegration<Context>({
isAvailable: async (context) => { isAvailable: async (context) => {
// Check for the HEADSCALE_CONTAINER environment variable first // Check for the HEADSCALE_CONTAINER environment variable first
// to avoid unnecessary fetching of the Docker socket // to avoid unnecessary fetching of the Docker socket
log.debug('INTG', 'Checking Docker integration availability') log.debug('INTG', 'Checking Docker integration availability');
context.container = process.env.HEADSCALE_CONTAINER context.container = process.env.HEADSCALE_CONTAINER?.trim().toLowerCase();
?.trim()
.toLowerCase()
if (!context.container || context.container.length === 0) { if (!context.container || context.container.length === 0) {
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable') log.error('INTG', 'Missing HEADSCALE_CONTAINER variable');
return false return false;
} }
log.info('INTG', 'Using container: %s', context.container) log.info('INTG', 'Using container: %s', context.container);
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock' const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock';
let url: URL | undefined let url: URL | undefined;
try { try {
url = new URL(path) url = new URL(path);
} catch { } catch {
log.error('INTG', 'Invalid Docker socket path: %s', path) log.error('INTG', 'Invalid Docker socket path: %s', path);
return false return false;
} }
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') { if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
log.error('INTG', 'Invalid Docker socket protocol: %s', log.error('INTG', 'Invalid Docker socket protocol: %s', url.protocol);
url.protocol, return false;
)
return false
} }
// The API is available as an HTTP endpoint and this // The API is available as an HTTP endpoint and this
// will simplify the fetching logic in undici // will simplify the fetching logic in undici
if (url.protocol === 'tcp:') { if (url.protocol === 'tcp:') {
// Apparently setting url.protocol doesn't work anymore? // Apparently setting url.protocol doesn't work anymore?
const fetchU = url.href.replace(url.protocol, 'http:') const fetchU = url.href.replace(url.protocol, 'http:');
try { try {
log.info('INTG', 'Checking API: %s', fetchU) log.info('INTG', 'Checking API: %s', fetchU);
await fetch(new URL('/v1.30/version', fetchU).href) await fetch(new URL('/v1.30/version', fetchU).href);
} catch (error) { } catch (error) {
log.debug('INTG', 'Failed to connect to Docker API', error) log.debug('INTG', 'Failed to connect to Docker API', error);
log.error('INTG', 'Failed to connect to Docker API') log.error('INTG', 'Failed to connect to Docker API');
return false return false;
} }
context.client = new Client(fetchU) context.client = new Client(fetchU);
} }
// Check if the socket is accessible // Check if the socket is accessible
if (url.protocol === 'unix:') { if (url.protocol === 'unix:') {
try { try {
log.info('INTG', 'Checking socket: %s', log.info('INTG', 'Checking socket: %s', url.pathname);
url.pathname, await access(url.pathname, constants.R_OK);
)
await access(url.pathname, constants.R_OK)
} catch (error) { } catch (error) {
log.debug('INTG', 'Failed to access Docker socket: %s', error) log.debug('INTG', 'Failed to access Docker socket: %s', error);
log.error('INTG', 'Failed to access Docker socket: %s', log.error('INTG', 'Failed to access Docker socket: %s', path);
path, return false;
)
return false
} }
context.client = new Client('http://localhost', { context.client = new Client('http://localhost', {
socketPath: url.pathname, socketPath: url.pathname,
}) });
} }
return context.client !== undefined return context.client !== undefined;
}, },
onConfigChange: async (context) => { onConfigChange: async (context) => {
if (!context.client || !context.container) { if (!context.client || !context.container) {
return return;
} }
log.info('INTG', 'Restarting Headscale via Docker') log.info('INTG', 'Restarting Headscale via Docker');
let attempts = 0 let attempts = 0;
while (attempts <= context.maxAttempts) { while (attempts <= context.maxAttempts) {
log.debug( log.debug(
'INTG', 'Restarting container: %s (attempt %d)', 'INTG',
'Restarting container: %s (attempt %d)',
context.container, context.container,
attempts, attempts,
) );
const response = await context.client.request({ const response = await context.client.request({
method: 'POST', method: 'POST',
path: `/v1.30/containers/${context.container}/restart`, path: `/v1.30/containers/${context.container}/restart`,
}) });
if (response.statusCode !== 204) { if (response.statusCode !== 204) {
if (attempts < context.maxAttempts) { if (attempts < context.maxAttempts) {
attempts++ attempts++;
await setTimeout(1000) await setTimeout(1000);
continue continue;
} }
const stringCode = response.statusCode.toString() const stringCode = response.statusCode.toString();
const body = await response.body.text() const body = await response.body.text();
throw new Error(`API request failed: ${stringCode} ${body}`) throw new Error(`API request failed: ${stringCode} ${body}`);
} }
break break;
} }
attempts = 0 attempts = 0;
while (attempts <= context.maxAttempts) { while (attempts <= context.maxAttempts) {
try { try {
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts) log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
await pull('v1', '') await pull('v1', '');
return return;
} catch (error) { } catch (error) {
if (error instanceof HeadscaleError && error.status === 401) { if (error instanceof HeadscaleError && error.status === 401) {
break break;
} }
if (error instanceof HeadscaleError && error.status === 404) { if (error instanceof HeadscaleError && error.status === 404) {
break break;
} }
if (attempts < context.maxAttempts) { if (attempts < context.maxAttempts) {
attempts++ attempts++;
await setTimeout(1000) await setTimeout(1000);
continue continue;
} }
throw new Error(`Missed restart deadline for ${context.container}`) throw new Error(`Missed restart deadline for ${context.container}`);
} }
} }
}, },
}) });

View File

@ -1,75 +1,68 @@
import log from '~/utils/log' import log from '~/utils/log';
import dockerIntegration from './docker' import dockerIntegration from './docker';
import { IntegrationFactory } from './integration' import { IntegrationFactory } from './integration';
import kubernetesIntegration from './kubernetes' import kubernetesIntegration from './kubernetes';
import procIntegration from './proc' import procIntegration from './proc';
export * from './integration' export * from './integration';
export async function loadIntegration() { export async function loadIntegration() {
let integration = process.env.HEADSCALE_INTEGRATION let integration = process.env.HEADSCALE_INTEGRATION?.trim().toLowerCase();
?.trim()
.toLowerCase()
// Old HEADSCALE_CONTAINER variable upgrade path // Old HEADSCALE_CONTAINER variable upgrade path
// This ensures that when people upgrade from older versions of Headplane // This ensures that when people upgrade from older versions of Headplane
// they don't explicitly need to define the new HEADSCALE_INTEGRATION // they don't explicitly need to define the new HEADSCALE_INTEGRATION
// variable that is needed to configure docker // variable that is needed to configure docker
if (!integration && process.env.HEADSCALE_CONTAINER) { if (!integration && process.env.HEADSCALE_CONTAINER) {
integration = 'docker' integration = 'docker';
} }
if (!integration) { if (!integration) {
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION') log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION');
return return;
} }
let integrationFactory: IntegrationFactory | undefined let integrationFactory: IntegrationFactory | undefined;
switch (integration.toLowerCase().trim()) { switch (integration.toLowerCase().trim()) {
case 'docker': { case 'docker': {
integrationFactory = dockerIntegration integrationFactory = dockerIntegration;
break break;
} }
case 'proc': case 'proc':
case 'native': case 'native':
case 'linux': { case 'linux': {
integrationFactory = procIntegration integrationFactory = procIntegration;
break break;
} }
case 'kubernetes': case 'kubernetes':
case 'k8s': { case 'k8s': {
integrationFactory = kubernetesIntegration integrationFactory = kubernetesIntegration;
break break;
} }
default: { default: {
log.error('INTG', 'Unknown integration: %s', integration) log.error('INTG', 'Unknown integration: %s', integration);
throw new Error(`Unknown integration: ${integration}`) throw new Error(`Unknown integration: ${integration}`);
} }
} }
log.info('INTG', 'Loading integration: %s', integration) log.info('INTG', 'Loading integration: %s', integration);
try { try {
const res = await integrationFactory.isAvailable( const res = await integrationFactory.isAvailable(
integrationFactory.context, integrationFactory.context,
) );
if (!res) { if (!res) {
log.error('INTG', 'Integration %s is not available', log.error('INTG', 'Integration %s is not available', integration);
integration, return;
)
return
} }
} catch (error) { } catch (error) {
log.error('INTG', 'Failed to load integration %s: %s', log.error('INTG', 'Failed to load integration %s: %s', integration, error);
integration, return;
error,
)
return
} }
log.info('INTG', 'Loaded integration: %s', integration) log.info('INTG', 'Loaded integration: %s', integration);
return integrationFactory return integrationFactory;
} }

View File

@ -1,13 +1,11 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface IntegrationFactory<T = any> { export interface IntegrationFactory<T = any> {
name: string name: string;
context: T context: T;
isAvailable: (context: T) => Promise<boolean> | boolean isAvailable: (context: T) => Promise<boolean> | boolean;
onConfigChange?: (context: T) => Promise<void> | void onConfigChange?: (context: T) => Promise<void> | void;
} }
export function createIntegration<T>( export function createIntegration<T>(options: IntegrationFactory<T>) {
options: IntegrationFactory<T>, return options;
) {
return options
} }

View File

@ -1,16 +1,16 @@
import { readdir, readFile } from 'node:fs/promises' import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os' import { platform } from 'node:os';
import { join, resolve } from 'node:path' import { join, resolve } from 'node:path';
import { kill } from 'node:process' import { kill } from 'node:process';
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node' import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
import log from '~/utils/log' import log from '~/utils/log';
import { createIntegration } from './integration' import { createIntegration } from './integration';
interface Context { interface Context {
pid: number | undefined pid: number | undefined;
} }
export default createIntegration<Context>({ export default createIntegration<Context>({
@ -20,185 +20,192 @@ export default createIntegration<Context>({
}, },
isAvailable: async (context) => { isAvailable: async (context) => {
if (platform() !== 'linux') { if (platform() !== 'linux') {
log.error('INTG', 'Kubernetes is only available on Linux') log.error('INTG', 'Kubernetes is only available on Linux');
return false return false;
} }
const svcRoot = Config.SERVICEACCOUNT_ROOT const svcRoot = Config.SERVICEACCOUNT_ROOT;
try { try {
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot) log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot);
const files = await readdir(svcRoot) const files = await readdir(svcRoot);
if (files.length === 0) { if (files.length === 0) {
log.error('INTG', 'Kubernetes service account not found') log.error('INTG', 'Kubernetes service account not found');
return false return false;
} }
const mappedFiles = new Set(files.map(file => join(svcRoot, file))) const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
const expectedFiles = [ const expectedFiles = [
Config.SERVICEACCOUNT_CA_PATH, Config.SERVICEACCOUNT_CA_PATH,
Config.SERVICEACCOUNT_TOKEN_PATH, Config.SERVICEACCOUNT_TOKEN_PATH,
Config.SERVICEACCOUNT_NAMESPACE_PATH, Config.SERVICEACCOUNT_NAMESPACE_PATH,
] ];
log.debug('INTG', 'Looking for %s', expectedFiles.join(', ')) log.debug('INTG', 'Looking for %s', expectedFiles.join(', '));
if (!expectedFiles.every(file => mappedFiles.has(file))) { if (!expectedFiles.every((file) => mappedFiles.has(file))) {
log.error('INTG', 'Malformed Kubernetes service account') log.error('INTG', 'Malformed Kubernetes service account');
return false return false;
} }
} catch (error) { } catch (error) {
log.error('INTG', 'Failed to access %s: %s', svcRoot, error) log.error('INTG', 'Failed to access %s: %s', svcRoot, error);
return false return false;
} }
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot) log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot);
const namespace = await readFile( const namespace = await readFile(
Config.SERVICEACCOUNT_NAMESPACE_PATH, Config.SERVICEACCOUNT_NAMESPACE_PATH,
'utf8', 'utf8',
) );
// Some very ugly nesting but it's necessary // Some very ugly nesting but it's necessary
if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') { if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') {
log.warn('INTG', 'Skipping strict Pod status check') log.warn('INTG', 'Skipping strict Pod status check');
} else { } else {
const pod = process.env.POD_NAME const pod = process.env.POD_NAME;
if (!pod) { if (!pod) {
log.error('INTG', 'Missing POD_NAME variable') log.error('INTG', 'Missing POD_NAME variable');
return false return false;
} }
if (pod.trim().length === 0) { if (pod.trim().length === 0) {
log.error('INTG', 'Pod name is empty') log.error('INTG', 'Pod name is empty');
return false return false;
} }
log.debug('INTG', 'Checking Kubernetes pod %s in namespace %s', log.debug(
'INTG',
'Checking Kubernetes pod %s in namespace %s',
pod, pod,
namespace, namespace,
) );
try { try {
log.debug('INTG', 'Attempgin to get cluster KubeConfig') log.debug('INTG', 'Attempgin to get cluster KubeConfig');
const kc = new KubeConfig() const kc = new KubeConfig();
kc.loadFromCluster() kc.loadFromCluster();
const cluster = kc.getCurrentCluster() const cluster = kc.getCurrentCluster();
if (!cluster) { if (!cluster) {
log.error('INTG', 'Malformed kubeconfig') log.error('INTG', 'Malformed kubeconfig');
return false return false;
} }
log.info('INTG', 'Service account connected to %s (%s)', log.info(
'INTG',
'Service account connected to %s (%s)',
cluster.name, cluster.name,
cluster.server, cluster.server,
) );
const kCoreV1Api = kc.makeApiClient(CoreV1Api) const kCoreV1Api = kc.makeApiClient(CoreV1Api);
log.info('INTG', 'Checking pod %s in namespace %s (%s)', log.info(
'INTG',
'Checking pod %s in namespace %s (%s)',
pod, pod,
namespace, namespace,
kCoreV1Api.basePath, kCoreV1Api.basePath,
) );
log.debug('INTG', 'Reading pod info for %s', pod) log.debug('INTG', 'Reading pod info for %s', pod);
const { response, body } = await kCoreV1Api.readNamespacedPod( const { response, body } = await kCoreV1Api.readNamespacedPod(
pod, pod,
namespace, namespace,
) );
if (response.statusCode !== 200) { if (response.statusCode !== 200) {
log.error('INTG', 'Failed to read pod info: http %d',
response.statusCode,
)
return false
}
log.debug('INTG', 'Got pod info: %o', body.spec)
const shared = body.spec?.shareProcessNamespace
if (shared === undefined) {
log.error( log.error(
'INTG', 'INTG',
'Pod does not have spec.shareProcessNamespace set', 'Failed to read pod info: http %d',
) response.statusCode,
return false );
return false;
}
log.debug('INTG', 'Got pod info: %o', body.spec);
const shared = body.spec?.shareProcessNamespace;
if (shared === undefined) {
log.error('INTG', 'Pod does not have spec.shareProcessNamespace set');
return false;
} }
if (!shared) { if (!shared) {
log.error( log.error(
'INTG', 'INTG',
'Pod has set but disabled spec.shareProcessNamespace', 'Pod has set but disabled spec.shareProcessNamespace',
) );
return false return false;
} }
log.info('INTG', 'Pod %s enabled shared processes', pod) log.info('INTG', 'Pod %s enabled shared processes', pod);
} catch (error) { } catch (error) {
log.error('INTG', 'Failed to read pod info: %s', error) log.error('INTG', 'Failed to read pod info: %s', error);
return false return false;
} }
} }
log.debug('INTG', 'Looking for namespaced process in /proc') log.debug('INTG', 'Looking for namespaced process in /proc');
const dir = resolve('/proc') const dir = resolve('/proc');
try { try {
const subdirs = await readdir(dir) const subdirs = await readdir(dir);
const promises = subdirs.map(async (dir) => { const promises = subdirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10) const pid = Number.parseInt(dir, 10);
if (Number.isNaN(pid)) { if (Number.isNaN(pid)) {
return return;
} }
const path = join('/proc', dir, 'cmdline') const path = join('/proc', dir, 'cmdline');
try { try {
log.debug('INTG', 'Reading %s', path) log.debug('INTG', 'Reading %s', path);
const data = await readFile(path, 'utf8') const data = await readFile(path, 'utf8');
if (data.includes('headscale')) { if (data.includes('headscale')) {
return pid return pid;
} }
} catch (error) { } catch (error) {
log.debug('INTG', 'Failed to read %s: %s', path, error) log.debug('INTG', 'Failed to read %s: %s', path, error);
} }
}) });
const results = await Promise.allSettled(promises) const results = await Promise.allSettled(promises);
const pids = [] const pids = [];
for (const result of results) { for (const result of results) {
if (result.status === 'fulfilled' && result.value) { if (result.status === 'fulfilled' && result.value) {
pids.push(result.value) pids.push(result.value);
} }
} }
log.debug('INTG', 'Found Headscale processes: %o', pids) log.debug('INTG', 'Found Headscale processes: %o', pids);
if (pids.length > 1) { if (pids.length > 1) {
log.error('INTG', 'Found %d Headscale processes: %s', log.error(
'INTG',
'Found %d Headscale processes: %s',
pids.length, pids.length,
pids.join(', '), pids.join(', '),
) );
return false return false;
} }
if (pids.length === 0) { if (pids.length === 0) {
log.error('INTG', 'Could not find Headscale process') log.error('INTG', 'Could not find Headscale process');
return false return false;
} }
context.pid = pids[0] context.pid = pids[0];
log.info('INTG', 'Found Headscale process with PID: %d', context.pid) log.info('INTG', 'Found Headscale process with PID: %d', context.pid);
return true return true;
} catch { } catch {
log.error('INTG', 'Failed to read /proc') log.error('INTG', 'Failed to read /proc');
return false return false;
} }
}, },
onConfigChange: (context) => { onConfigChange: (context) => {
if (!context.pid) { if (!context.pid) {
return return;
} }
log.info('INTG', 'Sending SIGTERM to Headscale') log.info('INTG', 'Sending SIGTERM to Headscale');
kill(context.pid, 'SIGTERM') kill(context.pid, 'SIGTERM');
}, },
}) });

View File

@ -1,14 +1,14 @@
import { readdir, readFile } from 'node:fs/promises' import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os' import { platform } from 'node:os';
import { join, resolve } from 'node:path' import { join, resolve } from 'node:path';
import { kill } from 'node:process' import { kill } from 'node:process';
import log from '~/utils/log' import log from '~/utils/log';
import { createIntegration } from './integration' import { createIntegration } from './integration';
interface Context { interface Context {
pid: number | undefined pid: number | undefined;
} }
export default createIntegration<Context>({ export default createIntegration<Context>({
@ -18,62 +18,64 @@ export default createIntegration<Context>({
}, },
isAvailable: async (context) => { isAvailable: async (context) => {
if (platform() !== 'linux') { if (platform() !== 'linux') {
log.error('INTG', '/proc is only available on Linux') log.error('INTG', '/proc is only available on Linux');
return false return false;
} }
log.debug('INTG', 'Checking /proc for Headscale process') log.debug('INTG', 'Checking /proc for Headscale process');
const dir = resolve('/proc') const dir = resolve('/proc');
try { try {
const subdirs = await readdir(dir) const subdirs = await readdir(dir);
const promises = subdirs.map(async (dir) => { const promises = subdirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10) const pid = Number.parseInt(dir, 10);
if (Number.isNaN(pid)) { if (Number.isNaN(pid)) {
return return;
} }
const path = join('/proc', dir, 'cmdline') const path = join('/proc', dir, 'cmdline');
try { try {
log.debug('INTG', 'Reading %s', path) log.debug('INTG', 'Reading %s', path);
const data = await readFile(path, 'utf8') const data = await readFile(path, 'utf8');
if (data.includes('headscale')) { if (data.includes('headscale')) {
return pid return pid;
} }
} catch (error) { } catch (error) {
log.error('INTG', 'Failed to read %s: %s', path, error) log.error('INTG', 'Failed to read %s: %s', path, error);
} }
}) });
const results = await Promise.allSettled(promises) const results = await Promise.allSettled(promises);
const pids = [] const pids = [];
for (const result of results) { for (const result of results) {
if (result.status === 'fulfilled' && result.value) { if (result.status === 'fulfilled' && result.value) {
pids.push(result.value) pids.push(result.value);
} }
} }
log.debug('INTG', 'Found Headscale processes: %o', pids) log.debug('INTG', 'Found Headscale processes: %o', pids);
if (pids.length > 1) { if (pids.length > 1) {
log.error('INTG', 'Found %d Headscale processes: %s', log.error(
'INTG',
'Found %d Headscale processes: %s',
pids.length, pids.length,
pids.join(', '), pids.join(', '),
) );
return false return false;
} }
if (pids.length === 0) { if (pids.length === 0) {
log.error('INTG', 'Could not find Headscale process') log.error('INTG', 'Could not find Headscale process');
return false return false;
} }
context.pid = pids[0] context.pid = pids[0];
log.info('INTG', 'Found Headscale process with PID: %d', context.pid) log.info('INTG', 'Found Headscale process with PID: %d', context.pid);
return true return true;
} catch { } catch {
log.error('INTG', 'Failed to read /proc') log.error('INTG', 'Failed to read /proc');
return false return false;
} }
} },
}) });

View File

@ -1,24 +1,24 @@
import { LoaderFunctionArgs, redirect } from '@remix-run/node' import { LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData, useNavigation } from '@remix-run/react' import { Outlet, useLoaderData, useNavigation } from 'react-router';
import { ProgressBar } from 'react-aria-components' import { ProgressBar } from 'react-aria-components';
import { ErrorPopup } from '~/components/Error' import { ErrorPopup } from '~/components/Error';
import Header from '~/components/Header' import Header from '~/components/Header';
import Footer from '~/components/Footer' import Footer from '~/components/Footer';
import Link from '~/components/Link' import Link from '~/components/Link';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane';
import { HeadscaleError, pull } from '~/utils/headscale' import { HeadscaleError, pull } from '~/utils/headscale';
import { destroySession, getSession } from '~/utils/sessions' import { destroySession, getSession } from '~/utils/sessions';
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return redirect('/login') return redirect('/login');
} }
try { try {
await pull('v1/apikey', session.get('hsApiKey')!) await pull('v1/apikey', session.get('hsApiKey')!);
} catch (error) { } catch (error) {
if (error instanceof HeadscaleError) { if (error instanceof HeadscaleError) {
// Safest to just redirect to login if we can't pull // Safest to just redirect to login if we can't pull
@ -26,31 +26,29 @@ export async function loader({ request }: LoaderFunctionArgs) {
headers: { headers: {
'Set-Cookie': await destroySession(session), 'Set-Cookie': await destroySession(session),
}, },
}) });
} }
// Otherwise propagate to boundary // Otherwise propagate to boundary
throw error throw error;
} }
const context = await loadContext() const context = await loadContext();
return { return {
config: context.config, config: context.config,
url: context.headscalePublicUrl ?? context.headscaleUrl, url: context.headscalePublicUrl ?? context.headscaleUrl,
debug: context.debug, debug: context.debug,
user: session.get('user'), user: session.get('user'),
} };
} }
export default function Layout() { export default function Layout() {
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>();
const nav = useNavigation() const nav = useNavigation();
return ( return (
<> <>
<ProgressBar <ProgressBar aria-label="Loading...">
aria-label="Loading..."
>
<div <div
className={cn( className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1', 'fixed top-0 left-0 z-50 w-1/2 h-1',
@ -65,7 +63,7 @@ export default function Layout() {
</main> </main>
<Footer {...data} /> <Footer {...data} />
</> </>
) );
} }
export function ErrorBoundary() { export function ErrorBoundary() {
@ -75,5 +73,5 @@ export function ErrorBoundary() {
<ErrorPopup type="embedded" /> <ErrorPopup type="embedded" />
<Footer url="Unknown" debug={false} /> <Footer url="Unknown" debug={false} />
</> </>
) );
} }

View File

@ -1,24 +1,21 @@
import type { LinksFunction, MetaFunction } from '@remix-run/node' import type { LinksFunction, MetaFunction } from 'react-router';
import { import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from '@remix-run/react'
import { ErrorPopup } from '~/components/Error' import { ErrorPopup } from '~/components/Error';
import { Toaster } from '~/components/Toaster' import { Toaster } from '~/components/Toaster';
import stylesheet from '~/tailwind.css?url' import stylesheet from '~/tailwind.css?url';
export const meta: MetaFunction = () => [ export const meta: MetaFunction = () => [
{ title: 'Headplane' }, { title: 'Headplane' },
{ name: 'description', content: 'A frontend for the headscale coordination server' }, {
] name: 'description',
content: 'A frontend for the headscale coordination server',
},
];
export const links: LinksFunction = () => [ export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet }, { rel: 'stylesheet', href: stylesheet },
] ];
export function Layout({ children }: { readonly children: React.ReactNode }) { export function Layout({ children }: { readonly children: React.ReactNode }) {
return ( return (
@ -36,13 +33,13 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Scripts /> <Scripts />
</body> </body>
</html> </html>
) );
} }
export function ErrorBoundary() { export function ErrorBoundary() {
return <ErrorPopup /> return <ErrorPopup />;
} }
export default function App() { export default function App() {
return <Outlet /> return <Outlet />;
} }

View File

@ -1,5 +1,5 @@
//import { flatRoutes } from '@remix-run/fs-routes' //import { flatRoutes } from '@remix-run/fs-routes'
import { index, layout, prefix, route } from '@remix-run/route-config' import { index, layout, prefix, route } from '@react-router/dev/routes';
export default [ export default [
// Utility Routes // Utility Routes
@ -26,6 +26,5 @@ export default [
index('routes/settings/overview.tsx'), index('routes/settings/overview.tsx'),
route('/auth-keys', 'routes/settings/auth-keys.tsx'), route('/auth-keys', 'routes/settings/auth-keys.tsx'),
]), ]),
]) ]),
] ];

View File

@ -1,122 +1,136 @@
import React, { useEffect } from 'react' import React, { useEffect } from 'react';
import Merge from 'react-codemirror-merge' import Merge from 'react-codemirror-merge';
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror';
import * as shopify from '@shopify/lang-jsonc' import * as shopify from '@shopify/lang-jsonc';
import { ClientOnly } from 'remix-utils/client-only' import { ClientOnly } from 'remix-utils/client-only';
import { ErrorBoundary } from 'react-error-boundary' import { ErrorBoundary } from 'react-error-boundary';
import { githubDark, githubLight } from '@uiw/codemirror-theme-github' import { githubDark, githubLight } from '@uiw/codemirror-theme-github';
import { useState } from 'react' import { useState } from 'react';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import Fallback from './fallback' import Fallback from './fallback';
interface EditorProps { interface EditorProps {
isDisabled?: boolean isDisabled?: boolean;
value: string value: string;
onChange: (value: string) => void onChange: (value: string) => void;
} }
export function Editor(props: EditorProps) { export function Editor(props: EditorProps) {
const [light, setLight] = useState(false) const [light, setLight] = useState(false);
useEffect(() => { useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)') const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches) setLight(theme.matches);
theme.addEventListener('change', (theme) => { theme.addEventListener('change', (theme) => {
setLight(theme.matches) setLight(theme.matches);
}) });
}) });
return ( return (
<div className={cn( <div
'border border-gray-200 dark:border-gray-700', className={cn(
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden', 'border border-gray-200 dark:border-gray-700',
)}> 'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)}
>
<div className="overflow-y-scroll h-editor text-sm"> <div className="overflow-y-scroll h-editor text-sm">
<ErrorBoundary fallback={ <ErrorBoundary
<p className={cn( fallback={
'w-full h-full flex items-center justify-center', <p
'text-gray-400 dark:text-gray-500 text-xl', className={cn(
)}> 'w-full h-full flex items-center justify-center',
Failed to load the editor. 'text-gray-400 dark:text-gray-500 text-xl',
</p> )}
}> >
Failed to load the editor.
</p>
}
>
<ClientOnly fallback={<Fallback acl={props.value} />}> <ClientOnly fallback={<Fallback acl={props.value} />}>
{() => ( {() => (
<CodeMirror <CodeMirror
value={props.value} value={props.value}
height="100%" height="100%"
extensions={[shopify.jsonc()]} extensions={[shopify.jsonc()]}
style={{ height: "100%" }} style={{ height: '100%' }}
theme={light ? githubLight : githubDark} theme={light ? githubLight : githubDark}
onChange={(value) => props.onChange(value)} onChange={(value) => props.onChange(value)}
/> />
)} )}
</ClientOnly> </ClientOnly>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</div> </div>
) );
} }
interface DifferProps { interface DifferProps {
left: string left: string;
right: string right: string;
} }
export function Differ(props: DifferProps) { export function Differ(props: DifferProps) {
const [light, setLight] = useState(false) const [light, setLight] = useState(false);
useEffect(() => { useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)') const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches) setLight(theme.matches);
theme.addEventListener('change', (theme) => { theme.addEventListener('change', (theme) => {
setLight(theme.matches) setLight(theme.matches);
}) });
}) });
return ( return (
<div className={cn( <div
'border border-gray-200 dark:border-gray-700', className={cn(
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden', 'border border-gray-200 dark:border-gray-700',
)}> 'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)}
>
<div className="overflow-y-scroll h-editor text-sm"> <div className="overflow-y-scroll h-editor text-sm">
{props.left === props.right ? ( {props.left === props.right ? (
<p className={cn( <p
'w-full h-full flex items-center justify-center', className={cn(
'text-gray-400 dark:text-gray-500 text-xl', 'w-full h-full flex items-center justify-center',
)}> 'text-gray-400 dark:text-gray-500 text-xl',
)}
>
No changes No changes
</p> </p>
) : ( ) : (
<ErrorBoundary fallback={ <ErrorBoundary
<p className={cn( fallback={
'w-full h-full flex items-center justify-center', <p
'text-gray-400 dark:text-gray-500 text-xl', className={cn(
)}> 'w-full h-full flex items-center justify-center',
Failed to load the editor. 'text-gray-400 dark:text-gray-500 text-xl',
</p> )}
}>
<ClientOnly fallback={<Fallback acl={props.right} />}>
{() => (
<Merge
orientation="a-b"
theme={light ? githubLight : githubDark}
> >
<Merge.Original Failed to load the editor.
readOnly </p>
value={props.left} }
extensions={[shopify.jsonc()]} >
/> <ClientOnly fallback={<Fallback acl={props.right} />}>
<Merge.Modified {() => (
readOnly <Merge
value={props.right} orientation="a-b"
extensions={[shopify.jsonc()]} theme={light ? githubLight : githubDark}
/> >
</Merge> <Merge.Original
)} readOnly
value={props.left}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
readOnly
value={props.right}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
</ClientOnly> </ClientOnly>
</ErrorBoundary> </ErrorBoundary>
)} )}
</div> </div>
</div> </div>
) );
} }

View File

@ -1,30 +1,25 @@
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import { AlertIcon } from '@primer/octicons-react' import { AlertIcon } from '@primer/octicons-react';
import Card from '~/components/Card' import Card from '~/components/Card';
import Code from '~/components/Code' import Code from '~/components/Code';
interface Props { interface Props {
message: string message: string;
} }
export function ErrorView({ message }: Props) { export function ErrorView({ message }: Props) {
return ( return (
<Card variant="flat" className="max-w-full mb-4"> <Card variant="flat" className="max-w-full mb-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0"> <Card.Title className="text-xl mb-0">Error</Card.Title>
Error <AlertIcon className="w-8 h-8 text-red-500" />
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500"/>
</div> </div>
<Card.Text className="mt-4"> <Card.Text className="mt-4">
Could not apply changes to your ACL policy Could not apply changes to your ACL policy due to the following error:
due to the following error:
<br /> <br />
<Code> <Code>{message}</Code>
{message}
</Code>
</Card.Text> </Card.Text>
</Card> </Card>
) );
} }

View File

@ -1,8 +1,8 @@
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface Props { interface Props {
readonly acl: string readonly acl: string;
} }
export default function Fallback({ acl }: Props) { export default function Fallback({ acl }: Props) {
@ -20,5 +20,5 @@ export default function Fallback({ acl }: Props) {
value={acl} value={acl}
/> />
</div> </div>
) );
} }

View File

@ -1,43 +1,39 @@
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import { AlertIcon } from '@primer/octicons-react' import { AlertIcon } from '@primer/octicons-react';
import Code from '~/components/Code' import Code from '~/components/Code';
import Card from '~/components/Card' import Card from '~/components/Card';
interface Props { interface Props {
mode: 'file' | 'database' mode: 'file' | 'database';
} }
export function Unavailable({ mode }: Props) { export function Unavailable({ mode }: Props) {
return ( return (
<Card variant="flat" className="max-w-prose mt-12"> <Card variant="flat" className="max-w-prose mt-12">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0"> <Card.Title className="text-xl mb-0">ACL Policy Unavailable</Card.Title>
ACL Policy Unavailable <AlertIcon className="w-8 h-8 text-red-500" />
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500"/>
</div> </div>
<Card.Text className="mt-4"> <Card.Text className="mt-4">
Unable to load a valid ACL policy configuration. Unable to load a valid ACL policy configuration. This is most likely due
This is most likely due to a misconfiguration in your to a misconfiguration in your Headscale configuration file.
Headscale configuration file.
</Card.Text> </Card.Text>
{mode !== 'file' ? ( {mode !== 'file' ? (
<p className="mt-4 text-sm"> <p className="mt-4 text-sm">
According to your configuration, the ACL policy mode According to your configuration, the ACL policy mode is set to{' '}
is set to <Code>file</Code> but the ACL file is not <Code>file</Code> but the ACL file is not available. Ensure that the{' '}
available. Ensure that the <Code>policy.path</Code> is <Code>policy.path</Code> is set to a valid path in your Headscale
set to a valid path in your Headscale configuration. configuration.
</p> </p>
) : ( ) : (
<p className="mt-4 text-sm"> <p className="mt-4 text-sm">
In order to fully utilize the ACL management features of In order to fully utilize the ACL management features of Headplane,
Headplane, please set <Code>policy.mode</Code> to either please set <Code>policy.mode</Code> to either <Code>file</Code> or{' '}
{' '}<Code>file</Code> or <Code>database</Code> in your <Code>database</Code> in your Headscale configuration.
Headscale configuration.
</p> </p>
)} )}
</Card> </Card>
) );
} }

View File

@ -1,32 +1,37 @@
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react' import {
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node' BeakerIcon,
import { useLoaderData, useRevalidator } from '@remix-run/react' EyeIcon,
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher' IssueDraftIcon,
import { useEffect, useState, useMemo } from 'react' PencilIcon,
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components' } from '@primer/octicons-react';
import { setTimeout } from 'node:timers/promises' import { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData, useRevalidator } from 'react-router';
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher';
import { useEffect, useState, useMemo } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { setTimeout } from 'node:timers/promises';
import Button from '~/components/Button' import Button from '~/components/Button';
import Code from '~/components/Code' import Code from '~/components/Code';
import Link from '~/components/Link' import Link from '~/components/Link';
import Notice from '~/components/Notice' import Notice from '~/components/Notice';
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner';
import { toast } from '~/components/Toaster' import { toast } from '~/components/Toaster';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale' import { loadConfig } from '~/utils/config/headscale';
import { HeadscaleError, pull, put } from '~/utils/headscale' import { HeadscaleError, pull, put } from '~/utils/headscale';
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions';
import { send } from '~/utils/res' import { send } from '~/utils/res';
import log from '~/utils/log' import log from '~/utils/log';
import { Editor, Differ } from './components/cm.client' import { Editor, Differ } from './components/cm.client';
import { Unavailable } from './components/unavailable' import { Unavailable } from './components/unavailable';
import { ErrorView } from './components/error' import { ErrorView } from './components/error';
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
// The way policy is handled in 0.23 of Headscale and later is verbose. // The way policy is handled in 0.23 of Headscale and later is verbose.
// The 2 ACL policy modes are either the database one or file one // The 2 ACL policy modes are either the database one or file one
// //
@ -51,11 +56,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
// We can do damage control by checking for write access and if we are not // We can do damage control by checking for write access and if we are not
// able to PUT an ACL policy on the v1/policy route, we can already know // able to PUT an ACL policy on the v1/policy route, we can already know
// that the policy is at the very-least readonly or not available. // that the policy is at the very-least readonly or not available.
const context = await loadContext() const context = await loadContext();
let modeGuess = 'database' // Assume database mode let modeGuess = 'database'; // Assume database mode
if (context.config.read) { if (context.config.read) {
const config = await loadConfig() const config = await loadConfig();
modeGuess = config.policy?.mode ?? 'database' modeGuess = config.policy?.mode ?? 'database';
} }
// Attempt to load the policy, for both the frontend and for checking // Attempt to load the policy, for both the frontend and for checking
@ -64,23 +69,19 @@ export async function loader({ request }: LoaderFunctionArgs) {
const { policy } = await pull<{ policy: string }>( const { policy } = await pull<{ policy: string }>(
'v1/policy', 'v1/policy',
session.get('hsApiKey')!, session.get('hsApiKey')!,
) );
let write = false // On file mode we already know it's readonly let write = false; // On file mode we already know it's readonly
if (modeGuess === 'database' && policy.length > 0) { if (modeGuess === 'database' && policy.length > 0) {
try { try {
await put('v1/policy', session.get('hsApiKey')!, { await put('v1/policy', session.get('hsApiKey')!, {
policy: policy, policy: policy,
}) });
write = true write = true;
} catch (error) { } catch (error) {
write = false write = false;
log.debug( log.debug('APIC', 'Failed to write to ACL policy with error %s', error);
'APIC',
'Failed to write to ACL policy with error %s',
error
)
} }
} }
@ -88,8 +89,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
read: true, read: true,
write, write,
mode: modeGuess, mode: modeGuess,
policy policy,
} };
} catch { } catch {
// If we are explicit on file mode then this is the end of the road // If we are explicit on file mode then this is the end of the road
if (modeGuess === 'file') { if (modeGuess === 'file') {
@ -97,8 +98,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
read: false, read: false,
write: false, write: false,
mode: modeGuess, mode: modeGuess,
policy: null policy: null,
} };
} }
// Assume that we have write access otherwise? // Assume that we have write access otherwise?
@ -108,124 +109,119 @@ export async function loader({ request }: LoaderFunctionArgs) {
read: true, read: true,
write: true, write: true,
mode: modeGuess, mode: modeGuess,
policy: null policy: null,
} };
} }
} }
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return send({ success: false, error: null }, 401) return send({ success: false, error: null }, 401);
} }
try { try {
const { acl } = await request.json() as { acl: string } const { acl } = (await request.json()) as { acl: string };
const { policy } = await put<{ policy: string }>( const { policy } = await put<{ policy: string }>(
'v1/policy', 'v1/policy',
session.get('hsApiKey')!, session.get('hsApiKey')!,
{ {
policy: acl, policy: acl,
} },
) );
return { success: true, policy, error: null } return { success: true, policy, error: null };
} catch (error) { } catch (error) {
log.debug('APIC', 'Failed to update ACL policy with error %s', error) log.debug('APIC', 'Failed to update ACL policy with error %s', error);
// @ts-ignore: Shut UP we know it's a string most of the time // @ts-ignore: Shut UP we know it's a string most of the time
const text = JSON.parse(error.message) const text = JSON.parse(error.message);
return send({ success: false, error: text.message }, { return send(
status: error instanceof HeadscaleError ? error.status : 500, { success: false, error: text.message },
}) {
status: error instanceof HeadscaleError ? error.status : 500,
},
);
} }
return { success: true, error: null } return { success: true, error: null };
} }
export default function Page() { export default function Page() {
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>();
const fetcher = useDebounceFetcher<typeof action>() const fetcher = useDebounceFetcher<typeof action>();
const revalidator = useRevalidator() const revalidator = useRevalidator();
const [acl, setAcl] = useState(data.policy ?? '') const [acl, setAcl] = useState(data.policy ?? '');
const [toasted, setToasted] = useState(false) const [toasted, setToasted] = useState(false);
useEffect(() => { useEffect(() => {
if (!fetcher.data || toasted) { if (!fetcher.data || toasted) {
return return;
} }
// @ts-ignore: useDebounceFetcher is not typed correctly // @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data.success) { if (fetcher.data.success) {
toast('Updated tailnet ACL policy') toast('Updated tailnet ACL policy');
} else { } else {
toast('Failed to update tailnet ACL policy') toast('Failed to update tailnet ACL policy');
} }
setToasted(true) setToasted(true);
if (revalidator.state === 'idle') { if (revalidator.state === 'idle') {
revalidator.revalidate() revalidator.revalidate();
} }
}, [fetcher.data, toasted, data.policy]) }, [fetcher.data, toasted, data.policy]);
// The state for if the save and discard buttons should be disabled // The state for if the save and discard buttons should be disabled
// is pretty complicated to calculate and varies on different states. // is pretty complicated to calculate and varies on different states.
const disabled = useMemo(() => { const disabled = useMemo(() => {
if (!data.read || !data.write) { if (!data.read || !data.write) {
return true return true;
} }
// First check our fetcher states // First check our fetcher states
if (fetcher.state === 'loading') { if (fetcher.state === 'loading') {
return true return true;
} }
if (revalidator.state === 'loading') { if (revalidator.state === 'loading') {
return true return true;
} }
// If we have a failed fetcher state allow the user to try again // If we have a failed fetcher state allow the user to try again
// @ts-ignore: useDebounceFetcher is not typed correctly // @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data?.success === false) { if (fetcher.data?.success === false) {
return false return false;
} }
return data.policy === acl return data.policy === acl;
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]) }, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]);
return ( return (
<div> <div>
{data.read && !data.write {data.read && !data.write ? (
? ( <div className="mb-4">
<div className="mb-4"> <Notice className="w-fit">
<Notice className="w-fit"> The ACL policy is read-only. You can view the current policy but you
The ACL policy is read-only. You can view the current policy cannot make changes to it.
but you cannot make changes to it. <br />
<br /> To resolve this, you need to set the ACL policy mode to database in
To resolve this, you need to set the ACL policy mode to your Headscale configuration.
database in your Headscale configuration. </Notice>
</Notice> </div>
</div> ) : undefined}
) : undefined} <h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<h1 className="text-2xl font-medium mb-4">
Access Control List (ACL)
</h1>
<p className="mb-4 max-w-prose"> <p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network. The ACL file is used to define the access control rules for your
You can find more information about the ACL file in the network. You can find more information about the ACL file in the{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1018/acls" to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation" name="Tailscale ACL documentation"
> >
Tailscale ACL guide Tailscale ACL guide
</Link> </Link>{' '}
{' '} and the{' '}
and the
{' '}
<Link <Link
to="https://headscale.net/stable/ref/acls/" to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation" name="Headscale ACL documentation"
@ -234,73 +230,71 @@ export default function Page() {
</Link> </Link>
. .
</p> </p>
{ {
// @ts-ignore: useDebounceFetcher is not typed correctly // @ts-ignore: useDebounceFetcher is not typed correctly
fetcher.data?.success === false fetcher.data?.success === false ? (
? (
// @ts-ignore: useDebounceFetcher is not typed correctly // @ts-ignore: useDebounceFetcher is not typed correctly
<ErrorView message={fetcher.data.error} /> <ErrorView message={fetcher.data.error} />
) : undefined} ) : undefined
}
{data.read ? ( {data.read ? (
<> <>
<Tabs> <Tabs>
<TabList className={cn( <TabList
'flex border-t border-gray-200 dark:border-gray-700', className={cn(
'w-fit rounded-t-lg overflow-hidden', 'flex border-t border-gray-200 dark:border-gray-700',
'text-gray-400 dark:text-gray-500', 'w-fit rounded-t-lg overflow-hidden',
)} 'text-gray-400 dark:text-gray-500',
)}
> >
<Tab <Tab
id="edit" id="edit"
className={({ isSelected }) => cn( className={({ isSelected }) =>
'px-4 py-2 rounded-tl-lg', cn(
'focus:outline-none flex items-center gap-2', 'px-4 py-2 rounded-tl-lg',
'border-x border-gray-200 dark:border-gray-700', 'focus:outline-none flex items-center gap-2',
isSelected ? 'text-gray-900 dark:text-gray-100' : '', 'border-x border-gray-200 dark:border-gray-700',
)} isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
> >
<PencilIcon className="w-5 h-5" /> <PencilIcon className="w-5 h-5" />
<p>Edit file</p> <p>Edit file</p>
</Tab> </Tab>
<Tab <Tab
id="diff" id="diff"
className={({ isSelected }) => cn( className={({ isSelected }) =>
'px-4 py-2', cn(
'focus:outline-none flex items-center gap-2', 'px-4 py-2',
'border-x border-gray-200 dark:border-gray-700', 'focus:outline-none flex items-center gap-2',
isSelected ? 'text-gray-900 dark:text-gray-100' : '', 'border-x border-gray-200 dark:border-gray-700',
)} isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
> >
<EyeIcon className="w-5 h-5" /> <EyeIcon className="w-5 h-5" />
<p>Preview changes</p> <p>Preview changes</p>
</Tab> </Tab>
<Tab <Tab
id="preview" id="preview"
className={({ isSelected }) => cn( className={({ isSelected }) =>
'px-4 py-2 rounded-tr-lg', cn(
'focus:outline-none flex items-center gap-2', 'px-4 py-2 rounded-tr-lg',
'border-x border-gray-200 dark:border-gray-700', 'focus:outline-none flex items-center gap-2',
isSelected ? 'text-gray-900 dark:text-gray-100' : '', 'border-x border-gray-200 dark:border-gray-700',
)} isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
> >
<BeakerIcon className="w-5 h-5" /> <BeakerIcon className="w-5 h-5" />
<p>Preview rules</p> <p>Preview rules</p>
</Tab> </Tab>
</TabList> </TabList>
<TabPanel id="edit"> <TabPanel id="edit">
<Editor <Editor isDisabled={!data.write} value={acl} onChange={setAcl} />
isDisabled={!data.write}
value={acl}
onChange={setAcl}
/>
</TabPanel> </TabPanel>
<TabPanel id="diff"> <TabPanel id="diff">
<Differ <Differ left={data?.policy ?? ''} right={acl} />
left={data?.policy ?? ''}
right={acl}
/>
</TabPanel> </TabPanel>
<TabPanel id="preview"> <TabPanel id="preview">
<div <div
@ -312,8 +306,9 @@ export default function Page() {
> >
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" /> <IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
<p className="w-1/2 text-center mt-4"> <p className="w-1/2 text-center mt-4">
The Preview rules is very much still a work in progress. The Preview rules is very much still a work in progress. It is
It is a bit complicated to implement right now but hopefully it will be available soon. a bit complicated to implement right now but hopefully it will
be available soon.
</p> </p>
</div> </div>
</TabPanel> </TabPanel>
@ -323,32 +318,35 @@ export default function Page() {
className="mr-2" className="mr-2"
isDisabled={disabled} isDisabled={disabled}
onPress={() => { onPress={() => {
setToasted(false) setToasted(false);
fetcher.submit({ fetcher.submit(
acl, {
}, { acl,
method: 'PATCH', },
encType: 'application/json', {
}) method: 'PATCH',
encType: 'application/json',
},
);
}} }}
> >
{fetcher.state === 'idle' {fetcher.state === 'idle' ? undefined : (
? undefined <Spinner className="w-3 h-3" />
: ( )}
<Spinner className="w-3 h-3" />
)}
Save Save
</Button> </Button>
<Button <Button
isDisabled={disabled} isDisabled={disabled}
onPress={() => { onPress={() => {
setAcl(data?.policy ?? '') setAcl(data?.policy ?? '');
}} }}
> >
Discard Changes Discard Changes
</Button> </Button>
</> </>
) : <Unavailable mode={data.mode as "database" | "file"} />} ) : (
<Unavailable mode={data.mode as 'database' | 'file'} />
)}
</div> </div>
) );
} }

View File

@ -1,75 +1,75 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from '@remix-run/node' import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from 'react-router';
import { Form, useActionData, useLoaderData } from '@remix-run/react' import { Form, useActionData, useLoaderData } from 'react-router';
import { useMemo } from 'react' import { useMemo } from 'react';
import Button from '~/components/Button' import Button from '~/components/Button';
import Card from '~/components/Card' import Card from '~/components/Card';
import Code from '~/components/Code' import Code from '~/components/Code';
import TextField from '~/components/TextField' import TextField from '~/components/TextField';
import { Key } from '~/types' import { Key } from '~/types';
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane';
import { pull } from '~/utils/headscale' import { pull } from '~/utils/headscale';
import { startOidc } from '~/utils/oidc' import { startOidc } from '~/utils/oidc';
import { commitSession, getSession } from '~/utils/sessions' import { commitSession, getSession } from '~/utils/sessions';
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) { if (session.has('hsApiKey')) {
return redirect('/machines', { return redirect('/machines', {
headers: { headers: {
'Set-Cookie': await commitSession(session), 'Set-Cookie': await commitSession(session),
}, },
}) });
} }
const context = await loadContext() const context = await loadContext();
// Only set if OIDC is properly enabled anyways // Only set if OIDC is properly enabled anyways
if (context.oidc?.disableKeyLogin) { if (context.oidc?.disableKeyLogin) {
return startOidc(context.oidc, request) return startOidc(context.oidc, request);
} }
return { return {
oidc: context.oidc?.issuer, oidc: context.oidc?.issuer,
apiKey: !context.oidc?.disableKeyLogin, apiKey: !context.oidc?.disableKeyLogin,
} };
} }
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData() const formData = await request.formData();
const oidcStart = formData.get('oidc-start') const oidcStart = formData.get('oidc-start');
if (oidcStart) { if (oidcStart) {
const context = await loadContext() const context = await loadContext();
if (!context.oidc) { if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided') throw new Error('An invalid OIDC configuration was provided');
} }
// We know it exists here because this action only happens on OIDC // We know it exists here because this action only happens on OIDC
return startOidc(context.oidc, request) return startOidc(context.oidc, request);
} }
const apiKey = String(formData.get('api-key')) const apiKey = String(formData.get('api-key'));
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
// Test the API key // Test the API key
try { try {
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey) const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey);
const key = apiKeys.apiKeys.find(k => apiKey.startsWith(k.prefix)) const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
if (!key) { if (!key) {
throw new Error('Invalid API key') throw new Error('Invalid API key');
} }
const expiry = new Date(key.expiration) const expiry = new Date(key.expiration);
const expiresIn = expiry.getTime() - Date.now() const expiresIn = expiry.getTime() - Date.now();
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24) const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
session.set('hsApiKey', apiKey) session.set('hsApiKey', apiKey);
session.set('user', { session.set('user', {
name: key.prefix, name: key.prefix,
email: `${expiresDays.toString()} days`, email: `${expiresDays.toString()} days`,
}) });
return redirect('/machines', { return redirect('/machines', {
headers: { headers: {
@ -77,85 +77,62 @@ export async function action({ request }: ActionFunctionArgs) {
maxAge: expiresIn, maxAge: expiresIn,
}), }),
}, },
}) });
} catch { } catch {
return { return {
error: 'Invalid API key', error: 'Invalid API key',
} };
} }
} }
export default function Page() { export default function Page() {
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>() const actionData = useActionData<typeof action>();
const showOr = useMemo(() => data.oidc && data.apiKey, [data]) const showOr = useMemo(() => data.oidc && data.apiKey, [data]);
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0 rounded-2xl"> <Card className="max-w-sm m-4 sm:m-0 rounded-2xl">
<Card.Title> <Card.Title>Welcome to Headplane</Card.Title>
Welcome to Headplane {data.apiKey ? (
</Card.Title> <Form method="post">
{data.apiKey <Card.Text className="mb-8 text-sm">
? ( Enter an API key to authenticate with Headplane. You can generate
<Form method="post"> one by running <Code>headscale apikeys create</Code> in your
<Card.Text className="mb-8 text-sm"> terminal.
Enter an API key to authenticate with Headplane. You can generate </Card.Text>
one by running
{' '}
<Code>
headscale apikeys create
</Code>
{' '}
in your terminal.
</Card.Text>
{actionData?.error {actionData?.error ? (
? ( <p className="text-red-500 text-sm mb-2">{actionData.error}</p>
<p className="text-red-500 text-sm mb-2">{actionData.error}</p> ) : undefined}
) <TextField
: undefined} isRequired
<TextField label="API Key"
isRequired name="api-key"
label="API Key" placeholder="API Key"
name="api-key" type="password"
placeholder="API Key" />
type="password" <Button className="w-full mt-2.5" variant="heavy" type="submit">
/> Login
<Button </Button>
className="w-full mt-2.5" </Form>
variant="heavy" ) : undefined}
type="submit" {showOr ? (
> <div className="flex items-center gap-x-1.5 py-1">
Login <hr className="flex-1 border-ui-300 dark:border-ui-800" />
</Button> <span className="text-gray-500 text-sm">or</span>
</Form> <hr className="flex-1 border-ui-300 dark:border-ui-800" />
) </div>
: undefined} ) : undefined}
{showOr {data.oidc ? (
? ( <Form method="POST">
<div className="flex items-center gap-x-1.5 py-1"> <input type="hidden" name="oidc-start" value="true" />
<hr className="flex-1 border-ui-300 dark:border-ui-800" /> <Button className="w-full" variant="heavy" type="submit">
<span className="text-gray-500 text-sm">or</span> Login with SSO
<hr className="flex-1 border-ui-300 dark:border-ui-800" /> </Button>
</div> </Form>
) ) : undefined}
: undefined}
{data.oidc
? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full"
variant="heavy"
type="submit"
>
Login with SSO
</Button>
</Form>
)
: undefined}
</Card> </Card>
</div> </div>
) );
} }

View File

@ -1,15 +1,15 @@
import { ActionFunctionArgs, redirect } from '@remix-run/node' import { ActionFunctionArgs, redirect } from 'react-router';
import { destroySession, getSession } from '~/utils/sessions' import { destroySession, getSession } from '~/utils/sessions';
export async function loader() { export async function loader() {
return redirect('/machines') return redirect('/machines');
} }
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
return redirect('/login', { return redirect('/login', {
headers: { headers: {
'Set-Cookie': await destroySession(session) 'Set-Cookie': await destroySession(session),
} },
}) });
} }

View File

@ -1,17 +1,17 @@
import { LoaderFunctionArgs, data } from '@remix-run/node' import { LoaderFunctionArgs, data } from 'react-router';
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane';
import { finishOidc } from '~/utils/oidc' import { finishOidc } from '~/utils/oidc';
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
try { try {
const context = await loadContext() const context = await loadContext();
if (!context.oidc) { if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided') throw new Error('An invalid OIDC configuration was provided');
} }
return finishOidc(context.oidc, request) return finishOidc(context.oidc, request);
} catch (error) { } catch (error) {
// Gracefully present OIDC errors // Gracefully present OIDC errors
return data({ error }, { status: 500 }) return data({ error }, { status: 500 });
} }
} }

View File

@ -1,32 +1,27 @@
import { useSubmit } from '@remix-run/react' import { useSubmit } from 'react-router';
import { Button } from 'react-aria-components' import { Button } from 'react-aria-components';
import Code from '~/components/Code' import Code from '~/components/Code';
import Link from '~/components/Link' import Link from '~/components/Link';
import TableList from '~/components/TableList' import TableList from '~/components/TableList';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import AddDNS from '../dialogs/dns' import AddDNS from '../dialogs/dns';
interface Props { interface Props {
records: { name: string, type: 'A', value: string }[] records: { name: string; type: 'A'; value: string }[];
isDisabled: boolean isDisabled: boolean;
} }
export default function DNS({ records, isDisabled }: Props) { export default function DNS({ records, isDisabled }: Props) {
const submit = useSubmit() const submit = useSubmit();
return ( return (
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1> <h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p className="text-gray-700 dark:text-gray-300"> <p className="text-gray-700 dark:text-gray-300">
Headscale supports adding custom DNS records to your Tailnet. Headscale supports adding custom DNS records to your Tailnet. As of now,
As of now, only only <Code>A</Code> records are supported.{' '}
{' '}
<Code>A</Code>
{' '}
records are supported.
{' '}
<Link <Link
to="https://headscale.net/stable/ref/dns" to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation" name="Headscale DNS Records documentation"
@ -36,15 +31,12 @@ export default function DNS({ records, isDisabled }: Props) {
</p> </p>
<div className="mt-4"> <div className="mt-4">
<TableList className="mb-8"> <TableList className="mb-8">
{records.length === 0 {records.length === 0 ? (
? ( <TableList.Item>
<TableList.Item> <p className="opacity-50 text-sm mx-auto">No DNS records found</p>
<p className="opacity-50 text-sm mx-auto"> </TableList.Item>
No DNS records found ) : (
</p> records.map((record, index) => (
</TableList.Item>
)
: records.map((record, index) => (
<TableList.Item key={index}> <TableList.Item key={index}>
<div className="flex gap-24"> <div className="flex gap-24">
<div className="flex gap-2"> <div className="flex gap-2">
@ -62,27 +54,28 @@ export default function DNS({ records, isDisabled }: Props) {
)} )}
isDisabled={isDisabled} isDisabled={isDisabled}
onPress={() => { onPress={() => {
submit({ submit(
'dns.extra_records': records {
.filter((_, i) => i !== index), 'dns.extra_records': records.filter(
}, { (_, i) => i !== index,
method: 'PATCH', ),
encType: 'application/json', },
}) {
method: 'PATCH',
encType: 'application/json',
},
);
}} }}
> >
Remove Remove
</Button> </Button>
</TableList.Item> </TableList.Item>
))} ))
)}
</TableList> </TableList>
{isDisabled {isDisabled ? undefined : <AddDNS records={records} />}
? undefined
: (
<AddDNS records={records} />
)}
</div> </div>
</div> </div>
) );
} }

View File

@ -1,88 +1,88 @@
/* eslint-disable unicorn/no-keyword-prefix */ /* eslint-disable unicorn/no-keyword-prefix */
import { import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
closestCorners,
DndContext,
DragOverlay
} from '@dnd-kit/core'
import { import {
restrictToParentElement, restrictToParentElement,
restrictToVerticalAxis restrictToVerticalAxis,
} from '@dnd-kit/modifiers' } from '@dnd-kit/modifiers';
import { import {
arrayMove, arrayMove,
SortableContext, SortableContext,
useSortable, useSortable,
verticalListSortingStrategy verticalListSortingStrategy,
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities';
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react' import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react';
import { FetcherWithComponents, useFetcher } from '@remix-run/react' import { FetcherWithComponents, useFetcher } from 'react-router';
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react';
import { Button, Input } from 'react-aria-components' import { Button, Input } from 'react-aria-components';
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner';
import TableList from '~/components/TableList' import TableList from '~/components/TableList';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
type Properties = { type Properties = {
readonly baseDomain?: string; readonly baseDomain?: string;
readonly searchDomains: string[]; readonly searchDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming // eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean; readonly disabled?: boolean;
} };
export default function Domains({ baseDomain, searchDomains, disabled }: Properties) { export default function Domains({
baseDomain,
searchDomains,
disabled,
}: Properties) {
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types // eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
const [activeId, setActiveId] = useState<number | string | null>(null) const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains) const [localDomains, setLocalDomains] = useState(searchDomains);
const [newDomain, setNewDomain] = useState('') const [newDomain, setNewDomain] = useState('');
const fetcher = useFetcher() const fetcher = useFetcher();
useEffect(() => { useEffect(() => {
setLocalDomains(searchDomains) setLocalDomains(searchDomains);
}, [searchDomains]) }, [searchDomains]);
return ( return (
<div className='flex flex-col w-2/3'> <div className="flex flex-col w-2/3">
<h1 className='text-2xl font-medium mb-4'>Search Domains</h1> <h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className='text-gray-700 dark:text-gray-300 mb-2'> <p className="text-gray-700 dark:text-gray-300 mb-2">
Set custom DNS search domains for your Tailnet. Set custom DNS search domains for your Tailnet. When using Magic DNS,
When using Magic DNS, your tailnet domain is used as the first search domain. your tailnet domain is used as the first search domain.
</p> </p>
<DndContext <DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]} modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners} collisionDetection={closestCorners}
onDragStart={event => { onDragStart={(event) => {
setActiveId(event.active.id) setActiveId(event.active.id);
}} }}
onDragEnd={event => { onDragEnd={(event) => {
// eslint-disable-next-line unicorn/no-null // eslint-disable-next-line unicorn/no-null
setActiveId(null) setActiveId(null);
const { active, over } = event const { active, over } = event;
if (!over) { if (!over) {
return return;
} }
const activeItem = localDomains[active.id as number - 1] const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[over.id as number - 1] const overItem = localDomains[(over.id as number) - 1];
if (!activeItem || !overItem) { if (!activeItem || !overItem) {
return return;
} }
const oldIndex = localDomains.indexOf(activeItem) const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem) const newIndex = localDomains.indexOf(overItem);
if (oldIndex !== newIndex) { if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex)) setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
} }
}} }}
> >
<TableList> <TableList>
{baseDomain ? ( {baseDomain ? (
<TableList.Item key='magic-dns-sd'> <TableList.Item key="magic-dns-sd">
<p className='font-mono text-sm'>{baseDomain}</p> <p className="font-mono text-sm">{baseDomain}</p>
<LockIcon className='h-4 w-4'/> <LockIcon className="h-4 w-4" />
</TableList.Item> </TableList.Item>
) : undefined} ) : undefined}
<SortableContext <SortableContext
@ -101,25 +101,27 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
/> />
))} ))}
<DragOverlay adjustScale> <DragOverlay adjustScale>
{activeId ? <Domain {activeId ? (
isDrag <Domain
domain={localDomains[activeId as number - 1]} isDrag
localDomains={localDomains} domain={localDomains[(activeId as number) - 1]}
id={activeId as number - 1} localDomains={localDomains}
disabled={disabled} id={(activeId as number) - 1}
fetcher={fetcher} disabled={disabled}
/> : undefined} fetcher={fetcher}
/>
) : undefined}
</DragOverlay> </DragOverlay>
</SortableContext> </SortableContext>
{disabled ? undefined : ( {disabled ? undefined : (
<TableList.Item key='add-sd'> <TableList.Item key="add-sd">
<Input <Input
type='text' type="text"
className='font-mono text-sm bg-transparent w-full mr-2' className="font-mono text-sm bg-transparent w-full mr-2"
placeholder='Search Domain' placeholder="Search Domain"
value={newDomain} value={newDomain}
onChange={event => { onChange={(event) => {
setNewDomain(event.target.value) setNewDomain(event.target.value);
}} }}
/> />
{fetcher.state === 'idle' ? ( {fetcher.state === 'idle' ? (
@ -128,32 +130,35 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
'text-sm font-semibold', 'text-sm font-semibold',
'text-blue-600 dark:text-blue-400', 'text-blue-600 dark:text-blue-400',
'hover:text-blue-700 dark:hover:text-blue-300', 'hover:text-blue-700 dark:hover:text-blue-300',
newDomain.length === 0 && 'opacity-50 cursor-not-allowed' newDomain.length === 0 && 'opacity-50 cursor-not-allowed',
)} )}
isDisabled={newDomain.length === 0} isDisabled={newDomain.length === 0}
onPress={() => { onPress={() => {
fetcher.submit({ fetcher.submit(
// eslint-disable-next-line @typescript-eslint/naming-convention {
'dns.search_domains': [...localDomains, newDomain] // eslint-disable-next-line @typescript-eslint/naming-convention
}, { 'dns.search_domains': [...localDomains, newDomain],
method: 'PATCH', },
encType: 'application/json' {
}) method: 'PATCH',
encType: 'application/json',
},
);
setNewDomain('') setNewDomain('');
}} }}
> >
Add Add
</Button> </Button>
) : ( ) : (
<Spinner className='w-3 h-3 mr-0'/> <Spinner className="w-3 h-3 mr-0" />
)} )}
</TableList.Item> </TableList.Item>
)} )}
</TableList> </TableList>
</DndContext> </DndContext>
</div> </div>
) );
} }
type DomainProperties = { type DomainProperties = {
@ -164,17 +169,24 @@ type DomainProperties = {
// eslint-disable-next-line react/boolean-prop-naming // eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean; readonly disabled?: boolean;
readonly fetcher: FetcherWithComponents<unknown>; readonly fetcher: FetcherWithComponents<unknown>;
} };
function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainProperties) { function Domain({
domain,
id,
localDomains,
isDrag,
disabled,
fetcher,
}: DomainProperties) {
const { const {
attributes, attributes,
listeners, listeners,
setNodeRef, setNodeRef,
transform, transform,
transition, transition,
isDragging isDragging,
} = useSortable({ id }) } = useSortable({ id });
// TODO: Figure out why TableList.Item breaks dndkit // TODO: Figure out why TableList.Item breaks dndkit
return ( return (
@ -184,17 +196,19 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
'flex items-center justify-between px-3 py-2', 'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800', 'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
isDragging ? 'text-gray-400' : '', isDragging ? 'text-gray-400' : '',
isDrag ? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800' : '' isDrag
? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800'
: '',
)} )}
style={{ style={{
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition transition,
}} }}
> >
<p className='font-mono text-sm flex items-center gap-4'> <p className="font-mono text-sm flex items-center gap-4">
{disabled ? undefined : ( {disabled ? undefined : (
<ThreeBarsIcon <ThreeBarsIcon
className='h-4 w-4 text-gray-400 focus:outline-none' className="h-4 w-4 text-gray-400 focus:outline-none"
{...attributes} {...attributes}
{...listeners} {...listeners}
/> />
@ -207,21 +221,26 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
'text-sm', 'text-sm',
'text-red-600 dark:text-red-400', 'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300', 'hover:text-red-700 dark:hover:text-red-300',
disabled && 'opacity-50 cursor-not-allowed' disabled && 'opacity-50 cursor-not-allowed',
)} )}
isDisabled={disabled} isDisabled={disabled}
onPress={() => { onPress={() => {
fetcher.submit({ fetcher.submit(
'dns.search_domains': localDomains.filter((_, index) => index !== id - 1) {
}, { 'dns.search_domains': localDomains.filter(
method: 'PATCH', (_, index) => index !== id - 1,
encType: 'application/json' ),
}) },
{
method: 'PATCH',
encType: 'application/json',
},
);
}} }}
> >
Remove Remove
</Button> </Button>
)} )}
</div> </div>
) );
} }

View File

@ -1,53 +1,51 @@
import { useFetcher } from '@remix-run/react' import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner';
type Properties = { type Properties = {
readonly isEnabled: boolean; readonly isEnabled: boolean;
readonly disabled?: boolean; readonly disabled?: boolean;
} };
export default function Modal({ isEnabled, disabled }: Properties) { export default function Modal({ isEnabled, disabled }: Properties) {
const fetcher = useFetcher() const fetcher = useFetcher();
return ( return (
<Dialog> <Dialog>
<Dialog.Button isDisabled={disabled}> <Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : ( {fetcher.state === 'idle' ? undefined : <Spinner className="w-3 h-3" />}
<Spinner className='w-3 h-3'/>
)}
{isEnabled ? 'Disable' : 'Enable'} Magic DNS {isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button> </Dialog.Button>
<Dialog.Panel> <Dialog.Panel>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS {isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title> </Dialog.Title>
<Dialog.Text> <Dialog.Text>
Devices will no longer be accessible via your tailnet domain. Devices will no longer be accessible via your tailnet domain. The
The search domain will also be disabled. search domain will also be disabled.
</Dialog.Text> </Dialog.Text>
<div className='mt-6 flex justify-end gap-2 mt-6'> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant='cancel'
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
variant='confirm' variant="confirm"
onPress={() => { onPress={() => {
fetcher.submit({ fetcher.submit(
// eslint-disable-next-line @typescript-eslint/naming-convention {
'dns.magic_dns': !isEnabled // eslint-disable-next-line @typescript-eslint/naming-convention
}, { 'dns.magic_dns': !isEnabled,
method: 'PATCH', },
encType: 'application/json' {
}) method: 'PATCH',
encType: 'application/json',
},
);
close() close();
}} }}
> >
{isEnabled ? 'Disable' : 'Enable'} Magic DNS {isEnabled ? 'Disable' : 'Enable'} Magic DNS
@ -57,5 +55,5 @@ export default function Modal({ isEnabled, disabled }: Properties) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }

View File

@ -1,17 +1,17 @@
import { useSubmit } from '@remix-run/react' import { useSubmit } from 'react-router';
import { useState } from 'react' import { useState } from 'react';
import { Button } from 'react-aria-components' import { Button } from 'react-aria-components';
import Link from '~/components/Link' import Link from '~/components/Link';
import Switch from '~/components/Switch' import Switch from '~/components/Switch';
import TableList from '~/components/TableList' import TableList from '~/components/TableList';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import AddNameserver from '../dialogs/nameserver' import AddNameserver from '../dialogs/nameserver';
interface Props { interface Props {
nameservers: Record<string, string[]> nameservers: Record<string, string[]>;
isDisabled: boolean isDisabled: boolean;
} }
export default function Nameservers({ nameservers, isDisabled }: Props) { export default function Nameservers({ nameservers, isDisabled }: Props) {
@ -19,9 +19,8 @@ export default function Nameservers({ nameservers, isDisabled }: Props) {
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1> <h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p className="text-gray-700 dark:text-gray-300"> <p className="text-gray-700 dark:text-gray-300">
Set the nameservers used by devices on the Tailnet Set the nameservers used by devices on the Tailnet to resolve DNS
to resolve DNS queries. queries.{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1054/dns" to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation" name="Tailscale DNS Documentation"
@ -30,7 +29,7 @@ export default function Nameservers({ nameservers, isDisabled }: Props) {
</Link> </Link>
</p> </p>
<div className="mt-4"> <div className="mt-4">
{Object.keys(nameservers).map(key => ( {Object.keys(nameservers).map((key) => (
<NameserverList <NameserverList
key={key} key={key}
isGlobal={key === 'global'} isGlobal={key === 'global'}
@ -40,28 +39,29 @@ export default function Nameservers({ nameservers, isDisabled }: Props) {
/> />
))} ))}
{isDisabled {isDisabled ? undefined : <AddNameserver nameservers={nameservers} />}
? undefined
: (
<AddNameserver nameservers={nameservers} />
)}
</div> </div>
</div> </div>
) );
} }
interface ListProps { interface ListProps {
isGlobal: boolean isGlobal: boolean;
isDisabled: boolean isDisabled: boolean;
nameservers: string[] nameservers: string[];
name: string name: string;
} }
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) { function NameserverList({
const submit = useSubmit() isGlobal,
const list = isGlobal ? nameservers['global'] : nameservers[name] isDisabled,
nameservers,
name,
}: ListProps) {
const submit = useSubmit();
const list = isGlobal ? nameservers['global'] : nameservers[name];
if (list.length === 0) { if (list.length === 0) {
return null return null;
} }
return ( return (
@ -72,45 +72,54 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps)
</h2> </h2>
</div> </div>
<TableList> <TableList>
{list.length > 0 ? list.map((ns, index) => ( {list.length > 0
// eslint-disable-next-line react/no-array-index-key ? list.map((ns, index) => (
<TableList.Item key={index}> // eslint-disable-next-line react/no-array-index-key
<p className="font-mono text-sm">{ns}</p> <TableList.Item key={index}>
<Button <p className="font-mono text-sm">{ns}</p>
className={cn( <Button
'text-sm', className={cn(
'text-red-600 dark:text-red-400', 'text-sm',
'hover:text-red-700 dark:hover:text-red-300', 'text-red-600 dark:text-red-400',
isDisabled && 'opacity-50 cursor-not-allowed', 'hover:text-red-700 dark:hover:text-red-300',
)} isDisabled && 'opacity-50 cursor-not-allowed',
isDisabled={isDisabled} )}
onPress={() => { isDisabled={isDisabled}
if (isGlobal) { onPress={() => {
submit({ if (isGlobal) {
'dns.nameservers.global': list submit(
.filter((_, i) => i !== index), {
}, { 'dns.nameservers.global': list.filter(
method: 'PATCH', (_, i) => i !== index,
encType: 'application/json', ),
}) },
} else { {
submit({ method: 'PATCH',
'dns.nameservers.split': { encType: 'application/json',
...nameservers, },
[name]: list.filter((_, i) => i !== index), );
} else {
submit(
{
'dns.nameservers.split': {
...nameservers,
[name]: list.filter((_, i) => i !== index),
},
},
{
method: 'PATCH',
encType: 'application/json',
},
);
} }
}, { }}
method: 'PATCH', >
encType: 'application/json', Remove
}) </Button>
} </TableList.Item>
}} ))
> : undefined}
Remove
</Button>
</TableList.Item>
)) : undefined}
</TableList> </TableList>
</div> </div>
) );
} }

View File

@ -1,34 +1,28 @@
import { useFetcher } from '@remix-run/react' import { useFetcher } from 'react-router';
import { useState } from 'react' import { useState } from 'react';
import { Input } from 'react-aria-components' import { Input } from 'react-aria-components';
import Code from '~/components/Code' import Code from '~/components/Code';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner';
import TextField from '~/components/TextField' import TextField from '~/components/TextField';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
type Properties = { type Properties = {
readonly name: string; readonly name: string;
readonly disabled?: boolean; readonly disabled?: boolean;
} };
export default function Modal({ name, disabled }: Properties) { export default function Modal({ name, disabled }: Properties) {
const [newName, setNewName] = useState(name) const [newName, setNewName] = useState(name);
const fetcher = useFetcher() const fetcher = useFetcher();
return ( return (
<div className='flex flex-col w-2/3'> <div className="flex flex-col w-2/3">
<h1 className='text-2xl font-medium mb-4'>Tailnet Name</h1> <h1 className="text-2xl font-medium mb-4">Tailnet Name</h1>
<p className='text-gray-700 dark:text-gray-300'> <p className="text-gray-700 dark:text-gray-300">
This is the base domain name of your Tailnet. This is the base domain name of your Tailnet. Devices are accessible at{' '}
Devices are accessible at <Code>[device].{name}</Code> when Magic DNS is enabled.
{' '}
<Code>
[device].{name}
</Code>
{' '}
when Magic DNS is enabled.
</p> </p>
<Input <Input
readOnly readOnly
@ -36,54 +30,54 @@ export default function Modal({ name, disabled }: Properties) {
'block px-2.5 py-1.5 w-1/2 rounded-lg my-4', 'block px-2.5 py-1.5 w-1/2 rounded-lg my-4',
'border border-ui-200 dark:border-ui-600', 'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300 text-sm', 'dark:bg-ui-800 dark:text-ui-300 text-sm',
'outline-none' 'outline-none',
)} )}
type='text' type="text"
value={name} value={name}
onFocus={event => { onFocus={(event) => {
event.target.select() event.target.select();
}} }}
/> />
<Dialog> <Dialog>
<Dialog.Button isDisabled={disabled}> <Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : ( {fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/> <Spinner className="w-3 h-3" />
)} )}
Rename Tailnet Rename Tailnet
</Dialog.Button> </Dialog.Button>
<Dialog.Panel> <Dialog.Panel>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Rename Tailnet</Dialog.Title>
Rename Tailnet
</Dialog.Title>
<Dialog.Text> <Dialog.Text>
Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet. Keep in mind that changing this can lead to all sorts of
unexpected behavior and may break existing devices in your
tailnet.
</Dialog.Text> </Dialog.Text>
<TextField <TextField
label='Tailnet name' label="Tailnet name"
placeholder='ts.net' placeholder="ts.net"
state={[newName, setNewName]} state={[newName, setNewName]}
className='my-2' className="my-2"
/> />
<div className='mt-6 flex justify-end gap-2 mt-6'> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant='cancel'
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
variant='confirm' variant="confirm"
onPress={() => { onPress={() => {
fetcher.submit({ fetcher.submit(
'dns.base_domain': newName {
}, { 'dns.base_domain': newName,
method: 'PATCH', },
encType: 'application/json' {
}) method: 'PATCH',
encType: 'application/json',
},
);
close() close();
}} }}
> >
Rename Rename
@ -94,5 +88,5 @@ export default function Modal({ name, disabled }: Properties) {
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
</div> </div>
) );
} }

View File

@ -1,66 +1,65 @@
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react';
import Code from '~/components/Code' import Code from '~/components/Code';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField' import TextField from '~/components/TextField';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface Props { interface Props {
records: { name: string, type: 'A', value: string }[] records: { name: string; type: 'A'; value: string }[];
} }
export default function AddDNS({ records }: Props) { export default function AddDNS({ records }: Props) {
const submit = useSubmit() const submit = useSubmit();
const [name, setName] = useState('') const [name, setName] = useState('');
const [ip, setIp] = useState('') const [ip, setIp] = useState('');
const isDuplicate = useMemo(() => { const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find(record => record.name === name) const lookup = records.find((record) => record.name === name);
if (!lookup) return false if (!lookup) return false;
return lookup.value === ip return lookup.value === ip;
}, [records, name, ip]) }, [records, name, ip]);
return ( return (
<Dialog> <Dialog>
<Dialog.Button> <Dialog.Button>Add DNS record</Dialog.Button>
Add DNS record
</Dialog.Button>
<Dialog.Panel> <Dialog.Panel>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Add DNS record</Dialog.Title>
Add DNS record
</Dialog.Title>
<Dialog.Text> <Dialog.Text>
Enter the domain and IP address for the new DNS record. Enter the domain and IP address for the new DNS record.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault() event.preventDefault();
if (!name || !ip) return if (!name || !ip) return;
setName('') setName('');
setIp('') setIp('');
submit({ submit(
'dns.extra_records': [ {
...records, 'dns.extra_records': [
{ ...records,
name, {
type: 'A', name,
value: ip, type: 'A',
}, value: ip,
], },
}, { ],
method: 'PATCH', },
encType: 'application/json', {
}) method: 'PATCH',
encType: 'application/json',
},
);
close() close();
}} }}
> >
<TextField <TextField
@ -68,40 +67,23 @@ export default function AddDNS({ records }: Props) {
placeholder="test.example.com" placeholder="test.example.com"
name="domain" name="domain"
state={[name, setName]} state={[name, setName]}
className={cn( className={cn('mt-2', isDuplicate && 'outline outline-red-500')}
'mt-2',
isDuplicate && 'outline outline-red-500',
)}
/> />
<TextField <TextField
label="IP Address" label="IP Address"
placeholder="101.101.101.101" placeholder="101.101.101.101"
name="ip" name="ip"
state={[ip, setIp]} state={[ip, setIp]}
className={cn( className={cn(isDuplicate && 'outline outline-red-500')}
isDuplicate && 'outline outline-red-500',
)}
/> />
{isDuplicate {isDuplicate ? (
? ( <p className="text-sm opacity-50">
<p className="text-sm opacity-50"> A record with the domain name <Code>{name}</Code> and IP
A record with the domain name address <Code>{ip}</Code> already exists.
{' '} </p>
<Code>{name}</Code> ) : undefined}
{' '}
and IP address
{' '}
<Code>{ip}</Code>
{' '}
already exists.
</p>
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-8"> <div className="mt-6 flex justify-end gap-2 mt-8">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
@ -117,5 +99,5 @@ export default function AddDNS({ records }: Props) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }

View File

@ -1,81 +1,81 @@
import { RepoForkedIcon } from '@primer/octicons-react' import { RepoForkedIcon } from '@primer/octicons-react';
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { useState } from 'react' import { useState } from 'react';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import Switch from '~/components/Switch' import Switch from '~/components/Switch';
import TextField from '~/components/TextField' import TextField from '~/components/TextField';
import Tooltip from '~/components/Tooltip' import Tooltip from '~/components/Tooltip';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface Props { interface Props {
nameservers: Record<string, string[]> nameservers: Record<string, string[]>;
} }
export default function AddNameserver({ nameservers }: Props) { export default function AddNameserver({ nameservers }: Props) {
const submit = useSubmit() const submit = useSubmit();
const [split, setSplit] = useState(false) const [split, setSplit] = useState(false);
const [ns, setNs] = useState('') const [ns, setNs] = useState('');
const [domain, setDomain] = useState('') const [domain, setDomain] = useState('');
return ( return (
<Dialog> <Dialog>
<Dialog.Button> <Dialog.Button>Add nameserver</Dialog.Button>
Add nameserver
</Dialog.Button>
<Dialog.Panel> <Dialog.Panel>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Add nameserver</Dialog.Title>
Add nameserver <Dialog.Text className="font-semibold">Nameserver</Dialog.Text>
</Dialog.Title>
<Dialog.Text className="font-semibold">
Nameserver
</Dialog.Text>
<Dialog.Text className="text-sm"> <Dialog.Text className="text-sm">
Use this IPv4 or IPv6 address to resolve names. Use this IPv4 or IPv6 address to resolve names.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault() event.preventDefault();
if (!ns) return if (!ns) return;
if (split) { if (split) {
const splitNs: Record<string, string[]> = {} const splitNs: Record<string, string[]> = {};
for (const [key, value] of Object.entries(nameservers)) { for (const [key, value] of Object.entries(nameservers)) {
if (key === 'global') continue if (key === 'global') continue;
splitNs[key] = value splitNs[key] = value;
} }
if (Object.keys(splitNs).includes(domain)) { if (Object.keys(splitNs).includes(domain)) {
splitNs[domain].push(ns) splitNs[domain].push(ns);
} else { } else {
splitNs[domain] = [ns] splitNs[domain] = [ns];
} }
submit({ submit(
'dns.nameservers.split': splitNs, {
}, { 'dns.nameservers.split': splitNs,
method: 'PATCH', },
encType: 'application/json', {
}) method: 'PATCH',
encType: 'application/json',
},
);
} else { } else {
const globalNs = nameservers.global const globalNs = nameservers.global;
globalNs.push(ns) globalNs.push(ns);
submit({ submit(
'dns.nameservers.global': globalNs, {
}, { 'dns.nameservers.global': globalNs,
method: 'PATCH', },
encType: 'application/json', {
}) method: 'PATCH',
encType: 'application/json',
},
);
} }
setNs('') setNs('');
setDomain('') setDomain('');
setSplit(false) setSplit(false);
close() close();
}} }}
> >
<TextField <TextField
@ -92,20 +92,20 @@ export default function AddNameserver({ nameservers }: Props) {
Restrict to domain Restrict to domain
</Dialog.Text> </Dialog.Text>
<Tooltip> <Tooltip>
<Tooltip.Button className={cn( <Tooltip.Button
'text-xs rounded-md px-1.5 py-0.5', className={cn(
'bg-ui-200 dark:bg-ui-800', 'text-xs rounded-md px-1.5 py-0.5',
'text-ui-600 dark:text-ui-300', 'bg-ui-200 dark:bg-ui-800',
)} 'text-ui-600 dark:text-ui-300',
)}
> >
<RepoForkedIcon className="w-4 h-4 mr-0.5" /> <RepoForkedIcon className="w-4 h-4 mr-0.5" />
Split DNS Split DNS
</Tooltip.Button> </Tooltip.Button>
<Tooltip.Body> <Tooltip.Body>
Only clients that support split DNS Only clients that support split DNS (Tailscale v1.8 or
(Tailscale v1.8 or later for most platforms) later for most platforms) will use this nameserver.
will use this nameserver. Older clients Older clients will ignore it.
will ignore it.
</Tooltip.Body> </Tooltip.Body>
</Tooltip> </Tooltip>
</div> </div>
@ -116,40 +116,34 @@ export default function AddNameserver({ nameservers }: Props) {
<Switch <Switch
label="Split DNS" label="Split DNS"
defaultSelected={split} defaultSelected={split}
onChange={() => { setSplit(!split) }} onChange={() => {
setSplit(!split);
}}
/> />
</div> </div>
{split {split ? (
? ( <>
<> <Dialog.Text className="font-semibold mt-8">
<Dialog.Text className="font-semibold mt-8"> Domain
Domain </Dialog.Text>
</Dialog.Text> <TextField
<TextField label="Domain"
label="Domain" placeholder="example.com"
placeholder="example.com" name="domain"
name="domain" state={[domain, setDomain]}
state={[domain, setDomain]} className="my-2"
className="my-2" />
/> <Dialog.Text className="text-sm">
<Dialog.Text className="text-sm"> Only single-label or fully-qualified queries matching this
Only single-label or fully-qualified queries suffix should use the nameserver.
matching this suffix should use the nameserver. </Dialog.Text>
</Dialog.Text> </>
</> ) : undefined}
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action variant="confirm" onPress={close}>
variant="confirm"
onPress={close}
>
Add Add
</Dialog.Action> </Dialog.Action>
</div> </div>
@ -158,5 +152,5 @@ export default function AddNameserver({ nameservers }: Props) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }

View File

@ -1,27 +1,27 @@
import { ActionFunctionArgs } from '@remix-run/node' import { ActionFunctionArgs } from 'react-router';
import { json, useLoaderData } from '@remix-run/react' import { json, useLoaderData } from 'react-router';
import Code from '~/components/Code' import Code from '~/components/Code';
import Notice from '~/components/Notice' import Notice from '~/components/Notice';
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane';
import { loadConfig, patchConfig } from '~/utils/config/headscale' import { loadConfig, patchConfig } from '~/utils/config/headscale';
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions';
import { useLiveData } from '~/utils/useLiveData' import { useLiveData } from '~/utils/useLiveData';
import DNS from './components/dns' import DNS from './components/dns';
import Domains from './components/domains' import Domains from './components/domains';
import MagicModal from './components/magic' import MagicModal from './components/magic';
import Nameservers from './components/nameservers' import Nameservers from './components/nameservers';
import RenameModal from './components/rename' import RenameModal from './components/rename';
// We do not want to expose every config value // We do not want to expose every config value
export async function loader() { export async function loader() {
const context = await loadContext() const context = await loadContext();
if (!context.config.read) { if (!context.config.read) {
throw new Error('No configuration is available') throw new Error('No configuration is available');
} }
const config = await loadConfig() const config = await loadConfig();
const dns = { const dns = {
prefixes: config.prefixes, prefixes: config.prefixes,
magicDns: config.dns.magic_dns, magicDns: config.dns.magic_dns,
@ -32,65 +32,58 @@ export async function loader() {
splitDns: config.dns.nameservers.split, splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains, searchDomains: config.dns.search_domains,
extraRecords: config.dns.extra_records, extraRecords: config.dns.extra_records,
} };
return { return {
...dns, ...dns,
...context, ...context,
} };
} }
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return send({ success: false }, 401) return send({ success: false }, 401);
} }
const context = await loadContext() const context = await loadContext();
if (!context.config.write) { if (!context.config.write) {
return send({ success: false }, 403) return send({ success: false }, 403);
} }
const data = await request.json() as Record<string, unknown> const data = (await request.json()) as Record<string, unknown>;
await patchConfig(data) await patchConfig(data);
if (context.integration?.onConfigChange) { if (context.integration?.onConfigChange) {
await context.integration.onConfigChange(context.integration.context) await context.integration.onConfigChange(context.integration.context);
} }
return { success: true } return { success: true };
} }
export default function Page() { export default function Page() {
useLiveData({ interval: 5000 }) useLiveData({ interval: 5000 });
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>();
const allNs: Record<string, string[]> = {} const allNs: Record<string, string[]> = {};
for (const key of Object.keys(data.splitDns)) { for (const key of Object.keys(data.splitDns)) {
allNs[key] = data.splitDns[key] allNs[key] = data.splitDns[key];
} }
allNs.global = data.nameservers allNs.global = data.nameservers;
return ( return (
<div className="flex flex-col gap-16 max-w-screen-lg"> <div className="flex flex-col gap-16 max-w-screen-lg">
{data.config.write {data.config.write ? undefined : (
? undefined <Notice>
: ( The Headscale configuration is read-only. You cannot make changes to
<Notice> the configuration
The Headscale configuration is read-only. You cannot make changes to the configuration </Notice>
</Notice> )}
)}
<RenameModal name={data.baseDomain} disabled={!data.config.write} /> <RenameModal name={data.baseDomain} disabled={!data.config.write} />
<Nameservers <Nameservers nameservers={allNs} isDisabled={!data.config.write} />
nameservers={allNs}
isDisabled={!data.config.write}
/>
<DNS <DNS records={data.extraRecords} isDisabled={!data.config.write} />
records={data.extraRecords}
isDisabled={!data.config.write}
/>
<Domains <Domains
baseDomain={data.magicDns ? data.baseDomain : undefined} baseDomain={data.magicDns ? data.baseDomain : undefined}
@ -101,18 +94,16 @@ export default function Page() {
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1> <h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4"> <p className="text-gray-700 dark:text-gray-300 mb-4">
Automatically register domain names for each device Automatically register domain names for each device on the tailnet.
on the tailnet. Devices will be accessible at Devices will be accessible at{' '}
{' '}
<Code> <Code>
[device]. [device].
{data.baseDomain} {data.baseDomain}
</Code> </Code>{' '}
{' '}
when Magic DNS is enabled. when Magic DNS is enabled.
</p> </p>
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} /> <MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
</div> </div>
</div> </div>
) );
} }

View File

@ -1,167 +1,208 @@
import { ActionFunctionArgs } from '@remix-run/node' import { ActionFunctionArgs } from 'react-router';
import { del, post } from '~/utils/headscale' import { del, post } from '~/utils/headscale';
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions';
import { send } from '~/utils/res' import { send } from '~/utils/res';
import log from '~/utils/log' import log from '~/utils/log';
export async function menuAction(request: ActionFunctionArgs['request']) { export async function menuAction(request: ActionFunctionArgs['request']) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return send({ message: 'Unauthorized' }, { return send(
status: 401, { message: 'Unauthorized' },
}) {
status: 401,
},
);
} }
const data = await request.formData() const data = await request.formData();
if (!data.has('_method') || !data.has('id')) { if (!data.has('_method') || !data.has('id')) {
return send({ message: 'No method or ID provided' }, { return send(
status: 400, { message: 'No method or ID provided' },
}) {
status: 400,
},
);
} }
const id = String(data.get('id')) const id = String(data.get('id'));
const method = String(data.get('_method')) const method = String(data.get('_method'));
switch (method) { switch (method) {
case 'delete': { case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!) await del(`v1/node/${id}`, session.get('hsApiKey')!);
return { message: 'Machine removed' } return { message: 'Machine removed' };
} }
case 'expire': { case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!) await post(`v1/node/${id}/expire`, session.get('hsApiKey')!);
return { message: 'Machine expired' } return { message: 'Machine expired' };
} }
case 'rename': { case 'rename': {
if (!data.has('name')) { if (!data.has('name')) {
return send({ message: 'No name provided' }, { return send(
status: 400, { message: 'No name provided' },
}) {
status: 400,
},
);
} }
const name = String(data.get('name')) const name = String(data.get('name'));
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!) await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!);
return { message: 'Machine renamed' } return { message: 'Machine renamed' };
} }
case 'routes': { case 'routes': {
if (!data.has('route') || !data.has('enabled')) { if (!data.has('route') || !data.has('enabled')) {
return send({ message: 'No route or enabled provided' }, { return send(
status: 400, { message: 'No route or enabled provided' },
}) {
status: 400,
},
);
} }
const route = String(data.get('route')) const route = String(data.get('route'));
const enabled = data.get('enabled') === 'true' const enabled = data.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable' const postfix = enabled ? 'enable' : 'disable';
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!) await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
return { message: 'Route updated' } return { message: 'Route updated' };
} }
case 'exit-node': { case 'exit-node': {
if (!data.has('routes') || !data.has('enabled')) { if (!data.has('routes') || !data.has('enabled')) {
return send({ message: 'No route or enabled provided' }, { return send(
status: 400, { message: 'No route or enabled provided' },
}) {
status: 400,
},
);
} }
const routes = data.get('routes')?.toString().split(',') ?? [] const routes = data.get('routes')?.toString().split(',') ?? [];
const enabled = data.get('enabled') === 'true' const enabled = data.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable' const postfix = enabled ? 'enable' : 'disable';
await Promise.all(routes.map(async (route) => { await Promise.all(
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!) routes.map(async (route) => {
})) await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
}),
);
return { message: 'Exit node updated' } return { message: 'Exit node updated' };
} }
case 'move': { case 'move': {
if (!data.has('to')) { if (!data.has('to')) {
return send({ message: 'No destination provided' }, { return send(
status: 400, { message: 'No destination provided' },
}) {
status: 400,
},
);
} }
const to = String(data.get('to')) const to = String(data.get('to'));
try { try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!) await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!);
return { message: `Moved node ${id} to ${to}` } return { message: `Moved node ${id} to ${to}` };
} catch { } catch {
return send({ message: `Failed to move node ${id} to ${to}` }, { return send(
status: 500, { message: `Failed to move node ${id} to ${to}` },
}) {
status: 500,
},
);
} }
} }
case 'tags': { case 'tags': {
const tags = data.get('tags')?.toString() const tags =
.split(',') data
.filter((tag) => tag.trim() !== '') .get('tags')
?? [] ?.toString()
.split(',')
.filter((tag) => tag.trim() !== '') ?? [];
try { try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, { await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
tags, tags,
}) });
return { message: 'Tags updated' } return { message: 'Tags updated' };
} catch (error) { } catch (error) {
log.debug('APIC', 'Failed to update tags: %s', error) log.debug('APIC', 'Failed to update tags: %s', error);
return send({ message: 'Failed to update tags' }, { return send(
status: 500, { message: 'Failed to update tags' },
}) {
status: 500,
},
);
} }
} }
case 'register': { case 'register': {
const key = data.get('mkey')?.toString() const key = data.get('mkey')?.toString();
const user = data.get('user')?.toString() const user = data.get('user')?.toString();
if (!key) { if (!key) {
return send({ message: 'No machine key provided' }, { return send(
status: 400, { message: 'No machine key provided' },
}) {
status: 400,
},
);
} }
if (!user) { if (!user) {
return send({ message: 'No user provided' }, { return send(
status: 400, { message: 'No user provided' },
}) {
status: 400,
},
);
} }
try { try {
const qp = new URLSearchParams() const qp = new URLSearchParams();
qp.append('user', user) qp.append('user', user);
qp.append('key', key) qp.append('key', key);
const url = `v1/node/register?${qp.toString()}` const url = `v1/node/register?${qp.toString()}`;
await post(url, session.get('hsApiKey')!, { await post(url, session.get('hsApiKey')!, {
user, key, user,
}) key,
});
return { return {
success: true, success: true,
message: 'Machine registered' message: 'Machine registered',
} };
} catch { } catch {
return send({ return send(
success: false, {
message: 'Failed to register machine' success: false,
}, { message: 'Failed to register machine',
status: 500, },
}) {
status: 500,
},
);
} }
} }
default: { default: {
return send({ message: 'Invalid method' }, { return send(
status: 400, { message: 'Invalid method' },
}) {
status: 400,
},
);
} }
} }
} }

View File

@ -1,68 +1,69 @@
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react' import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
import { Link } from '@remix-run/react' import { Link } from 'react-router';
import Menu from '~/components/Menu' import Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle' import StatusCircle from '~/components/StatusCircle';
import { toast } from '~/components/Toaster' import { toast } from '~/components/Toaster';
import { Machine, Route, User } from '~/types' import { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import MenuOptions from './menu' import MenuOptions from './menu';
interface Props { interface Props {
readonly machine: Machine readonly machine: Machine;
readonly routes: Route[] readonly routes: Route[];
readonly users: User[] readonly users: User[];
readonly magic?: string readonly magic?: string;
} }
export default function MachineRow({ machine, routes, magic, users }: Props) { export default function MachineRow({ machine, routes, magic, users }: Props) {
const expired = machine.expiry === '0001-01-01 00:00:00' const expired =
|| machine.expiry === '0001-01-01T00:00:00Z' machine.expiry === '0001-01-01 00:00:00' ||
|| machine.expiry === null machine.expiry === '0001-01-01T00:00:00Z' ||
? false machine.expiry === null
: new Date(machine.expiry).getTime() < Date.now() ? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [ const tags = [...machine.forcedTags, ...machine.validTags];
...machine.forcedTags,
...machine.validTags,
]
if (expired) { if (expired) {
tags.unshift('Expired') tags.unshift('Expired');
} }
let prefix = magic?.startsWith('[user]') let prefix = magic?.startsWith('[user]')
? magic.replace('[user]', machine.user.name) ? magic.replace('[user]', machine.user.name)
: magic : magic;
// This is much easier with Object.groupBy but it's too new for us // This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => { const { exit, subnet, subnetApproved } = routes.reduce(
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') { (acc, route) => {
acc.exit.push(route) if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
return acc acc.exit.push(route);
} return acc;
}
if (route.enabled) { if (route.enabled) {
acc.subnetApproved.push(route) acc.subnetApproved.push(route);
return acc return acc;
} }
acc.subnet.push(route) acc.subnet.push(route);
return acc return acc;
}, { exit: [], subnetApproved: [], subnet: [] }) },
{ exit: [], subnetApproved: [], subnet: [] },
);
const exitEnabled = useMemo(() => { const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled return exit[0].enabled && exit[1].enabled;
}, [exit]) }, [exit]);
if (exitEnabled) { if (exitEnabled) {
tags.unshift('Exit Node') tags.unshift('Exit Node');
} }
if (subnetApproved.length > 0) { if (subnetApproved.length > 0) {
tags.unshift('Subnets') tags.unshift('Subnets');
} }
return ( return (
@ -71,15 +72,13 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group" className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
> >
<td className="pl-0.5 py-2"> <td className="pl-0.5 py-2">
<Link <Link to={`/machines/${machine.id}`} className="group/link h-full">
to={`/machines/${machine.id}`} <p
className="group/link h-full" className={cn(
> 'font-semibold leading-snug',
<p className={cn( 'group-hover/link:text-blue-600',
'font-semibold leading-snug', 'group-hover/link:dark:text-blue-400',
'group-hover/link:text-blue-600', )}
'group-hover/link:dark:text-blue-400',
)}
> >
{machine.givenName} {machine.givenName}
</p> </p>
@ -87,7 +86,7 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
{machine.name} {machine.name}
</p> </p>
<div className="flex gap-1 mt-1"> <div className="flex gap-1 mt-1">
{tags.map(tag => ( {tags.map((tag) => (
<span <span
key={tag} key={tag}
className={cn( className={cn(
@ -110,7 +109,7 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
<ChevronDownIcon className="w-4 h-4" /> <ChevronDownIcon className="w-4 h-4" />
</Menu.Button> </Menu.Button>
<Menu.Items> <Menu.Items>
{machine.ipAddresses.map(ip => ( {machine.ipAddresses.map((ip) => (
<Menu.ItemButton <Menu.ItemButton
key={ip} key={ip}
type="button" type="button"
@ -119,44 +118,41 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
'justify-between w-full', 'justify-between w-full',
)} )}
onPress={async () => { onPress={async () => {
await navigator.clipboard.writeText(ip) await navigator.clipboard.writeText(ip);
toast('Copied IP address to clipboard') toast('Copied IP address to clipboard');
}} }}
> >
{ip} {ip}
<CopyIcon className="w-3 h-3" /> <CopyIcon className="w-3 h-3" />
</Menu.ItemButton> </Menu.ItemButton>
))} ))}
{magic {magic ? (
? ( <Menu.ItemButton
<Menu.ItemButton type="button"
type="button" className={cn(
className={cn( 'flex items-center gap-x-1.5 text-sm',
'flex items-center gap-x-1.5 text-sm', 'justify-between w-full break-keep',
'justify-between w-full break-keep', )}
)} onPress={async () => {
onPress={async () => { const ip = `${machine.givenName}.${prefix}`;
const ip = `${machine.givenName}.${prefix}` await navigator.clipboard.writeText(ip);
await navigator.clipboard.writeText(ip) toast('Copied hostname to clipboard');
toast('Copied hostname to clipboard') }}
}} >
> {machine.givenName}.{prefix}
{machine.givenName} <CopyIcon className="w-3 h-3" />
. </Menu.ItemButton>
{prefix} ) : undefined}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
)
: undefined}
</Menu.Items> </Menu.Items>
</Menu> </Menu>
</div> </div>
</td> </td>
<td className="py-2"> <td className="py-2">
<span className={cn( <span
'flex items-center gap-x-1 text-sm', className={cn(
'text-gray-500 dark:text-gray-400', 'flex items-center gap-x-1 text-sm',
)} 'text-gray-500 dark:text-gray-400',
)}
> >
<StatusCircle <StatusCircle
isOnline={machine.online && !expired} isOnline={machine.online && !expired}
@ -165,9 +161,7 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
<p> <p>
{machine.online && !expired {machine.online && !expired
? 'Connected' ? 'Connected'
: new Date( : new Date(machine.lastSeen).toLocaleString()}
machine.lastSeen,
).toLocaleString()}
</p> </p>
</span> </span>
</td> </td>
@ -180,5 +174,5 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
/> />
</td> </td>
</tr> </tr>
) );
} }

View File

@ -1,73 +1,54 @@
import { KebabHorizontalIcon } from '@primer/octicons-react' import { KebabHorizontalIcon } from '@primer/octicons-react';
import { ReactNode, useState } from 'react' import { ReactNode, useState } from 'react';
import MenuComponent from '~/components/Menu' import MenuComponent from '~/components/Menu';
import { Machine, Route, User } from '~/types' import { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import Delete from '../dialogs/delete' import Delete from '../dialogs/delete';
import Expire from '../dialogs/expire' import Expire from '../dialogs/expire';
import Move from '../dialogs/move' import Move from '../dialogs/move';
import Rename from '../dialogs/rename' import Rename from '../dialogs/rename';
import Routes from '../dialogs/routes' import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags' import Tags from '../dialogs/tags';
interface MenuProps { interface MenuProps {
machine: Machine machine: Machine;
routes: Route[] routes: Route[];
users: User[] users: User[];
magic?: string magic?: string;
buttonChild?: ReactNode buttonChild?: ReactNode;
} }
export default function Menu({ machine, routes, magic, users, buttonChild }: MenuProps) { export default function Menu({
const renameState = useState(false) machine,
const expireState = useState(false) routes,
const removeState = useState(false) magic,
const routesState = useState(false) users,
const moveState = useState(false) buttonChild,
const tagsState = useState(false) }: MenuProps) {
const renameState = useState(false);
const expireState = useState(false);
const removeState = useState(false);
const routesState = useState(false);
const moveState = useState(false);
const tagsState = useState(false);
const expired = machine.expiry === '0001-01-01 00:00:00' const expired =
|| machine.expiry === '0001-01-01T00:00:00Z' machine.expiry === '0001-01-01 00:00:00' ||
|| machine.expiry === null machine.expiry === '0001-01-01T00:00:00Z' ||
? false machine.expiry === null
: new Date(machine.expiry).getTime() < Date.now() ? false
: new Date(machine.expiry).getTime() < Date.now();
return ( return (
<> <>
<Rename <Rename machine={machine} state={renameState} magic={magic} />
machine={machine} <Delete machine={machine} state={removeState} />
state={renameState} {expired ? undefined : <Expire machine={machine} state={expireState} />}
magic={magic} <Routes machine={machine} routes={routes} state={routesState} />
/> <Tags machine={machine} state={tagsState} />
<Delete <Move machine={machine} state={moveState} users={users} magic={magic} />
machine={machine}
state={removeState}
/>
{expired
? undefined
: (
<Expire
machine={machine}
state={expireState}
/>
)}
<Routes
machine={machine}
routes={routes}
state={routesState}
/>
<Tags
machine={machine}
state={tagsState}
/>
<Move
machine={machine}
state={moveState}
users={users}
magic={magic}
/>
<MenuComponent> <MenuComponent>
{buttonChild ?? ( {buttonChild ?? (
@ -94,13 +75,11 @@ export default function Menu({ machine, routes, magic, users, buttonChild }: Men
<MenuComponent.ItemButton control={moveState}> <MenuComponent.ItemButton control={moveState}>
Change owner Change owner
</MenuComponent.ItemButton> </MenuComponent.ItemButton>
{expired {expired ? undefined : (
? undefined <MenuComponent.ItemButton control={expireState}>
: ( Expire
<MenuComponent.ItemButton control={expireState}> </MenuComponent.ItemButton>
Expire )}
</MenuComponent.ItemButton>
)}
<MenuComponent.ItemButton <MenuComponent.ItemButton
className="text-red-500 dark:text-red-400" className="text-red-500 dark:text-red-400"
control={removeState} control={removeState}
@ -110,5 +89,5 @@ export default function Menu({ machine, routes, magic, users, buttonChild }: Men
</MenuComponent.Items> </MenuComponent.Items>
</MenuComponent> </MenuComponent>
</> </>
) );
} }

View File

@ -1,46 +1,39 @@
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction } from 'react' import { type Dispatch, type SetStateAction } from 'react';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import { type Machine } from '~/types' import { type Machine } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface DeleteProps { interface DeleteProps {
readonly machine: Machine readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>] readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
} }
export default function Delete({ machine, state }: DeleteProps) { export default function Delete({ machine, state }: DeleteProps) {
const submit = useSubmit() const submit = useSubmit();
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Remove {machine.givenName}</Dialog.Title>
Remove
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text> <Dialog.Text>
This machine will be permanently removed from This machine will be permanently removed from your network. To
your network. To re-add it, you will need to re-add it, you will need to reauthenticate to your tailnet from
reauthenticate to your tailnet from the device. the device.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(e) => { onSubmit={(e) => {
submit(e.currentTarget) submit(e.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="delete" /> <input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} /> <input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
@ -61,5 +54,5 @@ export default function Delete({ machine, state }: DeleteProps) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }

View File

@ -1,46 +1,38 @@
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction } from 'react' import { type Dispatch, type SetStateAction } from 'react';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import { type Machine } from '~/types' import { type Machine } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface ExpireProps { interface ExpireProps {
readonly machine: Machine readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>] readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
} }
export default function Expire({ machine, state }: ExpireProps) { export default function Expire({ machine, state }: ExpireProps) {
const submit = useSubmit() const submit = useSubmit();
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Expire {machine.givenName}</Dialog.Title>
Expire
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text> <Dialog.Text>
This will disconnect the machine from your Tailnet. This will disconnect the machine from your Tailnet. In order to
In order to reconnect, you will need to re-authenticate reconnect, you will need to re-authenticate from the device.
from the device.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(e) => { onSubmit={(e) => {
submit(e.currentTarget) submit(e.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="expire" /> <input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} /> <input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
@ -61,5 +53,5 @@ export default function Expire({ machine, state }: ExpireProps) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }

View File

@ -1,39 +1,35 @@
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react' import { type Dispatch, type SetStateAction, useState } from 'react';
import Code from '~/components/Code' import Code from '~/components/Code';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import Select from '~/components/Select' import Select from '~/components/Select';
import { type Machine, User } from '~/types' import { type Machine, User } from '~/types';
interface MoveProps { interface MoveProps {
readonly machine: Machine readonly machine: Machine;
readonly users: User[] readonly users: User[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>] readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string readonly magic?: string;
} }
export default function Move({ machine, state, magic, users }: MoveProps) { export default function Move({ machine, state, magic, users }: MoveProps) {
const [owner, setOwner] = useState(machine.user.name) const [owner, setOwner] = useState(machine.user.name);
const submit = useSubmit() const submit = useSubmit();
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
Change the owner of
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text> <Dialog.Text>
The owner of the machine is the user associated with it. The owner of the machine is the user associated with it.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(e) => { onSubmit={(e) => {
submit(e.currentTarget) submit(e.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="move" /> <input type="hidden" name="_method" value="move" />
@ -44,37 +40,26 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
placeholder="Select a user" placeholder="Select a user"
state={[owner, setOwner]} state={[owner, setOwner]}
> >
{users.map(user => ( {users.map((user) => (
<Select.Item key={user.id} id={user.name}> <Select.Item key={user.id} id={user.name}>
{user.name} {user.name}
</Select.Item> </Select.Item>
))} ))}
</Select> </Select>
{magic {magic ? (
? ( <p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight"> This machine is accessible by the hostname{' '}
This machine is accessible by the hostname <Code className="text-sm">
{' '} {machine.givenName}.{magic}
<Code className="text-sm"> </Code>
{machine.givenName} .
. </p>
{magic} ) : undefined}
</Code>
.
</p>
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action variant="confirm" onPress={close}>
variant="confirm"
onPress={close}
>
Change owner Change owner
</Dialog.Action> </Dialog.Action>
</div> </div>
@ -83,5 +68,5 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }

View File

@ -1,74 +1,73 @@
import { Form, useFetcher, Link } from '@remix-run/react' import { Form, useFetcher, Link } from 'react-router';
import { Dispatch, SetStateAction, useState, useEffect } from 'react' import { Dispatch, SetStateAction, useState, useEffect } from 'react';
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react' import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import Code from '~/components/Code' import Code from '~/components/Code';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField' import TextField from '~/components/TextField';
import Select from '~/components/Select' import Select from '~/components/Select';
import Menu from '~/components/Menu' import Menu from '~/components/Menu';
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner';
import { toast } from '~/components/Toaster' import { toast } from '~/components/Toaster';
import { Machine, User } from '~/types' import { Machine, User } from '~/types';
export interface NewProps { export interface NewProps {
server: string server: string;
users: User[] users: User[];
} }
export default function New(data: NewProps) { export default function New(data: NewProps) {
const fetcher = useFetcher<{ success?: boolean }>() const fetcher = useFetcher<{ success?: boolean }>();
const mkeyState = useState(false) const mkeyState = useState(false);
const [mkey, setMkey] = useState('') const [mkey, setMkey] = useState('');
const [user, setUser] = useState('') const [user, setUser] = useState('');
const [toasted, setToasted] = useState(false) const [toasted, setToasted] = useState(false);
useEffect(() => { useEffect(() => {
if (!fetcher.data || toasted) { if (!fetcher.data || toasted) {
return return;
} }
if (fetcher.data.success) { if (fetcher.data.success) {
toast('Registered new machine') toast('Registered new machine');
} else { } else {
toast('Failed to register machine due to an invalid key') toast('Failed to register machine due to an invalid key');
} }
setToasted(true) setToasted(true);
}, [fetcher.data, toasted, mkey]) }, [fetcher.data, toasted, mkey]);
return ( return (
<> <>
<Dialog> <Dialog>
<Dialog.Panel control={mkeyState}> <Dialog.Panel control={mkeyState}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Register Machine Key</Dialog.Title>
Register Machine Key <Dialog.Text className="mb-4">
</Dialog.Title> The machine key is given when you run{' '}
<Dialog.Text className='mb-4'>
The machine key is given when you run
{' '}
<Code isCopyable> <Code isCopyable>
tailscale up --login-server= tailscale up --login-server=
{data.server} {data.server}
</Code> </Code>{' '}
{' '}
on your device. on your device.
</Dialog.Text> </Dialog.Text>
<fetcher.Form method="POST" onSubmit={e => { <fetcher.Form
fetcher.submit(e.currentTarget) method="POST"
close() onSubmit={(e) => {
}}> fetcher.submit(e.currentTarget);
close();
}}
>
<input type="hidden" name="_method" value="register" /> <input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" /> <input type="hidden" name="id" value="_" />
<TextField <TextField
label='Machine Key' label="Machine Key"
placeholder='mkey:ff.....' placeholder="mkey:ff....."
name="mkey" name="mkey"
state={[mkey, setMkey]} state={[mkey, setMkey]}
className='my-2 font-mono' className="my-2 font-mono"
/> />
<Select <Select
label="Owner" label="Owner"
@ -76,28 +75,25 @@ export default function New(data: NewProps) {
placeholder="Select a user" placeholder="Select a user"
state={[user, setUser]} state={[user, setUser]}
> >
{data.users.map(user => ( {data.users.map((user) => (
<Select.Item key={user.id} id={user.name}> <Select.Item key={user.id} id={user.name}>
{user.name} {user.name}
</Select.Item> </Select.Item>
))} ))}
</Select> </Select>
<div className='mt-6 flex justify-end gap-2 mt-6'> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
variant="confirm" variant="confirm"
isDisabled={!mkey || !mkey.trim().startsWith('mkey:') || !user} isDisabled={
!mkey || !mkey.trim().startsWith('mkey:') || !user
}
> >
{fetcher.state === 'idle' {fetcher.state === 'idle' ? undefined : (
? undefined <Spinner className="w-3 h-3" />
: ( )}
<Spinner className="w-3 h-3" />
)}
Register Register
</Dialog.Action> </Dialog.Action>
</div> </div>
@ -118,17 +114,17 @@ export default function New(data: NewProps) {
</Menu.Button> </Menu.Button>
<Menu.Items> <Menu.Items>
<Menu.ItemButton control={mkeyState}> <Menu.ItemButton control={mkeyState}>
<ServerIcon className='w-4 h-4 mr-2'/> <ServerIcon className="w-4 h-4 mr-2" />
Register Machine Key Register Machine Key
</Menu.ItemButton> </Menu.ItemButton>
<Menu.ItemButton> <Menu.ItemButton>
<Link to="/settings/auth-keys"> <Link to="/settings/auth-keys">
<KeyIcon className='w-4 h-4 mr-2'/> <KeyIcon className="w-4 h-4 mr-2" />
Generate Pre-auth Key Generate Pre-auth Key
</Link> </Link>
</Menu.ItemButton> </Menu.ItemButton>
</Menu.Items> </Menu.Items>
</Menu> </Menu>
</> </>
) );
} }

View File

@ -1,39 +1,37 @@
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react' import { type Dispatch, type SetStateAction, useState } from 'react';
import Code from '~/components/Code' import Code from '~/components/Code';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField' import TextField from '~/components/TextField';
import { type Machine } from '~/types' import { type Machine } from '~/types';
interface RenameProps { interface RenameProps {
readonly machine: Machine readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>] readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string readonly magic?: string;
} }
export default function Rename({ machine, state, magic }: RenameProps) { export default function Rename({ machine, state, magic }: RenameProps) {
const [name, setName] = useState(machine.givenName) const [name, setName] = useState(machine.givenName);
const submit = useSubmit() const submit = useSubmit();
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>
Edit machine name for Edit machine name for {machine.givenName}
{' '}
{machine.givenName}
</Dialog.Title> </Dialog.Title>
<Dialog.Text> <Dialog.Text>
This name is shown in the admin panel, in Tailscale clients, This name is shown in the admin panel, in Tailscale clients, and
and used when generating MagicDNS names. used when generating MagicDNS names.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(e) => { onSubmit={(e) => {
submit(e.currentTarget) submit(e.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="rename" /> <input type="hidden" name="_method" value="rename" />
@ -45,49 +43,30 @@ export default function Rename({ machine, state, magic }: RenameProps) {
state={[name, setName]} state={[name, setName]}
className="my-2" className="my-2"
/> />
{magic {magic ? (
? ( name.length > 0 && name !== machine.givenName ? (
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{' '}
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight"> <Code className="text-sm">
This machine will be accessible by the hostname {name.toLowerCase().replaceAll(/\s+/g, '-')}
{' '} </Code>
<Code className="text-sm"> {'. '}
{name.toLowerCase().replaceAll(/\s+/g, '-')} The hostname{' '}
</Code> <Code className="text-sm">{machine.givenName}</Code> will no
{'. '} longer point to this machine.
The hostname </p>
{' '} ) : (
<Code className="text-sm"> <p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
{machine.givenName} This machine is accessible by the hostname{' '}
</Code> <Code className="text-sm">{machine.givenName}</Code>.
{' '} </p>
will no longer point to this machine. )
</p> ) : undefined}
)
: (
<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"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action variant="confirm" onPress={close}>
variant="confirm"
onPress={close}
>
Rename Rename
</Dialog.Action> </Dialog.Action>
</div> </div>
@ -96,5 +75,5 @@ export default function Rename({ machine, state, magic }: RenameProps) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }

View File

@ -1,55 +1,53 @@
import { useFetcher } from '@remix-run/react' import { useFetcher } from 'react-router';
import { Dispatch, SetStateAction, useMemo } from 'react' import { Dispatch, SetStateAction, useMemo } from 'react';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import Switch from '~/components/Switch' import Switch from '~/components/Switch';
import Link from '~/components/Link' import Link from '~/components/Link';
import { Machine, Route } from '~/types' import { Machine, Route } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface RoutesProps { interface RoutesProps {
readonly machine: Machine readonly machine: Machine;
readonly routes: Route[] readonly routes: Route[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>] readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
} }
// TODO: Support deleting routes // TODO: Support deleting routes
export default function Routes({ machine, routes, state }: RoutesProps) { export default function Routes({ machine, routes, state }: RoutesProps) {
const fetcher = useFetcher() const fetcher = useFetcher();
// This is much easier with Object.groupBy but it's too new for us // This is much easier with Object.groupBy but it's too new for us
const { exit, subnet } = routes.reduce((acc, route) => { const { exit, subnet } = routes.reduce(
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') { (acc, route) => {
acc.exit.push(route) if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
return acc acc.exit.push(route);
} return acc;
}
acc.subnet.push(route) acc.subnet.push(route);
return acc return acc;
}, { exit: [], subnet: [] }) },
{ exit: [], subnet: [] },
);
const exitEnabled = useMemo(() => { const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled return exit[0].enabled && exit[1].enabled;
}, [exit]) }, [exit]);
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>
Edit route settings of Edit route settings of {machine.givenName}
{' '}
{machine.givenName}
</Dialog.Title> </Dialog.Title>
<Dialog.Text className="font-bold"> <Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
Subnet routes
</Dialog.Text>
<Dialog.Text> <Dialog.Text>
Connect to devices you can&apos;t install Tailscale on Connect to devices you can&apos;t install Tailscale on by
by advertising IP ranges as subnet routes. advertising IP ranges as subnet routes.{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1019/subnets" to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation" name="Tailscale Subnets Documentation"
@ -57,28 +55,25 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
Learn More Learn More
</Link> </Link>
</Dialog.Text> </Dialog.Text>
<div className={cn( <div
'rounded-lg overflow-y-auto my-2', className={cn(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top', 'rounded-lg overflow-y-auto my-2',
'border border-zinc-200 dark:border-zinc-700', 'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
)} 'border border-zinc-200 dark:border-zinc-700',
)}
> >
{subnet.length === 0 {subnet.length === 0 ? (
? ( <div
<div className={cn(
className={cn( 'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800', 'items-center justify-center',
'items-center justify-center', 'text-ui-600 dark:text-ui-300',
'text-ui-600 dark:text-ui-300', )}
)} >
> <p>No routes are advertised on this machine.</p>
<p> </div>
No routes are advertised on this machine. ) : undefined}
</p> {subnet.map((route) => (
</div>
)
: undefined}
{subnet.map(route => (
<div <div
key={route.id} key={route.id}
className={cn( className={cn(
@ -86,33 +81,28 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
'items-center justify-between', 'items-center justify-between',
)} )}
> >
<p> <p>{route.prefix}</p>
{route.prefix}
</p>
<Switch <Switch
defaultSelected={route.enabled} defaultSelected={route.enabled}
label="Enabled" label="Enabled"
onChange={(checked) => { onChange={(checked) => {
const form = new FormData() const form = new FormData();
form.set('id', machine.id) form.set('id', machine.id);
form.set('_method', 'routes') form.set('_method', 'routes');
form.set('route', route.id) form.set('route', route.id);
form.set('enabled', String(checked)) form.set('enabled', String(checked));
fetcher.submit(form, { fetcher.submit(form, {
method: 'POST', method: 'POST',
}) });
}} }}
/> />
</div> </div>
))} ))}
</div> </div>
<Dialog.Text className="font-bold mt-8"> <Dialog.Text className="font-bold mt-8">Exit nodes</Dialog.Text>
Exit nodes
</Dialog.Text>
<Dialog.Text> <Dialog.Text>
Allow your network to route internet traffic through this machine. Allow your network to route internet traffic through this machine.{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1103/exit-nodes" to="https://tailscale.com/kb/1103/exit-nodes"
name="Tailscale Exit-node Documentation" name="Tailscale Exit-node Documentation"
@ -120,52 +110,51 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
Learn More Learn More
</Link> </Link>
</Dialog.Text> </Dialog.Text>
<div className={cn( <div
'rounded-lg overflow-y-auto my-2', className={cn(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top', 'rounded-lg overflow-y-auto my-2',
'border border-zinc-200 dark:border-zinc-700', 'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
)} 'border border-zinc-200 dark:border-zinc-700',
)}
> >
{exit.length === 0 {exit.length === 0 ? (
? ( <div
<div className={cn(
className={cn( 'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800', 'items-center justify-center',
'items-center justify-center', 'text-ui-600 dark:text-ui-300',
'text-ui-600 dark:text-ui-300', )}
)} >
> <p>This machine is not an exit node.</p>
<p> </div>
This machine is not an exit node. ) : (
</p> <div
</div> className={cn(
) : ( 'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
<div 'items-center justify-between',
className={cn( )}
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800', >
'items-center justify-between', <p>Use as exit node</p>
)} <Switch
> defaultSelected={exitEnabled}
<p> label="Enabled"
Use as exit node onChange={(checked) => {
</p> const form = new FormData();
<Switch form.set('id', machine.id);
defaultSelected={exitEnabled} form.set('_method', 'exit-node');
label="Enabled" form.set(
onChange={(checked) => { 'routes',
const form = new FormData() exit.map((route) => route.id).join(','),
form.set('id', machine.id) );
form.set('_method', 'exit-node')
form.set('routes', exit.map(route => route.id).join(','))
form.set('enabled', String(checked)) form.set('enabled', String(checked));
fetcher.submit(form, { fetcher.submit(form, {
method: 'POST', method: 'POST',
}) });
}} }}
/> />
</div> </div>
)} )}
</div> </div>
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action
@ -180,5 +169,5 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }

View File

@ -1,51 +1,44 @@
import { PlusIcon, XIcon } from '@primer/octicons-react' import { PlusIcon, XIcon } from '@primer/octicons-react';
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { Dispatch, SetStateAction, useState } from 'react' import { Dispatch, SetStateAction, useState } from 'react';
import { Button, Input } from 'react-aria-components' import { Button, Input } from 'react-aria-components';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import Link from '~/components/Link' import Link from '~/components/Link';
import { Machine } from '~/types' import { Machine } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
interface TagsProps { interface TagsProps {
readonly machine: Machine readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>] readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
} }
export default function Tags({ machine, state }: TagsProps) { export default function Tags({ machine, state }: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags) const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('') const [tag, setTag] = useState('');
const submit = useSubmit() const submit = useSubmit();
return ( return (
<Dialog> <Dialog>
<Dialog.Panel control={state}> <Dialog.Panel control={state}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
Edit ACL tags for
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text> <Dialog.Text>
ACL tags can be used to reference machines in your ACL policies. ACL tags can be used to reference machines in your ACL policies.
See the See the{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1068/acl-tags" to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation" name="Tailscale documentation"
> >
Tailscale documentation Tailscale documentation
</Link> </Link>{' '}
{' '}
for more information. for more information.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(e) => { onSubmit={(e) => {
submit(e.currentTarget) submit(e.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="tags" /> <input type="hidden" name="_method" value="tags" />
@ -58,21 +51,18 @@ export default function Tags({ machine, state }: TagsProps) {
)} )}
> >
<div className="divide-y divide-ui-200 dark:divide-ui-600"> <div className="divide-y divide-ui-200 dark:divide-ui-600">
{tags.length === 0 {tags.length === 0 ? (
? ( <div
<div className={cn(
className={cn( 'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800', 'items-center justify-center rounded-t-lg',
'items-center justify-center rounded-t-lg', 'text-ui-600 dark:text-ui-300',
'text-ui-600 dark:text-ui-300', )}
)} >
> <p>No tags are set on this machine.</p>
<p> </div>
No tags are set on this machine. ) : (
</p> tags.map((item) => (
</div>
)
: tags.map(item => (
<div <div
key={item} key={item}
id={item} id={item}
@ -86,13 +76,14 @@ export default function Tags({ machine, state }: TagsProps) {
<Button <Button
className="rounded-full p-0 w-6 h-6" className="rounded-full p-0 w-6 h-6"
onPress={() => { onPress={() => {
setTags(tags.filter(tag => tag !== item)) setTags(tags.filter((tag) => tag !== item));
}} }}
> >
<XIcon className="w-4 h-4" /> <XIcon className="w-4 h-4" />
</Button> </Button>
</div> </div>
))} ))
)}
</div> </div>
<div <div
className={cn( className={cn(
@ -101,8 +92,9 @@ export default function Tags({ machine, state }: TagsProps) {
'rounded-b-lg justify-between items-center', 'rounded-b-lg justify-between items-center',
'dark:bg-ui-800 dark:text-ui-300', 'dark:bg-ui-800 dark:text-ui-300',
'focus-within:ring-2 focus-within:ring-blue-600', 'focus-within:ring-2 focus-within:ring-blue-600',
tag.length > 0 && !tag.startsWith('tag:') tag.length > 0 &&
&& 'outline outline-red-500', !tag.startsWith('tag:') &&
'outline outline-red-500',
)} )}
> >
<Input <Input
@ -115,23 +107,23 @@ export default function Tags({ machine, state }: TagsProps) {
)} )}
value={tag} value={tag}
onChange={(e) => { onChange={(e) => {
setTag(e.currentTarget.value) setTag(e.currentTarget.value);
}} }}
/> />
<Button <Button
className={cn( className={cn(
'rounded-lg p-0 h-6 w-6', 'rounded-lg p-0 h-6 w-6',
!tag.startsWith('tag:') !tag.startsWith('tag:') &&
&& 'opacity-50 cursor-not-allowed', 'opacity-50 cursor-not-allowed',
)} )}
isDisabled={ isDisabled={
tag.length === 0 tag.length === 0 ||
|| !tag.startsWith('tag:') !tag.startsWith('tag:') ||
|| tags.includes(tag) tags.includes(tag)
} }
onPress={() => { onPress={() => {
setTags([...tags, tag]) setTags([...tags, tag]);
setTag('') setTag('');
}} }}
> >
<PlusIcon className="w-4 h-4" /> <PlusIcon className="w-4 h-4" />
@ -139,16 +131,10 @@ export default function Tags({ machine, state }: TagsProps) {
</div> </div>
</div> </div>
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action variant="confirm" onPress={close}>
variant="confirm"
onPress={close}
>
Save Save
</Dialog.Action> </Dialog.Action>
</div> </div>
@ -157,5 +143,5 @@ export default function Tags({ machine, state }: TagsProps) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }

View File

@ -1,40 +1,46 @@
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node' import { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { Link as RemixLink, useLoaderData } from '@remix-run/react' import { Link as RemixLink, useLoaderData } from 'react-router';
import { InfoIcon, GearIcon, CheckCircleIcon, SkipIcon, PersonIcon } from '@primer/octicons-react' import {
import { useMemo, useState } from 'react' InfoIcon,
GearIcon,
CheckCircleIcon,
SkipIcon,
PersonIcon,
} from '@primer/octicons-react';
import { useMemo, useState } from 'react';
import Attribute from '~/components/Attribute' import Attribute from '~/components/Attribute';
import Button from '~/components/Button' import Button from '~/components/Button';
import Card from '~/components/Card' import Card from '~/components/Card';
import Menu from '~/components/Menu' import Menu from '~/components/Menu';
import Tooltip from '~/components/Tooltip' import Tooltip from '~/components/Tooltip';
import StatusCircle from '~/components/StatusCircle' import StatusCircle from '~/components/StatusCircle';
import { Machine, Route, User } from '~/types' import { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale' import { loadConfig } from '~/utils/config/headscale';
import { pull } from '~/utils/headscale' import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions';
import { useLiveData } from '~/utils/useLiveData' import { useLiveData } from '~/utils/useLiveData';
import Link from '~/components/Link' import Link from '~/components/Link';
import { menuAction } from './action' import { menuAction } from './action';
import MenuOptions from './components/menu' import MenuOptions from './components/menu';
import Routes from './dialogs/routes' import Routes from './dialogs/routes';
export async function loader({ request, params }: LoaderFunctionArgs) { export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
if (!params.id) { if (!params.id) {
throw new Error('No machine ID provided') throw new Error('No machine ID provided');
} }
const context = await loadContext() const context = await loadContext();
let magic: string | undefined let magic: string | undefined;
if (context.config.read) { if (context.config.read) {
const config = await loadConfig() const config = await loadConfig();
if (config.dns.magic_dns) { if (config.dns.magic_dns) {
magic = config.dns.base_domain magic = config.dns.base_domain;
} }
} }
@ -42,110 +48,106 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!), pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!), pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!), pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
]) ]);
return { return {
machine: machine.node, machine: machine.node,
routes: routes.routes.filter(route => route.node.id === params.id), routes: routes.routes.filter((route) => route.node.id === params.id),
users: users.users, users: users.users,
magic, magic,
} };
} }
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
return menuAction(request) return menuAction(request);
} }
export default function Page() { export default function Page() {
const { machine, magic, routes, users } = useLoaderData<typeof loader>() const { machine, magic, routes, users } = useLoaderData<typeof loader>();
const routesState = useState(false) const routesState = useState(false);
useLiveData({ interval: 1000 }) useLiveData({ interval: 1000 });
const expired = machine.expiry === '0001-01-01 00:00:00' const expired =
|| machine.expiry === '0001-01-01T00:00:00Z' machine.expiry === '0001-01-01 00:00:00' ||
|| machine.expiry === null machine.expiry === '0001-01-01T00:00:00Z' ||
? false machine.expiry === null
: new Date(machine.expiry).getTime() < Date.now() ? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [ const tags = [...machine.forcedTags, ...machine.validTags];
...machine.forcedTags,
...machine.validTags,
]
if (expired) { if (expired) {
tags.unshift('Expired') tags.unshift('Expired');
} }
// This is much easier with Object.groupBy but it's too new for us // This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => { const { exit, subnet, subnetApproved } = routes.reduce(
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') { (acc, route) => {
acc.exit.push(route) if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
return acc acc.exit.push(route);
} return acc;
}
if (route.enabled) { if (route.enabled) {
acc.subnetApproved.push(route) acc.subnetApproved.push(route);
return acc return acc;
} }
acc.subnet.push(route) acc.subnet.push(route);
return acc return acc;
}, { exit: [], subnetApproved: [], subnet: [] }) },
{ exit: [], subnetApproved: [], subnet: [] },
);
const exitEnabled = useMemo(() => { const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled return exit[0].enabled && exit[1].enabled;
}, [exit]) }, [exit]);
if (exitEnabled) { if (exitEnabled) {
tags.unshift('Exit Node') tags.unshift('Exit Node');
} }
if (subnetApproved.length > 0) { if (subnetApproved.length > 0) {
tags.unshift('Subnets') tags.unshift('Subnets');
} }
return ( return (
<div> <div>
<p className="mb-8 text-md"> <p className="mb-8 text-md">
<RemixLink <RemixLink to="/machines" className="font-medium">
to="/machines"
className="font-medium"
>
All Machines All Machines
</RemixLink> </RemixLink>
<span className="mx-2"> <span className="mx-2">/</span>
/
</span>
{machine.givenName} {machine.givenName}
</p> </p>
<div className={cn( <div
'flex justify-between items-center', className={cn(
'border-b border-ui-100 dark:border-ui-800', 'flex justify-between items-center',
)}> 'border-b border-ui-100 dark:border-ui-800',
)}
>
<span className="flex items-baseline gap-x-4 text-sm mb-4"> <span className="flex items-baseline gap-x-4 text-sm mb-4">
<h1 className="text-2xl font-medium"> <h1 className="text-2xl font-medium">{machine.givenName}</h1>
{machine.givenName}
</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" /> <StatusCircle isOnline={machine.online} className="w-4 h-4" />
</span> </span>
<MenuOptions <MenuOptions
className={cn( className={cn('bg-ui-100 dark:bg-ui-800')}
'bg-ui-100 dark:bg-ui-800',
)}
machine={machine} machine={machine}
routes={routes} routes={routes}
users={users} users={users}
magic={magic} magic={magic}
buttonChild={ buttonChild={
<Menu.Button className={cn( <Menu.Button
'flex items-center justify-center gap-x-2', className={cn(
'bg-main-200 dark:bg-main-700/30', 'flex items-center justify-center gap-x-2',
'hover:bg-main-300 dark:hover:bg-main-600/30', 'bg-main-200 dark:bg-main-700/30',
'text-ui-700 dark:text-ui-300 mb-2', 'hover:bg-main-300 dark:hover:bg-main-600/30',
'w-fit text-sm rounded-lg px-3 py-2' 'text-ui-700 dark:text-ui-300 mb-2',
)}> 'w-fit text-sm rounded-lg px-3 py-2',
)}
>
<GearIcon className="w-5" /> <GearIcon className="w-5" />
Machine Settings Machine Settings
</Menu.Button> </Menu.Button>
@ -166,21 +168,21 @@ export default function Page() {
</Tooltip> </Tooltip>
</span> </span>
<div className="flex items-center gap-x-2.5 mt-1"> <div className="flex items-center gap-x-2.5 mt-1">
<div className={cn( <div
'rounded-full h-7 w-7 flex items-center justify-center', className={cn(
'border border-ui-200 dark:border-ui-700', 'rounded-full h-7 w-7 flex items-center justify-center',
)}> 'border border-ui-200 dark:border-ui-700',
)}
>
<PersonIcon className="w-4 h-4" /> <PersonIcon className="w-4 h-4" />
</div> </div>
{machine.user.name} {machine.user.name}
</div> </div>
</div> </div>
<div className="p-2 pl-4"> <div className="p-2 pl-4">
<p className="text-sm text-ui-600 dark:text-ui-300"> <p className="text-sm text-ui-600 dark:text-ui-300">Status</p>
Status
</p>
<div className="flex gap-1 mt-1 mb-8"> <div className="flex gap-1 mt-1 mb-8">
{tags.map(tag => ( {tags.map((tag) => (
<span <span
key={tag} key={tag}
className={cn( className={cn(
@ -195,18 +197,11 @@ export default function Page() {
</div> </div>
</div> </div>
</div> </div>
<h2 className="text-xl font-medium mb-4 mt-8"> <h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
Subnets & Routing <Routes machine={machine} routes={routes} state={routesState} />
</h2>
<Routes
machine={machine}
routes={routes}
state={routesState}
/>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<p> <p>
Subnets let you expose physical network routes onto Tailscale. Subnets let you expose physical network routes onto Tailscale.{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1019/subnets" to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation" name="Tailscale Subnets Documentation"
@ -214,10 +209,7 @@ export default function Page() {
Learn More Learn More
</Link> </Link>
</p> </p>
<Button <Button variant="light" control={routesState}>
variant="light"
control={routesState}
>
Review Review
</Button> </Button>
</div> </div>
@ -236,22 +228,17 @@ export default function Page() {
<InfoIcon className="w-3.5 h-3.5" /> <InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button> </Tooltip.Button>
<Tooltip.Body> <Tooltip.Body>
Traffic to these routes are being Traffic to these routes are being routed through this machine.
routed through this machine.
</Tooltip.Body> </Tooltip.Body>
</Tooltip> </Tooltip>
</span> </span>
<div className="mt-1"> <div className="mt-1">
{subnetApproved.length === 0 ? ( {subnetApproved.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"> <span className="text-ui-400 dark:text-ui-300"></span>
</span>
) : ( ) : (
<ul className="leading-normal"> <ul className="leading-normal">
{subnetApproved.map(route => ( {subnetApproved.map((route) => (
<li key={route.id}> <li key={route.id}>{route.prefix}</li>
{route.prefix}
</li>
))} ))}
</ul> </ul>
)} )}
@ -276,23 +263,18 @@ export default function Page() {
<InfoIcon className="w-3.5 h-3.5" /> <InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button> </Tooltip.Button>
<Tooltip.Body> <Tooltip.Body>
This machine is advertising these routes, This machine is advertising these routes, but they must be
but they must be approved before traffic approved before traffic will be routed to them.
will be routed to them.
</Tooltip.Body> </Tooltip.Body>
</Tooltip> </Tooltip>
</span> </span>
<div className="mt-1"> <div className="mt-1">
{subnet.length === 0 ? ( {subnet.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"> <span className="text-ui-400 dark:text-ui-300"></span>
</span>
) : ( ) : (
<ul className="leading-normal"> <ul className="leading-normal">
{subnet.map(route => ( {subnet.map((route) => (
<li key={route.id}> <li key={route.id}>{route.prefix}</li>
{route.prefix}
</li>
))} ))}
</ul> </ul>
)} )}
@ -317,16 +299,13 @@ export default function Page() {
<InfoIcon className="w-3.5 h-3.5" /> <InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button> </Tooltip.Button>
<Tooltip.Body> <Tooltip.Body>
Whether this machine can act as an Whether this machine can act as an exit node for your tailnet.
exit node for your tailnet.
</Tooltip.Body> </Tooltip.Body>
</Tooltip> </Tooltip>
</span> </span>
<div className="mt-1"> <div className="mt-1">
{exit.length === 0 ? ( {exit.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300"> <span className="text-ui-400 dark:text-ui-300"></span>
</span>
) : exitEnabled ? ( ) : exitEnabled ? (
<span className="flex items-center gap-x-1"> <span className="flex items-center gap-x-1">
<CheckCircleIcon className="w-3.5 h-3.5 text-green-700" /> <CheckCircleIcon className="w-3.5 h-3.5 text-green-700" />
@ -352,19 +331,13 @@ export default function Page() {
</Button> </Button>
</div> </div>
</Card> </Card>
<h2 className="text-xl font-medium mb-4"> <h2 className="text-xl font-medium mb-4">Machine Details</h2>
Machine Details
</h2>
<Card variant="flat" className="w-full max-w-full"> <Card variant="flat" className="w-full max-w-full">
<Attribute name="Creator" value={machine.user.name} /> <Attribute name="Creator" value={machine.user.name} />
<Attribute name="Node ID" value={machine.id} /> <Attribute name="Node ID" value={machine.id} />
<Attribute name="Node Name" value={machine.givenName} /> <Attribute name="Node Name" value={machine.givenName} />
<Attribute name="Hostname" value={machine.name} /> <Attribute name="Hostname" value={machine.name} />
<Attribute <Attribute isCopyable name="Node Key" value={machine.nodeKey} />
isCopyable
name="Node Key"
value={machine.nodeKey}
/>
<Attribute <Attribute
name="Created" name="Created"
value={new Date(machine.createdAt).toLocaleString()} value={new Date(machine.createdAt).toLocaleString()}
@ -375,21 +348,16 @@ export default function Page() {
/> />
<Attribute <Attribute
name="Expiry" name="Expiry"
value={expired value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
? new Date(machine.expiry).toLocaleString()
: 'Never'
}
/> />
{magic {magic ? (
? ( <Attribute
<Attribute isCopyable
isCopyable name="Domain"
name="Domain" value={`${machine.givenName}.${magic}`}
value={`${machine.givenName}.${magic}`} />
/> ) : undefined}
)
: undefined}
</Card> </Card>
</div> </div>
) );
} }

View File

@ -1,41 +1,41 @@
import { InfoIcon } from '@primer/octicons-react' import { InfoIcon } from '@primer/octicons-react';
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node' import { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from '@remix-run/react' import { useLoaderData } from 'react-router';
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components' import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
import Code from '~/components/Code' import Code from '~/components/Code';
import Link from '~/components/Link' import Link from '~/components/Link';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale' import { loadConfig } from '~/utils/config/headscale';
import { pull } from '~/utils/headscale' import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions';
import { useLiveData } from '~/utils/useLiveData' import { useLiveData } from '~/utils/useLiveData';
import type { Machine, Route, User } from '~/types' import type { Machine, Route, User } from '~/types';
import { menuAction } from './action' import { menuAction } from './action';
import MachineRow from './components/machine' import MachineRow from './components/machine';
import NewMachine from './dialogs/new' import NewMachine from './dialogs/new';
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
const [machines, routes, users] = await Promise.all([ const [machines, routes, users] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!), pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!), pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!), pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
]) ]);
const context = await loadContext() const context = await loadContext();
let magic: string | undefined let magic: string | undefined;
if (context.config.read) { if (context.config.read) {
const config = await loadConfig() const config = await loadConfig();
if (config.dns.magic_dns) { if (config.dns.magic_dns) {
magic = config.dns.base_domain magic = config.dns.base_domain;
} }
if (config.dns.use_username_in_magic_dns) { if (config.dns.use_username_in_magic_dns) {
magic = `[user].${magic}` magic = `[user].${magic}`;
} }
} }
@ -46,25 +46,24 @@ export async function loader({ request }: LoaderFunctionArgs) {
magic, magic,
server: context.headscaleUrl, server: context.headscaleUrl,
publicServer: context.headscalePublicUrl, publicServer: context.headscalePublicUrl,
} };
} }
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
return menuAction(request) return menuAction(request);
} }
export default function Page() { export default function Page() {
useLiveData({ interval: 3000 }) useLiveData({ interval: 3000 });
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>();
return ( return (
<> <>
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className='text-2xl font-medium mb-4'>Machines</h1> <h1 className="text-2xl font-medium mb-4">Machines</h1>
<p className='text-gray-700 dark:text-gray-300'> <p className="text-gray-700 dark:text-gray-300">
Manage the devices connected to your Tailnet. Manage the devices connected to your Tailnet.{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1372/manage-devices" to="https://tailscale.com/kb/1372/manage-devices"
name="Tailscale Manage Devices Documentation" name="Tailscale Manage Devices Documentation"
@ -85,44 +84,45 @@ export default function Page() {
<th className="pb-2"> <th className="pb-2">
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
Addresses Addresses
{data.magic {data.magic ? (
? ( <TooltipTrigger delay={0}>
<TooltipTrigger delay={0}> <Button>
<Button> <InfoIcon className="w-4 h-4" />
<InfoIcon className="w-4 h-4" /> </Button>
</Button> <Tooltip
<Tooltip className={cn( className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2', 'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-zinc-800', 'bg-white dark:bg-zinc-800',
'border border-gray-200 dark:border-zinc-700', 'border border-gray-200 dark:border-zinc-700',
)} )}
> >
Since MagicDNS is enabled, you can access devices Since MagicDNS is enabled, you can access devices based on
based on their name and also at their name and also at{' '}
{' '} <Code>
<Code> [name].
[name]. {data.magic}
{data.magic} </Code>
</Code> </Tooltip>
</Tooltip> </TooltipTrigger>
</TooltipTrigger> ) : undefined}
)
: undefined}
</div> </div>
</th> </th>
<th className="pb-2">Last Seen</th> <th className="pb-2">Last Seen</th>
</tr> </tr>
</thead> </thead>
<tbody className={cn( <tbody
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top', className={cn(
'border-t border-zinc-200 dark:border-zinc-700', 'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
)} 'border-t border-zinc-200 dark:border-zinc-700',
)}
> >
{data.nodes.map(machine => ( {data.nodes.map((machine) => (
<MachineRow <MachineRow
key={machine.id} key={machine.id}
machine={machine} machine={machine}
routes={data.routes.filter(route => route.node.id === machine.id)} routes={data.routes.filter(
(route) => route.node.id === machine.id,
)}
users={data.users} users={data.users}
magic={data.magic} magic={data.magic}
/> />
@ -130,5 +130,5 @@ export default function Page() {
</tbody> </tbody>
</table> </table>
</> </>
) );
} }

View File

@ -1,168 +1,174 @@
import { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node' import { LoaderFunctionArgs, ActionFunctionArgs } from 'react-router';
import { useLoaderData } from '@remix-run/react' import { useLoaderData } from 'react-router';
import { useLiveData } from '~/utils/useLiveData' import { useLiveData } from '~/utils/useLiveData';
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions';
import { Link as RemixLink } from '@remix-run/react' import { Link as RemixLink } from 'react-router';
import { PreAuthKey, User } from '~/types' import { PreAuthKey, User } from '~/types';
import { pull, post } from '~/utils/headscale' import { pull, post } from '~/utils/headscale';
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane';
import { useState } from 'react' import { useState } from 'react';
import { send } from '~/utils/res' import { send } from '~/utils/res';
import Link from '~/components/Link' import Link from '~/components/Link';
import TableList from '~/components/TableList' import TableList from '~/components/TableList';
import Select from '~/components/Select' import Select from '~/components/Select';
import Switch from '~/components/Switch' import Switch from '~/components/Switch';
import AddPreAuthKey from './dialogs/new' import AddPreAuthKey from './dialogs/new';
import AuthKeyRow from './components/key' import AuthKeyRow from './components/key';
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return send({ message: 'Unauthorized' }, { return send(
status: 401, { message: 'Unauthorized' },
}) {
status: 401,
},
);
} }
const data = await request.formData() const data = await request.formData();
// Expiring a pre-auth key // Expiring a pre-auth key
if (request.method === 'DELETE') { if (request.method === 'DELETE') {
const key = data.get('key') const key = data.get('key');
const user = data.get('user') const user = data.get('user');
if (!key || !user) { if (!key || !user) {
return send({ message: 'Missing parameters' }, { return send(
status: 400, { message: 'Missing parameters' },
}) {
status: 400,
},
);
} }
await post<{ preAuthKey: PreAuthKey }>( await post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey/expire', 'v1/preauthkey/expire',
session.get('hsApiKey')!, session.get('hsApiKey')!,
{ {
user: user, user: user,
key: key, key: key,
} },
) );
return { message: 'Pre-auth key expired' } return { message: 'Pre-auth key expired' };
} }
// Creating a new pre-auth key // Creating a new pre-auth key
if (request.method === 'POST') { if (request.method === 'POST') {
const user = data.get('user') const user = data.get('user');
const expiry = data.get('expiry') const expiry = data.get('expiry');
const reusable = data.get('reusable') const reusable = data.get('reusable');
const ephemeral = data.get('ephemeral') const ephemeral = data.get('ephemeral');
if (!user || !expiry || !reusable || !ephemeral) { if (!user || !expiry || !reusable || !ephemeral) {
return send({ message: 'Missing parameters' }, { return send(
status: 400, { message: 'Missing parameters' },
}) {
status: 400,
},
);
} }
// Extract the first "word" from expiry which is the day number // Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number // Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(' ')[0]) const day = Number(expiry.toString().split(' ')[0]);
const date = new Date() const date = new Date();
date.setDate(date.getDate() + day) date.setDate(date.getDate() + day);
const key = await post<{ preAuthKey: PreAuthKey }>( const key = await post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey', 'v1/preauthkey',
session.get('hsApiKey')!, session.get('hsApiKey')!,
{ {
user: user, user: user,
ephemeral: ephemeral === 'on', ephemeral: ephemeral === 'on',
reusable: reusable === 'on', reusable: reusable === 'on',
expiration: date.toISOString(), expiration: date.toISOString(),
aclTags: [], // TODO aclTags: [], // TODO
} },
) );
return { message: 'Pre-auth key created', key } return { message: 'Pre-auth key created', key };
} }
} }
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext() const context = await loadContext();
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
const users = await pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!) const users = await pull<{ users: User[] }>(
'v1/user',
session.get('hsApiKey')!,
);
const preAuthKeys = await Promise.all(users.users.map(user => { const preAuthKeys = await Promise.all(
const qp = new URLSearchParams() users.users.map((user) => {
qp.set('user', user.name) const qp = new URLSearchParams();
qp.set('user', user.name);
return pull<{ preAuthKeys: PreAuthKey[] }>( return pull<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`, `v1/preauthkey?${qp.toString()}`,
session.get('hsApiKey')! session.get('hsApiKey')!,
) );
})) }),
);
return { return {
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys), keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys),
users: users.users, users: users.users,
server: context.headscalePublicUrl ?? context.headscaleUrl, server: context.headscalePublicUrl ?? context.headscaleUrl,
} };
} }
export default function Page() { export default function Page() {
const { keys, users, server } = useLoaderData<typeof loader>() const { keys, users, server } = useLoaderData<typeof loader>();
const [user, setUser] = useState('All') const [user, setUser] = useState('All');
const [status, setStatus] = useState('Active') const [status, setStatus] = useState('Active');
useLiveData({ interval: 3000 }) useLiveData({ interval: 3000 });
const filteredKeys = keys.filter(key => { const filteredKeys = keys.filter((key) => {
if (user !== 'All' && key.user !== user) { if (user !== 'All' && key.user !== user) {
return false return false;
} }
if (status !== 'All') { if (status !== 'All') {
const now = new Date() const now = new Date();
const expiry = new Date(key.expiration) const expiry = new Date(key.expiration);
if (status === 'Active') { if (status === 'Active') {
return !(expiry < now) && !key.used return !(expiry < now) && !key.used;
} }
if (status === 'Used/Expired') { if (status === 'Used/Expired') {
return key.used || expiry < now return key.used || expiry < now;
} }
if (status === 'Reusable') { if (status === 'Reusable') {
return key.reusable return key.reusable;
} }
if (status === 'Ephemeral') { if (status === 'Ephemeral') {
return key.ephemeral return key.ephemeral;
} }
} }
return true return true;
}) });
return ( return (
<div className='flex flex-col w-2/3'> <div className="flex flex-col w-2/3">
<p className="mb-8 text-md"> <p className="mb-8 text-md">
<RemixLink <RemixLink to="/settings" className="font-medium">
to="/settings"
className="font-medium"
>
Settings Settings
</RemixLink> </RemixLink>
<span className="mx-2"> <span className="mx-2">/</span> Pre-Auth Keys
/
</span>
{' '}
Pre-Auth Keys
</p> </p>
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1> <h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4"> <p className="text-gray-700 dark:text-gray-300 mb-4">
Headscale fully supports pre-authentication keys in order to Headscale fully supports pre-authentication keys in order to easily add
easily add devices to your Tailnet. devices to your Tailnet. To learn more about using pre-authentication
To learn more about using pre-authentication keys, visit the keys, visit the{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1085/auth-keys/" to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation" name="Tailscale Auth Keys documentation"
@ -182,7 +188,7 @@ export default function Page() {
state={[user, setUser]} state={[user, setUser]}
> >
<Select.Item id="All">All</Select.Item> <Select.Item id="All">All</Select.Item>
{users.map(user => ( {users.map((user) => (
<Select.Item key={user.id} id={user.name}> <Select.Item key={user.id} id={user.name}>
{user.name} {user.name}
</Select.Item> </Select.Item>
@ -209,16 +215,16 @@ export default function Page() {
<TableList className="mt-4"> <TableList className="mt-4">
{filteredKeys.length === 0 ? ( {filteredKeys.length === 0 ? (
<TableList.Item> <TableList.Item>
<p className="opacity-50 text-sm mx-auto"> <p className="opacity-50 text-sm mx-auto">No pre-auth keys</p>
No pre-auth keys
</p>
</TableList.Item> </TableList.Item>
) : filteredKeys.map(key => ( ) : (
<TableList.Item key={key.id}> filteredKeys.map((key) => (
<AuthKeyRow authKey={key} server={server} /> <TableList.Item key={key.id}>
</TableList.Item> <AuthKeyRow authKey={key} server={server} />
))} </TableList.Item>
))
)}
</TableList> </TableList>
</div> </div>
) );
} }

View File

@ -1,19 +1,19 @@
import type { PreAuthKey } from '~/types' import type { PreAuthKey } from '~/types';
import { toast } from '~/components/Toaster' import { toast } from '~/components/Toaster';
import Code from '~/components/Code' import Code from '~/components/Code';
import Button from '~/components/Button' import Button from '~/components/Button';
import Attribute from '~/components/Attribute' import Attribute from '~/components/Attribute';
import ExpireKey from '../dialogs/expire' import ExpireKey from '../dialogs/expire';
interface Props { interface Props {
authKey: PreAuthKey authKey: PreAuthKey;
server: string server: string;
} }
export default function AuthKeyRow({ authKey, server }: Props) { export default function AuthKeyRow({ authKey, server }: Props) {
const createdAt = new Date(authKey.createdAt).toLocaleString() const createdAt = new Date(authKey.createdAt).toLocaleString();
const expiration = new Date(authKey.expiration).toLocaleString() const expiration = new Date(authKey.expiration).toLocaleString();
return ( return (
<div className="w-full"> <div className="w-full">
@ -31,25 +31,24 @@ export default function AuthKeyRow({ authKey, server }: Props) {
tailscale up --login-server {server} --authkey {authKey.key} tailscale up --login-server {server} --authkey {authKey.key}
</Code> </Code>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
{authKey.used || new Date(authKey.expiration) < new Date() {authKey.used ||
? undefined new Date(authKey.expiration) < new Date() ? undefined : (
: ( <ExpireKey authKey={authKey} />
<ExpireKey authKey={authKey} /> )}
)}
<Button <Button
variant="light" variant="light"
className="my-4" className="my-4"
onPress={async () => { onPress={async () => {
await navigator.clipboard.writeText( await navigator.clipboard.writeText(
`tailscale up --login-server ${server} --authkey ${authKey.key}` `tailscale up --login-server ${server} --authkey ${authKey.key}`,
) );
toast('Copied command to clipboard') toast('Copied command to clipboard');
}} }}
> >
Copy Tailscale Command Copy Tailscale Command
</Button> </Button>
</div> </div>
</div> </div>
) );
} }

View File

@ -1,45 +1,40 @@
import { useFetcher } from '@remix-run/react' import { useFetcher } from 'react-router';
import type { PreAuthKey } from '~/types' import type { PreAuthKey } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner';
interface Props { interface Props {
authKey: PreAuthKey authKey: PreAuthKey;
} }
export default function ExpireKey({ authKey }: Props) { export default function ExpireKey({ authKey }: Props) {
const fetcher = useFetcher() const fetcher = useFetcher();
return ( return (
<Dialog> <Dialog>
<Dialog.Button className="my-4"> <Dialog.Button className="my-4">Expire Key</Dialog.Button>
Expire Key
</Dialog.Button>
<Dialog.Panel> <Dialog.Panel>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Expire auth key?</Dialog.Title>
Expire auth key? <fetcher.Form
</Dialog.Title> method="DELETE"
<fetcher.Form method="DELETE" onSubmit={e => { onSubmit={(e) => {
fetcher.submit(e.currentTarget) fetcher.submit(e.currentTarget);
close() close();
}}> }}
>
<input type="hidden" name="user" value={authKey.user} /> <input type="hidden" name="user" value={authKey.user} />
<input type="hidden" name="key" value={authKey.key} /> <input type="hidden" name="key" value={authKey.key} />
<Dialog.Text> <Dialog.Text>
Expiring this authentication key will immediately Expiring this authentication key will immediately prevent it
prevent it from being used to authenticate new devices. from being used to authenticate new devices. This action cannot
{' '} be undone.
This action cannot be undone.
</Dialog.Text> </Dialog.Text>
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
@ -52,11 +47,9 @@ export default function ExpireKey({ authKey }: Props) {
)} )}
onPress={close} onPress={close}
> >
{fetcher.state === 'idle' {fetcher.state === 'idle' ? undefined : (
? undefined <Spinner className="w-3 h-3" />
: ( )}
<Spinner className="w-3 h-3" />
)}
Expire Expire
</Dialog.Action> </Dialog.Action>
</div> </div>
@ -65,5 +58,5 @@ export default function ExpireKey({ authKey }: Props) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }

View File

@ -1,50 +1,47 @@
import { RepoForkedIcon } from '@primer/octicons-react' import { RepoForkedIcon } from '@primer/octicons-react';
import { useFetcher } from '@remix-run/react' import { useFetcher } from 'react-router';
import { useState } from 'react' import { useState } from 'react';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField' import TextField from '~/components/TextField';
import NumberField from '~/components/NumberField' import NumberField from '~/components/NumberField';
import Tooltip from '~/components/Tooltip' import Tooltip from '~/components/Tooltip';
import Select from '~/components/Select' import Select from '~/components/Select';
import Switch from '~/components/Switch' import Switch from '~/components/Switch';
import Link from '~/components/Link' import Link from '~/components/Link';
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import { User } from '~/types' import { User } from '~/types';
interface Props { interface Props {
users: User[] users: User[];
} }
// TODO: Tags // TODO: Tags
export default function AddPreAuthKey(data: Props) { export default function AddPreAuthKey(data: Props) {
const fetcher = useFetcher() const fetcher = useFetcher();
const [user, setUser] = useState('') const [user, setUser] = useState('');
const [reusable, setReusable] = useState(false) const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false) const [ephemeral, setEphemeral] = useState(false);
const [aclTags, setAclTags] = useState([]) const [aclTags, setAclTags] = useState([]);
const [expiry, setExpiry] = useState(90) const [expiry, setExpiry] = useState(90);
return ( return (
<Dialog> <Dialog>
<Dialog.Button className="my-4"> <Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
Create pre-auth key
</Dialog.Button>
<Dialog.Panel> <Dialog.Panel>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Generate auth key</Dialog.Title>
Generate auth key <fetcher.Form
</Dialog.Title> method="POST"
<fetcher.Form method="POST" onSubmit={e => { onSubmit={(e) => {
fetcher.submit(e.currentTarget) fetcher.submit(e.currentTarget);
close() close();
}}> }}
<Dialog.Text className="font-semibold"> >
User <Dialog.Text className="font-semibold">User</Dialog.Text>
</Dialog.Text>
<Dialog.Text className="text-sm"> <Dialog.Text className="text-sm">
Attach this key to a user Attach this key to a user
</Dialog.Text> </Dialog.Text>
@ -54,7 +51,7 @@ export default function AddPreAuthKey(data: Props) {
placeholder="Select a user" placeholder="Select a user"
state={[user, setUser]} state={[user, setUser]}
> >
{data.users.map(user => ( {data.users.map((user) => (
<Select.Item key={user.id} id={user.name}> <Select.Item key={user.id} id={user.name}>
{user.name} {user.name}
</Select.Item> </Select.Item>
@ -80,9 +77,7 @@ export default function AddPreAuthKey(data: Props) {
/> />
<div className="flex justify-between items-center mt-6"> <div className="flex justify-between items-center mt-6">
<div> <div>
<Dialog.Text className="font-semibold"> <Dialog.Text className="font-semibold">Reusable</Dialog.Text>
Reusable
</Dialog.Text>
<Dialog.Text className="text-sm"> <Dialog.Text className="text-sm">
Use this key to authenticate more than one device. Use this key to authenticate more than one device.
</Dialog.Text> </Dialog.Text>
@ -91,19 +86,22 @@ export default function AddPreAuthKey(data: Props) {
label="Reusable" label="Reusable"
name="reusable" name="reusable"
defaultSelected={reusable} defaultSelected={reusable}
onChange={() => { setReusable(!reusable) }} onChange={() => {
setReusable(!reusable);
}}
/> />
</div> </div>
<input type="hidden" name="reusable" value={reusable.toString()} /> <input
type="hidden"
name="reusable"
value={reusable.toString()}
/>
<div className="flex justify-between items-center mt-6"> <div className="flex justify-between items-center mt-6">
<div> <div>
<Dialog.Text className="font-semibold"> <Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
Ephemeral
</Dialog.Text>
<Dialog.Text className="text-sm"> <Dialog.Text className="text-sm">
Devices authenticated with this key will Devices authenticated with this key will be automatically
be automatically removed once they go offline. removed once they go offline.{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1111/ephemeral-nodes" to="https://tailscale.com/kb/1111/ephemeral-nodes"
name="Tailscale Ephemeral Nodes Documentation" name="Tailscale Ephemeral Nodes Documentation"
@ -117,16 +115,17 @@ export default function AddPreAuthKey(data: Props) {
name="ephemeral" name="ephemeral"
defaultSelected={ephemeral} defaultSelected={ephemeral}
onChange={() => { onChange={() => {
setEphemeral(!ephemeral) setEphemeral(!ephemeral);
}} }}
/> />
</div> </div>
<input type="hidden" name="ephemeral" value={ephemeral.toString()} /> <input
type="hidden"
name="ephemeral"
value={ephemeral.toString()}
/>
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
@ -134,11 +133,9 @@ export default function AddPreAuthKey(data: Props) {
onPress={close} onPress={close}
isDisabled={!user || !expiry} isDisabled={!user || !expiry}
> >
{fetcher.state === 'idle' {fetcher.state === 'idle' ? undefined : (
? undefined <Spinner className="w-3 h-3" />
: ( )}
<Spinner className="w-3 h-3" />
)}
Generate Generate
</Dialog.Action> </Dialog.Action>
</div> </div>
@ -147,5 +144,5 @@ export default function AddPreAuthKey(data: Props) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }

View File

@ -1,28 +1,26 @@
import Link from '~/components/Link' import Link from '~/components/Link';
import Button from '~/components/Button' import Button from '~/components/Button';
import { Link as RemixLink } from '@remix-run/react' import { Link as RemixLink } from 'react-router';
import { ArrowRightIcon } from '@primer/octicons-react' import { ArrowRightIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
export default function Page() { export default function Page() {
return ( return (
<div className="flex flex-col gap-8 max-w-screen-lg"> <div className="flex flex-col gap-8 max-w-screen-lg">
<div className='flex flex-col w-2/3'> <div className="flex flex-col w-2/3">
<h1 className='text-2xl font-medium mb-4'>Settings</h1> <h1 className="text-2xl font-medium mb-4">Settings</h1>
<p className="text-gray-700 dark:text-gray-300"> <p className="text-gray-700 dark:text-gray-300">
The settings page is still under construction. The settings page is still under construction. As I'm able to add more
As I'm able to add more features, I'll be adding them here. features, I'll be adding them here. If you require any features, feel
If you require any features, feel free to open an issue on free to open an issue on the GitHub repository.
the GitHub repository.
</p> </p>
</div> </div>
<div className='flex flex-col w-2/3'> <div className="flex flex-col w-2/3">
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1> <h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300"> <p className="text-gray-700 dark:text-gray-300">
Headscale fully supports pre-authentication keys in order to Headscale fully supports pre-authentication keys in order to easily
easily add devices to your Tailnet. add devices to your Tailnet. To learn more about using
To learn more about using pre-authentication keys, visit the pre-authentication keys, visit the{' '}
{' '}
<Link <Link
to="https://tailscale.com/kb/1085/auth-keys/" to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation" name="Tailscale Auth Keys documentation"
@ -32,14 +30,16 @@ export default function Page() {
</p> </p>
</div> </div>
<RemixLink to="/settings/auth-keys"> <RemixLink to="/settings/auth-keys">
<span className={cn( <span
'text-lg font-medium', className={cn(
'text-gray-700 dark:text-gray-300', 'text-lg font-medium',
)}> 'text-gray-700 dark:text-gray-300',
)}
>
Manage Auth Keys Manage Auth Keys
<ArrowRightIcon className="w-5 h-5 ml-2" /> <ArrowRightIcon className="w-5 h-5 ml-2" />
</span> </span>
</RemixLink> </RemixLink>
</div> </div>
) );
} }

View File

@ -1,12 +1,12 @@
import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react' import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react';
import Card from '~/components/Card' import Card from '~/components/Card';
import Link from '~/components/Link' import Link from '~/components/Link';
import Add from '../dialogs/add' import Add from '../dialogs/add';
interface Props { interface Props {
readonly magic: string | undefined readonly magic: string | undefined;
} }
export default function Auth({ magic }: Props) { export default function Auth({ magic }: Props) {
@ -15,14 +15,10 @@ export default function Auth({ magic }: Props) {
<div className="flex flex-col md:flex-row"> <div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700"> <div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
<HomeIcon className="w-5 h-5 mb-2" /> <HomeIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1"> <h2 className="font-medium mb-1">Basic Authentication</h2>
Basic Authentication
</h2>
<p className="text-sm text-ui-600 dark:text-ui-300"> <p className="text-sm text-ui-600 dark:text-ui-300">
Users are not managed externally. Users are not managed externally. Using OpenID Connect can create a
Using OpenID Connect can create a better better experience when using Headscale.{' '}
experience when using Headscale.
{' '}
<Link <Link
to="https://headscale.net/stable/ref/oidc" to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation" name="Headscale OIDC Documentation"
@ -33,9 +29,7 @@ export default function Auth({ magic }: Props) {
</div> </div>
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700"> <div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
<PasskeyFillIcon className="w-5 h-5 mb-2" /> <PasskeyFillIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1"> <h2 className="font-medium mb-1">User Management</h2>
User Management
</h2>
<p className="text-sm text-ui-600 dark:text-ui-300"> <p className="text-sm text-ui-600 dark:text-ui-300">
You can add, remove, and rename users here. You can add, remove, and rename users here.
</p> </p>
@ -45,5 +39,5 @@ export default function Auth({ magic }: Props) {
</div> </div>
</div> </div>
</Card> </Card>
) );
} }

View File

@ -1,14 +1,14 @@
import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react' import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react';
import Card from '~/components/Card' import Card from '~/components/Card';
import Link from '~/components/Link' import Link from '~/components/Link';
import { HeadplaneContext } from '~/utils/config/headplane' import { HeadplaneContext } from '~/utils/config/headplane';
import Add from '../dialogs/add' import Add from '../dialogs/add';
interface Props { interface Props {
readonly oidc: NonNullable<HeadplaneContext['oidc']> readonly oidc: NonNullable<HeadplaneContext['oidc']>;
readonly magic: string | undefined readonly magic: string | undefined;
} }
export default function Oidc({ oidc, magic }: Props) { export default function Oidc({ oidc, magic }: Props) {
@ -17,18 +17,14 @@ export default function Oidc({ oidc, magic }: Props) {
<div className="flex flex-col md:flex-row"> <div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700"> <div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
<OrganizationIcon className="w-5 h-5 mb-2" /> <OrganizationIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1"> <h2 className="font-medium mb-1">OpenID Connect</h2>
OpenID Connect
</h2>
<p className="text-sm text-ui-600 dark:text-ui-300"> <p className="text-sm text-ui-600 dark:text-ui-300">
Users are managed through your Users are managed through your{' '}
{' '}
<Link to={oidc.issuer} name="OIDC Provider"> <Link to={oidc.issuer} name="OIDC Provider">
OpenID Connect provider OpenID Connect provider
</Link> </Link>
{'. '} {'. '}
Groups and user information do not automatically sync. Groups and user information do not automatically sync.{' '}
{' '}
<Link <Link
to="https://headscale.net/stable/ref/oidc" to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation" name="Headscale OIDC Documentation"
@ -39,12 +35,10 @@ export default function Oidc({ oidc, magic }: Props) {
</div> </div>
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700"> <div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
<PasskeyFillIcon className="w-5 h-5 mb-2" /> <PasskeyFillIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1"> <h2 className="font-medium mb-1">User Management</h2>
User Management
</h2>
<p className="text-sm text-ui-600 dark:text-ui-300"> <p className="text-sm text-ui-600 dark:text-ui-300">
You can still add users manually, however it is recommended You can still add users manually, however it is recommended that you
that you manage users through your OIDC provider. manage users through your OIDC provider.
</p> </p>
<div className="flex items-center gap-2 mt-4"> <div className="flex items-center gap-2 mt-4">
<Add magic={magic} /> <Add magic={magic} />
@ -52,5 +46,5 @@ export default function Oidc({ oidc, magic }: Props) {
</div> </div>
</div> </div>
</Card> </Card>
) );
} }

View File

@ -1,53 +1,39 @@
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { useState } from 'react' import { useState } from 'react';
import Code from '~/components/Code' import Code from '~/components/Code';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField' import TextField from '~/components/TextField';
interface Props { interface Props {
magic?: string magic?: string;
} }
export default function Add({ magic }: Props) { export default function Add({ magic }: Props) {
const [username, setUsername] = useState('') const [username, setUsername] = useState('');
const submit = useSubmit() const submit = useSubmit();
return ( return (
<Dialog> <Dialog>
<Dialog.Button> <Dialog.Button>Add a new user</Dialog.Button>
Add a new user
</Dialog.Button>
<Dialog.Panel> <Dialog.Panel>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Add a new user</Dialog.Title>
Add a new user
</Dialog.Title>
<Dialog.Text className="mb-8"> <Dialog.Text className="mb-8">
Enter a username to create a new user. Enter a username to create a new user.{' '}
{' '} {magic ? (
{magic <>
? ( Since Magic DNS is enabled, machines will be accessible via{' '}
<> <Code>[machine]. .{magic}</Code>.
Since Magic DNS is enabled, machines will be </>
accessible via ) : undefined}
{' '}
<Code>
[machine].
.
{magic}
</Code>
.
</>
)
: undefined}
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(event) => { onSubmit={(event) => {
submit(event.currentTarget) submit(event.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="create" /> <input type="hidden" name="_method" value="create" />
@ -59,16 +45,10 @@ export default function Add({ magic }: Props) {
className="my-2" className="my-2"
/> />
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action variant="confirm" onPress={close}>
variant="confirm"
onPress={close}
>
Create Create
</Dialog.Action> </Dialog.Action>
</div> </div>
@ -77,5 +57,5 @@ export default function Add({ magic }: Props) {
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
) );
} }

View File

@ -1,18 +1,18 @@
import { XIcon } from '@primer/octicons-react' import { XIcon } from '@primer/octicons-react';
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { useState } from 'react' import { useState } from 'react';
import Button from '~/components/Button' import Button from '~/components/Button';
import Code from '~/components/Code' import Code from '~/components/Code';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
interface Props { interface Props {
username: string username: string;
} }
export default function Remove({ username }: Props) { export default function Remove({ username }: Props) {
const submit = useSubmit() const submit = useSubmit();
const dialogState = useState(false) const dialogState = useState(false);
return ( return (
<> <>
@ -25,41 +25,26 @@ export default function Remove({ username }: Props) {
</Button> </Button>
<Dialog control={dialogState}> <Dialog control={dialogState}>
<Dialog.Panel control={dialogState}> <Dialog.Panel control={dialogState}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Delete {username}?</Dialog.Title>
Delete
{' '}
{username}
?
</Dialog.Title>
<Dialog.Text className="mb-8"> <Dialog.Text className="mb-8">
Are you sure you want to delete Are you sure you want to delete {username}? A deleted user
{' '} cannot be recovered.
{username}
?
{' '}
A deleted user cannot be recovered.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(event) => { onSubmit={(event) => {
submit(event.currentTarget) submit(event.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="delete" /> <input type="hidden" name="_method" value="delete" />
<input type="hidden" name="username" value={username} /> <input type="hidden" name="username" value={username} />
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action variant="confirm" onPress={close}>
variant="confirm"
onPress={close}
>
Delete Delete
</Dialog.Action> </Dialog.Action>
</div> </div>
@ -69,5 +54,5 @@ export default function Remove({ username }: Props) {
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
</> </>
) );
} }

View File

@ -1,20 +1,20 @@
import { PencilIcon } from '@primer/octicons-react' import { PencilIcon } from '@primer/octicons-react';
import { Form, useSubmit } from '@remix-run/react' import { Form, useSubmit } from 'react-router';
import { useState } from 'react' import { useState } from 'react';
import Button from '~/components/Button' import Button from '~/components/Button';
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField' import TextField from '~/components/TextField';
interface Props { interface Props {
username: string username: string;
magic?: string magic?: string;
} }
export default function Rename({ username, magic }: Props) { export default function Rename({ username, magic }: Props) {
const submit = useSubmit() const submit = useSubmit();
const dialogState = useState(false) const dialogState = useState(false);
const [newName, setNewName] = useState(username) const [newName, setNewName] = useState(username);
return ( return (
<> <>
@ -27,23 +27,16 @@ export default function Rename({ username, magic }: Props) {
</Button> </Button>
<Dialog control={dialogState}> <Dialog control={dialogState}>
<Dialog.Panel control={dialogState}> <Dialog.Panel control={dialogState}>
{close => ( {(close) => (
<> <>
<Dialog.Title> <Dialog.Title>Rename {username}?</Dialog.Title>
Rename
{' '}
{username}
?
</Dialog.Title>
<Dialog.Text className="mb-8"> <Dialog.Text className="mb-8">
Enter a new username for Enter a new username for {username}
{' '}
{username}
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
onSubmit={(event) => { onSubmit={(event) => {
submit(event.currentTarget) submit(event.currentTarget);
}} }}
> >
<input type="hidden" name="_method" value="rename" /> <input type="hidden" name="_method" value="rename" />
@ -56,16 +49,10 @@ export default function Rename({ username, magic }: Props) {
className="my-2" className="my-2"
/> />
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action <Dialog.Action variant="cancel" onPress={close}>
variant="cancel"
onPress={close}
>
Cancel Cancel
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action variant="confirm" onPress={close}>
variant="confirm"
onPress={close}
>
Rename Rename
</Dialog.Action> </Dialog.Action>
</div> </div>
@ -75,5 +62,5 @@ export default function Rename({ username, magic }: Props) {
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
</> </>
) );
} }

View File

@ -1,48 +1,48 @@
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core' import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
import { PersonIcon } from '@primer/octicons-react' import { PersonIcon } from '@primer/octicons-react';
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node' import { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useActionData, useLoaderData, useSubmit } from '@remix-run/react' import { useActionData, useLoaderData, useSubmit } from 'react-router';
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only' import { ClientOnly } from 'remix-utils/client-only';
import Attribute from '~/components/Attribute' import Attribute from '~/components/Attribute';
import Card from '~/components/Card' import Card from '~/components/Card';
import StatusCircle from '~/components/StatusCircle' import StatusCircle from '~/components/StatusCircle';
import { toast } from '~/components/Toaster' import { toast } from '~/components/Toaster';
import { Machine, User } from '~/types' import { Machine, User } from '~/types';
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale' import { loadConfig } from '~/utils/config/headscale';
import { del, post, pull } from '~/utils/headscale' import { del, post, pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions';
import { useLiveData } from '~/utils/useLiveData' import { useLiveData } from '~/utils/useLiveData';
import { send } from '~/utils/res' import { send } from '~/utils/res';
import Auth from './components/auth' import Auth from './components/auth';
import Oidc from './components/oidc' import Oidc from './components/oidc';
import Remove from './dialogs/remove' import Remove from './dialogs/remove';
import Rename from './dialogs/rename' import Rename from './dialogs/rename';
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
const [machines, apiUsers] = await Promise.all([ const [machines, apiUsers] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!), pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!), pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
]) ]);
const users = apiUsers.users.map(user => ({ const users = apiUsers.users.map((user) => ({
...user, ...user,
machines: machines.nodes.filter(machine => machine.user.id === user.id), machines: machines.nodes.filter((machine) => machine.user.id === user.id),
})) }));
const context = await loadContext() const context = await loadContext();
let magic: string | undefined let magic: string | undefined;
if (context.config.read) { if (context.config.read) {
const config = await loadConfig() const config = await loadConfig();
if (config.dns.magic_dns) { if (config.dns.magic_dns) {
magic = config.dns.base_domain magic = config.dns.base_domain;
} }
} }
@ -50,125 +50,115 @@ export async function loader({ request }: LoaderFunctionArgs) {
oidc: context.oidc, oidc: context.oidc,
magic, magic,
users, users,
} };
} }
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return send({ message: 'Unauthorized' }, 401) return send({ message: 'Unauthorized' }, 401);
} }
const data = await request.formData() const data = await request.formData();
if (!data.has('_method')) { if (!data.has('_method')) {
return send({ message: 'No method provided' }, 400) return send({ message: 'No method provided' }, 400);
} }
const method = String(data.get('_method')) const method = String(data.get('_method'));
switch (method) { switch (method) {
case 'create': { case 'create': {
if (!data.has('username')) { if (!data.has('username')) {
return send({ message: 'No name provided' }, 400) return send({ message: 'No name provided' }, 400);
} }
const username = String(data.get('username')) const username = String(data.get('username'));
await post('v1/user', session.get('hsApiKey')!, { await post('v1/user', session.get('hsApiKey')!, {
name: username, name: username,
}) });
return { message: `User ${username} created` } return { message: `User ${username} created` };
} }
case 'delete': { case 'delete': {
if (!data.has('username')) { if (!data.has('username')) {
return send({ message: 'No name provided' }, 400) return send({ message: 'No name provided' }, 400);
} }
const username = String(data.get('username')) const username = String(data.get('username'));
await del(`v1/user/${username}`, session.get('hsApiKey')!) await del(`v1/user/${username}`, session.get('hsApiKey')!);
return { message: `User ${username} deleted` } return { message: `User ${username} deleted` };
} }
case 'rename': { case 'rename': {
if (!data.has('old') || !data.has('new')) { if (!data.has('old') || !data.has('new')) {
return send({ message: 'No old or new name provided' }, 400) return send({ message: 'No old or new name provided' }, 400);
} }
const old = String(data.get('old')) const old = String(data.get('old'));
const newName = String(data.get('new')) const newName = String(data.get('new'));
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!) await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!);
return { message: `User ${old} renamed to ${newName}` } return { message: `User ${old} renamed to ${newName}` };
} }
case 'move': { case 'move': {
if (!data.has('id') || !data.has('to') || !data.has('name')) { if (!data.has('id') || !data.has('to') || !data.has('name')) {
return send({ message: 'No ID or destination provided' }, 400) return send({ message: 'No ID or destination provided' }, 400);
} }
const id = String(data.get('id')) const id = String(data.get('id'));
const to = String(data.get('to')) const to = String(data.get('to'));
const name = String(data.get('name')) const name = String(data.get('name'));
try { try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!) await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!);
return { message: `Moved ${name} to ${to}` } return { message: `Moved ${name} to ${to}` };
} catch { } catch {
return send({ message: `Failed to move ${name} to ${to}` }, 500) return send({ message: `Failed to move ${name} to ${to}` }, 500);
} }
} }
default: { default: {
return send({ message: 'Invalid method' }, 400) return send({ message: 'Invalid method' }, 400);
} }
} }
} }
export default function Page() { export default function Page() {
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>();
const [users, setUsers] = useState(data.users) const [users, setUsers] = useState(data.users);
const actionData = useActionData<typeof action>() const actionData = useActionData<typeof action>();
useLiveData({ interval: 3000 }) useLiveData({ interval: 3000 });
useEffect(() => { useEffect(() => {
if (!actionData) { if (!actionData) {
return return;
} }
toast(actionData.message) toast(actionData.message);
if (actionData.message.startsWith('Failed')) { if (actionData.message.startsWith('Failed')) {
setUsers(data.users) setUsers(data.users);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionData]) }, [actionData]);
useEffect(() => { useEffect(() => {
setUsers(data.users) setUsers(data.users);
}, [data.users]) }, [data.users]);
return ( return (
<> <>
<h1 className="text-2xl font-medium mb-1.5"> <h1 className="text-2xl font-medium mb-1.5">Users</h1>
Users
</h1>
<p className="mb-8 text-md"> <p className="mb-8 text-md">
Manage the users in your network and their permissions. Manage the users in your network and their permissions. Tip: You can
Tip: You can drag machines between users to change ownership. drag machines between users to change ownership.
</p> </p>
{data.oidc {data.oidc ? (
? ( <Oidc oidc={data.oidc} magic={data.magic} />
<Oidc ) : (
oidc={data.oidc} <Auth magic={data.magic} />
magic={data.magic} )}
/> <ClientOnly fallback={<Users users={users} />}>
)
: (
<Auth magic={data.magic} />
)}
<ClientOnly fallback={
<Users users={users} />
}
>
{() => ( {() => (
<InteractiveUsers <InteractiveUsers
users={users} users={users}
@ -178,92 +168,86 @@ export default function Page() {
)} )}
</ClientOnly> </ClientOnly>
</> </>
) );
} }
type UserMachine = User & { machines: Machine[] } type UserMachine = User & { machines: Machine[] };
interface UserProps { interface UserProps {
users: UserMachine[] users: UserMachine[];
setUsers?: (users: UserMachine[]) => void setUsers?: (users: UserMachine[]) => void;
magic?: string magic?: string;
} }
function Users({ users, magic }: UserProps) { function Users({ users, magic }: UserProps) {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map(user => ( {users.map((user) => (
<UserCard <UserCard key={user.id} user={user} magic={magic} />
key={user.id}
user={user}
magic={magic}
/>
))} ))}
</div> </div>
) );
} }
function InteractiveUsers({ users, setUsers, magic }: UserProps) { function InteractiveUsers({ users, setUsers, magic }: UserProps) {
const submit = useSubmit() const submit = useSubmit();
return ( return (
<DndContext onDragEnd={(event) => { <DndContext
const { over, active } = event onDragEnd={(event) => {
if (!over) { const { over, active } = event;
return if (!over) {
} return;
}
// Update the UI optimistically // Update the UI optimistically
const newUsers = new Array<UserMachine>() const newUsers = new Array<UserMachine>();
const reference = active.data as DataRef<Machine> const reference = active.data as DataRef<Machine>;
if (!reference.current) { if (!reference.current) {
return return;
} }
// Ignore if the user is unchanged // Ignore if the user is unchanged
if (reference.current.user.name === over.id) { if (reference.current.user.name === over.id) {
return return;
} }
for (const user of users) { for (const user of users) {
newUsers.push({ newUsers.push({
...user, ...user,
machines: over.id === user.name machines:
? [...user.machines, reference.current] over.id === user.name
: user.machines.filter(m => m.id !== active.id), ? [...user.machines, reference.current]
}) : user.machines.filter((m) => m.id !== active.id),
} });
}
setUsers?.(newUsers) setUsers?.(newUsers);
const data = new FormData() const data = new FormData();
data.append('_method', 'move') data.append('_method', 'move');
data.append('id', active.id.toString()) data.append('id', active.id.toString());
data.append('to', over.id.toString()) data.append('to', over.id.toString());
data.append('name', reference.current.givenName) data.append('name', reference.current.givenName);
submit(data, { submit(data, {
method: 'POST', method: 'POST',
}) });
}} }}
> >
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map(user => ( {users.map((user) => (
<UserCard <UserCard key={user.id} user={user} magic={magic} />
key={user.id}
user={user}
magic={magic}
/>
))} ))}
</div> </div>
</DndContext> </DndContext>
) );
} }
function MachineChip({ machine }: { readonly machine: Machine }) { function MachineChip({ machine }: { readonly machine: Machine }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({ const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: machine.id, id: machine.id,
data: machine, data: machine,
}) });
return ( return (
<div <div
@ -287,18 +271,18 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
value={machine.ipAddresses[0]} value={machine.ipAddresses[0]}
/> />
</div> </div>
) );
} }
interface CardProps { interface CardProps {
user: UserMachine user: UserMachine;
magic?: string magic?: string;
} }
function UserCard({ user, magic }: CardProps) { function UserCard({ user, magic }: CardProps) {
const { isOver, setNodeRef } = useDroppable({ const { isOver, setNodeRef } = useDroppable({
id: user.name, id: user.name,
}) });
return ( return (
<div ref={setNodeRef}> <div ref={setNodeRef}>
@ -312,25 +296,21 @@ function UserCard({ user, magic }: CardProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<PersonIcon className="w-6 h-6" /> <PersonIcon className="w-6 h-6" />
<span className="text-lg font-mono"> <span className="text-lg font-mono">{user.name}</span>
{user.name}
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Rename username={user.name} magic={magic} /> <Rename username={user.name} magic={magic} />
{user.machines.length === 0 {user.machines.length === 0 ? (
? ( <Remove username={user.name} />
<Remove username={user.name} /> ) : undefined}
)
: undefined}
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
{user.machines.map(machine => ( {user.machines.map((machine) => (
<MachineChip key={machine.id} machine={machine} /> <MachineChip key={machine.id} machine={machine} />
))} ))}
</div> </div>
</Card> </Card>
</div> </div>
) );
} }

View File

@ -1,23 +1,26 @@
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane';
import { HeadscaleError, pull } from '~/utils/headscale' import { HeadscaleError, pull } from '~/utils/headscale';
import { data } from '@remix-run/node' import { data } from 'react-router';
import log from '~/utils/log' import log from '~/utils/log';
export async function loader() { export async function loader() {
const context = await loadContext() const context = await loadContext();
try { try {
// Doesn't matter, we just need a 401 // Doesn't matter, we just need a 401
await pull('v1/', 'wrongkey') await pull('v1/', 'wrongkey');
} catch (e) { } catch (e) {
if (!(e instanceof HeadscaleError)) { if (!(e instanceof HeadscaleError)) {
log.debug('Healthz', 'Headscale is not reachable') log.debug('Healthz', 'Headscale is not reachable');
return data({ return data(
status: 'NOT OK', {
error: e.message status: 'NOT OK',
}, { status: 500 }) error: e.message,
},
{ status: 500 },
);
} }
} }
return { status: 'OK' } return { status: 'OK' };
} }

View File

@ -1,5 +1,5 @@
import { redirect } from '@remix-run/node' import { redirect } from 'react-router';
export async function loader() { export async function loader() {
return redirect('/machines') return redirect('/machines');
} }

View File

@ -3,9 +3,9 @@
@tailwind utilities; @tailwind utilities;
@supports (scrollbar-gutter: stable) { @supports (scrollbar-gutter: stable) {
html { html {
scrollbar-gutter: stable scrollbar-gutter: stable;
} }
} }
.cm-merge-theme { .cm-merge-theme {
@ -25,6 +25,7 @@
} }
/* Weirdest class name characters but ok */ /* Weirdest class name characters but ok */
.cm-mergeView .ͼ1 .cm-scroller, .cm-mergeView .ͼ1 { .cm-mergeView .ͼ1 .cm-scroller,
.cm-mergeView .ͼ1 {
height: 100% !important; height: 100% !important;
} }

View File

@ -4,4 +4,4 @@ export type Key = {
expiration: string; expiration: string;
createdAt: Date; createdAt: Date;
lastSeen: Date; lastSeen: Date;
} };

View File

@ -1,28 +1,29 @@
import type { User } from './User' import type { User } from './User';
export interface Machine { export interface Machine {
id: string id: string;
machineKey: string machineKey: string;
nodeKey: string nodeKey: string;
discoKey: string discoKey: string;
ipAddresses: string[] ipAddresses: string[];
name: string name: string;
user: User user: User;
lastSeen: string lastSeen: string;
expiry: string expiry: string;
preAuthKey?: unknown // TODO preAuthKey?: unknown; // TODO
createdAt: string createdAt: string;
registerMethod: 'REGISTER_METHOD_UNSPECIFIED' registerMethod:
| 'REGISTER_METHOD_AUTH_KEY' | 'REGISTER_METHOD_UNSPECIFIED'
| 'REGISTER_METHOD_CLI' | 'REGISTER_METHOD_AUTH_KEY'
| 'REGISTER_METHOD_OIDC' | 'REGISTER_METHOD_CLI'
| 'REGISTER_METHOD_OIDC';
forcedTags: string[] forcedTags: string[];
invalidTags: string[] invalidTags: string[];
validTags: string[] validTags: string[];
givenName: string givenName: string;
online: boolean online: boolean;
} }

View File

@ -1,11 +1,11 @@
export interface PreAuthKey { export interface PreAuthKey {
id: string id: string;
key: string key: string;
user: string user: string;
reusable: boolean reusable: boolean;
ephemeral: boolean ephemeral: boolean;
used: boolean used: boolean;
expiration: string expiration: string;
createdAt: string createdAt: string;
aclTags: string[] aclTags: string[];
} }

View File

@ -1,13 +1,13 @@
import type { Machine } from './Machine' import type { Machine } from './Machine';
export interface Route { export interface Route {
id: string id: string;
node: Machine node: Machine;
prefix: string prefix: string;
advertised: boolean advertised: boolean;
enabled: boolean enabled: boolean;
isPrimary: boolean isPrimary: boolean;
createdAt: string createdAt: string;
updatedAt: string updatedAt: string;
deletedAt: string deletedAt: string;
} }

View File

@ -1,5 +1,5 @@
export interface User { export interface User {
id: string id: string;
name: string name: string;
createdAt: string createdAt: string;
} }

View File

@ -1,5 +1,5 @@
export * from './Key' export * from './Key';
export * from './Machine' export * from './Machine';
export * from './Route' export * from './Route';
export * from './User' export * from './User';
export * from './PreAuthKey' export * from './PreAuthKey';

View File

@ -1,4 +1,4 @@
import { type ClassValue, clsx } from 'clsx' import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)) export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));

View File

@ -3,82 +3,82 @@
// //
// Around the codebase, this is referred to as the context // Around the codebase, this is referred to as the context
import { access, constants, readFile, writeFile } from 'node:fs/promises' import { access, constants, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path' import { resolve } from 'node:path';
import { parse } from 'yaml' import { parse } from 'yaml';
import { IntegrationFactory, loadIntegration } from '~/integration' import { IntegrationFactory, loadIntegration } from '~/integration';
import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale' import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale';
import { testOidc } from '~/utils/oidc' import { testOidc } from '~/utils/oidc';
import log from '~/utils/log' import log from '~/utils/log';
export interface HeadplaneContext { export interface HeadplaneContext {
debug: boolean debug: boolean;
headscaleUrl: string headscaleUrl: string;
headscalePublicUrl?: string headscalePublicUrl?: string;
cookieSecret: string cookieSecret: string;
integration: IntegrationFactory | undefined integration: IntegrationFactory | undefined;
config: { config: {
read: boolean read: boolean;
write: boolean write: boolean;
} };
oidc?: { oidc?: {
issuer: string issuer: string;
client: string client: string;
secret: string secret: string;
rootKey: string rootKey: string;
method: string method: string;
disableKeyLogin: boolean disableKeyLogin: boolean;
} };
} }
let context: HeadplaneContext | undefined let context: HeadplaneContext | undefined;
export async function loadContext(): Promise<HeadplaneContext> { export async function loadContext(): Promise<HeadplaneContext> {
if (context) { if (context) {
return context return context;
} }
const envFile = process.env.LOAD_ENV_FILE === 'true' const envFile = process.env.LOAD_ENV_FILE === 'true';
if (envFile) { if (envFile) {
log.info('CTXT', 'Loading environment variables from .env') log.info('CTXT', 'Loading environment variables from .env');
await import('dotenv/config') await import('dotenv/config');
} }
const debug = process.env.DEBUG === 'true' const debug = process.env.DEBUG === 'true';
if (debug) { if (debug) {
log.info('CTXT', 'Debug mode is enabled! Logs will spam a lot.') log.info('CTXT', 'Debug mode is enabled! Logs will spam a lot.');
log.info('CTXT', 'Please disable debug mode in production.') log.info('CTXT', 'Please disable debug mode in production.');
} }
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml') const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml');
const { config, contextData } = await checkConfig(path) const { config, contextData } = await checkConfig(path);
let headscaleUrl = process.env.HEADSCALE_URL let headscaleUrl = process.env.HEADSCALE_URL;
let headscalePublicUrl = process.env.HEADSCALE_PUBLIC_URL let headscalePublicUrl = process.env.HEADSCALE_PUBLIC_URL;
if (!headscaleUrl && !config) { if (!headscaleUrl && !config) {
throw new Error('HEADSCALE_URL not set') throw new Error('HEADSCALE_URL not set');
} }
if (config) { if (config) {
headscaleUrl = headscaleUrl ?? config.server_url headscaleUrl = headscaleUrl ?? config.server_url;
if (!headscalePublicUrl) { if (!headscalePublicUrl) {
// Fallback to the config value if the env var is not set // Fallback to the config value if the env var is not set
headscalePublicUrl = config.public_url headscalePublicUrl = config.public_url;
} }
} }
if (!headscaleUrl) { if (!headscaleUrl) {
throw new Error('Missing server_url in headscale config') throw new Error('Missing server_url in headscale config');
} }
const cookieSecret = process.env.COOKIE_SECRET const cookieSecret = process.env.COOKIE_SECRET;
if (!cookieSecret) { if (!cookieSecret) {
throw new Error('COOKIE_SECRET not set') throw new Error('COOKIE_SECRET not set');
} }
context = { context = {
@ -89,48 +89,51 @@ export async function loadContext(): Promise<HeadplaneContext> {
integration: await loadIntegration(), integration: await loadIntegration(),
config: contextData, config: contextData,
oidc: await checkOidc(config), oidc: await checkOidc(config),
} };
log.info('CTXT', 'Starting Headplane with Context') log.info('CTXT', 'Starting Headplane with Context');
log.info('CTXT', 'HEADSCALE_URL: %s', headscaleUrl) log.info('CTXT', 'HEADSCALE_URL: %s', headscaleUrl);
if (headscalePublicUrl) { if (headscalePublicUrl) {
log.info('CTXT', 'HEADSCALE_PUBLIC_URL: %s', headscalePublicUrl) log.info('CTXT', 'HEADSCALE_PUBLIC_URL: %s', headscalePublicUrl);
} }
log.info('CTXT', 'Integration: %s', context.integration?.name ?? 'None') log.info('CTXT', 'Integration: %s', context.integration?.name ?? 'None');
log.info('CTXT', 'Config: %s', contextData.read log.info(
? `Found ${contextData.write ? '' : '(Read Only)'}` 'CTXT',
: 'Unavailable', 'Config: %s',
) contextData.read
? `Found ${contextData.write ? '' : '(Read Only)'}`
: 'Unavailable',
);
log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable') log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable');
return context return context;
} }
async function checkConfig(path: string) { async function checkConfig(path: string) {
log.debug('CTXT', 'Checking config at %s', path) log.debug('CTXT', 'Checking config at %s', path);
let config: HeadscaleConfig | undefined let config: HeadscaleConfig | undefined;
try { try {
config = await loadConfig(path) config = await loadConfig(path);
} catch { } catch {
log.debug('CTXT', 'Config at %s failed to load', path) log.debug('CTXT', 'Config at %s failed to load', path);
return { return {
config: undefined, config: undefined,
contextData: { contextData: {
read: false, read: false,
write: false, write: false,
}, },
} };
} }
let write = false let write = false;
try { try {
log.debug('CTXT', 'Checking write access to %s', path) log.debug('CTXT', 'Checking write access to %s', path);
await access(path, constants.W_OK) await access(path, constants.W_OK);
write = true write = true;
} catch { } catch {
log.debug('CTXT', 'No write access to %s', path) log.debug('CTXT', 'No write access to %s', path);
} }
return { return {
@ -139,49 +142,52 @@ async function checkConfig(path: string) {
read: true, read: true,
write, write,
}, },
} };
} }
async function checkOidc(config?: HeadscaleConfig) { async function checkOidc(config?: HeadscaleConfig) {
log.debug('CTXT', 'Checking OIDC configuration') log.debug('CTXT', 'Checking OIDC configuration');
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true' const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true';
log.debug('CTXT', 'API Key Login Enabled: %s', !disableKeyLogin) log.debug('CTXT', 'API Key Login Enabled: %s', !disableKeyLogin);
log.debug('CTXT', 'Checking ROOT_API_KEY and falling back to API_KEY') log.debug('CTXT', 'Checking ROOT_API_KEY and falling back to API_KEY');
const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY;
if (!rootKey) { if (!rootKey) {
throw new Error('ROOT_API_KEY or API_KEY not set') throw new Error('ROOT_API_KEY or API_KEY not set');
} }
let issuer = process.env.OIDC_ISSUER let issuer = process.env.OIDC_ISSUER;
let client = process.env.OIDC_CLIENT_ID let client = process.env.OIDC_CLIENT_ID;
let secret = process.env.OIDC_CLIENT_SECRET let secret = process.env.OIDC_CLIENT_SECRET;
let method = process.env.OIDC_CLIENT_SECRET_METHOD ?? 'client_secret_basic' let method = process.env.OIDC_CLIENT_SECRET_METHOD ?? 'client_secret_basic';
let skip = process.env.OIDC_SKIP_CONFIG_VALIDATION === 'true' let skip = process.env.OIDC_SKIP_CONFIG_VALIDATION === 'true';
log.debug('CTXT', 'Checking OIDC environment variables') log.debug('CTXT', 'Checking OIDC environment variables');
log.debug('CTXT', 'Issuer: %s', issuer) log.debug('CTXT', 'Issuer: %s', issuer);
log.debug('CTXT', 'Client: %s', client) log.debug('CTXT', 'Client: %s', client);
if ( if (
(issuer ?? client ?? secret) (issuer ?? client ?? secret) &&
&& !(issuer && client && secret) !(issuer && client && secret) &&
&& !config !config
) { ) {
throw new Error('OIDC environment variables are incomplete') throw new Error('OIDC environment variables are incomplete');
} }
if (issuer && client && secret) { if (issuer && client && secret) {
if (!skip) { if (!skip) {
log.debug('CTXT', 'Validating OIDC configuration from environment variables') log.debug(
const result = await testOidc(issuer, client, secret) 'CTXT',
'Validating OIDC configuration from environment variables',
);
const result = await testOidc(issuer, client, secret);
if (!result) { if (!result) {
return return;
} }
} else { } else {
log.debug('CTXT', 'OIDC_SKIP_CONFIG_VALIDATION is set') log.debug('CTXT', 'OIDC_SKIP_CONFIG_VALIDATION is set');
log.debug('CTXT', 'Skipping OIDC configuration validation') log.debug('CTXT', 'Skipping OIDC configuration validation');
} }
return { return {
@ -191,51 +197,53 @@ async function checkOidc(config?: HeadscaleConfig) {
method, method,
rootKey, rootKey,
disableKeyLogin, disableKeyLogin,
} };
} }
if ((!issuer || !client || !secret) && config) { if ((!issuer || !client || !secret) && config) {
issuer = config.oidc?.issuer issuer = config.oidc?.issuer;
client = config.oidc?.client_id client = config.oidc?.client_id;
secret = config.oidc?.client_secret secret = config.oidc?.client_secret;
if (!secret && config.oidc?.client_secret_path) { if (!secret && config.oidc?.client_secret_path) {
log.debug('CTXT', 'Trying to read OIDC client secret from %s', config.oidc.client_secret_path) log.debug(
'CTXT',
'Trying to read OIDC client secret from %s',
config.oidc.client_secret_path,
);
try { try {
const data = await readFile( const data = await readFile(config.oidc.client_secret_path, 'utf8');
config.oidc.client_secret_path,
'utf8',
)
if (data && data.length > 0) { if (data && data.length > 0) {
secret = data.trim() secret = data.trim();
} }
} catch { } catch {
log.error('CTXT', 'Failed to read OIDC client secret from %s', config.oidc.client_secret_path) log.error(
'CTXT',
'Failed to read OIDC client secret from %s',
config.oidc.client_secret_path,
);
} }
} }
} }
if ( if ((issuer ?? client ?? secret) && !(issuer && client && secret)) {
(issuer ?? client ?? secret) throw new Error('OIDC configuration is incomplete');
&& !(issuer && client && secret)
) {
throw new Error('OIDC configuration is incomplete')
} }
if (!issuer || !client || !secret) { if (!issuer || !client || !secret) {
return return;
} }
if (config.oidc.only_start_if_oidc_is_available) { if (config.oidc.only_start_if_oidc_is_available) {
log.debug('CTXT', 'Validating OIDC configuration from headscale config') log.debug('CTXT', 'Validating OIDC configuration from headscale config');
const result = await testOidc(issuer, client, secret) const result = await testOidc(issuer, client, secret);
if (!result) { if (!result) {
return return;
} }
} else { } else {
log.debug('CTXT', 'OIDC validation is disabled in headscale config') log.debug('CTXT', 'OIDC validation is disabled in headscale config');
log.debug('CTXT', 'Skipping OIDC configuration validation') log.debug('CTXT', 'Skipping OIDC configuration validation');
} }
return { return {
@ -245,5 +253,5 @@ async function checkOidc(config?: HeadscaleConfig) {
rootKey, rootKey,
method, method,
disableKeyLogin, disableKeyLogin,
} };
} }

View File

@ -6,29 +6,31 @@
// Around the codebase, this is referred to as the config // Around the codebase, this is referred to as the config
// Refer to this file on juanfont/headscale for the default values: // Refer to this file on juanfont/headscale for the default values:
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/config.go // https://github.com/juanfont/headscale/blob/main/hscontrol/types/config.go
import { readFile, writeFile } from 'node:fs/promises' import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path' import { resolve } from 'node:path';
import { type Document, parseDocument } from 'yaml' import { type Document, parseDocument } from 'yaml';
import { z } from 'zod' import { z } from 'zod';
import log from '~/utils/log' import log from '~/utils/log';
const goBool = z const goBool = z
.union([z.boolean(), z.literal('true'), z.literal('false')]) .union([z.boolean(), z.literal('true'), z.literal('false')])
.transform((value) => { .transform((value) => {
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
return value return value;
} }
return value === 'true' return value === 'true';
}) });
const goDuration = z.union([z.literal(0), z.string()]) const goDuration = z.union([z.literal(0), z.string()]);
const HeadscaleConfig = z.object({ const HeadscaleConfig = z.object({
tls_letsencrypt_cache_dir: z.string().default('/var/www/cache'), tls_letsencrypt_cache_dir: z.string().default('/var/www/cache'),
tls_letsencrypt_challenge_type: z.enum(['HTTP-01', 'TLS-ALPN-01']).default('HTTP-01'), tls_letsencrypt_challenge_type: z
.enum(['HTTP-01', 'TLS-ALPN-01'])
.default('HTTP-01'),
tls_letsencrypt_hostname: z.string().optional(), tls_letsencrypt_hostname: z.string().optional(),
tls_letsencrypt_listen: z.string().optional(), tls_letsencrypt_listen: z.string().optional(),
@ -52,35 +54,45 @@ const HeadscaleConfig = z.object({
unix_socket: z.string().default('/var/run/headscale/headscale.sock'), unix_socket: z.string().default('/var/run/headscale/headscale.sock'),
unix_socket_permission: z.string().default('0o770'), unix_socket_permission: z.string().default('0o770'),
policy: z.object({ policy: z
mode: z.enum(['file', 'database']).default('file'), .object({
path: z.string().optional(), mode: z.enum(['file', 'database']).default('file'),
}).optional(), path: z.string().optional(),
})
.optional(),
tuning: z.object({ tuning: z
batch_change_delay: goDuration.default('800ms'), .object({
node_mapsession_buffered_chan_size: z.number().default(30), batch_change_delay: goDuration.default('800ms'),
}).optional(), node_mapsession_buffered_chan_size: z.number().default(30),
})
.optional(),
noise: z.object({ noise: z.object({
private_key_path: z.string(), private_key_path: z.string(),
}), }),
log: z.object({ log: z
level: z.string().default('info'), .object({
format: z.enum(['text', 'json']).default('text'), level: z.string().default('info'),
}).default({ level: 'info', format: 'text' }), format: z.enum(['text', 'json']).default('text'),
})
.default({ level: 'info', format: 'text' }),
logtail: z.object({ logtail: z
enabled: goBool.default(false), .object({
}).default({ enabled: false }), enabled: goBool.default(false),
})
.default({ enabled: false }),
cli: z.object({ cli: z
address: z.string().optional(), .object({
api_key: z.string().optional(), address: z.string().optional(),
timeout: goDuration.default('10s'), api_key: z.string().optional(),
insecure: goBool.default(false), timeout: goDuration.default('10s'),
}).optional(), insecure: goBool.default(false),
})
.optional(),
prefixes: z.object({ prefixes: z.object({
allocation: z.enum(['sequential', 'random']).default('sequential'), allocation: z.enum(['sequential', 'random']).default('sequential'),
@ -91,34 +103,42 @@ const HeadscaleConfig = z.object({
dns: z.object({ dns: z.object({
magic_dns: goBool.default(true), magic_dns: goBool.default(true),
base_domain: z.string().default('headscale.net'), base_domain: z.string().default('headscale.net'),
nameservers: z.object({ nameservers: z
global: z.array(z.string()).default([]), .object({
split: z.record(z.array(z.string())).default({}), global: z.array(z.string()).default([]),
}).default({ global: [], split: {} }), split: z.record(z.array(z.string())).default({}),
})
.default({ global: [], split: {} }),
search_domains: z.array(z.string()).default([]), search_domains: z.array(z.string()).default([]),
extra_records: z.array(z.object({ extra_records: z
name: z.string(), .array(
type: z.literal('A'), z.object({
value: z.string(), name: z.string(),
})).default([]), type: z.literal('A'),
value: z.string(),
}),
)
.default([]),
use_username_in_magic_dns: goBool.default(false), use_username_in_magic_dns: goBool.default(false),
}), }),
oidc: z.object({ oidc: z
only_start_if_oidc_is_available: goBool.default(false), .object({
issuer: z.string().optional(), only_start_if_oidc_is_available: goBool.default(false),
client_id: z.string().optional(), issuer: z.string().optional(),
client_secret: z.string().optional(), client_id: z.string().optional(),
client_secret_path: z.string().optional(), client_secret: z.string().optional(),
scope: z.array(z.string()).default(['openid', 'profile', 'email']), client_secret_path: z.string().optional(),
extra_params: z.record(z.unknown()).default({}), scope: z.array(z.string()).default(['openid', 'profile', 'email']),
allowed_domains: z.array(z.string()).optional(), extra_params: z.record(z.unknown()).default({}),
allowed_users: z.array(z.string()).optional(), allowed_domains: z.array(z.string()).optional(),
allowed_groups: z.array(z.string()).optional(), allowed_users: z.array(z.string()).optional(),
strip_email_domain: goBool.default(false), allowed_groups: z.array(z.string()).optional(),
expiry: goDuration.default('180d'), strip_email_domain: goBool.default(false),
use_expiry_from_token: goBool.default(false), expiry: goDuration.default('180d'),
}).optional(), use_expiry_from_token: goBool.default(false),
})
.optional(),
database: z.union([ database: z.union([
z.object({ z.object({
@ -171,33 +191,35 @@ const HeadscaleConfig = z.object({
auto_update_enabled: goBool.default(true), auto_update_enabled: goBool.default(true),
update_frequency: goDuration.default('24h'), update_frequency: goDuration.default('24h'),
}), }),
}) });
export type HeadscaleConfig = z.infer<typeof HeadscaleConfig> export type HeadscaleConfig = z.infer<typeof HeadscaleConfig>;
export let configYaml: Document | undefined export let configYaml: Document | undefined;
export let config: HeadscaleConfig | undefined export let config: HeadscaleConfig | undefined;
export async function loadConfig(path?: string) { export async function loadConfig(path?: string) {
if (config) { if (config) {
return config return config;
} }
if (!path) { if (!path) {
throw new Error('Path is required to lazy load config') throw new Error('Path is required to lazy load config');
} }
log.debug('CFGX', 'Loading Headscale configuration from %s', path) log.debug('CFGX', 'Loading Headscale configuration from %s', path);
const data = await readFile(path, 'utf8') const data = await readFile(path, 'utf8');
configYaml = parseDocument(data) configYaml = parseDocument(data);
if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') { if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') {
log.debug('CFGX', 'Loaded Headscale configuration in non-strict mode') log.debug('CFGX', 'Loaded Headscale configuration in non-strict mode');
const loaded = configYaml.toJSON() as Record<string, unknown> const loaded = configYaml.toJSON() as Record<string, unknown>;
config = { config = {
...loaded, ...loaded,
tls_letsencrypt_cache_dir: loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache', tls_letsencrypt_cache_dir:
tls_letsencrypt_challenge_type: loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01', loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache',
tls_letsencrypt_challenge_type:
loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01',
grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443', grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443',
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false, grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
randomize_client_port: loaded.randomize_client_port ?? false, randomize_client_port: loaded.randomize_client_port ?? false,
@ -238,54 +260,54 @@ export async function loadConfig(path?: string) {
magic_dns: false, magic_dns: false,
base_domain: 'headscale.net', base_domain: 'headscale.net',
}, },
} as HeadscaleConfig } as HeadscaleConfig;
log.warn('CFGX', 'Loaded Headscale configuration in non-strict mode') log.warn('CFGX', 'Loaded Headscale configuration in non-strict mode');
log.warn('CFGX', 'By using this mode you forfeit GitHub issue support') log.warn('CFGX', 'By using this mode you forfeit GitHub issue support');
log.warn('CFGX', 'This is very dangerous and comes with a few caveats:') log.warn('CFGX', 'This is very dangerous and comes with a few caveats:');
log.warn('CFGX', 'Headplane could very easily crash') log.warn('CFGX', 'Headplane could very easily crash');
log.warn('CFGX', 'Headplane could break your Headscale installation') log.warn('CFGX', 'Headplane could break your Headscale installation');
log.warn('CFGX', 'The UI could throw random errors/show incorrect data') log.warn('CFGX', 'The UI could throw random errors/show incorrect data');
log.warn('CFGX', '') log.warn('CFGX', '');
return config return config;
} }
try { try {
log.debug('CFGX', 'Attempting to parse Headscale configuration') log.debug('CFGX', 'Attempting to parse Headscale configuration');
config = await HeadscaleConfig.parseAsync(configYaml.toJSON()) config = await HeadscaleConfig.parseAsync(configYaml.toJSON());
} catch (error) { } catch (error) {
log.debug('CFGX', 'Failed to load Headscale configuration') log.debug('CFGX', 'Failed to load Headscale configuration');
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
log.error('CFGX', 'Recieved invalid configuration file') log.error('CFGX', 'Recieved invalid configuration file');
log.error('CFGX', 'The following schema issues were found:') log.error('CFGX', 'The following schema issues were found:');
for (const issue of error.issues) { for (const issue of error.issues) {
const path = issue.path.map(String).join('.') const path = issue.path.map(String).join('.');
const message = issue.message const message = issue.message;
log.error('CFGX', ` '${path}': ${message}`) log.error('CFGX', ` '${path}': ${message}`);
} }
log.error('CFGX', '') log.error('CFGX', '');
log.error('CFGX', 'Resolve these issues and try again.') log.error('CFGX', 'Resolve these issues and try again.');
log.error('CFGX', 'Headplane will operate without the config') log.error('CFGX', 'Headplane will operate without the config');
log.error('CFGX', '') log.error('CFGX', '');
} }
throw error throw error;
} }
return config return config;
} }
// This is so obscenely dangerous, please have a check around it // This is so obscenely dangerous, please have a check around it
export async function patchConfig(partial: Record<string, unknown>) { export async function patchConfig(partial: Record<string, unknown>) {
if (!configYaml || !config) { if (!configYaml || !config) {
throw new Error('Config not loaded') throw new Error('Config not loaded');
} }
log.debug('CFGX', 'Patching Headscale configuration') log.debug('CFGX', 'Patching Headscale configuration');
for (const [key, value] of Object.entries(partial)) { for (const [key, value] of Object.entries(partial)) {
log.debug('CFGX', 'Patching %s with %s', key, value) log.debug('CFGX', 'Patching %s with %s', key, value);
// If the key is something like `test.bar."foo.bar"`, then we treat // If the key is something like `test.bar."foo.bar"`, then we treat
// the foo.bar as a single key, and not as two keys, so that needs // the foo.bar as a single key, and not as two keys, so that needs
// to be split correctly. // to be split correctly.
@ -294,39 +316,40 @@ export async function patchConfig(partial: Record<string, unknown>) {
// the next character is a quote, and if it is, we skip until the next // the next character is a quote, and if it is, we skip until the next
// quote, and then we skip the next character, which should be a dot. // quote, and then we skip the next character, which should be a dot.
// If it's not a quote, we split it. // If it's not a quote, we split it.
const path = [] const path = [];
let temp = '' let temp = '';
let inQuote = false let inQuote = false;
for (const element of key) { for (const element of key) {
if (element === '"') { if (element === '"') {
inQuote = !inQuote inQuote = !inQuote;
} }
if (element === '.' && !inQuote) { if (element === '.' && !inQuote) {
path.push(temp.replaceAll('"', '')) path.push(temp.replaceAll('"', ''));
temp = '' temp = '';
continue continue;
} }
temp += element temp += element;
} }
// Push the remaining element // Push the remaining element
path.push(temp.replaceAll('"', '')) path.push(temp.replaceAll('"', ''));
if (value === null) { if (value === null) {
configYaml.deleteIn(path) configYaml.deleteIn(path);
continue continue;
} }
configYaml.setIn(path, value) configYaml.setIn(path, value);
} }
config = process.env.HEADSCALE_CONFIG_UNSTRICT === 'true' config =
? configYaml.toJSON() as HeadscaleConfig process.env.HEADSCALE_CONFIG_UNSTRICT === 'true'
: (await HeadscaleConfig.parseAsync(configYaml.toJSON())) ? (configYaml.toJSON() as HeadscaleConfig)
: await HeadscaleConfig.parseAsync(configYaml.toJSON());
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml') const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml');
log.debug('CFGX', 'Writing patched configuration to %s', path) log.debug('CFGX', 'Writing patched configuration to %s', path);
await writeFile(path, configYaml.toString(), 'utf8') await writeFile(path, configYaml.toString(), 'utf8');
} }

View File

@ -1,116 +1,138 @@
import { loadContext } from './config/headplane' import { loadContext } from './config/headplane';
import log from './log' import log from './log';
export class HeadscaleError extends Error { export class HeadscaleError extends Error {
status: number status: number;
constructor(message: string, status: number) { constructor(message: string, status: number) {
super(message) super(message);
this.name = 'HeadscaleError' this.name = 'HeadscaleError';
this.status = status this.status = status;
} }
} }
export class FatalError extends Error { export class FatalError extends Error {
constructor() { constructor() {
super('The Headscale server is not accessible or the supplied API key is invalid') super(
this.name = 'FatalError' 'The Headscale server is not accessible or the supplied API key is invalid',
);
this.name = 'FatalError';
} }
} }
export async function pull<T>(url: string, key: string) { export async function pull<T>(url: string, key: string) {
if (!key || key === 'undefined' || key.length === 0) { if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?') throw new Error('Missing API key, could this be a cookie setting issue?');
} }
const context = await loadContext() const context = await loadContext();
const prefix = context.headscaleUrl const prefix = context.headscaleUrl;
log.debug('APIC', 'GET %s', `${prefix}/api/${url}`) log.debug('APIC', 'GET %s', `${prefix}/api/${url}`);
const response = await fetch(`${prefix}/api/${url}`, { const response = await fetch(`${prefix}/api/${url}`, {
headers: { headers: {
Authorization: `Bearer ${key}`, Authorization: `Bearer ${key}`,
}, },
}) });
if (!response.ok) { if (!response.ok) {
log.debug('APIC', 'GET %s failed with status %d', `${prefix}/api/${url}`, response.status) log.debug(
throw new HeadscaleError(await response.text(), response.status) 'APIC',
'GET %s failed with status %d',
`${prefix}/api/${url}`,
response.status,
);
throw new HeadscaleError(await response.text(), response.status);
} }
return (response.json() as Promise<T>) return response.json() as Promise<T>;
} }
export async function post<T>(url: string, key: string, body?: unknown) { export async function post<T>(url: string, key: string, body?: unknown) {
if (!key || key === 'undefined' || key.length === 0) { if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?') throw new Error('Missing API key, could this be a cookie setting issue?');
} }
const context = await loadContext() const context = await loadContext();
const prefix = context.headscaleUrl const prefix = context.headscaleUrl;
log.debug('APIC', 'POST %s', `${prefix}/api/${url}`) log.debug('APIC', 'POST %s', `${prefix}/api/${url}`);
const response = await fetch(`${prefix}/api/${url}`, { const response = await fetch(`${prefix}/api/${url}`, {
method: 'POST', method: 'POST',
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
headers: { headers: {
Authorization: `Bearer ${key}`, Authorization: `Bearer ${key}`,
}, },
}) });
if (!response.ok) { if (!response.ok) {
log.debug('APIC', 'POST %s failed with status %d', `${prefix}/api/${url}`, response.status) log.debug(
throw new HeadscaleError(await response.text(), response.status) 'APIC',
'POST %s failed with status %d',
`${prefix}/api/${url}`,
response.status,
);
throw new HeadscaleError(await response.text(), response.status);
} }
return (response.json() as Promise<T>) return response.json() as Promise<T>;
} }
export async function put<T>(url: string, key: string, body?: unknown) { export async function put<T>(url: string, key: string, body?: unknown) {
if (!key || key === 'undefined' || key.length === 0) { if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?') throw new Error('Missing API key, could this be a cookie setting issue?');
} }
const context = await loadContext() const context = await loadContext();
const prefix = context.headscaleUrl const prefix = context.headscaleUrl;
log.debug('APIC', 'PUT %s', `${prefix}/api/${url}`) log.debug('APIC', 'PUT %s', `${prefix}/api/${url}`);
const response = await fetch(`${prefix}/api/${url}`, { const response = await fetch(`${prefix}/api/${url}`, {
method: 'PUT', method: 'PUT',
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
headers: { headers: {
Authorization: `Bearer ${key}`, Authorization: `Bearer ${key}`,
}, },
}) });
if (!response.ok) { if (!response.ok) {
log.debug('APIC', 'PUT %s failed with status %d', `${prefix}/api/${url}`, response.status) log.debug(
throw new HeadscaleError(await response.text(), response.status) 'APIC',
'PUT %s failed with status %d',
`${prefix}/api/${url}`,
response.status,
);
throw new HeadscaleError(await response.text(), response.status);
} }
return (response.json() as Promise<T>) return response.json() as Promise<T>;
} }
export async function del<T>(url: string, key: string) { export async function del<T>(url: string, key: string) {
if (!key || key === 'undefined' || key.length === 0) { if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?') throw new Error('Missing API key, could this be a cookie setting issue?');
} }
const context = await loadContext() const context = await loadContext();
const prefix = context.headscaleUrl const prefix = context.headscaleUrl;
log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`) log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`);
const response = await fetch(`${prefix}/api/${url}`, { const response = await fetch(`${prefix}/api/${url}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
Authorization: `Bearer ${key}`, Authorization: `Bearer ${key}`,
}, },
}) });
if (!response.ok) { if (!response.ok) {
log.debug('APIC', 'DELETE %s failed with status %d', `${prefix}/api/${url}`, response.status) log.debug(
throw new HeadscaleError(await response.text(), response.status) 'APIC',
'DELETE %s failed with status %d',
`${prefix}/api/${url}`,
response.status,
);
throw new HeadscaleError(await response.text(), response.status);
} }
return (response.json() as Promise<T>) return response.json() as Promise<T>;
} }

View File

@ -1,22 +1,22 @@
export default { export default {
info: (category: string, message: string, ...args: unknown[]) => { info: (category: string, message: string, ...args: unknown[]) => {
defaultLog('INFO', category, message, ...args) defaultLog('INFO', category, message, ...args);
}, },
warn: (category: string, message: string, ...args: unknown[]) => { warn: (category: string, message: string, ...args: unknown[]) => {
defaultLog('WARN', category, message, ...args) defaultLog('WARN', category, message, ...args);
}, },
error: (category: string, message: string, ...args: unknown[]) => { error: (category: string, message: string, ...args: unknown[]) => {
defaultLog('ERRO', category, message, ...args) defaultLog('ERRO', category, message, ...args);
}, },
debug: (category: string, message: string, ...args: unknown[]) => { debug: (category: string, message: string, ...args: unknown[]) => {
if (process.env.DEBUG === 'true') { if (process.env.DEBUG === 'true') {
defaultLog('DEBG', category, message, ...args) defaultLog('DEBG', category, message, ...args);
} }
} },
} };
function defaultLog( function defaultLog(
level: string, level: string,
@ -24,6 +24,6 @@ function defaultLog(
message: string, message: string,
...args: unknown[] ...args: unknown[]
) { ) {
const date = new Date().toISOString() const date = new Date().toISOString();
console.log(`${date} (${level}) [${category}] ${message}`, ...args) console.log(`${date} (${level}) [${category}] ${message}`, ...args);
} }

View File

@ -1,4 +1,4 @@
import { redirect } from '@remix-run/node' import { redirect } from 'react-router';
import { import {
authorizationCodeGrantRequest, authorizationCodeGrantRequest,
calculatePKCECodeChallenge, calculatePKCECodeChallenge,
@ -13,99 +13,99 @@ import {
processAuthorizationCodeOpenIDResponse, processAuthorizationCodeOpenIDResponse,
processDiscoveryResponse, processDiscoveryResponse,
validateAuthResponse, validateAuthResponse,
} from 'oauth4webapi' } from 'oauth4webapi';
import { post } from '~/utils/headscale' import { post } from '~/utils/headscale';
import { commitSession, getSession } from '~/utils/sessions' import { commitSession, getSession } from '~/utils/sessions';
import log from '~/utils/log' import log from '~/utils/log';
import { HeadplaneContext } from './config/headplane' import { HeadplaneContext } from './config/headplane';
type OidcConfig = NonNullable<HeadplaneContext['oidc']> type OidcConfig = NonNullable<HeadplaneContext['oidc']>;
export async function startOidc(oidc: OidcConfig, req: Request) { export async function startOidc(oidc: OidcConfig, req: Request) {
const session = await getSession(req.headers.get('Cookie')) const session = await getSession(req.headers.get('Cookie'));
if (session.has('hsApiKey')) { if (session.has('hsApiKey')) {
return redirect('/', { return redirect('/', {
status: 302, status: 302,
headers: { headers: {
'Set-Cookie': await commitSession(session), 'Set-Cookie': await commitSession(session),
}, },
}) });
} }
const issuerUrl = new URL(oidc.issuer) const issuerUrl = new URL(oidc.issuer);
const oidcClient = { const oidcClient = {
client_id: oidc.client, client_id: oidc.client,
token_endpoint_auth_method: oidc.method, token_endpoint_auth_method: oidc.method,
} satisfies Client } satisfies Client;
const response = await discoveryRequest(issuerUrl) const response = await discoveryRequest(issuerUrl);
const processed = await processDiscoveryResponse(issuerUrl, response) const processed = await processDiscoveryResponse(issuerUrl, response);
if (!processed.authorization_endpoint) { if (!processed.authorization_endpoint) {
throw new Error('No authorization endpoint found on the OIDC provider') throw new Error('No authorization endpoint found on the OIDC provider');
} }
const state = generateRandomState() const state = generateRandomState();
const nonce = generateRandomNonce() const nonce = generateRandomNonce();
const verifier = generateRandomCodeVerifier() const verifier = generateRandomCodeVerifier();
const challenge = await calculatePKCECodeChallenge(verifier) const challenge = await calculatePKCECodeChallenge(verifier);
const callback = new URL('/admin/oidc/callback', req.url) const callback = new URL('/admin/oidc/callback', req.url);
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:' callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:';
callback.host = req.headers.get('Host') ?? '' callback.host = req.headers.get('Host') ?? '';
const authUrl = new URL(processed.authorization_endpoint) const authUrl = new URL(processed.authorization_endpoint);
authUrl.searchParams.set('client_id', oidcClient.client_id) authUrl.searchParams.set('client_id', oidcClient.client_id);
authUrl.searchParams.set('response_type', 'code') authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('redirect_uri', callback.href) authUrl.searchParams.set('redirect_uri', callback.href);
authUrl.searchParams.set('scope', 'openid profile email') authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('code_challenge', challenge) authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256') authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', state) authUrl.searchParams.set('state', state);
authUrl.searchParams.set('nonce', nonce) authUrl.searchParams.set('nonce', nonce);
session.set('authState', state) session.set('authState', state);
session.set('authNonce', nonce) session.set('authNonce', nonce);
session.set('authVerifier', verifier) session.set('authVerifier', verifier);
return redirect(authUrl.href, { return redirect(authUrl.href, {
status: 302, status: 302,
headers: { headers: {
'Set-Cookie': await commitSession(session), 'Set-Cookie': await commitSession(session),
}, },
}) });
} }
export async function finishOidc(oidc: OidcConfig, req: Request) { export async function finishOidc(oidc: OidcConfig, req: Request) {
const session = await getSession(req.headers.get('Cookie')) const session = await getSession(req.headers.get('Cookie'));
if (session.has('hsApiKey')) { if (session.has('hsApiKey')) {
return redirect('/', { return redirect('/', {
status: 302, status: 302,
headers: { headers: {
'Set-Cookie': await commitSession(session), 'Set-Cookie': await commitSession(session),
}, },
}) });
} }
const issuerUrl = new URL(oidc.issuer) const issuerUrl = new URL(oidc.issuer);
const oidcClient = { const oidcClient = {
client_id: oidc.client, client_id: oidc.client,
client_secret: oidc.secret, client_secret: oidc.secret,
token_endpoint_auth_method: oidc.method, token_endpoint_auth_method: oidc.method,
} satisfies Client } satisfies Client;
const response = await discoveryRequest(issuerUrl) const response = await discoveryRequest(issuerUrl);
const processed = await processDiscoveryResponse(issuerUrl, response) const processed = await processDiscoveryResponse(issuerUrl, response);
if (!processed.authorization_endpoint) { if (!processed.authorization_endpoint) {
throw new Error('No authorization endpoint found on the OIDC provider') throw new Error('No authorization endpoint found on the OIDC provider');
} }
const state = session.get('authState') const state = session.get('authState');
const nonce = session.get('authNonce') const nonce = session.get('authNonce');
const verifier = session.get('authVerifier') const verifier = session.get('authVerifier');
if (!state || !nonce || !verifier) { if (!state || !nonce || !verifier) {
throw new Error('No OIDC state found in the session') throw new Error('No OIDC state found in the session');
} }
const parameters = validateAuthResponse( const parameters = validateAuthResponse(
@ -113,15 +113,15 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
oidcClient, oidcClient,
new URL(req.url), new URL(req.url),
state, state,
) );
if (isOAuth2Error(parameters)) { if (isOAuth2Error(parameters)) {
throw new Error('Invalid response from the OIDC provider') throw new Error('Invalid response from the OIDC provider');
} }
const callback = new URL('/admin/oidc/callback', req.url) const callback = new URL('/admin/oidc/callback', req.url);
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:' callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:';
callback.host = req.headers.get('Host') ?? '' callback.host = req.headers.get('Host') ?? '';
const tokenResponse = await authorizationCodeGrantRequest( const tokenResponse = await authorizationCodeGrantRequest(
processed, processed,
@ -129,11 +129,11 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
parameters, parameters,
callback.href, callback.href,
verifier, verifier,
) );
const challenges = parseWwwAuthenticateChallenges(tokenResponse) const challenges = parseWwwAuthenticateChallenges(tokenResponse);
if (challenges) { if (challenges) {
throw new Error('Recieved a challenge from the OIDC provider') throw new Error('Recieved a challenge from the OIDC provider');
} }
const result = await processAuthorizationCodeOpenIDResponse( const result = await processAuthorizationCodeOpenIDResponse(
@ -141,14 +141,14 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
oidcClient, oidcClient,
tokenResponse, tokenResponse,
nonce, nonce,
) );
if (isOAuth2Error(result)) { if (isOAuth2Error(result)) {
throw new Error('Invalid response from the OIDC provider') throw new Error('Invalid response from the OIDC provider');
} }
const claims = getValidatedIdTokenClaims(result) const claims = getValidatedIdTokenClaims(result);
const expDate = new Date(claims.exp * 1000).toISOString() const expDate = new Date(claims.exp * 1000).toISOString();
const keyResponse = await post<{ apiKey: string }>( const keyResponse = await post<{ apiKey: string }>(
'v1/apikey', 'v1/apikey',
@ -156,19 +156,19 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
{ {
expiration: expDate, expiration: expDate,
}, },
) );
session.set('hsApiKey', keyResponse.apiKey) session.set('hsApiKey', keyResponse.apiKey);
session.set('user', { session.set('user', {
name: claims.name ? String(claims.name) : 'Anonymous', name: claims.name ? String(claims.name) : 'Anonymous',
email: claims.email ? String(claims.email) : undefined, email: claims.email ? String(claims.email) : undefined,
}) });
return redirect('/machines', { return redirect('/machines', {
headers: { headers: {
'Set-Cookie': await commitSession(session), 'Set-Cookie': await commitSession(session),
}, },
}) });
} }
// Runs at application startup to validate the OIDC configuration // Runs at application startup to validate the OIDC configuration
@ -177,23 +177,27 @@ export async function testOidc(issuer: string, client: string, secret: string) {
client_id: client, client_id: client,
client_secret: secret, client_secret: secret,
token_endpoint_auth_method: 'client_secret_post', token_endpoint_auth_method: 'client_secret_post',
} satisfies Client } satisfies Client;
const issuerUrl = new URL(issuer) const issuerUrl = new URL(issuer);
try { try {
log.debug('OIDC', 'Checking OIDC well-known endpoint') log.debug('OIDC', 'Checking OIDC well-known endpoint');
const response = await discoveryRequest(issuerUrl) const response = await discoveryRequest(issuerUrl);
const processed = await processDiscoveryResponse(issuerUrl, response) const processed = await processDiscoveryResponse(issuerUrl, response);
if (!processed.authorization_endpoint) { if (!processed.authorization_endpoint) {
log.debug('OIDC', 'No authorization endpoint found on the OIDC provider') log.debug('OIDC', 'No authorization endpoint found on the OIDC provider');
return false return false;
} }
log.debug('OIDC', 'Found auth endpoint: %s', processed.authorization_endpoint) log.debug(
return true 'OIDC',
'Found auth endpoint: %s',
processed.authorization_endpoint,
);
return true;
} catch (e) { } catch (e) {
log.debug('OIDC', 'Validation failed: %s', e.message) log.debug('OIDC', 'Validation failed: %s', e.message);
return false return false;
} }
} }

View File

@ -1,5 +1,5 @@
import { data } from '@remix-run/node' import { data } from 'react-router';
export function send<T>(payload: T, init?: number | ResponseInit) { export function send<T>(payload: T, init?: number | ResponseInit) {
return data(payload, init) return data(payload, init);
} }

View File

@ -1,4 +1,4 @@
import { createCookieSessionStorage } from '@remix-run/node' // Or cloudflare/deno import { createCookieSessionStorage } from 'react-router'; // Or cloudflare/deno
export type SessionData = { export type SessionData = {
hsApiKey: string; hsApiKey: string;
@ -9,18 +9,14 @@ export type SessionData = {
name: string; name: string;
email?: string; email?: string;
}; };
} };
type SessionFlashData = { type SessionFlashData = {
error: string; error: string;
} };
export const { export const { getSession, commitSession, destroySession } =
getSession, createCookieSessionStorage<SessionData, SessionFlashData>({
commitSession,
destroySession
} = createCookieSessionStorage<SessionData, SessionFlashData>(
{
cookie: { cookie: {
name: 'hp_sess', name: 'hp_sess',
httpOnly: true, httpOnly: true,
@ -29,7 +25,5 @@ export const {
sameSite: 'lax', sameSite: 'lax',
secrets: [process.env.COOKIE_SECRET!], secrets: [process.env.COOKIE_SECRET!],
secure: process.env.COOKIE_SECURE !== 'false', secure: process.env.COOKIE_SECURE !== 'false',
} },
} });
)

View File

@ -1,35 +1,35 @@
import { useRevalidator } from '@remix-run/react' import { useRevalidator } from 'react-router';
import { useEffect } from 'react' import { useEffect } from 'react';
import { useInterval } from 'usehooks-ts' import { useInterval } from 'usehooks-ts';
interface Props { interface Props {
interval: number interval: number;
} }
export function useLiveData({ interval }: Props) { export function useLiveData({ interval }: Props) {
const revalidator = useRevalidator() const revalidator = useRevalidator();
// Handle normal stale-while-revalidate behavior // Handle normal stale-while-revalidate behavior
useInterval(() => { useInterval(() => {
if (revalidator.state === 'idle') { if (revalidator.state === 'idle') {
revalidator.revalidate() revalidator.revalidate();
} }
}, interval) }, interval);
useEffect(() => { useEffect(() => {
const handler = () => { const handler = () => {
if (revalidator.state === 'idle') { if (revalidator.state === 'idle') {
revalidator.revalidate() revalidator.revalidate();
} }
} };
window.addEventListener('online', handler) window.addEventListener('online', handler);
document.addEventListener('focus', handler) document.addEventListener('focus', handler);
return () => { return () => {
window.removeEventListener('online', handler) window.removeEventListener('online', handler);
document.removeEventListener('focus', handler) document.removeEventListener('focus', handler);
} };
}, [revalidator]) }, [revalidator]);
return revalidator return revalidator;
} }

View File

@ -1,47 +1,47 @@
// This is a "side-effect" but we want a lifecycle cache map of // This is a "side-effect" but we want a lifecycle cache map of
// peer statuses to prevent unnecessary fetches to the agent. // peer statuses to prevent unnecessary fetches to the agent.
import type { LoaderFunctionArgs } from 'remix' import type { LoaderFunctionArgs } from 'react-router';
type Context = LoaderFunctionArgs['context'] type Context = LoaderFunctionArgs['context'];
const cache: { [nodeID: string]: unknown } = {} const cache: { [nodeID: string]: unknown } = {};
export async function queryWS(context: Context, nodeIDs: string[]) { export async function queryWS(context: Context, nodeIDs: string[]) {
const ws = context.ws const ws = context.ws;
const firstClient = ws.clients.values().next().value const firstClient = ws.clients.values().next().value;
if (!firstClient) { if (!firstClient) {
return cache return cache;
} }
const cached = nodeIDs.map((nodeID) => { const cached = nodeIDs.map((nodeID) => {
const cached = cache[nodeID] const cached = cache[nodeID];
if (cached) { if (cached) {
return cached return cached;
} }
}) });
// We only need to query the nodes that are not cached // We only need to query the nodes that are not cached
const uncached = nodeIDs.filter((nodeID) => !cached.includes(nodeID)) const uncached = nodeIDs.filter((nodeID) => !cached.includes(nodeID));
if (uncached.length === 0) { if (uncached.length === 0) {
return cache return cache;
} }
firstClient.send(JSON.stringify({ NodeIDs: uncached })) firstClient.send(JSON.stringify({ NodeIDs: uncached }));
await new Promise((resolve) => { await new Promise<void>((resolve) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
resolve() resolve();
}, 3000) }, 3000);
firstClient.on('message', (message) => { firstClient.on('message', (message: string) => {
const data = JSON.parse(message.toString()) const data = JSON.parse(message.toString());
if (Object.keys(data).length === 0) { if (Object.keys(data).length === 0) {
resolve() resolve();
} }
for (const [nodeID, status] of Object.entries(data)) { for (const [nodeID, status] of Object.entries(data)) {
cache[nodeID] = status cache[nodeID] = status;
} }
}) });
}) });
return cache return cache;
} }

33
biome.json Normal file
View File

@ -0,0 +1,33 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": []
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 80,
"lineEnding": "lf"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double"
}
}
}

View File

@ -17,9 +17,8 @@
"@kubernetes/client-node": "^0.22.3", "@kubernetes/client-node": "^0.22.3",
"@primer/octicons-react": "^19.13.0", "@primer/octicons-react": "^19.13.0",
"@react-aria/toast": "3.0.0-beta.18", "@react-aria/toast": "3.0.0-beta.18",
"@react-router/node": "^7.0.0",
"@react-stately/toast": "3.0.0-beta.7", "@react-stately/toast": "3.0.0-beta.7",
"@remix-run/node": "^2.15.0",
"@remix-run/react": "^2.15.0",
"@shopify/lang-jsonc": "^1.0.0", "@shopify/lang-jsonc": "^1.0.0",
"@types/react": "^19.0.1", "@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1", "@types/react-dom": "^19.0.1",
@ -35,6 +34,7 @@
"react-codemirror-merge": "^4.23.6", "react-codemirror-merge": "^4.23.6",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-error-boundary": "^4.1.2", "react-error-boundary": "^4.1.2",
"react-router": "^7.0.0",
"remix-utils": "^7.7.0", "remix-utils": "^7.7.0",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-react-aria-components": "^1.2.0", "tailwindcss-react-aria-components": "^1.2.0",
@ -46,8 +46,8 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/preset-typescript": "^7.26.0", "@babel/preset-typescript": "^7.26.0",
"@remix-run/dev": "^2.15.0", "@biomejs/biome": "^1.9.4",
"@remix-run/route-config": "^2.15.0", "@react-router/dev": "^7.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124", "babel-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
"postcss": "^8.4.49", "postcss": "^8.4.49",

3402
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };

5
react-router.config.ts Normal file
View File

@ -0,0 +1,5 @@
import type { Config } from '@react-router/dev/config';
export default {
basename: '/admin',
} satisfies Config;

View File

@ -1,12 +1,12 @@
// This is a polyglot entrypoint for Headplane when running in development // This is a polyglot entrypoint for Headplane when running in development
// It does some silly magic to load the vite config, set some globals that // It does some silly magic to load the vite config, set some globals that
// are required to function, and create the vite development server. // are required to function, and create the vite development server.
import { createServer } from 'vite' import { createServer } from 'vite';
import { env, exit } from 'node:process' import { env, exit } from 'node:process';
import { log } from './utils.mjs' import { log } from './utils.mjs';
log('DEVX', 'INFO', 'This script is only intended for development') log('DEVX', 'INFO', 'This script is only intended for development');
env.NODE_ENV = 'development' env.NODE_ENV = 'development';
// The production entrypoint uses a global called "PREFIX" to determine // The production entrypoint uses a global called "PREFIX" to determine
// what route the application is being served at and a global called "BUILD" // what route the application is being served at and a global called "BUILD"
@ -14,20 +14,20 @@ env.NODE_ENV = 'development'
// the development server can function correctly and override the production // the development server can function correctly and override the production
// values. // values.
log('DEVX', 'INFO', 'Creating Vite Development Server') log('DEVX', 'INFO', 'Creating Vite Development Server');
const server = await createServer({ const server = await createServer({
server: { server: {
middlewareMode: true middlewareMode: true,
} },
}) });
// This entrypoint is defined in the documentation to load the server // This entrypoint is defined in the documentation to load the server
const build = await server.ssrLoadModule('virtual:remix/server-build') const build = await server.ssrLoadModule('virtual:react-router/server-build');
// We already handle this logic in the Vite configuration // We already handle this logic in the Vite configuration
global.PREFIX = server.config.base.slice(0, -1) global.PREFIX = server.config.base.slice(0, -1);
global.BUILD = build global.BUILD = build;
global.MODE = 'development' global.MODE = 'development';
global.MIDDLEWARE = server.middlewares global.MIDDLEWARE = server.middlewares;
await import('./prod.mjs') await import('./prod.mjs');

View File

@ -4,69 +4,70 @@
// we can only need this file and a Node.js installation to run the server. // we can only need this file and a Node.js installation to run the server.
// PREFIX is defined globally, see vite.config.ts // PREFIX is defined globally, see vite.config.ts
import { access, constants } from 'node:fs/promises' import { access, constants } from 'node:fs/promises';
import { createReadStream, existsSync, statSync } from 'node:fs' import { createReadStream, existsSync, statSync } from 'node:fs';
import { createServer } from 'node:http' import { createServer } from 'node:http';
import { join, resolve } from 'node:path' import { join, resolve } from 'node:path';
import { env } from 'node:process' import { env } from 'node:process';
import { log } from './utils.mjs' import { log } from './utils.mjs';
import { getWss, registerWss } from './ws.mjs' import { getWss, registerWss } from './ws.mjs';
log('SRVX', 'INFO', `Running with Node.js ${process.versions.node}`) log('SRVX', 'INFO', `Running with Node.js ${process.versions.node}`);
try { try {
await access('./node_modules/@remix-run', constants.F_OK | constants.R_OK) await access('./node_modules/@react-router', constants.F_OK | constants.R_OK);
log('SRVX', 'INFO', 'Found node_modules dependencies') log('SRVX', 'INFO', 'Found node_modules dependencies');
} catch (error) { } catch (error) {
log('SRVX', 'ERROR', 'No node_modules found. Please run `pnpm install`') log('SRVX', 'ERROR', 'No node_modules found. Please run `pnpm install`');
log('SRVX', 'ERROR', error) log('SRVX', 'ERROR', error);
process.exit(1) process.exit(1);
} }
const { const {
createRequestHandler: remixRequestHandler, createRequestHandler: remixRequestHandler,
createReadableStreamFromReadable, createReadableStreamFromReadable,
writeReadableStreamToWritable writeReadableStreamToWritable,
} = await import('@remix-run/node') } = await import('@react-router/node');
const { default: mime } = await import('mime') const { default: mime } = await import('mime');
const port = env.PORT || 3000 const port = env.PORT || 3000;
const host = env.HOST || '0.0.0.0' const host = env.HOST || '0.0.0.0';
const buildPath = env.BUILD_PATH || './build' const buildPath = env.BUILD_PATH || './build';
const baseDir = resolve(join(buildPath, 'client')) const baseDir = resolve(join(buildPath, 'client'));
if (!global.BUILD) { if (!global.BUILD) {
try { try {
await access(join(buildPath, 'server'), constants.F_OK | constants.R_OK) await access(join(buildPath, 'server'), constants.F_OK | constants.R_OK);
log('SRVX', 'INFO', 'Found build directory') log('SRVX', 'INFO', 'Found build directory');
} catch (error) { } catch (error) {
const date = new Date().toISOString() const date = new Date().toISOString();
log('SRVX', 'ERROR', 'No build found. Please run `pnpm build`') log('SRVX', 'ERROR', 'No build found. Please run `pnpm build`');
log('SRVX', 'ERROR', error) log('SRVX', 'ERROR', error);
process.exit(1) process.exit(1);
} }
// Because this is a dynamic import without an easily discernable path // Because this is a dynamic import without an easily discernable path
// we gain the "deoptimization" we want so that Vite doesn't bundle this // we gain the "deoptimization" we want so that Vite doesn't bundle this
const build = await import(resolve(join(buildPath, 'server', 'index.js'))) const build = await import(resolve(join(buildPath, 'server', 'index.js')));
global.BUILD = build global.BUILD = build;
global.MODE = 'production' global.MODE = 'production';
} }
const handler = remixRequestHandler(global.BUILD, global.MODE) console.log(remixRequestHandler);
const handler = remixRequestHandler(global.BUILD, global.MODE);
const http = createServer(async (req, res) => { const http = createServer(async (req, res) => {
const url = new URL(`http://${req.headers.host}${req.url}`) const url = new URL(`http://${req.headers.host}${req.url}`);
if (global.MIDDLEWARE) { if (global.MIDDLEWARE) {
await new Promise(resolve => { await new Promise((resolve) => {
global.MIDDLEWARE(req, res, resolve) global.MIDDLEWARE(req, res, resolve);
}) });
} }
if (!url.pathname.startsWith(PREFIX)) { if (!url.pathname.startsWith(PREFIX)) {
res.writeHead(404) res.writeHead(404);
res.end() res.end();
return return;
} }
// We need to handle an issue where say we are navigating to $PREFIX // We need to handle an issue where say we are navigating to $PREFIX
@ -76,10 +77,10 @@ const http = createServer(async (req, res) => {
// URL so that Remix can handle it correctly. // URL so that Remix can handle it correctly.
if (url.pathname === PREFIX) { if (url.pathname === PREFIX) {
res.writeHead(302, { res.writeHead(302, {
Location: `${PREFIX}/` Location: `${PREFIX}/`,
}) });
res.end() res.end();
return return;
} }
// Before we pass any requests to our Remix handler we need to check // Before we pass any requests to our Remix handler we need to check
@ -89,44 +90,44 @@ const http = createServer(async (req, res) => {
// To optimize this, we send them as readable streams in the node // To optimize this, we send them as readable streams in the node
// response and we also set headers for aggressive caching. // response and we also set headers for aggressive caching.
if (url.pathname.startsWith(`${PREFIX}/assets/`)) { if (url.pathname.startsWith(`${PREFIX}/assets/`)) {
const filePath = join(baseDir, url.pathname.replace(PREFIX, '')) const filePath = join(baseDir, url.pathname.replace(PREFIX, ''));
const exists = existsSync(filePath) const exists = existsSync(filePath);
const stats = statSync(filePath) const stats = statSync(filePath);
if (exists && stats.isFile()) { if (exists && stats.isFile()) {
// Build assets are cache-bust friendly so we can cache them heavily // Build assets are cache-bust friendly so we can cache them heavily
if (req.url.startsWith('/build')) { if (req.url.startsWith('/build')) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
} }
// Send the file as a readable stream // Send the file as a readable stream
const fileStream = createReadStream(filePath) const fileStream = createReadStream(filePath);
const type = mime.getType(filePath) const type = mime.getType(filePath);
res.setHeader('Content-Length', stats.size) res.setHeader('Content-Length', stats.size);
res.setHeader('Content-Type', type) res.setHeader('Content-Type', type);
fileStream.pipe(res) fileStream.pipe(res);
return return;
} }
} }
// Handling the request // Handling the request
const controller = new AbortController() const controller = new AbortController();
res.on('close', () => controller.abort()) res.on('close', () => controller.abort());
const headers = new Headers() const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) { for (const [key, value] of Object.entries(req.headers)) {
if (!value) continue if (!value) continue;
if (Array.isArray(value)) { if (Array.isArray(value)) {
for (const v of value) { for (const v of value) {
headers.append(key, v) headers.append(key, v);
} }
continue continue;
} }
headers.append(key, value) headers.append(key, value);
} }
const remixReq = new Request(url.href, { const remixReq = new Request(url.href, {
@ -135,35 +136,36 @@ const http = createServer(async (req, res) => {
signal: controller.signal, signal: controller.signal,
// If we have a body we set a duplex and we load the body // If we have a body we set a duplex and we load the body
...(req.method !== 'GET' && req.method !== 'HEAD' ? { ...(req.method !== 'GET' && req.method !== 'HEAD'
body: createReadableStreamFromReadable(req), ? {
duplex: 'half' body: createReadableStreamFromReadable(req),
} : {} duplex: 'half',
) }
}) : {}),
});
// Pass our request to the Remix handler and get a response // Pass our request to the Remix handler and get a response
const response = await handler(remixReq, { const response = await handler(remixReq, {
ws: getWss() ws: getWss(),
}) });
// Handle our response and reply // Handle our response and reply
res.statusCode = response.status res.statusCode = response.status;
res.statusMessage = response.statusText res.statusMessage = response.statusText;
for (const [key, value] of response.headers.entries()) { for (const [key, value] of response.headers.entries()) {
res.appendHeader(key, value) res.appendHeader(key, value);
} }
if (response.body) { if (response.body) {
await writeReadableStreamToWritable(response.body, res) await writeReadableStreamToWritable(response.body, res);
return return;
} }
res.end() res.end();
}) });
registerWss(http) registerWss(http);
http.listen(port, host, () => { http.listen(port, host, () => {
log('SRVX', 'INFO', `Running on ${host}:${port}`) log('SRVX', 'INFO', `Running on ${host}:${port}`);
}) });

View File

@ -1,4 +1,4 @@
export function log(topic, level, message) { export function log(topic, level, message) {
const date = new Date().toISOString() const date = new Date().toISOString();
console.log(`${date} (${level}) [${topic}] ${message}`) console.log(`${date} (${level}) [${topic}] ${message}`);
} }

View File

@ -1,28 +1,28 @@
// The Websocket server is wholly responsible for ingesting messages from // The Websocket server is wholly responsible for ingesting messages from
// Headplane agent instances (hopefully not more than 1 is running lol) // Headplane agent instances (hopefully not more than 1 is running lol)
import { WebSocketServer } from 'ws' import { WebSocketServer } from 'ws';
import { log } from './utils.mjs' import { log } from './utils.mjs';
const wss = new WebSocketServer({ noServer: true }) const wss = new WebSocketServer({ noServer: true });
wss.on('connection', (ws, req) => { wss.on('connection', (ws, req) => {
// On connection the agent will send its NodeID via Headers // On connection the agent will send its NodeID via Headers
// We store this for later use to validate and show on the UI // We store this for later use to validate and show on the UI
const nodeID = req.headers['x-headplane-ts-node-id'] const nodeID = req.headers['x-headplane-ts-node-id'];
if (!nodeID) { if (!nodeID) {
ws.close(1008, 'ERR_NO_HP_TS_NODE_ID') ws.close(1008, 'ERR_NO_HP_TS_NODE_ID');
return return;
} }
}) });
export async function registerWss(server) { export async function registerWss(server) {
log('SRVX', 'INFO', 'Registering Websocket Server') log('SRVX', 'INFO', 'Registering Websocket Server');
server.on('upgrade', (request, socket, head) => { server.on('upgrade', (request, socket, head) => {
wss.handleUpgrade(request, socket, head, ws => { wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request) wss.emit('connection', ws, request);
}) });
}) });
} }
export function getWss() { export function getWss() {
return wss return wss;
} }

View File

@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
import type { Config } from 'tailwindcss' import type { Config } from 'tailwindcss';
import colors from 'tailwindcss/colors' import colors from 'tailwindcss/colors';
import animate from 'tailwindcss-animate' import animate from 'tailwindcss-animate';
import aria from 'tailwindcss-react-aria-components' import aria from 'tailwindcss-react-aria-components';
export default { export default {
content: ['./app/**/*.{js,jsx,ts,tsx}'], content: ['./app/**/*.{js,jsx,ts,tsx}'],
@ -10,10 +10,10 @@ export default {
container: { container: {
center: true, center: true,
padding: { padding: {
'DEFAULT': '1rem', DEFAULT: '1rem',
'sm': '2rem', sm: '2rem',
'lg': '4rem', lg: '4rem',
'xl': '5rem', xl: '5rem',
'2xl': '6rem', '2xl': '6rem',
}, },
}, },
@ -41,4 +41,4 @@ export default {
}, },
}, },
plugins: [animate, aria], plugins: [animate, aria],
} satisfies Config } satisfies Config;

View File

@ -9,7 +9,7 @@
], ],
"compilerOptions": { "compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"], "lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["@remix-run/node", "vite/client"], "types": ["vite/client"],
"isolatedModules": true, "isolatedModules": true,
"esModuleInterop": true, "esModuleInterop": true,
"jsx": "react-jsx", "jsx": "react-jsx",

Some files were not shown because too many files have changed in this diff Show More