chore: switch to react-router v7
This commit is contained in:
parent
39504e2487
commit
aa9872a45b
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,5 @@
|
||||
node_modules
|
||||
|
||||
/.react-router
|
||||
/.cache
|
||||
/build
|
||||
.env
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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');
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
@ -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} />
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
31
app/root.tsx
31
app/root.tsx
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
]),
|
||||
])
|
||||
]
|
||||
|
||||
]),
|
||||
];
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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't install Tailscale on
|
||||
by advertising IP ranges as subnet routes.
|
||||
{' '}
|
||||
Connect to devices you can'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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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' };
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -4,4 +4,4 @@ export type Key = {
|
||||
expiration: string;
|
||||
createdAt: Date;
|
||||
lastSeen: Date;
|
||||
}
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
33
biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
3402
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
5
react-router.config.ts
Normal file
5
react-router.config.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { Config } from '@react-router/dev/config';
|
||||
|
||||
export default {
|
||||
basename: '/admin',
|
||||
} satisfies Config;
|
||||
@ -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');
|
||||
|
||||
154
server/prod.mjs
154
server/prod.mjs
@ -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}`);
|
||||
});
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user