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
/.react-router
/.cache
/build
.env

View File

@ -1,16 +1,15 @@
import { CopyIcon } from '@primer/octicons-react'
import { toast } from './Toaster'
import { CopyIcon } from '@primer/octicons-react';
import { toast } from './Toaster';
interface Props {
name: string
value: string
isCopyable?: boolean
link?: string
name: string;
value: string;
isCopyable?: boolean;
link?: string;
}
export default function Attribute({ name, value, link, isCopyable }: Props) {
const canCopy = isCopyable ?? false
const canCopy = isCopyable ?? false;
return (
<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">
@ -18,31 +17,26 @@ export default function Attribute({ name, value, link, isCopyable }: Props) {
<a className="hover:underline" href={link}>
{name}
</a>
) : name}
) : (
name
)}
</dt>
{canCopy
? (
<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"
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick={async () => {
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" />
</button>
)
: (
<dd className="min-w-0 truncate px-2 py-1">
{value}
</dd>
)}
{canCopy ? (
<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"
onClick={async () => {
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" />
</button>
) : (
<dd className="min-w-0 truncate px-2 py-1">{value}</dd>
)}
</dl>
)
);
}

View File

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

View File

@ -1,53 +1,43 @@
import { type HTMLProps } from 'react'
import { Heading as AriaHeading } from 'react-aria-components'
import { HTMLProps } from 'react';
import { Heading as AriaHeading } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
function Title(properties: Parameters<typeof AriaHeading>[0]) {
function Title(props: Parameters<typeof AriaHeading>[0]) {
return (
<AriaHeading
{...properties}
slot='title'
className={cn(
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
{...props}
slot="title"
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
/>
)
);
}
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return (
<p
{...properties}
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
);
}
type Properties = HTMLProps<HTMLDivElement> & {
type Props = HTMLProps<HTMLDivElement> & {
variant?: 'raised' | 'flat';
}
};
function Card(properties: Properties) {
function Card(props: Props) {
return (
<div
{...properties}
{...props}
className={cn(
'w-full max-w-md overflow-hidden rounded-xl p-4',
properties.variant === 'flat'
props.variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-ui-50 dark:bg-ui-900 shadow-sm',
'border border-ui-200 dark:border-ui-700',
properties.className
props.className,
)}
>
{properties.children}
{props.children}
</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 { CopyIcon, CheckIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
import { toast } from '~/components/Toaster'
import { useState, HTMLProps } from 'react';
import { CopyIcon, CheckIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import { toast } from '~/components/Toaster';
interface Props extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean
isCopyable?: boolean;
}
export default function Code(props: Props) {
const [isCopied, setIsCopied] = useState(false)
const [isCopied, setIsCopied] = useState(false);
return (
<>
<code className={cn(
'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
props.className
)}>
<code
className={cn(
'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
props.className,
)}
>
{props.children}
</code>
{props.isCopyable && (
{props.isCopyable && props.children ? (
<button
className={cn(
'ml-1 p-1 rounded-md',
'bg-ui-100 dark:bg-ui-800',
'text-ui-500 dark:text-ui-400',
'inline-flex items-center justify-center'
'inline-flex items-center justify-center',
)}
onClick={() => {
navigator.clipboard.writeText(props.children.join(''))
toast('Copied to clipboard')
setIsCopied(true)
setTimeout(() => setIsCopied(false), 1000)
navigator.clipboard.writeText(props.children.join(''));
toast('Copied to clipboard');
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1000);
}}
>
{isCopied ?
<CheckIcon className="h-3 w-3" /> :
{isCopied ? (
<CheckIcon className="h-3 w-3" />
) : (
<CopyIcon className="h-3 w-3" />
}
)}
</button>
)}
) : undefined}
</>
)
);
}

View File

@ -1,106 +1,99 @@
/* eslint-disable unicorn/no-keyword-prefix */
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import { Dispatch, ReactNode, SetStateAction } from 'react';
import {
Button as AriaButton,
Dialog as AriaDialog,
DialogTrigger,
Heading as AriaHeading,
Modal,
ModalOverlay
} from 'react-aria-components'
ModalOverlay,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
type ButtonProps = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
};
function Button(properties: ButtonProperties) {
function Button(props: ButtonProps) {
return (
<AriaButton
{...properties}
aria-label='Dialog'
{...props}
aria-label="Dialog"
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
'hover:bg-main-800 dark:hover:bg-main-700',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.className,
)}
// If control is passed, set the state value
onPress={properties.control ? () => {
properties.control?.[1](true)
} : undefined}
onPress={
props.control
? () => {
props.control?.[1](true);
}
: undefined
}
/>
)
);
}
type ActionProperties = Parameters<typeof AriaButton>[0] & {
type ActionProps = Parameters<typeof AriaButton>[0] & {
readonly variant: 'cancel' | 'confirm';
}
};
function Action(properties: ActionProperties) {
function Action(props: ActionProps) {
return (
<AriaButton
{...properties}
type={properties.variant === 'confirm' ? 'submit' : 'button'}
{...props}
type={props.variant === 'confirm' ? 'submit' : 'button'}
className={cn(
'px-4 py-2 rounded-lg',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.variant === 'cancel'
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.variant === 'cancel'
? 'text-ui-700 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-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 (
<AriaHeading
{...properties}
slot='title'
className={cn(
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
{...props}
slot="title"
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
/>
)
);
}
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
return (
<p
{...properties}
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
);
}
type PanelProperties = {
readonly children: (close: () => void) => ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly className?: string;
interface PanelProps {
children: (close: () => void) => ReactNode;
control?: [boolean, Dispatch<SetStateAction<boolean>>];
className?: string;
}
function Panel({ children, control, className }: PanelProperties) {
function Panel({ children, control, className }: PanelProps) {
return (
<ModalOverlay
aria-hidden='true'
aria-hidden="true"
className={cn(
'fixed inset-0 h-screen w-screen z-50 bg-black/30',
'flex items-center justify-center dark:bg-black/70',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-200 entering:ease-out',
'exiting:fade-out exiting:duration-100 exiting:ease-in',
className
className,
)}
isOpen={control ? control[0] : undefined}
onOpenChange={control ? control[1] : undefined}
@ -112,32 +105,28 @@ function Panel({ children, control, className }: PanelProperties) {
'entering:animate-in exiting:animate-out',
'dark:border dark:border-ui-700',
'entering:zoom-in-95 entering:ease-out entering:duration-200',
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100'
'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)}
</AriaDialog>
</Modal>
</ModalOverlay>
)
);
}
type DialogProperties = {
readonly children: ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
interface DialogProps {
children: ReactNode;
control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
function Dialog({ children, control }: DialogProperties) {
function Dialog({ children, control }: DialogProps) {
if (control) {
return children
return children;
}
return (
<DialogTrigger>
{children}
</DialogTrigger>
)
return <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 { isRouteErrorResponse, useRouteError } from '@remix-run/react'
import { AlertIcon } from '@primer/octicons-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'
import Card from './Card'
import Code from './Code'
type Properties = {
readonly type?: 'full' | 'embedded';
interface Props {
type?: 'full' | 'embedded';
}
export function ErrorPopup({ type = 'full' }: Properties) {
const error = useRouteError()
const routing = isRouteErrorResponse(error)
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const message =
error instanceof Error ? error.message : 'An unexpected error occurred';
return (
<div
@ -21,26 +20,20 @@ export function ErrorPopup({ type = 'full' }: Properties) {
'flex items-center justify-center',
type === 'embedded'
? 'pointer-events-none mt-24'
: 'fixed inset-0 h-screen w-screen z-50'
: 'fixed inset-0 h-screen w-screen z-50',
)}
>
<Card>
<div className='flex items-center justify-between'>
<Card.Title className='text-3xl mb-0'>
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">
{routing ? error.status : 'Error'}
</Card.Title>
<AlertIcon className='w-12 h-12 text-red-500'/>
<AlertIcon className="w-12 h-12 text-red-500" />
</div>
<Card.Text className='mt-4 text-lg'>
{routing ? (
error.statusText
) : (
<Code>
{message}
</Code>
)}
<Card.Text className="mt-4 text-lg">
{routing ? error.statusText : <Code>{message}</Code>}
</Card.Text>
</Card>
</div>
)
);
}

View File

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

View File

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

View File

@ -1,12 +1,11 @@
import { LinkExternalIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
import { LinkExternalIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
interface Props {
to: string
name: string
children: string
className?: string
to: string;
name: string;
children: string;
className?: string;
}
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}
<LinkExternalIcon className="h-3 w-3" />
</a>
)
);
}

View File

@ -1,98 +1,91 @@
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import { Dispatch, ReactNode, SetStateAction } from 'react';
import {
Button as AriaButton,
Menu as AriaMenu,
MenuItem,
MenuTrigger,
Popover
} from 'react-aria-components'
Popover,
} from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
function Button(properties: Parameters<typeof AriaButton>[0]) {
function Button(props: Parameters<typeof AriaButton>[0]) {
return (
<AriaButton
{...properties}
className={cn(
'outline-none',
properties.className
)}
aria-label='Menu'
{...props}
className={cn('outline-none', props.className)}
aria-label="Menu"
/>
)
);
}
function Items(properties: Parameters<typeof AriaMenu>[0]) {
function Items(props: Parameters<typeof AriaMenu>[0]) {
return (
<Popover className={cn(
'mt-2 rounded-md',
'bg-ui-50 dark:bg-ui-800',
'overflow-hidden z-50',
'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95',
'exiting:fade-out exiting:zoom-out-95',
'fill-mode-forwards origin-left-right'
)}
<Popover
className={cn(
'mt-2 rounded-md',
'bg-ui-50 dark:bg-ui-800',
'overflow-hidden z-50',
'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95',
'exiting:fade-out exiting:zoom-out-95',
'fill-mode-forwards origin-left-right',
)}
>
<AriaMenu
{...properties}
{...props}
className={cn(
'outline-none',
'divide-y divide-ui-200 dark:divide-ui-600',
properties.className
props.className,
)}
>
{properties.children}
{props.children}
</AriaMenu>
</Popover>
)
);
}
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
type ButtonProps = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
};
function ItemButton(properties: ButtonProperties) {
function ItemButton(props: ButtonProps) {
return (
<MenuItem className='outline-none'>
<MenuItem className="outline-none">
<AriaButton
{...properties}
{...props}
className={cn(
'px-4 py-2 w-full outline-none text-left',
'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
onPress={event => {
properties.onPress?.(event)
properties.control?.[1](true)
onPress={(event) => {
props.onPress?.(event);
props.control?.[1](true);
}}
/>
</MenuItem>
)
);
}
function Item(properties: Parameters<typeof MenuItem>[0]) {
function Item(props: Parameters<typeof MenuItem>[0]) {
return (
<MenuItem
{...properties}
{...props}
className={cn(
'px-4 py-2 w-full outline-none',
'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className
props.className,
)}
/>
)
);
}
function Menu({ children }: { readonly children: ReactNode }) {
return (
<MenuTrigger>
{children}
</MenuTrigger>
)
function Menu({ children }: { children: ReactNode }) {
return <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 type { ReactNode } from 'react'
import { cn } from '~/utils/cn'
import { InfoIcon } from '@primer/octicons-react';
import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
interface Props {
className?: string
children: ReactNode
className?: string;
children: ReactNode;
}
export default function Notice({ children, className }: Props) {
return (
<div className={cn(
'p-4 rounded-md w-full flex items-center gap-3',
'bg-ui-200 dark:bg-ui-800',
className,
)}
<div
className={cn(
'p-4 rounded-md w-full flex items-center gap-3',
'bg-ui-200 dark:bg-ui-800',
className,
)}
>
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
{children}
</div>
)
);
}

View File

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

View File

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

View File

@ -1,24 +1,24 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import clsx from 'clsx';
import { HTMLProps } from 'react';
type Properties = HTMLProps<SVGElement> & {
type Props = HTMLProps<SVGElement> & {
readonly isOnline: boolean;
}
};
// eslint-disable-next-line unicorn/no-keyword-prefix
export default function StatusCircle({ isOnline, className }: Properties) {
export default function StatusCircle({ isOnline, className }: Props) {
return (
<svg
className={clsx(
className,
isOnline
? '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'
fill='currentColor'
viewBox="0 0 24 24"
fill="currentColor"
>
<circle cx='12' cy='12' r='8'/>
<circle cx="12" cy="12" r="8" />
</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 SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
type SwitchProps = Parameters<typeof AriaSwitch>[0] & {
readonly label: string;
}
};
export default function Switch(properties: SwitchProperties) {
export default function Switch(props: SwitchProps) {
return (
<AriaSwitch
{...properties}
aria-label={properties.label}
className='group flex gap-2 items-center'
{...props}
aria-label={props.label}
className="group flex gap-2 items-center"
>
<div
className={cn(
'flex h-[26px] w-[44px] p-[4px] shrink-0',
'rounded-full outline-none group-focus-visible:ring-2',
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
props.isDisabled && 'opacity-50 cursor-not-allowed',
props.className,
)}
>
<span className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-100 ease-in-out',
'translate-x-0 group-selected:translate-x-[100%]'
)}
<span
className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-100 ease-in-out',
'translate-x-0 group-selected:translate-x-[100%]',
)}
/>
</div>
</AriaSwitch>
)
);
}

View File

@ -1,32 +1,33 @@
import { NavLink } from '@remix-run/react'
import type { ReactNode } from 'react'
import { NavLink } from 'react-router';
import type { ReactNode } from 'react';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type Properties = {
readonly name: string;
readonly to: string;
readonly icon: ReactNode;
interface Props {
name: string;
to: string;
icon: ReactNode;
}
export default function TabLink({ name, to, icon }: Properties) {
export default function TabLink({ name, to, icon }: Props) {
return (
<NavLink
to={to}
prefetch='intent'
className={({ isActive }) => cn(
'border-b-2 py-1.5',
isActive ? 'border-white' : 'border-transparent'
)}
prefetch="intent"
className={({ isActive }) =>
cn(
'border-b-2 py-1.5',
isActive ? 'border-white' : 'border-transparent',
)
}
>
<div
className={cn(
'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}
</div>
</NavLink>
)
);
}

View File

@ -1,37 +1,36 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import clsx from 'clsx';
import { HTMLProps } from 'react';
function TableList(properties: HTMLProps<HTMLDivElement>) {
function TableList(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...properties}
{...props}
className={clsx(
'border border-gray-300 rounded-lg overflow-clip',
'dark:border-zinc-700 dark:text-gray-300',
// 'dark:bg-zinc-800',
properties.className
props.className,
)}
>
{properties.children}
{props.children}
</div>
)
);
}
function Item(properties: HTMLProps<HTMLDivElement>) {
function Item(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...properties}
{...props}
className={clsx(
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0',
'dark:border-zinc-800',
properties.className
props.className,
)}
>
{properties.children}
{props.children}
</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 {
Input,
TextField as AriaTextField
} from 'react-aria-components'
import { Dispatch, SetStateAction } from 'react';
import { Input, TextField as AriaTextField } from 'react-aria-components';
import { cn } from '~/utils/cn';
import { cn } from '~/utils/cn'
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
type TextFieldProps = Parameters<typeof AriaTextField>[0] & {
readonly label: string;
readonly placeholder: string;
readonly state?: [string, Dispatch<SetStateAction<string>>];
}
};
export default function TextField(properties: TextFieldProperties) {
export default function TextField(props: TextFieldProps) {
return (
<AriaTextField
{...properties}
aria-label={properties.label}
className='w-full'
>
<AriaTextField {...props} aria-label={props.label} className="w-full">
<Input
placeholder={properties.placeholder}
value={properties.state?.[0]}
name={properties.name}
placeholder={props.placeholder}
value={props.state?.[0]}
name={props.name}
className={cn(
'block px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300',
properties.className
props.className,
)}
onChange={event => {
properties.state?.[1](event.target.value)
onChange={(event) => {
props.state?.[1](event.target.value);
}}
/>
</AriaTextField>
)
);
}

View File

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

View File

@ -1,43 +1,37 @@
import { ReactNode } from 'react'
import { ReactNode } from 'react';
import {
Button as AriaButton,
Tooltip as AriaTooltip,
TooltipTrigger,
} from 'react-aria-components'
import { cn } from '~/utils/cn'
} from 'react-aria-components';
import { cn } from '~/utils/cn';
interface Props {
children: ReactNode
className?: string
children: ReactNode;
className?: string;
}
function Tooltip({ children }: Props) {
return (
<TooltipTrigger delay={0}>
{children}
</TooltipTrigger>
)
return <TooltipTrigger delay={0}>{children}</TooltipTrigger>;
}
function Button(props: Parameters<typeof AriaButton>[0]) {
return (
<AriaButton {...props} />
)
return <AriaButton {...props} />;
}
function Body({ children, className }: Props) {
return (
<AriaTooltip className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-ui-900 drop-shadow-sm',
'border border-gray-200 dark:border-zinc-700',
className,
)}
<AriaTooltip
className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-ui-900 drop-shadow-sm',
'border border-gray-200 dark:border-zinc-700',
className,
)}
>
{children}
</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 { createReadableStreamFromReadable } from '@remix-run/node'
import { RemixServer } from '@remix-run/react'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
import type { AppLoadContext, EntryContext } from 'react-router';
import { createReadableStreamFromReadable } from '@react-router/node';
import { ServerRouter } from 'react-router';
import { isbot } from 'isbot';
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(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
reactRouterContext: EntryContext,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_loadContext: AppLoadContext,
) {
const ua = request.headers.get('user-agent')
const isBot = ua ? isbot(ua) : false
const ua = request.headers.get('user-agent');
const isBot = ua ? isbot(ua) : false;
return new Promise((resolve, reject) => {
let shellRendered = false
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
/>,
<ServerRouter context={reactRouterContext} url={request.url} />,
{
[isBot ? 'onAllReady' : 'onShellReady']() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html')
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
)
);
pipe(body)
pipe(body);
},
onShellError(error: unknown) {
reject(error as Error)
reject(error as Error);
},
onError(error: unknown) {
responseStatusCode = 500
responseStatusCode = 500;
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 { setTimeout } from 'node:timers/promises'
import { access, constants } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import { Client } from 'undici'
import { Client } from 'undici';
import { HeadscaleError, pull } from '~/utils/headscale'
import log from '~/utils/log'
import { HeadscaleError, pull } from '~/utils/headscale';
import log from '~/utils/log';
import { createIntegration } from './integration'
import { createIntegration } from './integration';
interface Context {
client: Client | undefined
container: string | undefined
maxAttempts: number
client: Client | undefined;
container: string | undefined;
maxAttempts: number;
}
export default createIntegration<Context>({
@ -24,133 +24,126 @@ export default createIntegration<Context>({
isAvailable: async (context) => {
// Check for the HEADSCALE_CONTAINER environment variable first
// to avoid unnecessary fetching of the Docker socket
log.debug('INTG', 'Checking Docker integration availability')
context.container = process.env.HEADSCALE_CONTAINER
?.trim()
.toLowerCase()
log.debug('INTG', 'Checking Docker integration availability');
context.container = process.env.HEADSCALE_CONTAINER?.trim().toLowerCase();
if (!context.container || context.container.length === 0) {
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable')
return false
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable');
return false;
}
log.info('INTG', 'Using container: %s', context.container)
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
let url: URL | undefined
log.info('INTG', 'Using container: %s', context.container);
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock';
let url: URL | undefined;
try {
url = new URL(path)
url = new URL(path);
} catch {
log.error('INTG', 'Invalid Docker socket path: %s', path)
return false
log.error('INTG', 'Invalid Docker socket path: %s', path);
return false;
}
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
log.error('INTG', 'Invalid Docker socket protocol: %s',
url.protocol,
)
return false
log.error('INTG', 'Invalid Docker socket protocol: %s', url.protocol);
return false;
}
// The API is available as an HTTP endpoint and this
// will simplify the fetching logic in undici
if (url.protocol === 'tcp:') {
// 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 {
log.info('INTG', 'Checking API: %s', fetchU)
await fetch(new URL('/v1.30/version', fetchU).href)
log.info('INTG', 'Checking API: %s', fetchU);
await fetch(new URL('/v1.30/version', fetchU).href);
} catch (error) {
log.debug('INTG', 'Failed to connect to Docker API', error)
log.error('INTG', 'Failed to connect to Docker API')
return false
log.debug('INTG', 'Failed to connect to Docker API', error);
log.error('INTG', 'Failed to connect to Docker API');
return false;
}
context.client = new Client(fetchU)
context.client = new Client(fetchU);
}
// Check if the socket is accessible
if (url.protocol === 'unix:') {
try {
log.info('INTG', 'Checking socket: %s',
url.pathname,
)
await access(url.pathname, constants.R_OK)
log.info('INTG', 'Checking socket: %s', url.pathname);
await access(url.pathname, constants.R_OK);
} catch (error) {
log.debug('INTG', 'Failed to access Docker socket: %s', error)
log.error('INTG', 'Failed to access Docker socket: %s',
path,
)
return false
log.debug('INTG', 'Failed to access Docker socket: %s', error);
log.error('INTG', 'Failed to access Docker socket: %s', path);
return false;
}
context.client = new Client('http://localhost', {
socketPath: url.pathname,
})
});
}
return context.client !== undefined
return context.client !== undefined;
},
onConfigChange: async (context) => {
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) {
log.debug(
'INTG', 'Restarting container: %s (attempt %d)',
'INTG',
'Restarting container: %s (attempt %d)',
context.container,
attempts,
)
);
const response = await context.client.request({
method: 'POST',
path: `/v1.30/containers/${context.container}/restart`,
})
});
if (response.statusCode !== 204) {
if (attempts < context.maxAttempts) {
attempts++
await setTimeout(1000)
continue
attempts++;
await setTimeout(1000);
continue;
}
const stringCode = response.statusCode.toString()
const body = await response.body.text()
throw new Error(`API request failed: ${stringCode} ${body}`)
const stringCode = response.statusCode.toString();
const body = await response.body.text();
throw new Error(`API request failed: ${stringCode} ${body}`);
}
break
break;
}
attempts = 0
attempts = 0;
while (attempts <= context.maxAttempts) {
try {
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts)
await pull('v1', '')
return
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
await pull('v1', '');
return;
} catch (error) {
if (error instanceof HeadscaleError && error.status === 401) {
break
break;
}
if (error instanceof HeadscaleError && error.status === 404) {
break
break;
}
if (attempts < context.maxAttempts) {
attempts++
await setTimeout(1000)
continue
attempts++;
await setTimeout(1000);
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 { IntegrationFactory } from './integration'
import kubernetesIntegration from './kubernetes'
import procIntegration from './proc'
import dockerIntegration from './docker';
import { IntegrationFactory } from './integration';
import kubernetesIntegration from './kubernetes';
import procIntegration from './proc';
export * from './integration'
export * from './integration';
export async function loadIntegration() {
let integration = process.env.HEADSCALE_INTEGRATION
?.trim()
.toLowerCase()
let integration = process.env.HEADSCALE_INTEGRATION?.trim().toLowerCase();
// Old HEADSCALE_CONTAINER variable upgrade path
// This ensures that when people upgrade from older versions of Headplane
// they don't explicitly need to define the new HEADSCALE_INTEGRATION
// variable that is needed to configure docker
if (!integration && process.env.HEADSCALE_CONTAINER) {
integration = 'docker'
integration = 'docker';
}
if (!integration) {
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION')
return
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION');
return;
}
let integrationFactory: IntegrationFactory | undefined
let integrationFactory: IntegrationFactory | undefined;
switch (integration.toLowerCase().trim()) {
case 'docker': {
integrationFactory = dockerIntegration
break
integrationFactory = dockerIntegration;
break;
}
case 'proc':
case 'native':
case 'linux': {
integrationFactory = procIntegration
break
integrationFactory = procIntegration;
break;
}
case 'kubernetes':
case 'k8s': {
integrationFactory = kubernetesIntegration
break
integrationFactory = kubernetesIntegration;
break;
}
default: {
log.error('INTG', 'Unknown integration: %s', integration)
throw new Error(`Unknown integration: ${integration}`)
log.error('INTG', 'Unknown integration: %s', integration);
throw new Error(`Unknown integration: ${integration}`);
}
}
log.info('INTG', 'Loading integration: %s', integration)
log.info('INTG', 'Loading integration: %s', integration);
try {
const res = await integrationFactory.isAvailable(
integrationFactory.context,
)
);
if (!res) {
log.error('INTG', 'Integration %s is not available',
integration,
)
return
log.error('INTG', 'Integration %s is not available', integration);
return;
}
} catch (error) {
log.error('INTG', 'Failed to load integration %s: %s',
integration,
error,
)
return
log.error('INTG', 'Failed to load integration %s: %s', integration, error);
return;
}
log.info('INTG', 'Loaded integration: %s', integration)
return integrationFactory
log.info('INTG', 'Loaded integration: %s', integration);
return integrationFactory;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,21 @@
import type { LinksFunction, MetaFunction } from '@remix-run/node'
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from '@remix-run/react'
import type { LinksFunction, MetaFunction } from 'react-router';
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
import { ErrorPopup } from '~/components/Error'
import { Toaster } from '~/components/Toaster'
import stylesheet from '~/tailwind.css?url'
import { ErrorPopup } from '~/components/Error';
import { Toaster } from '~/components/Toaster';
import stylesheet from '~/tailwind.css?url';
export const meta: MetaFunction = () => [
{ 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 = () => [
{ rel: 'stylesheet', href: stylesheet },
]
];
export function Layout({ children }: { readonly children: React.ReactNode }) {
return (
@ -36,13 +33,13 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Scripts />
</body>
</html>
)
);
}
export function ErrorBoundary() {
return <ErrorPopup />
return <ErrorPopup />;
}
export default function App() {
return <Outlet />
return <Outlet />;
}

View File

@ -1,5 +1,5 @@
//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 [
// Utility Routes
@ -26,6 +26,5 @@ export default [
index('routes/settings/overview.tsx'),
route('/auth-keys', 'routes/settings/auth-keys.tsx'),
]),
])
]
]),
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,28 +1,26 @@
import Link from '~/components/Link'
import Button from '~/components/Button'
import { Link as RemixLink } from '@remix-run/react'
import { ArrowRightIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
import Link from '~/components/Link';
import Button from '~/components/Button';
import { Link as RemixLink } from 'react-router';
import { ArrowRightIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
export default function Page() {
return (
<div className="flex flex-col gap-8 max-w-screen-lg">
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Settings</h1>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Settings</h1>
<p className="text-gray-700 dark:text-gray-300">
The settings page is still under construction.
As I'm able to add more features, I'll be adding them here.
If you require any features, feel free to open an issue on
the GitHub repository.
The settings page is still under construction. As I'm able to add more
features, I'll be adding them here. If you require any features, feel
free to open an issue on the GitHub repository.
</p>
</div>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300">
Headscale fully supports pre-authentication keys in order to
easily add devices to your Tailnet.
To learn more about using pre-authentication keys, visit the
{' '}
Headscale fully supports pre-authentication keys in order to easily
add devices to your Tailnet. To learn more about using
pre-authentication keys, visit the{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
@ -32,14 +30,16 @@ export default function Page() {
</p>
</div>
<RemixLink to="/settings/auth-keys">
<span className={cn(
'text-lg font-medium',
'text-gray-700 dark:text-gray-300',
)}>
<span
className={cn(
'text-lg font-medium',
'text-gray-700 dark:text-gray-300',
)}
>
Manage Auth Keys
<ArrowRightIcon className="w-5 h-5 ml-2" />
</span>
</RemixLink>
</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 Link from '~/components/Link'
import Card from '~/components/Card';
import Link from '~/components/Link';
import Add from '../dialogs/add'
import Add from '../dialogs/add';
interface Props {
readonly magic: string | undefined
readonly magic: string | undefined;
}
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="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" />
<h2 className="font-medium mb-1">
Basic Authentication
</h2>
<h2 className="font-medium mb-1">Basic Authentication</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
Users are not managed externally.
Using OpenID Connect can create a better
experience when using Headscale.
{' '}
Users are not managed externally. Using OpenID Connect can create a
better experience when using Headscale.{' '}
<Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation"
@ -33,9 +29,7 @@ export default function Auth({ magic }: Props) {
</div>
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
<PasskeyFillIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
User Management
</h2>
<h2 className="font-medium mb-1">User Management</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
You can add, remove, and rename users here.
</p>
@ -45,5 +39,5 @@ export default function Auth({ magic }: Props) {
</div>
</div>
</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 Link from '~/components/Link'
import { HeadplaneContext } from '~/utils/config/headplane'
import Card from '~/components/Card';
import Link from '~/components/Link';
import { HeadplaneContext } from '~/utils/config/headplane';
import Add from '../dialogs/add'
import Add from '../dialogs/add';
interface Props {
readonly oidc: NonNullable<HeadplaneContext['oidc']>
readonly magic: string | undefined
readonly oidc: NonNullable<HeadplaneContext['oidc']>;
readonly magic: string | undefined;
}
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="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" />
<h2 className="font-medium mb-1">
OpenID Connect
</h2>
<h2 className="font-medium mb-1">OpenID Connect</h2>
<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">
OpenID Connect provider
</Link>
{'. '}
Groups and user information do not automatically sync.
{' '}
Groups and user information do not automatically sync.{' '}
<Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation"
@ -39,12 +35,10 @@ export default function Oidc({ oidc, magic }: Props) {
</div>
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
<PasskeyFillIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
User Management
</h2>
<h2 className="font-medium mb-1">User Management</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
You can still add users manually, however it is recommended
that you manage users through your OIDC provider.
You can still add users manually, however it is recommended that you
manage users through your OIDC provider.
</p>
<div className="flex items-center gap-2 mt-4">
<Add magic={magic} />
@ -52,5 +46,5 @@ export default function Oidc({ oidc, magic }: Props) {
</div>
</div>
</Card>
)
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,9 +3,9 @@
@tailwind utilities;
@supports (scrollbar-gutter: stable) {
html {
scrollbar-gutter: stable
}
html {
scrollbar-gutter: stable;
}
}
.cm-merge-theme {
@ -25,6 +25,7 @@
}
/* 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,116 +1,138 @@
import { loadContext } from './config/headplane'
import log from './log'
import { loadContext } from './config/headplane';
import log from './log';
export class HeadscaleError extends Error {
status: number
status: number;
constructor(message: string, status: number) {
super(message)
this.name = 'HeadscaleError'
this.status = status
super(message);
this.name = 'HeadscaleError';
this.status = status;
}
}
export class FatalError extends Error {
constructor() {
super('The Headscale server is not accessible or the supplied API key is invalid')
this.name = 'FatalError'
super(
'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) {
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 prefix = context.headscaleUrl
const context = await loadContext();
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}`, {
headers: {
Authorization: `Bearer ${key}`,
},
})
});
if (!response.ok) {
log.debug('APIC', 'GET %s failed with status %d', `${prefix}/api/${url}`, response.status)
throw new HeadscaleError(await response.text(), response.status)
log.debug(
'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) {
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 prefix = context.headscaleUrl
const context = await loadContext();
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}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
})
});
if (!response.ok) {
log.debug('APIC', 'POST %s failed with status %d', `${prefix}/api/${url}`, response.status)
throw new HeadscaleError(await response.text(), response.status)
log.debug(
'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) {
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 prefix = context.headscaleUrl
const context = await loadContext();
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}`, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
})
});
if (!response.ok) {
log.debug('APIC', 'PUT %s failed with status %d', `${prefix}/api/${url}`, response.status)
throw new HeadscaleError(await response.text(), response.status)
log.debug(
'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) {
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 prefix = context.headscaleUrl
const context = await loadContext();
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}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${key}`,
},
})
});
if (!response.ok) {
log.debug('APIC', 'DELETE %s failed with status %d', `${prefix}/api/${url}`, response.status)
throw new HeadscaleError(await response.text(), response.status)
log.debug(
'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 {
info: (category: string, message: string, ...args: unknown[]) => {
defaultLog('INFO', category, message, ...args)
defaultLog('INFO', category, message, ...args);
},
warn: (category: string, message: string, ...args: unknown[]) => {
defaultLog('WARN', category, message, ...args)
defaultLog('WARN', category, message, ...args);
},
error: (category: string, message: string, ...args: unknown[]) => {
defaultLog('ERRO', category, message, ...args)
defaultLog('ERRO', category, message, ...args);
},
debug: (category: string, message: string, ...args: unknown[]) => {
if (process.env.DEBUG === 'true') {
defaultLog('DEBG', category, message, ...args)
defaultLog('DEBG', category, message, ...args);
}
}
}
},
};
function defaultLog(
level: string,
@ -24,6 +24,6 @@ function defaultLog(
message: string,
...args: unknown[]
) {
const date = new Date().toISOString()
console.log(`${date} (${level}) [${category}] ${message}`, ...args)
const date = new Date().toISOString();
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 {
authorizationCodeGrantRequest,
calculatePKCECodeChallenge,
@ -13,99 +13,99 @@ import {
processAuthorizationCodeOpenIDResponse,
processDiscoveryResponse,
validateAuthResponse,
} from 'oauth4webapi'
} from 'oauth4webapi';
import { post } from '~/utils/headscale'
import { commitSession, getSession } from '~/utils/sessions'
import log from '~/utils/log'
import { post } from '~/utils/headscale';
import { commitSession, getSession } from '~/utils/sessions';
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) {
const session = await getSession(req.headers.get('Cookie'))
const session = await getSession(req.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/', {
status: 302,
headers: {
'Set-Cookie': await commitSession(session),
},
})
});
}
const issuerUrl = new URL(oidc.issuer)
const issuerUrl = new URL(oidc.issuer);
const oidcClient = {
client_id: oidc.client,
token_endpoint_auth_method: oidc.method,
} satisfies Client
} satisfies Client;
const response = await discoveryRequest(issuerUrl)
const processed = await processDiscoveryResponse(issuerUrl, response)
const response = await discoveryRequest(issuerUrl);
const processed = await processDiscoveryResponse(issuerUrl, response);
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 nonce = generateRandomNonce()
const verifier = generateRandomCodeVerifier()
const challenge = await calculatePKCECodeChallenge(verifier)
const state = generateRandomState();
const nonce = generateRandomNonce();
const verifier = generateRandomCodeVerifier();
const challenge = await calculatePKCECodeChallenge(verifier);
const callback = new URL('/admin/oidc/callback', req.url)
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
callback.host = req.headers.get('Host') ?? ''
const authUrl = new URL(processed.authorization_endpoint)
const callback = new URL('/admin/oidc/callback', req.url);
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:';
callback.host = req.headers.get('Host') ?? '';
const authUrl = new URL(processed.authorization_endpoint);
authUrl.searchParams.set('client_id', oidcClient.client_id)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('redirect_uri', callback.href)
authUrl.searchParams.set('scope', 'openid profile email')
authUrl.searchParams.set('code_challenge', challenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
authUrl.searchParams.set('state', state)
authUrl.searchParams.set('nonce', nonce)
authUrl.searchParams.set('client_id', oidcClient.client_id);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('redirect_uri', callback.href);
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('nonce', nonce);
session.set('authState', state)
session.set('authNonce', nonce)
session.set('authVerifier', verifier)
session.set('authState', state);
session.set('authNonce', nonce);
session.set('authVerifier', verifier);
return redirect(authUrl.href, {
status: 302,
headers: {
'Set-Cookie': await commitSession(session),
},
})
});
}
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')) {
return redirect('/', {
status: 302,
headers: {
'Set-Cookie': await commitSession(session),
},
})
});
}
const issuerUrl = new URL(oidc.issuer)
const issuerUrl = new URL(oidc.issuer);
const oidcClient = {
client_id: oidc.client,
client_secret: oidc.secret,
token_endpoint_auth_method: oidc.method,
} satisfies Client
} satisfies Client;
const response = await discoveryRequest(issuerUrl)
const processed = await processDiscoveryResponse(issuerUrl, response)
const response = await discoveryRequest(issuerUrl);
const processed = await processDiscoveryResponse(issuerUrl, response);
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 nonce = session.get('authNonce')
const verifier = session.get('authVerifier')
const state = session.get('authState');
const nonce = session.get('authNonce');
const verifier = session.get('authVerifier');
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(
@ -113,15 +113,15 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
oidcClient,
new URL(req.url),
state,
)
);
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)
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
callback.host = req.headers.get('Host') ?? ''
const callback = new URL('/admin/oidc/callback', req.url);
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:';
callback.host = req.headers.get('Host') ?? '';
const tokenResponse = await authorizationCodeGrantRequest(
processed,
@ -129,11 +129,11 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
parameters,
callback.href,
verifier,
)
);
const challenges = parseWwwAuthenticateChallenges(tokenResponse)
const challenges = parseWwwAuthenticateChallenges(tokenResponse);
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(
@ -141,14 +141,14 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
oidcClient,
tokenResponse,
nonce,
)
);
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 expDate = new Date(claims.exp * 1000).toISOString()
const claims = getValidatedIdTokenClaims(result);
const expDate = new Date(claims.exp * 1000).toISOString();
const keyResponse = await post<{ apiKey: string }>(
'v1/apikey',
@ -156,19 +156,19 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
{
expiration: expDate,
},
)
);
session.set('hsApiKey', keyResponse.apiKey)
session.set('hsApiKey', keyResponse.apiKey);
session.set('user', {
name: claims.name ? String(claims.name) : 'Anonymous',
email: claims.email ? String(claims.email) : undefined,
})
});
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session),
},
})
});
}
// 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_secret: secret,
token_endpoint_auth_method: 'client_secret_post',
} satisfies Client
} satisfies Client;
const issuerUrl = new URL(issuer)
const issuerUrl = new URL(issuer);
try {
log.debug('OIDC', 'Checking OIDC well-known endpoint')
const response = await discoveryRequest(issuerUrl)
const processed = await processDiscoveryResponse(issuerUrl, response)
log.debug('OIDC', 'Checking OIDC well-known endpoint');
const response = await discoveryRequest(issuerUrl);
const processed = await processDiscoveryResponse(issuerUrl, response);
if (!processed.authorization_endpoint) {
log.debug('OIDC', 'No authorization endpoint found on the OIDC provider')
return false
log.debug('OIDC', 'No authorization endpoint found on the OIDC provider');
return false;
}
log.debug('OIDC', 'Found auth endpoint: %s', processed.authorization_endpoint)
return true
log.debug(
'OIDC',
'Found auth endpoint: %s',
processed.authorization_endpoint,
);
return true;
} catch (e) {
log.debug('OIDC', 'Validation failed: %s', e.message)
return false
log.debug('OIDC', 'Validation failed: %s', e.message);
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) {
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 = {
hsApiKey: string;
@ -9,18 +9,14 @@ export type SessionData = {
name: string;
email?: string;
};
}
};
type SessionFlashData = {
error: string;
}
};
export const {
getSession,
commitSession,
destroySession
} = createCookieSessionStorage<SessionData, SessionFlashData>(
{
export const { getSession, commitSession, destroySession } =
createCookieSessionStorage<SessionData, SessionFlashData>({
cookie: {
name: 'hp_sess',
httpOnly: true,
@ -29,7 +25,5 @@ export const {
sameSite: 'lax',
secrets: [process.env.COOKIE_SECRET!],
secure: process.env.COOKIE_SECURE !== 'false',
}
}
)
},
});

View File

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

View File

@ -1,47 +1,47 @@
// This is a "side-effect" but we want a lifecycle cache map of
// peer statuses to prevent unnecessary fetches to the agent.
import type { LoaderFunctionArgs } from 'remix'
import type { LoaderFunctionArgs } from 'react-router';
type Context = LoaderFunctionArgs['context']
const cache: { [nodeID: string]: unknown } = {}
type Context = LoaderFunctionArgs['context'];
const cache: { [nodeID: string]: unknown } = {};
export async function queryWS(context: Context, nodeIDs: string[]) {
const ws = context.ws
const firstClient = ws.clients.values().next().value
const ws = context.ws;
const firstClient = ws.clients.values().next().value;
if (!firstClient) {
return cache
return cache;
}
const cached = nodeIDs.map((nodeID) => {
const cached = cache[nodeID]
const cached = cache[nodeID];
if (cached) {
return cached
return 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) {
return cache
return cache;
}
firstClient.send(JSON.stringify({ NodeIDs: uncached }))
await new Promise((resolve) => {
firstClient.send(JSON.stringify({ NodeIDs: uncached }));
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
resolve()
}, 3000)
resolve();
}, 3000);
firstClient.on('message', (message) => {
const data = JSON.parse(message.toString())
firstClient.on('message', (message: string) => {
const data = JSON.parse(message.toString());
if (Object.keys(data).length === 0) {
resolve()
resolve();
}
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",
"@primer/octicons-react": "^19.13.0",
"@react-aria/toast": "3.0.0-beta.18",
"@react-router/node": "^7.0.0",
"@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",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
@ -35,6 +34,7 @@
"react-codemirror-merge": "^4.23.6",
"react-dom": "19.0.0",
"react-error-boundary": "^4.1.2",
"react-router": "^7.0.0",
"remix-utils": "^7.7.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-react-aria-components": "^1.2.0",
@ -46,8 +46,8 @@
},
"devDependencies": {
"@babel/preset-typescript": "^7.26.0",
"@remix-run/dev": "^2.15.0",
"@remix-run/route-config": "^2.15.0",
"@biomejs/biome": "^1.9.4",
"@react-router/dev": "^7.0.0",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
"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 {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
plugins: {
tailwindcss: {},
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
// It does some silly magic to load the vite config, set some globals that
// are required to function, and create the vite development server.
import { createServer } from 'vite'
import { env, exit } from 'node:process'
import { log } from './utils.mjs'
import { createServer } from 'vite';
import { env, exit } from 'node:process';
import { log } from './utils.mjs';
log('DEVX', 'INFO', 'This script is only intended for development')
env.NODE_ENV = 'development'
log('DEVX', 'INFO', 'This script is only intended for development');
env.NODE_ENV = 'development';
// The production entrypoint uses a global called "PREFIX" to determine
// 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
// values.
log('DEVX', 'INFO', 'Creating Vite Development Server')
log('DEVX', 'INFO', 'Creating Vite Development Server');
const server = await createServer({
server: {
middlewareMode: true
}
})
middlewareMode: true,
},
});
// 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
global.PREFIX = server.config.base.slice(0, -1)
global.BUILD = build
global.MODE = 'development'
global.MIDDLEWARE = server.middlewares
global.PREFIX = server.config.base.slice(0, -1);
global.BUILD = build;
global.MODE = 'development';
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.
// PREFIX is defined globally, see vite.config.ts
import { access, constants } from 'node:fs/promises'
import { createReadStream, existsSync, statSync } from 'node:fs'
import { createServer } from 'node:http'
import { join, resolve } from 'node:path'
import { env } from 'node:process'
import { log } from './utils.mjs'
import { getWss, registerWss } from './ws.mjs'
import { access, constants } from 'node:fs/promises';
import { createReadStream, existsSync, statSync } from 'node:fs';
import { createServer } from 'node:http';
import { join, resolve } from 'node:path';
import { env } from 'node:process';
import { log } from './utils.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 {
await access('./node_modules/@remix-run', constants.F_OK | constants.R_OK)
log('SRVX', 'INFO', 'Found node_modules dependencies')
await access('./node_modules/@react-router', constants.F_OK | constants.R_OK);
log('SRVX', 'INFO', 'Found node_modules dependencies');
} catch (error) {
log('SRVX', 'ERROR', 'No node_modules found. Please run `pnpm install`')
log('SRVX', 'ERROR', error)
process.exit(1)
log('SRVX', 'ERROR', 'No node_modules found. Please run `pnpm install`');
log('SRVX', 'ERROR', error);
process.exit(1);
}
const {
createRequestHandler: remixRequestHandler,
createReadableStreamFromReadable,
writeReadableStreamToWritable
} = await import('@remix-run/node')
const { default: mime } = await import('mime')
writeReadableStreamToWritable,
} = await import('@react-router/node');
const { default: mime } = await import('mime');
const port = env.PORT || 3000
const host = env.HOST || '0.0.0.0'
const buildPath = env.BUILD_PATH || './build'
const baseDir = resolve(join(buildPath, 'client'))
const port = env.PORT || 3000;
const host = env.HOST || '0.0.0.0';
const buildPath = env.BUILD_PATH || './build';
const baseDir = resolve(join(buildPath, 'client'));
if (!global.BUILD) {
try {
await access(join(buildPath, 'server'), constants.F_OK | constants.R_OK)
log('SRVX', 'INFO', 'Found build directory')
await access(join(buildPath, 'server'), constants.F_OK | constants.R_OK);
log('SRVX', 'INFO', 'Found build directory');
} catch (error) {
const date = new Date().toISOString()
log('SRVX', 'ERROR', 'No build found. Please run `pnpm build`')
log('SRVX', 'ERROR', error)
process.exit(1)
const date = new Date().toISOString();
log('SRVX', 'ERROR', 'No build found. Please run `pnpm build`');
log('SRVX', 'ERROR', error);
process.exit(1);
}
// Because this is a dynamic import without an easily discernable path
// we gain the "deoptimization" we want so that Vite doesn't bundle this
const build = await import(resolve(join(buildPath, 'server', 'index.js')))
global.BUILD = build
global.MODE = 'production'
const build = await import(resolve(join(buildPath, 'server', 'index.js')));
global.BUILD = build;
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 url = new URL(`http://${req.headers.host}${req.url}`)
const url = new URL(`http://${req.headers.host}${req.url}`);
if (global.MIDDLEWARE) {
await new Promise(resolve => {
global.MIDDLEWARE(req, res, resolve)
})
await new Promise((resolve) => {
global.MIDDLEWARE(req, res, resolve);
});
}
if (!url.pathname.startsWith(PREFIX)) {
res.writeHead(404)
res.end()
return
res.writeHead(404);
res.end();
return;
}
// 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.
if (url.pathname === PREFIX) {
res.writeHead(302, {
Location: `${PREFIX}/`
})
res.end()
return
Location: `${PREFIX}/`,
});
res.end();
return;
}
// 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
// response and we also set headers for aggressive caching.
if (url.pathname.startsWith(`${PREFIX}/assets/`)) {
const filePath = join(baseDir, url.pathname.replace(PREFIX, ''))
const exists = existsSync(filePath)
const stats = statSync(filePath)
const filePath = join(baseDir, url.pathname.replace(PREFIX, ''));
const exists = existsSync(filePath);
const stats = statSync(filePath);
if (exists && stats.isFile()) {
// Build assets are cache-bust friendly so we can cache them heavily
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
const fileStream = createReadStream(filePath)
const type = mime.getType(filePath)
const fileStream = createReadStream(filePath);
const type = mime.getType(filePath);
res.setHeader('Content-Length', stats.size)
res.setHeader('Content-Type', type)
fileStream.pipe(res)
return
res.setHeader('Content-Length', stats.size);
res.setHeader('Content-Type', type);
fileStream.pipe(res);
return;
}
}
// Handling the request
const controller = new AbortController()
res.on('close', () => controller.abort())
const controller = new AbortController();
res.on('close', () => controller.abort());
const headers = new Headers()
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (!value) continue
if (!value) continue;
if (Array.isArray(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, {
@ -135,35 +136,36 @@ const http = createServer(async (req, res) => {
signal: controller.signal,
// If we have a body we set a duplex and we load the body
...(req.method !== 'GET' && req.method !== 'HEAD' ? {
body: createReadableStreamFromReadable(req),
duplex: 'half'
} : {}
)
})
...(req.method !== 'GET' && req.method !== 'HEAD'
? {
body: createReadableStreamFromReadable(req),
duplex: 'half',
}
: {}),
});
// Pass our request to the Remix handler and get a response
const response = await handler(remixReq, {
ws: getWss()
})
ws: getWss(),
});
// Handle our response and reply
res.statusCode = response.status
res.statusMessage = response.statusText
res.statusCode = response.status;
res.statusMessage = response.statusText;
for (const [key, value] of response.headers.entries()) {
res.appendHeader(key, value)
res.appendHeader(key, value);
}
if (response.body) {
await writeReadableStreamToWritable(response.body, res)
return
await writeReadableStreamToWritable(response.body, res);
return;
}
res.end()
})
res.end();
});
registerWss(http)
registerWss(http);
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) {
const date = new Date().toISOString()
console.log(`${date} (${level}) [${topic}] ${message}`)
const date = new Date().toISOString();
console.log(`${date} (${level}) [${topic}] ${message}`);
}

View File

@ -1,28 +1,28 @@
// The Websocket server is wholly responsible for ingesting messages from
// Headplane agent instances (hopefully not more than 1 is running lol)
import { WebSocketServer } from 'ws'
import { log } from './utils.mjs'
import { WebSocketServer } from 'ws';
import { log } from './utils.mjs';
const wss = new WebSocketServer({ noServer: true })
const wss = new WebSocketServer({ noServer: true });
wss.on('connection', (ws, req) => {
// On connection the agent will send its NodeID via Headers
// 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) {
ws.close(1008, 'ERR_NO_HP_TS_NODE_ID')
return
ws.close(1008, 'ERR_NO_HP_TS_NODE_ID');
return;
}
})
});
export async function registerWss(server) {
log('SRVX', 'INFO', 'Registering Websocket Server')
log('SRVX', 'INFO', 'Registering Websocket Server');
server.on('upgrade', (request, socket, head) => {
wss.handleUpgrade(request, socket, head, ws => {
wss.emit('connection', ws, request)
})
})
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
}
export function getWss() {
return wss
return wss;
}

View File

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

View File

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

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