chore: switch to react-router v7

This commit is contained in:
Aarnav Tale
2024-12-31 10:30:14 +05:30
parent 39504e2487
commit aa9872a45b
101 changed files with 3825 additions and 6796 deletions
+98 -84
View File
@@ -1,122 +1,136 @@
import React, { useEffect } from 'react'
import Merge from 'react-codemirror-merge'
import CodeMirror from '@uiw/react-codemirror'
import * as shopify from '@shopify/lang-jsonc'
import { ClientOnly } from 'remix-utils/client-only'
import { ErrorBoundary } from 'react-error-boundary'
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
import { useState } from 'react'
import { cn } from '~/utils/cn'
import React, { useEffect } from 'react';
import Merge from 'react-codemirror-merge';
import CodeMirror from '@uiw/react-codemirror';
import * as shopify from '@shopify/lang-jsonc';
import { ClientOnly } from 'remix-utils/client-only';
import { ErrorBoundary } from 'react-error-boundary';
import { githubDark, githubLight } from '@uiw/codemirror-theme-github';
import { useState } from 'react';
import { cn } from '~/utils/cn';
import Fallback from './fallback'
import Fallback from './fallback';
interface EditorProps {
isDisabled?: boolean
value: string
onChange: (value: string) => void
isDisabled?: boolean;
value: string;
onChange: (value: string) => void;
}
export function Editor(props: EditorProps) {
const [light, setLight] = useState(false)
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)')
setLight(theme.matches)
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches)
})
})
setLight(theme.matches);
});
});
return (
<div className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)}>
<div
className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)}
>
<div className="overflow-y-scroll h-editor text-sm">
<ErrorBoundary fallback={
<p className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}>
Failed to load the editor.
</p>
}>
<ErrorBoundary
fallback={
<p
className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}
>
Failed to load the editor.
</p>
}
>
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
value={props.value}
height="100%"
extensions={[shopify.jsonc()]}
style={{ height: "100%" }}
theme={light ? githubLight : githubDark}
onChange={(value) => props.onChange(value)}
/>
)}
{() => (
<CodeMirror
value={props.value}
height="100%"
extensions={[shopify.jsonc()]}
style={{ height: '100%' }}
theme={light ? githubLight : githubDark}
onChange={(value) => props.onChange(value)}
/>
)}
</ClientOnly>
</ErrorBoundary>
</div>
</div>
)
);
}
interface DifferProps {
left: string
right: string
left: string;
right: string;
}
export function Differ(props: DifferProps) {
const [light, setLight] = useState(false)
const [light, setLight] = useState(false);
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)')
setLight(theme.matches)
const theme = window.matchMedia('(prefers-color-scheme: light)');
setLight(theme.matches);
theme.addEventListener('change', (theme) => {
setLight(theme.matches)
})
})
setLight(theme.matches);
});
});
return (
<div className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)}>
<div
className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)}
>
<div className="overflow-y-scroll h-editor text-sm">
{props.left === props.right ? (
<p className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}>
<p
className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}
>
No changes
</p>
) : (
<ErrorBoundary fallback={
<p className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}>
Failed to load the editor.
</p>
}>
<ClientOnly fallback={<Fallback acl={props.right} />}>
{() => (
<Merge
orientation="a-b"
theme={light ? githubLight : githubDark}
<ErrorBoundary
fallback={
<p
className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}
>
<Merge.Original
readOnly
value={props.left}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
readOnly
value={props.right}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
Failed to load the editor.
</p>
}
>
<ClientOnly fallback={<Fallback acl={props.right} />}>
{() => (
<Merge
orientation="a-b"
theme={light ? githubLight : githubDark}
>
<Merge.Original
readOnly
value={props.left}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
readOnly
value={props.right}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
</ClientOnly>
</ErrorBoundary>
)}
</div>
</div>
)
);
}
+10 -15
View File
@@ -1,30 +1,25 @@
import { cn } from '~/utils/cn'
import { AlertIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn';
import { AlertIcon } from '@primer/octicons-react';
import Card from '~/components/Card'
import Code from '~/components/Code'
import Card from '~/components/Card';
import Code from '~/components/Code';
interface Props {
message: string
message: string;
}
export function ErrorView({ message }: Props) {
return (
<Card variant="flat" className="max-w-full mb-4">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">
Error
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500"/>
<Card.Title className="text-xl mb-0">Error</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Could not apply changes to your ACL policy
due to the following error:
Could not apply changes to your ACL policy due to the following error:
<br />
<Code>
{message}
</Code>
<Code>{message}</Code>
</Card.Text>
</Card>
)
);
}
+4 -4
View File
@@ -1,8 +1,8 @@
import Spinner from '~/components/Spinner'
import { cn } from '~/utils/cn'
import Spinner from '~/components/Spinner';
import { cn } from '~/utils/cn';
interface Props {
readonly acl: string
readonly acl: string;
}
export default function Fallback({ acl }: Props) {
@@ -20,5 +20,5 @@ export default function Fallback({ acl }: Props) {
value={acl}
/>
</div>
)
);
}
+17 -21
View File
@@ -1,43 +1,39 @@
import { cn } from '~/utils/cn'
import { AlertIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn';
import { AlertIcon } from '@primer/octicons-react';
import Code from '~/components/Code'
import Card from '~/components/Card'
import Code from '~/components/Code';
import Card from '~/components/Card';
interface Props {
mode: 'file' | 'database'
mode: 'file' | 'database';
}
export function Unavailable({ mode }: Props) {
return (
<Card variant="flat" className="max-w-prose mt-12">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">
ACL Policy Unavailable
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500"/>
<Card.Title className="text-xl mb-0">ACL Policy Unavailable</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Unable to load a valid ACL policy configuration.
This is most likely due to a misconfiguration in your
Headscale configuration file.
Unable to load a valid ACL policy configuration. This is most likely due
to a misconfiguration in your Headscale configuration file.
</Card.Text>
{mode !== 'file' ? (
<p className="mt-4 text-sm">
According to your configuration, the ACL policy mode
is set to <Code>file</Code> but the ACL file is not
available. Ensure that the <Code>policy.path</Code> is
set to a valid path in your Headscale configuration.
According to your configuration, the ACL policy mode is set to{' '}
<Code>file</Code> but the ACL file is not available. Ensure that the{' '}
<Code>policy.path</Code> is set to a valid path in your Headscale
configuration.
</p>
) : (
<p className="mt-4 text-sm">
In order to fully utilize the ACL management features of
Headplane, please set <Code>policy.mode</Code> to either
{' '}<Code>file</Code> or <Code>database</Code> in your
Headscale configuration.
In order to fully utilize the ACL management features of Headplane,
please set <Code>policy.mode</Code> to either <Code>file</Code> or{' '}
<Code>database</Code> in your Headscale configuration.
</p>
)}
</Card>
)
);
}
+150 -152
View File
@@ -1,32 +1,37 @@
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData, useRevalidator } from '@remix-run/react'
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher'
import { useEffect, useState, useMemo } from 'react'
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import { setTimeout } from 'node:timers/promises'
import {
BeakerIcon,
EyeIcon,
IssueDraftIcon,
PencilIcon,
} from '@primer/octicons-react';
import { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData, useRevalidator } from 'react-router';
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher';
import { useEffect, useState, useMemo } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { setTimeout } from 'node:timers/promises';
import Button from '~/components/Button'
import Code from '~/components/Code'
import Link from '~/components/Link'
import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { HeadscaleError, pull, put } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { send } from '~/utils/res'
import log from '~/utils/log'
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Spinner from '~/components/Spinner';
import { toast } from '~/components/Toaster';
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale';
import { HeadscaleError, pull, put } from '~/utils/headscale';
import { getSession } from '~/utils/sessions';
import { send } from '~/utils/res';
import log from '~/utils/log';
import { Editor, Differ } from './components/cm.client'
import { Unavailable } from './components/unavailable'
import { ErrorView } from './components/error'
import { Editor, Differ } from './components/cm.client';
import { Unavailable } from './components/unavailable';
import { ErrorView } from './components/error';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const session = await getSession(request.headers.get('Cookie'));
// The way policy is handled in 0.23 of Headscale and later is verbose.
// The 2 ACL policy modes are either the database one or file one
//
@@ -51,11 +56,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
// We can do damage control by checking for write access and if we are not
// able to PUT an ACL policy on the v1/policy route, we can already know
// that the policy is at the very-least readonly or not available.
const context = await loadContext()
let modeGuess = 'database' // Assume database mode
const context = await loadContext();
let modeGuess = 'database'; // Assume database mode
if (context.config.read) {
const config = await loadConfig()
modeGuess = config.policy?.mode ?? 'database'
const config = await loadConfig();
modeGuess = config.policy?.mode ?? 'database';
}
// Attempt to load the policy, for both the frontend and for checking
@@ -64,23 +69,19 @@ export async function loader({ request }: LoaderFunctionArgs) {
const { policy } = await pull<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
)
);
let write = false // On file mode we already know it's readonly
let write = false; // On file mode we already know it's readonly
if (modeGuess === 'database' && policy.length > 0) {
try {
await put('v1/policy', session.get('hsApiKey')!, {
policy: policy,
})
});
write = true
write = true;
} catch (error) {
write = false
log.debug(
'APIC',
'Failed to write to ACL policy with error %s',
error
)
write = false;
log.debug('APIC', 'Failed to write to ACL policy with error %s', error);
}
}
@@ -88,8 +89,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
read: true,
write,
mode: modeGuess,
policy
}
policy,
};
} catch {
// If we are explicit on file mode then this is the end of the road
if (modeGuess === 'file') {
@@ -97,8 +98,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
read: false,
write: false,
mode: modeGuess,
policy: null
}
policy: null,
};
}
// Assume that we have write access otherwise?
@@ -108,124 +109,119 @@ export async function loader({ request }: LoaderFunctionArgs) {
read: true,
write: true,
mode: modeGuess,
policy: null
}
policy: null,
};
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send({ success: false, error: null }, 401)
return send({ success: false, error: null }, 401);
}
try {
const { acl } = await request.json() as { acl: string }
const { acl } = (await request.json()) as { acl: string };
const { policy } = await put<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
{
policy: acl,
}
)
},
);
return { success: true, policy, error: null }
return { success: true, policy, error: null };
} catch (error) {
log.debug('APIC', 'Failed to update ACL policy with error %s', error)
log.debug('APIC', 'Failed to update ACL policy with error %s', error);
// @ts-ignore: Shut UP we know it's a string most of the time
const text = JSON.parse(error.message)
return send({ success: false, error: text.message }, {
status: error instanceof HeadscaleError ? error.status : 500,
})
const text = JSON.parse(error.message);
return send(
{ success: false, error: text.message },
{
status: error instanceof HeadscaleError ? error.status : 500,
},
);
}
return { success: true, error: null }
return { success: true, error: null };
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const fetcher = useDebounceFetcher<typeof action>()
const revalidator = useRevalidator()
const data = useLoaderData<typeof loader>();
const fetcher = useDebounceFetcher<typeof action>();
const revalidator = useRevalidator();
const [acl, setAcl] = useState(data.policy ?? '')
const [toasted, setToasted] = useState(false)
const [acl, setAcl] = useState(data.policy ?? '');
const [toasted, setToasted] = useState(false);
useEffect(() => {
if (!fetcher.data || toasted) {
return
return;
}
// @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data.success) {
toast('Updated tailnet ACL policy')
toast('Updated tailnet ACL policy');
} else {
toast('Failed to update tailnet ACL policy')
toast('Failed to update tailnet ACL policy');
}
setToasted(true)
setToasted(true);
if (revalidator.state === 'idle') {
revalidator.revalidate()
revalidator.revalidate();
}
}, [fetcher.data, toasted, data.policy])
}, [fetcher.data, toasted, data.policy]);
// The state for if the save and discard buttons should be disabled
// is pretty complicated to calculate and varies on different states.
const disabled = useMemo(() => {
if (!data.read || !data.write) {
return true
return true;
}
// First check our fetcher states
if (fetcher.state === 'loading') {
return true
return true;
}
if (revalidator.state === 'loading') {
return true
return true;
}
// If we have a failed fetcher state allow the user to try again
// @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data?.success === false) {
return false
return false;
}
return data.policy === acl
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl])
return data.policy === acl;
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]);
return (
<div>
{data.read && !data.write
? (
<div className="mb-4">
<Notice className="w-fit">
The ACL policy is read-only. You can view the current policy
but you cannot make changes to it.
<br />
To resolve this, you need to set the ACL policy mode to
database in your Headscale configuration.
</Notice>
</div>
) : undefined}
<h1 className="text-2xl font-medium mb-4">
Access Control List (ACL)
</h1>
{data.read && !data.write ? (
<div className="mb-4">
<Notice className="w-fit">
The ACL policy is read-only. You can view the current policy but you
cannot make changes to it.
<br />
To resolve this, you need to set the ACL policy mode to database in
your Headscale configuration.
</Notice>
</div>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network.
You can find more information about the ACL file in the
{' '}
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>
{' '}
and the
{' '}
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
@@ -234,73 +230,71 @@ export default function Page() {
</Link>
.
</p>
{
// @ts-ignore: useDebounceFetcher is not typed correctly
fetcher.data?.success === false
? (
fetcher.data?.success === false ? (
// @ts-ignore: useDebounceFetcher is not typed correctly
<ErrorView message={fetcher.data.error} />
) : undefined}
) : undefined
}
{data.read ? (
<>
<Tabs>
<TabList className={cn(
'flex border-t border-gray-200 dark:border-gray-700',
'w-fit rounded-t-lg overflow-hidden',
'text-gray-400 dark:text-gray-500',
)}
<TabList
className={cn(
'flex border-t border-gray-200 dark:border-gray-700',
'w-fit rounded-t-lg overflow-hidden',
'text-gray-400 dark:text-gray-500',
)}
>
<Tab
id="edit"
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tl-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
className={({ isSelected }) =>
cn(
'px-4 py-2 rounded-tl-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
>
<PencilIcon className="w-5 h-5" />
<p>Edit file</p>
</Tab>
<Tab
id="diff"
className={({ isSelected }) => cn(
'px-4 py-2',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
className={({ isSelected }) =>
cn(
'px-4 py-2',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
>
<EyeIcon className="w-5 h-5" />
<p>Preview changes</p>
</Tab>
<Tab
id="preview"
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tr-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
className={({ isSelected }) =>
cn(
'px-4 py-2 rounded-tr-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)
}
>
<BeakerIcon className="w-5 h-5" />
<p>Preview rules</p>
</Tab>
</TabList>
<TabPanel id="edit">
<Editor
isDisabled={!data.write}
value={acl}
onChange={setAcl}
/>
<Editor isDisabled={!data.write} value={acl} onChange={setAcl} />
</TabPanel>
<TabPanel id="diff">
<Differ
left={data?.policy ?? ''}
right={acl}
/>
<Differ left={data?.policy ?? ''} right={acl} />
</TabPanel>
<TabPanel id="preview">
<div
@@ -312,8 +306,9 @@ export default function Page() {
>
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
<p className="w-1/2 text-center mt-4">
The Preview rules is very much still a work in progress.
It is a bit complicated to implement right now but hopefully it will be available soon.
The Preview rules is very much still a work in progress. It is
a bit complicated to implement right now but hopefully it will
be available soon.
</p>
</div>
</TabPanel>
@@ -323,32 +318,35 @@ export default function Page() {
className="mr-2"
isDisabled={disabled}
onPress={() => {
setToasted(false)
fetcher.submit({
acl,
}, {
method: 'PATCH',
encType: 'application/json',
})
setToasted(false);
fetcher.submit(
{
acl,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Save
</Button>
<Button
isDisabled={disabled}
onPress={() => {
setAcl(data?.policy ?? '')
setAcl(data?.policy ?? '');
}}
>
Discard Changes
</Button>
</>
) : <Unavailable mode={data.mode as "database" | "file"} />}
) : (
<Unavailable mode={data.mode as 'database' | 'file'} />
)}
</div>
)
);
}
+76 -99
View File
@@ -1,75 +1,75 @@
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, useActionData, useLoaderData } from '@remix-run/react'
import { useMemo } from 'react'
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from 'react-router';
import { Form, useActionData, useLoaderData } from 'react-router';
import { useMemo } from 'react';
import Button from '~/components/Button'
import Card from '~/components/Card'
import Code from '~/components/Code'
import TextField from '~/components/TextField'
import { Key } from '~/types'
import { loadContext } from '~/utils/config/headplane'
import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc'
import { commitSession, getSession } from '~/utils/sessions'
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import TextField from '~/components/TextField';
import { Key } from '~/types';
import { loadContext } from '~/utils/config/headplane';
import { pull } from '~/utils/headscale';
import { startOidc } from '~/utils/oidc';
import { commitSession, getSession } from '~/utils/sessions';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session),
},
})
});
}
const context = await loadContext()
const context = await loadContext();
// Only set if OIDC is properly enabled anyways
if (context.oidc?.disableKeyLogin) {
return startOidc(context.oidc, request)
return startOidc(context.oidc, request);
}
return {
oidc: context.oidc?.issuer,
apiKey: !context.oidc?.disableKeyLogin,
}
};
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const oidcStart = formData.get('oidc-start')
const formData = await request.formData();
const oidcStart = formData.get('oidc-start');
if (oidcStart) {
const context = await loadContext()
const context = await loadContext();
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided')
throw new Error('An invalid OIDC configuration was provided');
}
// We know it exists here because this action only happens on OIDC
return startOidc(context.oidc, request)
return startOidc(context.oidc, request);
}
const apiKey = String(formData.get('api-key'))
const session = await getSession(request.headers.get('Cookie'))
const apiKey = String(formData.get('api-key'));
const session = await getSession(request.headers.get('Cookie'));
// Test the API key
try {
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey)
const key = apiKeys.apiKeys.find(k => apiKey.startsWith(k.prefix))
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey);
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
if (!key) {
throw new Error('Invalid API key')
throw new Error('Invalid API key');
}
const expiry = new Date(key.expiration)
const expiresIn = expiry.getTime() - Date.now()
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24)
const expiry = new Date(key.expiration);
const expiresIn = expiry.getTime() - Date.now();
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
session.set('hsApiKey', apiKey)
session.set('hsApiKey', apiKey);
session.set('user', {
name: key.prefix,
email: `${expiresDays.toString()} days`,
})
});
return redirect('/machines', {
headers: {
@@ -77,85 +77,62 @@ export async function action({ request }: ActionFunctionArgs) {
maxAge: expiresIn,
}),
},
})
});
} catch {
return {
error: 'Invalid API key',
}
};
}
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const actionData = useActionData<typeof action>()
const showOr = useMemo(() => data.oidc && data.apiKey, [data])
const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const showOr = useMemo(() => data.oidc && data.apiKey, [data]);
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0 rounded-2xl">
<Card.Title>
Welcome to Headplane
</Card.Title>
{data.apiKey
? (
<Form method="post">
<Card.Text className="mb-8 text-sm">
Enter an API key to authenticate with Headplane. You can generate
one by running
{' '}
<Code>
headscale apikeys create
</Code>
{' '}
in your terminal.
</Card.Text>
<Card.Title>Welcome to Headplane</Card.Title>
{data.apiKey ? (
<Form method="post">
<Card.Text className="mb-8 text-sm">
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
{actionData?.error
? (
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
)
: undefined}
<TextField
isRequired
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
/>
<Button
className="w-full mt-2.5"
variant="heavy"
type="submit"
>
Login
</Button>
</Form>
)
: undefined}
{showOr
? (
<div className="flex items-center gap-x-1.5 py-1">
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
<span className="text-gray-500 text-sm">or</span>
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
</div>
)
: undefined}
{data.oidc
? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full"
variant="heavy"
type="submit"
>
Login with SSO
</Button>
</Form>
)
: undefined}
{actionData?.error ? (
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
) : undefined}
<TextField
isRequired
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
/>
<Button className="w-full mt-2.5" variant="heavy" type="submit">
Login
</Button>
</Form>
) : undefined}
{showOr ? (
<div className="flex items-center gap-x-1.5 py-1">
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
<span className="text-gray-500 text-sm">or</span>
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
</div>
) : undefined}
{data.oidc ? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button className="w-full" variant="heavy" type="submit">
Login with SSO
</Button>
</Form>
) : undefined}
</Card>
</div>
)
);
}
+7 -7
View File
@@ -1,15 +1,15 @@
import { ActionFunctionArgs, redirect } from '@remix-run/node'
import { destroySession, getSession } from '~/utils/sessions'
import { ActionFunctionArgs, redirect } from 'react-router';
import { destroySession, getSession } from '~/utils/sessions';
export async function loader() {
return redirect('/machines')
return redirect('/machines');
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const session = await getSession(request.headers.get('Cookie'));
return redirect('/login', {
headers: {
'Set-Cookie': await destroySession(session)
}
})
'Set-Cookie': await destroySession(session),
},
});
}
+7 -7
View File
@@ -1,17 +1,17 @@
import { LoaderFunctionArgs, data } from '@remix-run/node'
import { loadContext } from '~/utils/config/headplane'
import { finishOidc } from '~/utils/oidc'
import { LoaderFunctionArgs, data } from 'react-router';
import { loadContext } from '~/utils/config/headplane';
import { finishOidc } from '~/utils/oidc';
export async function loader({ request }: LoaderFunctionArgs) {
try {
const context = await loadContext()
const context = await loadContext();
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided')
throw new Error('An invalid OIDC configuration was provided');
}
return finishOidc(context.oidc, request)
return finishOidc(context.oidc, request);
} catch (error) {
// Gracefully present OIDC errors
return data({ error }, { status: 500 })
return data({ error }, { status: 500 });
}
}
+33 -40
View File
@@ -1,32 +1,27 @@
import { useSubmit } from '@remix-run/react'
import { Button } from 'react-aria-components'
import { useSubmit } from 'react-router';
import { Button } from 'react-aria-components';
import Code from '~/components/Code'
import Link from '~/components/Link'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import Code from '~/components/Code';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import { cn } from '~/utils/cn';
import AddDNS from '../dialogs/dns'
import AddDNS from '../dialogs/dns';
interface Props {
records: { name: string, type: 'A', value: string }[]
isDisabled: boolean
records: { name: string; type: 'A'; value: string }[];
isDisabled: boolean;
}
export default function DNS({ records, isDisabled }: Props) {
const submit = useSubmit()
const submit = useSubmit();
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p className="text-gray-700 dark:text-gray-300">
Headscale supports adding custom DNS records to your Tailnet.
As of now, only
{' '}
<Code>A</Code>
{' '}
records are supported.
{' '}
Headscale supports adding custom DNS records to your Tailnet. As of now,
only <Code>A</Code> records are supported.{' '}
<Link
to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation"
@@ -36,15 +31,12 @@ export default function DNS({ records, isDisabled }: Props) {
</p>
<div className="mt-4">
<TableList className="mb-8">
{records.length === 0
? (
<TableList.Item>
<p className="opacity-50 text-sm mx-auto">
No DNS records found
</p>
</TableList.Item>
)
: records.map((record, index) => (
{records.length === 0 ? (
<TableList.Item>
<p className="opacity-50 text-sm mx-auto">No DNS records found</p>
</TableList.Item>
) : (
records.map((record, index) => (
<TableList.Item key={index}>
<div className="flex gap-24">
<div className="flex gap-2">
@@ -62,27 +54,28 @@ export default function DNS({ records, isDisabled }: Props) {
)}
isDisabled={isDisabled}
onPress={() => {
submit({
'dns.extra_records': records
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
submit(
{
'dns.extra_records': records.filter(
(_, i) => i !== index,
),
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
Remove
</Button>
</TableList.Item>
))}
))
)}
</TableList>
{isDisabled
? undefined
: (
<AddDNS records={records} />
)}
{isDisabled ? undefined : <AddDNS records={records} />}
</div>
</div>
)
);
}
+105 -86
View File
@@ -1,88 +1,88 @@
/* eslint-disable unicorn/no-keyword-prefix */
import {
closestCorners,
DndContext,
DragOverlay
} from '@dnd-kit/core'
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis
} from '@dnd-kit/modifiers'
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react'
import { FetcherWithComponents, useFetcher } from '@remix-run/react'
import { useEffect, useState } from 'react'
import { Button, Input } from 'react-aria-components'
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react';
import { FetcherWithComponents, useFetcher } from 'react-router';
import { useEffect, useState } from 'react';
import { Button, Input } from 'react-aria-components';
import Spinner from '~/components/Spinner'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import Spinner from '~/components/Spinner';
import TableList from '~/components/TableList';
import { cn } from '~/utils/cn';
type Properties = {
readonly baseDomain?: string;
readonly searchDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
};
export default function Domains({ baseDomain, searchDomains, disabled }: Properties) {
export default function Domains({
baseDomain,
searchDomains,
disabled,
}: Properties) {
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
const [activeId, setActiveId] = useState<number | string | null>(null)
const [localDomains, setLocalDomains] = useState(searchDomains)
const [newDomain, setNewDomain] = useState('')
const fetcher = useFetcher()
const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains);
const [newDomain, setNewDomain] = useState('');
const fetcher = useFetcher();
useEffect(() => {
setLocalDomains(searchDomains)
}, [searchDomains])
setLocalDomains(searchDomains);
}, [searchDomains]);
return (
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Search Domains</h1>
<p className='text-gray-700 dark:text-gray-300 mb-2'>
Set custom DNS search domains for your Tailnet.
When using Magic DNS, your tailnet domain is used as the first search domain.
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className="text-gray-700 dark:text-gray-300 mb-2">
Set custom DNS search domains for your Tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain.
</p>
<DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={event => {
setActiveId(event.active.id)
onDragStart={(event) => {
setActiveId(event.active.id);
}}
onDragEnd={event => {
onDragEnd={(event) => {
// eslint-disable-next-line unicorn/no-null
setActiveId(null)
const { active, over } = event
setActiveId(null);
const { active, over } = event;
if (!over) {
return
return;
}
const activeItem = localDomains[active.id as number - 1]
const overItem = localDomains[over.id as number - 1]
const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[(over.id as number) - 1];
if (!activeItem || !overItem) {
return
return;
}
const oldIndex = localDomains.indexOf(activeItem)
const newIndex = localDomains.indexOf(overItem)
const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem);
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex))
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
>
<TableList>
{baseDomain ? (
<TableList.Item key='magic-dns-sd'>
<p className='font-mono text-sm'>{baseDomain}</p>
<LockIcon className='h-4 w-4'/>
<TableList.Item key="magic-dns-sd">
<p className="font-mono text-sm">{baseDomain}</p>
<LockIcon className="h-4 w-4" />
</TableList.Item>
) : undefined}
<SortableContext
@@ -101,25 +101,27 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
/>
))}
<DragOverlay adjustScale>
{activeId ? <Domain
isDrag
domain={localDomains[activeId as number - 1]}
localDomains={localDomains}
id={activeId as number - 1}
disabled={disabled}
fetcher={fetcher}
/> : undefined}
{activeId ? (
<Domain
isDrag
domain={localDomains[(activeId as number) - 1]}
localDomains={localDomains}
id={(activeId as number) - 1}
disabled={disabled}
fetcher={fetcher}
/>
) : undefined}
</DragOverlay>
</SortableContext>
{disabled ? undefined : (
<TableList.Item key='add-sd'>
<TableList.Item key="add-sd">
<Input
type='text'
className='font-mono text-sm bg-transparent w-full mr-2'
placeholder='Search Domain'
type="text"
className="font-mono text-sm bg-transparent w-full mr-2"
placeholder="Search Domain"
value={newDomain}
onChange={event => {
setNewDomain(event.target.value)
onChange={(event) => {
setNewDomain(event.target.value);
}}
/>
{fetcher.state === 'idle' ? (
@@ -128,32 +130,35 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
'text-sm font-semibold',
'text-blue-600 dark:text-blue-400',
'hover:text-blue-700 dark:hover:text-blue-300',
newDomain.length === 0 && 'opacity-50 cursor-not-allowed'
newDomain.length === 0 && 'opacity-50 cursor-not-allowed',
)}
isDisabled={newDomain.length === 0}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns.search_domains': [...localDomains, newDomain]
}, {
method: 'PATCH',
encType: 'application/json'
})
fetcher.submit(
{
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns.search_domains': [...localDomains, newDomain],
},
{
method: 'PATCH',
encType: 'application/json',
},
);
setNewDomain('')
setNewDomain('');
}}
>
Add
</Button>
) : (
<Spinner className='w-3 h-3 mr-0'/>
<Spinner className="w-3 h-3 mr-0" />
)}
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
)
);
}
type DomainProperties = {
@@ -164,17 +169,24 @@ type DomainProperties = {
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
readonly fetcher: FetcherWithComponents<unknown>;
}
};
function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainProperties) {
function Domain({
domain,
id,
localDomains,
isDrag,
disabled,
fetcher,
}: DomainProperties) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id })
isDragging,
} = useSortable({ id });
// TODO: Figure out why TableList.Item breaks dndkit
return (
@@ -184,17 +196,19 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
isDragging ? 'text-gray-400' : '',
isDrag ? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800' : ''
isDrag
? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800'
: '',
)}
style={{
transform: CSS.Transform.toString(transform),
transition
transition,
}}
>
<p className='font-mono text-sm flex items-center gap-4'>
<p className="font-mono text-sm flex items-center gap-4">
{disabled ? undefined : (
<ThreeBarsIcon
className='h-4 w-4 text-gray-400 focus:outline-none'
className="h-4 w-4 text-gray-400 focus:outline-none"
{...attributes}
{...listeners}
/>
@@ -207,21 +221,26 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
disabled && 'opacity-50 cursor-not-allowed'
disabled && 'opacity-50 cursor-not-allowed',
)}
isDisabled={disabled}
onPress={() => {
fetcher.submit({
'dns.search_domains': localDomains.filter((_, index) => index !== id - 1)
}, {
method: 'PATCH',
encType: 'application/json'
})
fetcher.submit(
{
'dns.search_domains': localDomains.filter(
(_, index) => index !== id - 1,
),
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
Remove
</Button>
)}
</div>
)
);
}
+24 -26
View File
@@ -1,53 +1,51 @@
import { useFetcher } from '@remix-run/react'
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner';
type Properties = {
readonly isEnabled: boolean;
readonly disabled?: boolean;
}
};
export default function Modal({ isEnabled, disabled }: Properties) {
const fetcher = useFetcher()
const fetcher = useFetcher();
return (
<Dialog>
<Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
{fetcher.state === 'idle' ? undefined : <Spinner className="w-3 h-3" />}
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel>
{close => (
{(close) => (
<>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain.
The search domain will also be disabled.
Devices will no longer be accessible via your tailnet domain. The
search domain will also be disabled.
</Dialog.Text>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
onPress={close}
>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
variant="confirm"
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns.magic_dns': !isEnabled
}, {
method: 'PATCH',
encType: 'application/json'
})
fetcher.submit(
{
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns.magic_dns': !isEnabled,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
close()
close();
}}
>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
@@ -57,5 +55,5 @@ export default function Modal({ isEnabled, disabled }: Properties) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
+75 -66
View File
@@ -1,17 +1,17 @@
import { useSubmit } from '@remix-run/react'
import { useState } from 'react'
import { Button } from 'react-aria-components'
import { useSubmit } from 'react-router';
import { useState } from 'react';
import { Button } from 'react-aria-components';
import Link from '~/components/Link'
import Switch from '~/components/Switch'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import Link from '~/components/Link';
import Switch from '~/components/Switch';
import TableList from '~/components/TableList';
import { cn } from '~/utils/cn';
import AddNameserver from '../dialogs/nameserver'
import AddNameserver from '../dialogs/nameserver';
interface Props {
nameservers: Record<string, string[]>
isDisabled: boolean
nameservers: Record<string, string[]>;
isDisabled: boolean;
}
export default function Nameservers({ nameservers, isDisabled }: Props) {
@@ -19,9 +19,8 @@ export default function Nameservers({ nameservers, isDisabled }: Props) {
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p className="text-gray-700 dark:text-gray-300">
Set the nameservers used by devices on the Tailnet
to resolve DNS queries.
{' '}
Set the nameservers used by devices on the Tailnet to resolve DNS
queries.{' '}
<Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
@@ -30,7 +29,7 @@ export default function Nameservers({ nameservers, isDisabled }: Props) {
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map(key => (
{Object.keys(nameservers).map((key) => (
<NameserverList
key={key}
isGlobal={key === 'global'}
@@ -40,28 +39,29 @@ export default function Nameservers({ nameservers, isDisabled }: Props) {
/>
))}
{isDisabled
? undefined
: (
<AddNameserver nameservers={nameservers} />
)}
{isDisabled ? undefined : <AddNameserver nameservers={nameservers} />}
</div>
</div>
)
);
}
interface ListProps {
isGlobal: boolean
isDisabled: boolean
nameservers: string[]
name: string
isGlobal: boolean;
isDisabled: boolean;
nameservers: string[];
name: string;
}
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
const submit = useSubmit()
const list = isGlobal ? nameservers['global'] : nameservers[name]
function NameserverList({
isGlobal,
isDisabled,
nameservers,
name,
}: ListProps) {
const submit = useSubmit();
const list = isGlobal ? nameservers['global'] : nameservers[name];
if (list.length === 0) {
return null
return null;
}
return (
@@ -72,45 +72,54 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps)
</h2>
</div>
<TableList>
{list.length > 0 ? list.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className="font-mono text-sm">{ns}</p>
<Button
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
isDisabled && 'opacity-50 cursor-not-allowed',
)}
isDisabled={isDisabled}
onPress={() => {
if (isGlobal) {
submit({
'dns.nameservers.global': list
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
submit({
'dns.nameservers.split': {
...nameservers,
[name]: list.filter((_, i) => i !== index),
{list.length > 0
? list.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className="font-mono text-sm">{ns}</p>
<Button
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
isDisabled && 'opacity-50 cursor-not-allowed',
)}
isDisabled={isDisabled}
onPress={() => {
if (isGlobal) {
submit(
{
'dns.nameservers.global': list.filter(
(_, i) => i !== index,
),
},
{
method: 'PATCH',
encType: 'application/json',
},
);
} else {
submit(
{
'dns.nameservers.split': {
...nameservers,
[name]: list.filter((_, i) => i !== index),
},
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}
}, {
method: 'PATCH',
encType: 'application/json',
})
}
}}
>
Remove
</Button>
</TableList.Item>
)) : undefined}
}}
>
Remove
</Button>
</TableList.Item>
))
: undefined}
</TableList>
</div>
)
);
}
+43 -49
View File
@@ -1,34 +1,28 @@
import { useFetcher } from '@remix-run/react'
import { useState } from 'react'
import { Input } from 'react-aria-components'
import { useFetcher } from 'react-router';
import { useState } from 'react';
import { Input } from 'react-aria-components';
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
import TextField from '~/components/TextField'
import { cn } from '~/utils/cn'
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner';
import TextField from '~/components/TextField';
import { cn } from '~/utils/cn';
type Properties = {
readonly name: string;
readonly disabled?: boolean;
}
};
export default function Modal({ name, disabled }: Properties) {
const [newName, setNewName] = useState(name)
const fetcher = useFetcher()
const [newName, setNewName] = useState(name);
const fetcher = useFetcher();
return (
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Tailnet Name</h1>
<p className='text-gray-700 dark:text-gray-300'>
This is the base domain name of your Tailnet.
Devices are accessible at
{' '}
<Code>
[device].{name}
</Code>
{' '}
when Magic DNS is enabled.
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Tailnet Name</h1>
<p className="text-gray-700 dark:text-gray-300">
This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
readOnly
@@ -36,54 +30,54 @@ export default function Modal({ name, disabled }: Properties) {
'block px-2.5 py-1.5 w-1/2 rounded-lg my-4',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300 text-sm',
'outline-none'
'outline-none',
)}
type='text'
type="text"
value={name}
onFocus={event => {
event.target.select()
onFocus={(event) => {
event.target.select();
}}
/>
<Dialog>
<Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
<Spinner className="w-3 h-3" />
)}
Rename Tailnet
</Dialog.Button>
<Dialog.Panel>
{close => (
{(close) => (
<>
<Dialog.Title>
Rename Tailnet
</Dialog.Title>
<Dialog.Title>Rename Tailnet</Dialog.Title>
<Dialog.Text>
Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.
Keep in mind that changing this can lead to all sorts of
unexpected behavior and may break existing devices in your
tailnet.
</Dialog.Text>
<TextField
label='Tailnet name'
placeholder='ts.net'
label="Tailnet name"
placeholder="ts.net"
state={[newName, setNewName]}
className='my-2'
className="my-2"
/>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
onPress={close}
>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
variant="confirm"
onPress={() => {
fetcher.submit({
'dns.base_domain': newName
}, {
method: 'PATCH',
encType: 'application/json'
})
fetcher.submit(
{
'dns.base_domain': newName,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
close()
close();
}}
>
Rename
@@ -94,5 +88,5 @@ export default function Modal({ name, disabled }: Properties) {
</Dialog.Panel>
</Dialog>
</div>
)
);
}
+49 -67
View File
@@ -1,66 +1,65 @@
import { Form, useSubmit } from '@remix-run/react'
import { useMemo, useState } from 'react'
import { Form, useSubmit } from 'react-router';
import { useMemo, useState } from 'react';
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import { cn } from '~/utils/cn'
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import { cn } from '~/utils/cn';
interface Props {
records: { name: string, type: 'A', value: string }[]
records: { name: string; type: 'A'; value: string }[];
}
export default function AddDNS({ records }: Props) {
const submit = useSubmit()
const [name, setName] = useState('')
const [ip, setIp] = useState('')
const submit = useSubmit();
const [name, setName] = useState('');
const [ip, setIp] = useState('');
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false
const lookup = records.find(record => record.name === name)
if (!lookup) return false
if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find((record) => record.name === name);
if (!lookup) return false;
return lookup.value === ip
}, [records, name, ip])
return lookup.value === ip;
}, [records, name, ip]);
return (
<Dialog>
<Dialog.Button>
Add DNS record
</Dialog.Button>
<Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel>
{close => (
{(close) => (
<>
<Dialog.Title>
Add DNS record
</Dialog.Title>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault()
if (!name || !ip) return
event.preventDefault();
if (!name || !ip) return;
setName('')
setIp('')
setName('');
setIp('');
submit({
'dns.extra_records': [
...records,
{
name,
type: 'A',
value: ip,
},
],
}, {
method: 'PATCH',
encType: 'application/json',
})
submit(
{
'dns.extra_records': [
...records,
{
name,
type: 'A',
value: ip,
},
],
},
{
method: 'PATCH',
encType: 'application/json',
},
);
close()
close();
}}
>
<TextField
@@ -68,40 +67,23 @@ export default function AddDNS({ records }: Props) {
placeholder="test.example.com"
name="domain"
state={[name, setName]}
className={cn(
'mt-2',
isDuplicate && 'outline outline-red-500',
)}
className={cn('mt-2', isDuplicate && 'outline outline-red-500')}
/>
<TextField
label="IP Address"
placeholder="101.101.101.101"
name="ip"
state={[ip, setIp]}
className={cn(
isDuplicate && 'outline outline-red-500',
)}
className={cn(isDuplicate && 'outline outline-red-500')}
/>
{isDuplicate
? (
<p className="text-sm opacity-50">
A record with the domain name
{' '}
<Code>{name}</Code>
{' '}
and IP address
{' '}
<Code>{ip}</Code>
{' '}
already exists.
</p>
)
: undefined}
{isDuplicate ? (
<p className="text-sm opacity-50">
A record with the domain name <Code>{name}</Code> and IP
address <Code>{ip}</Code> already exists.
</p>
) : undefined}
<div className="mt-6 flex justify-end gap-2 mt-8">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
@@ -117,5 +99,5 @@ export default function AddDNS({ records }: Props) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
+81 -87
View File
@@ -1,81 +1,81 @@
import { RepoForkedIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import { RepoForkedIcon } from '@primer/octicons-react';
import { Form, useSubmit } from 'react-router';
import { useState } from 'react';
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import TextField from '~/components/TextField'
import Tooltip from '~/components/Tooltip'
import { cn } from '~/utils/cn'
import Dialog from '~/components/Dialog';
import Switch from '~/components/Switch';
import TextField from '~/components/TextField';
import Tooltip from '~/components/Tooltip';
import { cn } from '~/utils/cn';
interface Props {
nameservers: Record<string, string[]>
nameservers: Record<string, string[]>;
}
export default function AddNameserver({ nameservers }: Props) {
const submit = useSubmit()
const [split, setSplit] = useState(false)
const [ns, setNs] = useState('')
const [domain, setDomain] = useState('')
const submit = useSubmit();
const [split, setSplit] = useState(false);
const [ns, setNs] = useState('');
const [domain, setDomain] = useState('');
return (
<Dialog>
<Dialog.Button>
Add nameserver
</Dialog.Button>
<Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel>
{close => (
{(close) => (
<>
<Dialog.Title>
Add nameserver
</Dialog.Title>
<Dialog.Text className="font-semibold">
Nameserver
</Dialog.Text>
<Dialog.Title>Add nameserver</Dialog.Title>
<Dialog.Text className="font-semibold">Nameserver</Dialog.Text>
<Dialog.Text className="text-sm">
Use this IPv4 or IPv6 address to resolve names.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault()
if (!ns) return
event.preventDefault();
if (!ns) return;
if (split) {
const splitNs: Record<string, string[]> = {}
const splitNs: Record<string, string[]> = {};
for (const [key, value] of Object.entries(nameservers)) {
if (key === 'global') continue
splitNs[key] = value
if (key === 'global') continue;
splitNs[key] = value;
}
if (Object.keys(splitNs).includes(domain)) {
splitNs[domain].push(ns)
splitNs[domain].push(ns);
} else {
splitNs[domain] = [ns]
splitNs[domain] = [ns];
}
submit({
'dns.nameservers.split': splitNs,
}, {
method: 'PATCH',
encType: 'application/json',
})
submit(
{
'dns.nameservers.split': splitNs,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
} else {
const globalNs = nameservers.global
globalNs.push(ns)
const globalNs = nameservers.global;
globalNs.push(ns);
submit({
'dns.nameservers.global': globalNs,
}, {
method: 'PATCH',
encType: 'application/json',
})
submit(
{
'dns.nameservers.global': globalNs,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}
setNs('')
setDomain('')
setSplit(false)
close()
setNs('');
setDomain('');
setSplit(false);
close();
}}
>
<TextField
@@ -92,20 +92,20 @@ export default function AddNameserver({ nameservers }: Props) {
Restrict to domain
</Dialog.Text>
<Tooltip>
<Tooltip.Button className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
<Tooltip.Button
className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
Split DNS
</Tooltip.Button>
<Tooltip.Body>
Only clients that support split DNS
(Tailscale v1.8 or later for most platforms)
will use this nameserver. Older clients
will ignore it.
Only clients that support split DNS (Tailscale v1.8 or
later for most platforms) will use this nameserver.
Older clients will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
@@ -116,40 +116,34 @@ export default function AddNameserver({ nameservers }: Props) {
<Switch
label="Split DNS"
defaultSelected={split}
onChange={() => { setSplit(!split) }}
onChange={() => {
setSplit(!split);
}}
/>
</div>
{split
? (
<>
<Dialog.Text className="font-semibold mt-8">
Domain
</Dialog.Text>
<TextField
label="Domain"
placeholder="example.com"
name="domain"
state={[domain, setDomain]}
className="my-2"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries
matching this suffix should use the nameserver.
</Dialog.Text>
</>
)
: undefined}
{split ? (
<>
<Dialog.Text className="font-semibold mt-8">
Domain
</Dialog.Text>
<TextField
label="Domain"
placeholder="example.com"
name="domain"
state={[domain, setDomain]}
className="my-2"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this
suffix should use the nameserver.
</Dialog.Text>
</>
) : undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
<Dialog.Action variant="confirm" onPress={close}>
Add
</Dialog.Action>
</div>
@@ -158,5 +152,5 @@ export default function AddNameserver({ nameservers }: Props) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
+43 -52
View File
@@ -1,27 +1,27 @@
import { ActionFunctionArgs } from '@remix-run/node'
import { json, useLoaderData } from '@remix-run/react'
import { ActionFunctionArgs } from 'react-router';
import { json, useLoaderData } from 'react-router';
import Code from '~/components/Code'
import Notice from '~/components/Notice'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig, patchConfig } from '~/utils/config/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import Code from '~/components/Code';
import Notice from '~/components/Notice';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig, patchConfig } from '~/utils/config/headscale';
import { getSession } from '~/utils/sessions';
import { useLiveData } from '~/utils/useLiveData';
import DNS from './components/dns'
import Domains from './components/domains'
import MagicModal from './components/magic'
import Nameservers from './components/nameservers'
import RenameModal from './components/rename'
import DNS from './components/dns';
import Domains from './components/domains';
import MagicModal from './components/magic';
import Nameservers from './components/nameservers';
import RenameModal from './components/rename';
// We do not want to expose every config value
export async function loader() {
const context = await loadContext()
const context = await loadContext();
if (!context.config.read) {
throw new Error('No configuration is available')
throw new Error('No configuration is available');
}
const config = await loadConfig()
const config = await loadConfig();
const dns = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
@@ -32,65 +32,58 @@ export async function loader() {
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
extraRecords: config.dns.extra_records,
}
};
return {
...dns,
...context,
}
};
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send({ success: false }, 401)
return send({ success: false }, 401);
}
const context = await loadContext()
const context = await loadContext();
if (!context.config.write) {
return send({ success: false }, 403)
return send({ success: false }, 403);
}
const data = await request.json() as Record<string, unknown>
await patchConfig(data)
const data = (await request.json()) as Record<string, unknown>;
await patchConfig(data);
if (context.integration?.onConfigChange) {
await context.integration.onConfigChange(context.integration.context)
await context.integration.onConfigChange(context.integration.context);
}
return { success: true }
return { success: true };
}
export default function Page() {
useLiveData({ interval: 5000 })
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 5000 });
const data = useLoaderData<typeof loader>();
const allNs: Record<string, string[]> = {}
const allNs: Record<string, string[]> = {};
for (const key of Object.keys(data.splitDns)) {
allNs[key] = data.splitDns[key]
allNs[key] = data.splitDns[key];
}
allNs.global = data.nameservers
allNs.global = data.nameservers;
return (
<div className="flex flex-col gap-16 max-w-screen-lg">
{data.config.write
? undefined
: (
<Notice>
The Headscale configuration is read-only. You cannot make changes to the configuration
</Notice>
)}
{data.config.write ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to
the configuration
</Notice>
)}
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
<Nameservers
nameservers={allNs}
isDisabled={!data.config.write}
/>
<Nameservers nameservers={allNs} isDisabled={!data.config.write} />
<DNS
records={data.extraRecords}
isDisabled={!data.config.write}
/>
<DNS records={data.extraRecords} isDisabled={!data.config.write} />
<Domains
baseDomain={data.magicDns ? data.baseDomain : undefined}
@@ -101,18 +94,16 @@ export default function Page() {
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4">
Automatically register domain names for each device
on the tailnet. Devices will be accessible at
{' '}
Automatically register domain names for each device on the tailnet.
Devices will be accessible at{' '}
<Code>
[device].
{data.baseDomain}
</Code>
{' '}
</Code>{' '}
when Magic DNS is enabled.
</p>
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
</div>
</div>
)
);
}
+128 -87
View File
@@ -1,167 +1,208 @@
import { ActionFunctionArgs } from '@remix-run/node'
import { del, post } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { send } from '~/utils/res'
import log from '~/utils/log'
import { ActionFunctionArgs } from 'react-router';
import { del, post } from '~/utils/headscale';
import { getSession } from '~/utils/sessions';
import { send } from '~/utils/res';
import log from '~/utils/log';
export async function menuAction(request: ActionFunctionArgs['request']) {
const session = await getSession(request.headers.get('Cookie'))
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send({ message: 'Unauthorized' }, {
status: 401,
})
return send(
{ message: 'Unauthorized' },
{
status: 401,
},
);
}
const data = await request.formData()
const data = await request.formData();
if (!data.has('_method') || !data.has('id')) {
return send({ message: 'No method or ID provided' }, {
status: 400,
})
return send(
{ message: 'No method or ID provided' },
{
status: 400,
},
);
}
const id = String(data.get('id'))
const method = String(data.get('_method'))
const id = String(data.get('id'));
const method = String(data.get('_method'));
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!)
return { message: 'Machine removed' }
await del(`v1/node/${id}`, session.get('hsApiKey')!);
return { message: 'Machine removed' };
}
case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
return { message: 'Machine expired' }
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!);
return { message: 'Machine expired' };
}
case 'rename': {
if (!data.has('name')) {
return send({ message: 'No name provided' }, {
status: 400,
})
return send(
{ message: 'No name provided' },
{
status: 400,
},
);
}
const name = String(data.get('name'))
const name = String(data.get('name'));
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
return { message: 'Machine renamed' }
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!);
return { message: 'Machine renamed' };
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return send({ message: 'No route or enabled provided' }, {
status: 400,
})
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const route = String(data.get('route'))
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
const route = String(data.get('route'));
const enabled = data.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
return { message: 'Route updated' }
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
return { message: 'Route updated' };
}
case 'exit-node': {
if (!data.has('routes') || !data.has('enabled')) {
return send({ message: 'No route or enabled provided' }, {
status: 400,
})
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const routes = data.get('routes')?.toString().split(',') ?? []
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
const routes = data.get('routes')?.toString().split(',') ?? [];
const enabled = data.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await Promise.all(routes.map(async (route) => {
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
}))
await Promise.all(
routes.map(async (route) => {
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
}),
);
return { message: 'Exit node updated' }
return { message: 'Exit node updated' };
}
case 'move': {
if (!data.has('to')) {
return send({ message: 'No destination provided' }, {
status: 400,
})
return send(
{ message: 'No destination provided' },
{
status: 400,
},
);
}
const to = String(data.get('to'))
const to = String(data.get('to'));
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
return { message: `Moved node ${id} to ${to}` }
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!);
return { message: `Moved node ${id} to ${to}` };
} catch {
return send({ message: `Failed to move node ${id} to ${to}` }, {
status: 500,
})
return send(
{ message: `Failed to move node ${id} to ${to}` },
{
status: 500,
},
);
}
}
case 'tags': {
const tags = data.get('tags')?.toString()
.split(',')
.filter((tag) => tag.trim() !== '')
?? []
const tags =
data
.get('tags')
?.toString()
.split(',')
.filter((tag) => tag.trim() !== '') ?? [];
try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
tags,
})
});
return { message: 'Tags updated' }
return { message: 'Tags updated' };
} catch (error) {
log.debug('APIC', 'Failed to update tags: %s', error)
return send({ message: 'Failed to update tags' }, {
status: 500,
})
log.debug('APIC', 'Failed to update tags: %s', error);
return send(
{ message: 'Failed to update tags' },
{
status: 500,
},
);
}
}
case 'register': {
const key = data.get('mkey')?.toString()
const user = data.get('user')?.toString()
const key = data.get('mkey')?.toString();
const user = data.get('user')?.toString();
if (!key) {
return send({ message: 'No machine key provided' }, {
status: 400,
})
return send(
{ message: 'No machine key provided' },
{
status: 400,
},
);
}
if (!user) {
return send({ message: 'No user provided' }, {
status: 400,
})
return send(
{ message: 'No user provided' },
{
status: 400,
},
);
}
try {
const qp = new URLSearchParams()
qp.append('user', user)
qp.append('key', key)
const qp = new URLSearchParams();
qp.append('user', user);
qp.append('key', key);
const url = `v1/node/register?${qp.toString()}`
const url = `v1/node/register?${qp.toString()}`;
await post(url, session.get('hsApiKey')!, {
user, key,
})
user,
key,
});
return {
success: true,
message: 'Machine registered'
}
message: 'Machine registered',
};
} catch {
return send({
success: false,
message: 'Failed to register machine'
}, {
status: 500,
})
return send(
{
success: false,
message: 'Failed to register machine',
},
{
status: 500,
},
);
}
}
default: {
return send({ message: 'Invalid method' }, {
status: 400,
})
return send(
{ message: 'Invalid method' },
{
status: 400,
},
);
}
}
}
+76 -82
View File
@@ -1,68 +1,69 @@
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react'
import { Link } from '@remix-run/react'
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
import { Link } from 'react-router';
import Menu from '~/components/Menu'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
import Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle';
import { toast } from '~/components/Toaster';
import { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn';
import MenuOptions from './menu'
import MenuOptions from './menu';
interface Props {
readonly machine: Machine
readonly routes: Route[]
readonly users: User[]
readonly magic?: string
readonly machine: Machine;
readonly routes: Route[];
readonly users: User[];
readonly magic?: string;
}
export default function MachineRow({ machine, routes, magic, users }: Props) {
const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z'
|| machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now()
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
machine.expiry === '0001-01-01T00:00:00Z' ||
machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [
...machine.forcedTags,
...machine.validTags,
]
const tags = [...machine.forcedTags, ...machine.validTags];
if (expired) {
tags.unshift('Expired')
tags.unshift('Expired');
}
let prefix = magic?.startsWith('[user]')
? magic.replace('[user]', machine.user.name)
: magic
: magic;
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route)
return acc
}
const { exit, subnet, subnetApproved } = routes.reduce(
(acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route);
return acc;
}
if (route.enabled) {
acc.subnetApproved.push(route)
return acc
}
if (route.enabled) {
acc.subnetApproved.push(route);
return acc;
}
acc.subnet.push(route)
return acc
}, { exit: [], subnetApproved: [], subnet: [] })
acc.subnet.push(route);
return acc;
},
{ exit: [], subnetApproved: [], subnet: [] },
);
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false
return exit[0].enabled && exit[1].enabled
}, [exit])
if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled;
}, [exit]);
if (exitEnabled) {
tags.unshift('Exit Node')
tags.unshift('Exit Node');
}
if (subnetApproved.length > 0) {
tags.unshift('Subnets')
tags.unshift('Subnets');
}
return (
@@ -71,15 +72,13 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
>
<td className="pl-0.5 py-2">
<Link
to={`/machines/${machine.id}`}
className="group/link h-full"
>
<p className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400',
)}
<Link to={`/machines/${machine.id}`} className="group/link h-full">
<p
className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
</p>
@@ -87,7 +86,7 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
{machine.name}
</p>
<div className="flex gap-1 mt-1">
{tags.map(tag => (
{tags.map((tag) => (
<span
key={tag}
className={cn(
@@ -110,7 +109,7 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
<ChevronDownIcon className="w-4 h-4" />
</Menu.Button>
<Menu.Items>
{machine.ipAddresses.map(ip => (
{machine.ipAddresses.map((ip) => (
<Menu.ItemButton
key={ip}
type="button"
@@ -119,44 +118,41 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
'justify-between w-full',
)}
onPress={async () => {
await navigator.clipboard.writeText(ip)
toast('Copied IP address to clipboard')
await navigator.clipboard.writeText(ip);
toast('Copied IP address to clipboard');
}}
>
{ip}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
))}
{magic
? (
<Menu.ItemButton
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full break-keep',
)}
onPress={async () => {
const ip = `${machine.givenName}.${prefix}`
await navigator.clipboard.writeText(ip)
toast('Copied hostname to clipboard')
}}
>
{machine.givenName}
.
{prefix}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
)
: undefined}
{magic ? (
<Menu.ItemButton
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full break-keep',
)}
onPress={async () => {
const ip = `${machine.givenName}.${prefix}`;
await navigator.clipboard.writeText(ip);
toast('Copied hostname to clipboard');
}}
>
{machine.givenName}.{prefix}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
) : undefined}
</Menu.Items>
</Menu>
</div>
</td>
<td className="py-2">
<span className={cn(
'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400',
)}
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400',
)}
>
<StatusCircle
isOnline={machine.online && !expired}
@@ -165,9 +161,7 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
<p>
{machine.online && !expired
? 'Connected'
: new Date(
machine.lastSeen,
).toLocaleString()}
: new Date(machine.lastSeen).toLocaleString()}
</p>
</span>
</td>
@@ -180,5 +174,5 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
/>
</td>
</tr>
)
);
}
+47 -68
View File
@@ -1,73 +1,54 @@
import { KebabHorizontalIcon } from '@primer/octicons-react'
import { ReactNode, useState } from 'react'
import { KebabHorizontalIcon } from '@primer/octicons-react';
import { ReactNode, useState } from 'react';
import MenuComponent from '~/components/Menu'
import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
import MenuComponent from '~/components/Menu';
import { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn';
import Delete from '../dialogs/delete'
import Expire from '../dialogs/expire'
import Move from '../dialogs/move'
import Rename from '../dialogs/rename'
import Routes from '../dialogs/routes'
import Tags from '../dialogs/tags'
import Delete from '../dialogs/delete';
import Expire from '../dialogs/expire';
import Move from '../dialogs/move';
import Rename from '../dialogs/rename';
import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags';
interface MenuProps {
machine: Machine
routes: Route[]
users: User[]
magic?: string
buttonChild?: ReactNode
machine: Machine;
routes: Route[];
users: User[];
magic?: string;
buttonChild?: ReactNode;
}
export default function Menu({ machine, routes, magic, users, buttonChild }: MenuProps) {
const renameState = useState(false)
const expireState = useState(false)
const removeState = useState(false)
const routesState = useState(false)
const moveState = useState(false)
const tagsState = useState(false)
export default function Menu({
machine,
routes,
magic,
users,
buttonChild,
}: MenuProps) {
const renameState = useState(false);
const expireState = useState(false);
const removeState = useState(false);
const routesState = useState(false);
const moveState = useState(false);
const tagsState = useState(false);
const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z'
|| machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now()
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
machine.expiry === '0001-01-01T00:00:00Z' ||
machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now();
return (
<>
<Rename
machine={machine}
state={renameState}
magic={magic}
/>
<Delete
machine={machine}
state={removeState}
/>
{expired
? undefined
: (
<Expire
machine={machine}
state={expireState}
/>
)}
<Routes
machine={machine}
routes={routes}
state={routesState}
/>
<Tags
machine={machine}
state={tagsState}
/>
<Move
machine={machine}
state={moveState}
users={users}
magic={magic}
/>
<Rename machine={machine} state={renameState} magic={magic} />
<Delete machine={machine} state={removeState} />
{expired ? undefined : <Expire machine={machine} state={expireState} />}
<Routes machine={machine} routes={routes} state={routesState} />
<Tags machine={machine} state={tagsState} />
<Move machine={machine} state={moveState} users={users} magic={magic} />
<MenuComponent>
{buttonChild ?? (
@@ -94,13 +75,11 @@ export default function Menu({ machine, routes, magic, users, buttonChild }: Men
<MenuComponent.ItemButton control={moveState}>
Change owner
</MenuComponent.ItemButton>
{expired
? undefined
: (
<MenuComponent.ItemButton control={expireState}>
Expire
</MenuComponent.ItemButton>
)}
{expired ? undefined : (
<MenuComponent.ItemButton control={expireState}>
Expire
</MenuComponent.ItemButton>
)}
<MenuComponent.ItemButton
className="text-red-500 dark:text-red-400"
control={removeState}
@@ -110,5 +89,5 @@ export default function Menu({ machine, routes, magic, users, buttonChild }: Men
</MenuComponent.Items>
</MenuComponent>
</>
)
);
}
+16 -23
View File
@@ -1,46 +1,39 @@
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction } from 'react';
import Dialog from '~/components/Dialog'
import { type Machine } from '~/types'
import { cn } from '~/utils/cn'
import Dialog from '~/components/Dialog';
import { type Machine } from '~/types';
import { cn } from '~/utils/cn';
interface DeleteProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
export default function Delete({ machine, state }: DeleteProps) {
const submit = useSubmit()
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
{(close) => (
<>
<Dialog.Title>
Remove
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from
your network. To re-add it, you will need to
reauthenticate to your tailnet from the device.
This machine will be permanently removed from your network. To
re-add it, you will need to reauthenticate to your tailnet from
the device.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
@@ -61,5 +54,5 @@ export default function Delete({ machine, state }: DeleteProps) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
+15 -23
View File
@@ -1,46 +1,38 @@
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction } from 'react';
import Dialog from '~/components/Dialog'
import { type Machine } from '~/types'
import { cn } from '~/utils/cn'
import Dialog from '~/components/Dialog';
import { type Machine } from '~/types';
import { cn } from '~/utils/cn';
interface ExpireProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
export default function Expire({ machine, state }: ExpireProps) {
const submit = useSubmit()
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
{(close) => (
<>
<Dialog.Title>
Expire
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet.
In order to reconnect, you will need to re-authenticate
from the device.
This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
@@ -61,5 +53,5 @@ export default function Expire({ machine, state }: ExpireProps) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
+28 -43
View File
@@ -1,39 +1,35 @@
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react';
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Select from '~/components/Select'
import { type Machine, User } from '~/types'
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Select from '~/components/Select';
import { type Machine, User } from '~/types';
interface MoveProps {
readonly machine: Machine
readonly users: User[]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly magic?: string
readonly machine: Machine;
readonly users: User[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
}
export default function Move({ machine, state, magic, users }: MoveProps) {
const [owner, setOwner] = useState(machine.user.name)
const submit = useSubmit()
const [owner, setOwner] = useState(machine.user.name);
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
{(close) => (
<>
<Dialog.Title>
Change the owner of
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="move" />
@@ -44,37 +40,26 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
placeholder="Select a user"
state={[owner, setOwner]}
>
{users.map(user => (
{users.map((user) => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
{magic
? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname
{' '}
<Code className="text-sm">
{machine.givenName}
.
{magic}
</Code>
.
</p>
)
: undefined}
{magic ? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname{' '}
<Code className="text-sm">
{machine.givenName}.{magic}
</Code>
.
</p>
) : undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
<Dialog.Action variant="confirm" onPress={close}>
Change owner
</Dialog.Action>
</div>
@@ -83,5 +68,5 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
+51 -55
View File
@@ -1,74 +1,73 @@
import { Form, useFetcher, Link } from '@remix-run/react'
import { Dispatch, SetStateAction, useState, useEffect } from 'react'
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
import { Form, useFetcher, Link } from 'react-router';
import { Dispatch, SetStateAction, useState, useEffect } from 'react';
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import Select from '~/components/Select'
import Menu from '~/components/Menu'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import { Machine, User } from '~/types'
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import Select from '~/components/Select';
import Menu from '~/components/Menu';
import Spinner from '~/components/Spinner';
import { toast } from '~/components/Toaster';
import { Machine, User } from '~/types';
export interface NewProps {
server: string
users: User[]
server: string;
users: User[];
}
export default function New(data: NewProps) {
const fetcher = useFetcher<{ success?: boolean }>()
const mkeyState = useState(false)
const [mkey, setMkey] = useState('')
const [user, setUser] = useState('')
const [toasted, setToasted] = useState(false)
const fetcher = useFetcher<{ success?: boolean }>();
const mkeyState = useState(false);
const [mkey, setMkey] = useState('');
const [user, setUser] = useState('');
const [toasted, setToasted] = useState(false);
useEffect(() => {
if (!fetcher.data || toasted) {
return
return;
}
if (fetcher.data.success) {
toast('Registered new machine')
toast('Registered new machine');
} else {
toast('Failed to register machine due to an invalid key')
toast('Failed to register machine due to an invalid key');
}
setToasted(true)
}, [fetcher.data, toasted, mkey])
setToasted(true);
}, [fetcher.data, toasted, mkey]);
return (
<>
<Dialog>
<Dialog.Panel control={mkeyState}>
{close => (
{(close) => (
<>
<Dialog.Title>
Register Machine Key
</Dialog.Title>
<Dialog.Text className='mb-4'>
The machine key is given when you run
{' '}
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
The machine key is given when you run{' '}
<Code isCopyable>
tailscale up --login-server=
{data.server}
</Code>
{' '}
</Code>{' '}
on your device.
</Dialog.Text>
<fetcher.Form method="POST" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<fetcher.Form
method="POST"
onSubmit={(e) => {
fetcher.submit(e.currentTarget);
close();
}}
>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<TextField
label='Machine Key'
placeholder='mkey:ff.....'
label="Machine Key"
placeholder="mkey:ff....."
name="mkey"
state={[mkey, setMkey]}
className='my-2 font-mono'
className="my-2 font-mono"
/>
<Select
label="Owner"
@@ -76,28 +75,25 @@ export default function New(data: NewProps) {
placeholder="Select a user"
state={[user, setUser]}
>
{data.users.map(user => (
{data.users.map((user) => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant="cancel"
onPress={close}
>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
isDisabled={!mkey || !mkey.trim().startsWith('mkey:') || !user}
isDisabled={
!mkey || !mkey.trim().startsWith('mkey:') || !user
}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Register
</Dialog.Action>
</div>
@@ -118,17 +114,17 @@ export default function New(data: NewProps) {
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={mkeyState}>
<ServerIcon className='w-4 h-4 mr-2'/>
<ServerIcon className="w-4 h-4 mr-2" />
Register Machine Key
</Menu.ItemButton>
<Menu.ItemButton>
<Link to="/settings/auth-keys">
<KeyIcon className='w-4 h-4 mr-2'/>
<KeyIcon className="w-4 h-4 mr-2" />
Generate Pre-auth Key
</Link>
</Menu.ItemButton>
</Menu.Items>
</Menu>
</>
)
);
}
+38 -59
View File
@@ -1,39 +1,37 @@
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react';
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import { type Machine } from '~/types'
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import { type Machine } from '~/types';
interface RenameProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly magic?: string
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
}
export default function Rename({ machine, state, magic }: RenameProps) {
const [name, setName] = useState(machine.givenName)
const submit = useSubmit()
const [name, setName] = useState(machine.givenName);
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
{(close) => (
<>
<Dialog.Title>
Edit machine name for
{' '}
{machine.givenName}
Edit machine name for {machine.givenName}
</Dialog.Title>
<Dialog.Text>
This name is shown in the admin panel, in Tailscale clients,
and used when generating MagicDNS names.
This name is shown in the admin panel, in Tailscale clients, and
used when generating MagicDNS names.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="rename" />
@@ -45,49 +43,30 @@ export default function Rename({ machine, state, magic }: RenameProps) {
state={[name, setName]}
className="my-2"
/>
{magic
? (
name.length > 0 && name !== machine.givenName
? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine will be accessible by the hostname
{' '}
<Code className="text-sm">
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname
{' '}
<Code className="text-sm">
{machine.givenName}
</Code>
{' '}
will no longer point to this machine.
</p>
)
: (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname
{' '}
<Code className="text-sm">
{machine.givenName}
</Code>
.
</p>
)
)
: undefined}
{magic ? (
name.length > 0 && name !== machine.givenName ? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine will be accessible by the hostname{' '}
<Code className="text-sm">
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname{' '}
<Code className="text-sm">{machine.givenName}</Code> will no
longer point to this machine.
</p>
) : (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname{' '}
<Code className="text-sm">{machine.givenName}</Code>.
</p>
)
) : undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
<Dialog.Action variant="confirm" onPress={close}>
Rename
</Dialog.Action>
</div>
@@ -96,5 +75,5 @@ export default function Rename({ machine, state, magic }: RenameProps) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
+101 -112
View File
@@ -1,55 +1,53 @@
import { useFetcher } from '@remix-run/react'
import { Dispatch, SetStateAction, useMemo } from 'react'
import { useFetcher } from 'react-router';
import { Dispatch, SetStateAction, useMemo } from 'react';
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import Link from '~/components/Link'
import { Machine, Route } from '~/types'
import { cn } from '~/utils/cn'
import Dialog from '~/components/Dialog';
import Switch from '~/components/Switch';
import Link from '~/components/Link';
import { Machine, Route } from '~/types';
import { cn } from '~/utils/cn';
interface RoutesProps {
readonly machine: Machine
readonly routes: Route[]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly machine: Machine;
readonly routes: Route[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, state }: RoutesProps) {
const fetcher = useFetcher()
const fetcher = useFetcher();
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet } = routes.reduce((acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route)
return acc
}
const { exit, subnet } = routes.reduce(
(acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route);
return acc;
}
acc.subnet.push(route)
return acc
}, { exit: [], subnet: [] })
acc.subnet.push(route);
return acc;
},
{ exit: [], subnet: [] },
);
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false
return exit[0].enabled && exit[1].enabled
}, [exit])
if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled;
}, [exit]);
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
{(close) => (
<>
<Dialog.Title>
Edit route settings of
{' '}
{machine.givenName}
Edit route settings of {machine.givenName}
</Dialog.Title>
<Dialog.Text className="font-bold">
Subnet routes
</Dialog.Text>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on
by advertising IP ranges as subnet routes.
{' '}
Connect to devices you can&apos;t install Tailscale on by
advertising IP ranges as subnet routes.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
@@ -57,28 +55,25 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
Learn More
</Link>
</Dialog.Text>
<div className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700',
)}
<div
className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700',
)}
>
{subnet.length === 0
? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
)
: undefined}
{subnet.map(route => (
{subnet.length === 0 ? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>No routes are advertised on this machine.</p>
</div>
) : undefined}
{subnet.map((route) => (
<div
key={route.id}
className={cn(
@@ -86,33 +81,28 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
'items-center justify-between',
)}
>
<p>
{route.prefix}
</p>
<p>{route.prefix}</p>
<Switch
defaultSelected={route.enabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData()
form.set('id', machine.id)
form.set('_method', 'routes')
form.set('route', route.id)
const form = new FormData();
form.set('id', machine.id);
form.set('_method', 'routes');
form.set('route', route.id);
form.set('enabled', String(checked))
form.set('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
})
});
}}
/>
</div>
))}
</div>
<Dialog.Text className="font-bold mt-8">
Exit nodes
</Dialog.Text>
<Dialog.Text className="font-bold mt-8">Exit nodes</Dialog.Text>
<Dialog.Text>
Allow your network to route internet traffic through this machine.
{' '}
Allow your network to route internet traffic through this machine.{' '}
<Link
to="https://tailscale.com/kb/1103/exit-nodes"
name="Tailscale Exit-node Documentation"
@@ -120,52 +110,51 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
Learn More
</Link>
</Dialog.Text>
<div className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700',
)}
<div
className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700',
)}
>
{exit.length === 0
? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
This machine is not an exit node.
</p>
</div>
) : (
<div
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
)}
>
<p>
Use as exit node
</p>
<Switch
defaultSelected={exitEnabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData()
form.set('id', machine.id)
form.set('_method', 'exit-node')
form.set('routes', exit.map(route => route.id).join(','))
{exit.length === 0 ? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>This machine is not an exit node.</p>
</div>
) : (
<div
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
)}
>
<p>Use as exit node</p>
<Switch
defaultSelected={exitEnabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('id', machine.id);
form.set('_method', 'exit-node');
form.set(
'routes',
exit.map((route) => route.id).join(','),
);
form.set('enabled', String(checked))
fetcher.submit(form, {
method: 'POST',
})
}}
/>
</div>
)}
form.set('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
});
}}
/>
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
@@ -180,5 +169,5 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
+47 -61
View File
@@ -1,51 +1,44 @@
import { PlusIcon, XIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { Dispatch, SetStateAction, useState } from 'react'
import { Button, Input } from 'react-aria-components'
import { PlusIcon, XIcon } from '@primer/octicons-react';
import { Form, useSubmit } from 'react-router';
import { Dispatch, SetStateAction, useState } from 'react';
import { Button, Input } from 'react-aria-components';
import Dialog from '~/components/Dialog'
import Link from '~/components/Link'
import { Machine } from '~/types'
import { cn } from '~/utils/cn'
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import { Machine } from '~/types';
import { cn } from '~/utils/cn';
interface TagsProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
export default function Tags({ machine, state }: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags)
const [tag, setTag] = useState('')
const submit = useSubmit()
const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('');
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
{(close) => (
<>
<Dialog.Title>
Edit ACL tags for
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text>
ACL tags can be used to reference machines in your ACL policies.
See the
{' '}
See the{' '}
<Link
to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation"
>
Tailscale documentation
</Link>
{' '}
</Link>{' '}
for more information.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="tags" />
@@ -58,21 +51,18 @@ export default function Tags({ machine, state }: TagsProps) {
)}
>
<div className="divide-y divide-ui-200 dark:divide-ui-600">
{tags.length === 0
? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center rounded-t-lg',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
No tags are set on this machine.
</p>
</div>
)
: tags.map(item => (
{tags.length === 0 ? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center rounded-t-lg',
'text-ui-600 dark:text-ui-300',
)}
>
<p>No tags are set on this machine.</p>
</div>
) : (
tags.map((item) => (
<div
key={item}
id={item}
@@ -86,13 +76,14 @@ export default function Tags({ machine, state }: TagsProps) {
<Button
className="rounded-full p-0 w-6 h-6"
onPress={() => {
setTags(tags.filter(tag => tag !== item))
setTags(tags.filter((tag) => tag !== item));
}}
>
<XIcon className="w-4 h-4" />
</Button>
</div>
))}
))
)}
</div>
<div
className={cn(
@@ -101,8 +92,9 @@ export default function Tags({ machine, state }: TagsProps) {
'rounded-b-lg justify-between items-center',
'dark:bg-ui-800 dark:text-ui-300',
'focus-within:ring-2 focus-within:ring-blue-600',
tag.length > 0 && !tag.startsWith('tag:')
&& 'outline outline-red-500',
tag.length > 0 &&
!tag.startsWith('tag:') &&
'outline outline-red-500',
)}
>
<Input
@@ -115,23 +107,23 @@ export default function Tags({ machine, state }: TagsProps) {
)}
value={tag}
onChange={(e) => {
setTag(e.currentTarget.value)
setTag(e.currentTarget.value);
}}
/>
<Button
className={cn(
'rounded-lg p-0 h-6 w-6',
!tag.startsWith('tag:')
&& 'opacity-50 cursor-not-allowed',
!tag.startsWith('tag:') &&
'opacity-50 cursor-not-allowed',
)}
isDisabled={
tag.length === 0
|| !tag.startsWith('tag:')
|| tags.includes(tag)
tag.length === 0 ||
!tag.startsWith('tag:') ||
tags.includes(tag)
}
onPress={() => {
setTags([...tags, tag])
setTag('')
setTags([...tags, tag]);
setTag('');
}}
>
<PlusIcon className="w-4 h-4" />
@@ -139,16 +131,10 @@ export default function Tags({ machine, state }: TagsProps) {
</div>
</div>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
<Dialog.Action variant="confirm" onPress={close}>
Save
</Dialog.Action>
</div>
@@ -157,5 +143,5 @@ export default function Tags({ machine, state }: TagsProps) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
+121 -153
View File
@@ -1,40 +1,46 @@
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { Link as RemixLink, useLoaderData } from '@remix-run/react'
import { InfoIcon, GearIcon, CheckCircleIcon, SkipIcon, PersonIcon } from '@primer/octicons-react'
import { useMemo, useState } from 'react'
import { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { Link as RemixLink, useLoaderData } from 'react-router';
import {
InfoIcon,
GearIcon,
CheckCircleIcon,
SkipIcon,
PersonIcon,
} from '@primer/octicons-react';
import { useMemo, useState } from 'react';
import Attribute from '~/components/Attribute'
import Button from '~/components/Button'
import Card from '~/components/Card'
import Menu from '~/components/Menu'
import Tooltip from '~/components/Tooltip'
import StatusCircle from '~/components/StatusCircle'
import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import Link from '~/components/Link'
import Attribute from '~/components/Attribute';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Menu from '~/components/Menu';
import Tooltip from '~/components/Tooltip';
import StatusCircle from '~/components/StatusCircle';
import { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions';
import { useLiveData } from '~/utils/useLiveData';
import Link from '~/components/Link';
import { menuAction } from './action'
import MenuOptions from './components/menu'
import Routes from './dialogs/routes'
import { menuAction } from './action';
import MenuOptions from './components/menu';
import Routes from './dialogs/routes';
export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const session = await getSession(request.headers.get('Cookie'));
if (!params.id) {
throw new Error('No machine ID provided')
throw new Error('No machine ID provided');
}
const context = await loadContext()
let magic: string | undefined
const context = await loadContext();
let magic: string | undefined;
if (context.config.read) {
const config = await loadConfig()
const config = await loadConfig();
if (config.dns.magic_dns) {
magic = config.dns.base_domain
magic = config.dns.base_domain;
}
}
@@ -42,110 +48,106 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
])
]);
return {
machine: machine.node,
routes: routes.routes.filter(route => route.node.id === params.id),
routes: routes.routes.filter((route) => route.node.id === params.id),
users: users.users,
magic,
}
};
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request)
return menuAction(request);
}
export default function Page() {
const { machine, magic, routes, users } = useLoaderData<typeof loader>()
const routesState = useState(false)
useLiveData({ interval: 1000 })
const { machine, magic, routes, users } = useLoaderData<typeof loader>();
const routesState = useState(false);
useLiveData({ interval: 1000 });
const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z'
|| machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now()
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
machine.expiry === '0001-01-01T00:00:00Z' ||
machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [
...machine.forcedTags,
...machine.validTags,
]
const tags = [...machine.forcedTags, ...machine.validTags];
if (expired) {
tags.unshift('Expired')
tags.unshift('Expired');
}
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route)
return acc
}
const { exit, subnet, subnetApproved } = routes.reduce(
(acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route);
return acc;
}
if (route.enabled) {
acc.subnetApproved.push(route)
return acc
}
if (route.enabled) {
acc.subnetApproved.push(route);
return acc;
}
acc.subnet.push(route)
return acc
}, { exit: [], subnetApproved: [], subnet: [] })
acc.subnet.push(route);
return acc;
},
{ exit: [], subnetApproved: [], subnet: [] },
);
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false
return exit[0].enabled && exit[1].enabled
}, [exit])
if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled;
}, [exit]);
if (exitEnabled) {
tags.unshift('Exit Node')
tags.unshift('Exit Node');
}
if (subnetApproved.length > 0) {
tags.unshift('Subnets')
tags.unshift('Subnets');
}
return (
<div>
<p className="mb-8 text-md">
<RemixLink
to="/machines"
className="font-medium"
>
<RemixLink to="/machines" className="font-medium">
All Machines
</RemixLink>
<span className="mx-2">
/
</span>
<span className="mx-2">/</span>
{machine.givenName}
</p>
<div className={cn(
'flex justify-between items-center',
'border-b border-ui-100 dark:border-ui-800',
)}>
<div
className={cn(
'flex justify-between items-center',
'border-b border-ui-100 dark:border-ui-800',
)}
>
<span className="flex items-baseline gap-x-4 text-sm mb-4">
<h1 className="text-2xl font-medium">
{machine.givenName}
</h1>
<h1 className="text-2xl font-medium">{machine.givenName}</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
</span>
<MenuOptions
className={cn(
'bg-ui-100 dark:bg-ui-800',
)}
className={cn('bg-ui-100 dark:bg-ui-800')}
machine={machine}
routes={routes}
users={users}
magic={magic}
buttonChild={
<Menu.Button className={cn(
'flex items-center justify-center gap-x-2',
'bg-main-200 dark:bg-main-700/30',
'hover:bg-main-300 dark:hover:bg-main-600/30',
'text-ui-700 dark:text-ui-300 mb-2',
'w-fit text-sm rounded-lg px-3 py-2'
)}>
<Menu.Button
className={cn(
'flex items-center justify-center gap-x-2',
'bg-main-200 dark:bg-main-700/30',
'hover:bg-main-300 dark:hover:bg-main-600/30',
'text-ui-700 dark:text-ui-300 mb-2',
'w-fit text-sm rounded-lg px-3 py-2',
)}
>
<GearIcon className="w-5" />
Machine Settings
</Menu.Button>
@@ -166,21 +168,21 @@ export default function Page() {
</Tooltip>
</span>
<div className="flex items-center gap-x-2.5 mt-1">
<div className={cn(
'rounded-full h-7 w-7 flex items-center justify-center',
'border border-ui-200 dark:border-ui-700',
)}>
<div
className={cn(
'rounded-full h-7 w-7 flex items-center justify-center',
'border border-ui-200 dark:border-ui-700',
)}
>
<PersonIcon className="w-4 h-4" />
</div>
{machine.user.name}
</div>
</div>
<div className="p-2 pl-4">
<p className="text-sm text-ui-600 dark:text-ui-300">
Status
</p>
<p className="text-sm text-ui-600 dark:text-ui-300">Status</p>
<div className="flex gap-1 mt-1 mb-8">
{tags.map(tag => (
{tags.map((tag) => (
<span
key={tag}
className={cn(
@@ -195,18 +197,11 @@ export default function Page() {
</div>
</div>
</div>
<h2 className="text-xl font-medium mb-4 mt-8">
Subnets & Routing
</h2>
<Routes
machine={machine}
routes={routes}
state={routesState}
/>
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
<Routes machine={machine} routes={routes} state={routesState} />
<div className="flex items-center justify-between mb-4">
<p>
Subnets let you expose physical network routes onto Tailscale.
{' '}
Subnets let you expose physical network routes onto Tailscale.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
@@ -214,10 +209,7 @@ export default function Page() {
Learn More
</Link>
</p>
<Button
variant="light"
control={routesState}
>
<Button variant="light" control={routesState}>
Review
</Button>
</div>
@@ -236,22 +228,17 @@ export default function Page() {
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
Traffic to these routes are being
routed through this machine.
Traffic to these routes are being routed through this machine.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{subnetApproved.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300">
</span>
<span className="text-ui-400 dark:text-ui-300"></span>
) : (
<ul className="leading-normal">
{subnetApproved.map(route => (
<li key={route.id}>
{route.prefix}
</li>
{subnetApproved.map((route) => (
<li key={route.id}>{route.prefix}</li>
))}
</ul>
)}
@@ -276,23 +263,18 @@ export default function Page() {
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
This machine is advertising these routes,
but they must be approved before traffic
will be routed to them.
This machine is advertising these routes, but they must be
approved before traffic will be routed to them.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{subnet.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300">
</span>
<span className="text-ui-400 dark:text-ui-300"></span>
) : (
<ul className="leading-normal">
{subnet.map(route => (
<li key={route.id}>
{route.prefix}
</li>
{subnet.map((route) => (
<li key={route.id}>{route.prefix}</li>
))}
</ul>
)}
@@ -317,16 +299,13 @@ export default function Page() {
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
Whether this machine can act as an
exit node for your tailnet.
Whether this machine can act as an exit node for your tailnet.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{exit.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300">
</span>
<span className="text-ui-400 dark:text-ui-300"></span>
) : exitEnabled ? (
<span className="flex items-center gap-x-1">
<CheckCircleIcon className="w-3.5 h-3.5 text-green-700" />
@@ -352,19 +331,13 @@ export default function Page() {
</Button>
</div>
</Card>
<h2 className="text-xl font-medium mb-4">
Machine Details
</h2>
<h2 className="text-xl font-medium mb-4">Machine Details</h2>
<Card variant="flat" className="w-full max-w-full">
<Attribute name="Creator" value={machine.user.name} />
<Attribute name="Node ID" value={machine.id} />
<Attribute name="Node Name" value={machine.givenName} />
<Attribute name="Hostname" value={machine.name} />
<Attribute
isCopyable
name="Node Key"
value={machine.nodeKey}
/>
<Attribute isCopyable name="Node Key" value={machine.nodeKey} />
<Attribute
name="Created"
value={new Date(machine.createdAt).toLocaleString()}
@@ -375,21 +348,16 @@ export default function Page() {
/>
<Attribute
name="Expiry"
value={expired
? new Date(machine.expiry).toLocaleString()
: 'Never'
}
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
/>
{magic
? (
<Attribute
isCopyable
name="Domain"
value={`${machine.givenName}.${magic}`}
/>
)
: undefined}
{magic ? (
<Attribute
isCopyable
name="Domain"
value={`${machine.givenName}.${magic}`}
/>
) : undefined}
</Card>
</div>
)
);
}
+57 -57
View File
@@ -1,41 +1,41 @@
import { InfoIcon } from '@primer/octicons-react'
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
import { InfoIcon } from '@primer/octicons-react';
import { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
import Code from '~/components/Code'
import Link from '~/components/Link'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import type { Machine, Route, User } from '~/types'
import Code from '~/components/Code';
import Link from '~/components/Link';
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions';
import { useLiveData } from '~/utils/useLiveData';
import type { Machine, Route, User } from '~/types';
import { menuAction } from './action'
import MachineRow from './components/machine'
import NewMachine from './dialogs/new'
import { menuAction } from './action';
import MachineRow from './components/machine';
import NewMachine from './dialogs/new';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const session = await getSession(request.headers.get('Cookie'));
const [machines, routes, users] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
])
]);
const context = await loadContext()
let magic: string | undefined
const context = await loadContext();
let magic: string | undefined;
if (context.config.read) {
const config = await loadConfig()
const config = await loadConfig();
if (config.dns.magic_dns) {
magic = config.dns.base_domain
magic = config.dns.base_domain;
}
if (config.dns.use_username_in_magic_dns) {
magic = `[user].${magic}`
magic = `[user].${magic}`;
}
}
@@ -46,25 +46,24 @@ export async function loader({ request }: LoaderFunctionArgs) {
magic,
server: context.headscaleUrl,
publicServer: context.headscalePublicUrl,
}
};
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request)
return menuAction(request);
}
export default function Page() {
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 3000 });
const data = useLoaderData<typeof loader>();
return (
<>
<div className="flex justify-between items-center mb-8">
<div className="flex flex-col w-2/3">
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
<p className='text-gray-700 dark:text-gray-300'>
Manage the devices connected to your Tailnet.
{' '}
<h1 className="text-2xl font-medium mb-4">Machines</h1>
<p className="text-gray-700 dark:text-gray-300">
Manage the devices connected to your Tailnet.{' '}
<Link
to="https://tailscale.com/kb/1372/manage-devices"
name="Tailscale Manage Devices Documentation"
@@ -85,44 +84,45 @@ export default function Page() {
<th className="pb-2">
<div className="flex items-center gap-x-1">
Addresses
{data.magic
? (
<TooltipTrigger delay={0}>
<Button>
<InfoIcon className="w-4 h-4" />
</Button>
<Tooltip className={cn(
{data.magic ? (
<TooltipTrigger delay={0}>
<Button>
<InfoIcon className="w-4 h-4" />
</Button>
<Tooltip
className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-zinc-800',
'border border-gray-200 dark:border-zinc-700',
)}
>
Since MagicDNS is enabled, you can access devices
based on their name and also at
{' '}
<Code>
[name].
{data.magic}
</Code>
</Tooltip>
</TooltipTrigger>
)
: undefined}
>
Since MagicDNS is enabled, you can access devices based on
their name and also at{' '}
<Code>
[name].
{data.magic}
</Code>
</Tooltip>
</TooltipTrigger>
) : undefined}
</div>
</th>
<th className="pb-2">Last Seen</th>
</tr>
</thead>
<tbody className={cn(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border-t border-zinc-200 dark:border-zinc-700',
)}
<tbody
className={cn(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border-t border-zinc-200 dark:border-zinc-700',
)}
>
{data.nodes.map(machine => (
{data.nodes.map((machine) => (
<MachineRow
key={machine.id}
machine={machine}
routes={data.routes.filter(route => route.node.id === machine.id)}
routes={data.routes.filter(
(route) => route.node.id === machine.id,
)}
users={data.users}
magic={data.magic}
/>
@@ -130,5 +130,5 @@ export default function Page() {
</tbody>
</table>
</>
)
);
}
+102 -96
View File
@@ -1,168 +1,174 @@
import { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { useLiveData } from '~/utils/useLiveData'
import { getSession } from '~/utils/sessions'
import { Link as RemixLink } from '@remix-run/react'
import { PreAuthKey, User } from '~/types'
import { pull, post } from '~/utils/headscale'
import { loadContext } from '~/utils/config/headplane'
import { useState } from 'react'
import { send } from '~/utils/res'
import { LoaderFunctionArgs, ActionFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { useLiveData } from '~/utils/useLiveData';
import { getSession } from '~/utils/sessions';
import { Link as RemixLink } from 'react-router';
import { PreAuthKey, User } from '~/types';
import { pull, post } from '~/utils/headscale';
import { loadContext } from '~/utils/config/headplane';
import { useState } from 'react';
import { send } from '~/utils/res';
import Link from '~/components/Link'
import TableList from '~/components/TableList'
import Select from '~/components/Select'
import Switch from '~/components/Switch'
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import Select from '~/components/Select';
import Switch from '~/components/Switch';
import AddPreAuthKey from './dialogs/new'
import AuthKeyRow from './components/key'
import AddPreAuthKey from './dialogs/new';
import AuthKeyRow from './components/key';
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send({ message: 'Unauthorized' }, {
status: 401,
})
return send(
{ message: 'Unauthorized' },
{
status: 401,
},
);
}
const data = await request.formData()
const data = await request.formData();
// Expiring a pre-auth key
if (request.method === 'DELETE') {
const key = data.get('key')
const user = data.get('user')
const key = data.get('key');
const user = data.get('user');
if (!key || !user) {
return send({ message: 'Missing parameters' }, {
status: 400,
})
return send(
{ message: 'Missing parameters' },
{
status: 400,
},
);
}
await post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey/expire',
session.get('hsApiKey')!,
session.get('hsApiKey')!,
{
user: user,
key: key,
}
)
},
);
return { message: 'Pre-auth key expired' }
return { message: 'Pre-auth key expired' };
}
// Creating a new pre-auth key
if (request.method === 'POST') {
const user = data.get('user')
const expiry = data.get('expiry')
const reusable = data.get('reusable')
const ephemeral = data.get('ephemeral')
const user = data.get('user');
const expiry = data.get('expiry');
const reusable = data.get('reusable');
const ephemeral = data.get('ephemeral');
if (!user || !expiry || !reusable || !ephemeral) {
return send({ message: 'Missing parameters' }, {
status: 400,
})
return send(
{ message: 'Missing parameters' },
{
status: 400,
},
);
}
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(' ')[0])
const date = new Date()
date.setDate(date.getDate() + day)
const day = Number(expiry.toString().split(' ')[0]);
const date = new Date();
date.setDate(date.getDate() + day);
const key = await post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.get('hsApiKey')!,
session.get('hsApiKey')!,
{
user: user,
ephemeral: ephemeral === 'on',
reusable: reusable === 'on',
expiration: date.toISOString(),
aclTags: [], // TODO
}
)
},
);
return { message: 'Pre-auth key created', key }
return { message: 'Pre-auth key created', key };
}
}
export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext()
const session = await getSession(request.headers.get('Cookie'))
const users = await pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!)
const context = await loadContext();
const session = await getSession(request.headers.get('Cookie'));
const users = await pull<{ users: User[] }>(
'v1/user',
session.get('hsApiKey')!,
);
const preAuthKeys = await Promise.all(users.users.map(user => {
const qp = new URLSearchParams()
qp.set('user', user.name)
const preAuthKeys = await Promise.all(
users.users.map((user) => {
const qp = new URLSearchParams();
qp.set('user', user.name);
return pull<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('hsApiKey')!
)
}))
return pull<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('hsApiKey')!,
);
}),
);
return {
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys),
keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys),
users: users.users,
server: context.headscalePublicUrl ?? context.headscaleUrl,
}
};
}
export default function Page() {
const { keys, users, server } = useLoaderData<typeof loader>()
const [user, setUser] = useState('All')
const [status, setStatus] = useState('Active')
useLiveData({ interval: 3000 })
const { keys, users, server } = useLoaderData<typeof loader>();
const [user, setUser] = useState('All');
const [status, setStatus] = useState('Active');
useLiveData({ interval: 3000 });
const filteredKeys = keys.filter(key => {
const filteredKeys = keys.filter((key) => {
if (user !== 'All' && key.user !== user) {
return false
return false;
}
if (status !== 'All') {
const now = new Date()
const expiry = new Date(key.expiration)
const now = new Date();
const expiry = new Date(key.expiration);
if (status === 'Active') {
return !(expiry < now) && !key.used
return !(expiry < now) && !key.used;
}
if (status === 'Used/Expired') {
return key.used || expiry < now
return key.used || expiry < now;
}
if (status === 'Reusable') {
return key.reusable
return key.reusable;
}
if (status === 'Ephemeral') {
return key.ephemeral
return key.ephemeral;
}
}
return true
})
return true;
});
return (
<div className='flex flex-col w-2/3'>
<div className="flex flex-col w-2/3">
<p className="mb-8 text-md">
<RemixLink
to="/settings"
className="font-medium"
>
<RemixLink to="/settings" className="font-medium">
Settings
</RemixLink>
<span className="mx-2">
/
</span>
{' '}
Pre-Auth Keys
<span className="mx-2">/</span> Pre-Auth Keys
</p>
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4">
Headscale fully supports pre-authentication keys in order to
easily add devices to your Tailnet.
To learn more about using pre-authentication keys, visit the
{' '}
Headscale fully supports pre-authentication keys in order to easily add
devices to your Tailnet. To learn more about using pre-authentication
keys, visit the{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
@@ -182,7 +188,7 @@ export default function Page() {
state={[user, setUser]}
>
<Select.Item id="All">All</Select.Item>
{users.map(user => (
{users.map((user) => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
@@ -209,16 +215,16 @@ export default function Page() {
<TableList className="mt-4">
{filteredKeys.length === 0 ? (
<TableList.Item>
<p className="opacity-50 text-sm mx-auto">
No pre-auth keys
</p>
<p className="opacity-50 text-sm mx-auto">No pre-auth keys</p>
</TableList.Item>
) : filteredKeys.map(key => (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} server={server} />
</TableList.Item>
))}
) : (
filteredKeys.map((key) => (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} server={server} />
</TableList.Item>
))
)}
</TableList>
</div>
)
);
}
+19 -20
View File
@@ -1,19 +1,19 @@
import type { PreAuthKey } from '~/types'
import { toast } from '~/components/Toaster'
import type { PreAuthKey } from '~/types';
import { toast } from '~/components/Toaster';
import Code from '~/components/Code'
import Button from '~/components/Button'
import Attribute from '~/components/Attribute'
import ExpireKey from '../dialogs/expire'
import Code from '~/components/Code';
import Button from '~/components/Button';
import Attribute from '~/components/Attribute';
import ExpireKey from '../dialogs/expire';
interface Props {
authKey: PreAuthKey
server: string
authKey: PreAuthKey;
server: string;
}
export default function AuthKeyRow({ authKey, server }: Props) {
const createdAt = new Date(authKey.createdAt).toLocaleString()
const expiration = new Date(authKey.expiration).toLocaleString()
const createdAt = new Date(authKey.createdAt).toLocaleString();
const expiration = new Date(authKey.expiration).toLocaleString();
return (
<div className="w-full">
@@ -31,25 +31,24 @@ export default function AuthKeyRow({ authKey, server }: Props) {
tailscale up --login-server {server} --authkey {authKey.key}
</Code>
<div className="flex gap-4 items-center">
{authKey.used || new Date(authKey.expiration) < new Date()
? undefined
: (
<ExpireKey authKey={authKey} />
)}
{authKey.used ||
new Date(authKey.expiration) < new Date() ? undefined : (
<ExpireKey authKey={authKey} />
)}
<Button
variant="light"
variant="light"
className="my-4"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server ${server} --authkey ${authKey.key}`
)
`tailscale up --login-server ${server} --authkey ${authKey.key}`,
);
toast('Copied command to clipboard')
toast('Copied command to clipboard');
}}
>
Copy Tailscale Command
</Button>
</div>
</div>
)
);
}
+25 -32
View File
@@ -1,45 +1,40 @@
import { useFetcher } from '@remix-run/react'
import type { PreAuthKey } from '~/types'
import { cn } from '~/utils/cn'
import { useFetcher } from 'react-router';
import type { PreAuthKey } from '~/types';
import { cn } from '~/utils/cn';
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner';
interface Props {
authKey: PreAuthKey
authKey: PreAuthKey;
}
export default function ExpireKey({ authKey }: Props) {
const fetcher = useFetcher()
const fetcher = useFetcher();
return (
<Dialog>
<Dialog.Button className="my-4">
Expire Key
</Dialog.Button>
<Dialog.Button className="my-4">Expire Key</Dialog.Button>
<Dialog.Panel>
{close => (
{(close) => (
<>
<Dialog.Title>
Expire auth key?
</Dialog.Title>
<fetcher.Form method="DELETE" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<Dialog.Title>Expire auth key?</Dialog.Title>
<fetcher.Form
method="DELETE"
onSubmit={(e) => {
fetcher.submit(e.currentTarget);
close();
}}
>
<input type="hidden" name="user" value={authKey.user} />
<input type="hidden" name="key" value={authKey.key} />
<Dialog.Text>
Expiring this authentication key will immediately
prevent it from being used to authenticate new devices.
{' '}
This action cannot be undone.
Expiring this authentication key will immediately prevent it
from being used to authenticate new devices. This action cannot
be undone.
</Dialog.Text>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
@@ -52,11 +47,9 @@ export default function ExpireKey({ authKey }: Props) {
)}
onPress={close}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Expire
</Dialog.Action>
</div>
@@ -65,5 +58,5 @@ export default function ExpireKey({ authKey }: Props) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
+55 -58
View File
@@ -1,50 +1,47 @@
import { RepoForkedIcon } from '@primer/octicons-react'
import { useFetcher } from '@remix-run/react'
import { useState } from 'react'
import { RepoForkedIcon } from '@primer/octicons-react';
import { useFetcher } from 'react-router';
import { useState } from 'react';
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import NumberField from '~/components/NumberField'
import Tooltip from '~/components/Tooltip'
import Select from '~/components/Select'
import Switch from '~/components/Switch'
import Link from '~/components/Link'
import Spinner from '~/components/Spinner'
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import NumberField from '~/components/NumberField';
import Tooltip from '~/components/Tooltip';
import Select from '~/components/Select';
import Switch from '~/components/Switch';
import Link from '~/components/Link';
import Spinner from '~/components/Spinner';
import { cn } from '~/utils/cn'
import { User } from '~/types'
import { cn } from '~/utils/cn';
import { User } from '~/types';
interface Props {
users: User[]
users: User[];
}
// TODO: Tags
export default function AddPreAuthKey(data: Props) {
const fetcher = useFetcher()
const [user, setUser] = useState('')
const [reusable, setReusable] = useState(false)
const [ephemeral, setEphemeral] = useState(false)
const [aclTags, setAclTags] = useState([])
const [expiry, setExpiry] = useState(90)
const fetcher = useFetcher();
const [user, setUser] = useState('');
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [aclTags, setAclTags] = useState([]);
const [expiry, setExpiry] = useState(90);
return (
<Dialog>
<Dialog.Button className="my-4">
Create pre-auth key
</Dialog.Button>
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
<Dialog.Panel>
{close => (
{(close) => (
<>
<Dialog.Title>
Generate auth key
</Dialog.Title>
<fetcher.Form method="POST" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<Dialog.Text className="font-semibold">
User
</Dialog.Text>
<Dialog.Title>Generate auth key</Dialog.Title>
<fetcher.Form
method="POST"
onSubmit={(e) => {
fetcher.submit(e.currentTarget);
close();
}}
>
<Dialog.Text className="font-semibold">User</Dialog.Text>
<Dialog.Text className="text-sm">
Attach this key to a user
</Dialog.Text>
@@ -54,7 +51,7 @@ export default function AddPreAuthKey(data: Props) {
placeholder="Select a user"
state={[user, setUser]}
>
{data.users.map(user => (
{data.users.map((user) => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
@@ -80,9 +77,7 @@ export default function AddPreAuthKey(data: Props) {
/>
<div className="flex justify-between items-center mt-6">
<div>
<Dialog.Text className="font-semibold">
Reusable
</Dialog.Text>
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
<Dialog.Text className="text-sm">
Use this key to authenticate more than one device.
</Dialog.Text>
@@ -91,19 +86,22 @@ export default function AddPreAuthKey(data: Props) {
label="Reusable"
name="reusable"
defaultSelected={reusable}
onChange={() => { setReusable(!reusable) }}
onChange={() => {
setReusable(!reusable);
}}
/>
</div>
<input type="hidden" name="reusable" value={reusable.toString()} />
<input
type="hidden"
name="reusable"
value={reusable.toString()}
/>
<div className="flex justify-between items-center mt-6">
<div>
<Dialog.Text className="font-semibold">
Ephemeral
</Dialog.Text>
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
<Dialog.Text className="text-sm">
Devices authenticated with this key will
be automatically removed once they go offline.
{' '}
Devices authenticated with this key will be automatically
removed once they go offline.{' '}
<Link
to="https://tailscale.com/kb/1111/ephemeral-nodes"
name="Tailscale Ephemeral Nodes Documentation"
@@ -117,16 +115,17 @@ export default function AddPreAuthKey(data: Props) {
name="ephemeral"
defaultSelected={ephemeral}
onChange={() => {
setEphemeral(!ephemeral)
setEphemeral(!ephemeral);
}}
/>
</div>
<input type="hidden" name="ephemeral" value={ephemeral.toString()} />
<input
type="hidden"
name="ephemeral"
value={ephemeral.toString()}
/>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
@@ -134,11 +133,9 @@ export default function AddPreAuthKey(data: Props) {
onPress={close}
isDisabled={!user || !expiry}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Generate
</Dialog.Action>
</div>
@@ -147,5 +144,5 @@ export default function AddPreAuthKey(data: Props) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
+22 -22
View File
@@ -1,28 +1,26 @@
import Link from '~/components/Link'
import Button from '~/components/Button'
import { Link as RemixLink } from '@remix-run/react'
import { ArrowRightIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
import Link from '~/components/Link';
import Button from '~/components/Button';
import { Link as RemixLink } from 'react-router';
import { ArrowRightIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
export default function Page() {
return (
<div className="flex flex-col gap-8 max-w-screen-lg">
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Settings</h1>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Settings</h1>
<p className="text-gray-700 dark:text-gray-300">
The settings page is still under construction.
As I'm able to add more features, I'll be adding them here.
If you require any features, feel free to open an issue on
the GitHub repository.
The settings page is still under construction. As I'm able to add more
features, I'll be adding them here. If you require any features, feel
free to open an issue on the GitHub repository.
</p>
</div>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300">
Headscale fully supports pre-authentication keys in order to
easily add devices to your Tailnet.
To learn more about using pre-authentication keys, visit the
{' '}
Headscale fully supports pre-authentication keys in order to easily
add devices to your Tailnet. To learn more about using
pre-authentication keys, visit the{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
@@ -32,14 +30,16 @@ export default function Page() {
</p>
</div>
<RemixLink to="/settings/auth-keys">
<span className={cn(
'text-lg font-medium',
'text-gray-700 dark:text-gray-300',
)}>
<span
className={cn(
'text-lg font-medium',
'text-gray-700 dark:text-gray-300',
)}
>
Manage Auth Keys
<ArrowRightIcon className="w-5 h-5 ml-2" />
</span>
</RemixLink>
</div>
)
);
}
+10 -16
View File
@@ -1,12 +1,12 @@
import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react'
import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react';
import Card from '~/components/Card'
import Link from '~/components/Link'
import Card from '~/components/Card';
import Link from '~/components/Link';
import Add from '../dialogs/add'
import Add from '../dialogs/add';
interface Props {
readonly magic: string | undefined
readonly magic: string | undefined;
}
export default function Auth({ magic }: Props) {
@@ -15,14 +15,10 @@ export default function Auth({ magic }: Props) {
<div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
<HomeIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
Basic Authentication
</h2>
<h2 className="font-medium mb-1">Basic Authentication</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
Users are not managed externally.
Using OpenID Connect can create a better
experience when using Headscale.
{' '}
Users are not managed externally. Using OpenID Connect can create a
better experience when using Headscale.{' '}
<Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation"
@@ -33,9 +29,7 @@ export default function Auth({ magic }: Props) {
</div>
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
<PasskeyFillIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
User Management
</h2>
<h2 className="font-medium mb-1">User Management</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
You can add, remove, and rename users here.
</p>
@@ -45,5 +39,5 @@ export default function Auth({ magic }: Props) {
</div>
</div>
</Card>
)
);
}
+14 -20
View File
@@ -1,14 +1,14 @@
import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react'
import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react';
import Card from '~/components/Card'
import Link from '~/components/Link'
import { HeadplaneContext } from '~/utils/config/headplane'
import Card from '~/components/Card';
import Link from '~/components/Link';
import { HeadplaneContext } from '~/utils/config/headplane';
import Add from '../dialogs/add'
import Add from '../dialogs/add';
interface Props {
readonly oidc: NonNullable<HeadplaneContext['oidc']>
readonly magic: string | undefined
readonly oidc: NonNullable<HeadplaneContext['oidc']>;
readonly magic: string | undefined;
}
export default function Oidc({ oidc, magic }: Props) {
@@ -17,18 +17,14 @@ export default function Oidc({ oidc, magic }: Props) {
<div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
<OrganizationIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
OpenID Connect
</h2>
<h2 className="font-medium mb-1">OpenID Connect</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
Users are managed through your
{' '}
Users are managed through your{' '}
<Link to={oidc.issuer} name="OIDC Provider">
OpenID Connect provider
</Link>
{'. '}
Groups and user information do not automatically sync.
{' '}
Groups and user information do not automatically sync.{' '}
<Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation"
@@ -39,12 +35,10 @@ export default function Oidc({ oidc, magic }: Props) {
</div>
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
<PasskeyFillIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
User Management
</h2>
<h2 className="font-medium mb-1">User Management</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
You can still add users manually, however it is recommended
that you manage users through your OIDC provider.
You can still add users manually, however it is recommended that you
manage users through your OIDC provider.
</p>
<div className="flex items-center gap-2 mt-4">
<Add magic={magic} />
@@ -52,5 +46,5 @@ export default function Oidc({ oidc, magic }: Props) {
</div>
</div>
</Card>
)
);
}
+22 -42
View File
@@ -1,53 +1,39 @@
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import { Form, useSubmit } from 'react-router';
import { useState } from 'react';
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
interface Props {
magic?: string
magic?: string;
}
export default function Add({ magic }: Props) {
const [username, setUsername] = useState('')
const submit = useSubmit()
const [username, setUsername] = useState('');
const submit = useSubmit();
return (
<Dialog>
<Dialog.Button>
Add a new user
</Dialog.Button>
<Dialog.Button>Add a new user</Dialog.Button>
<Dialog.Panel>
{close => (
{(close) => (
<>
<Dialog.Title>
Add a new user
</Dialog.Title>
<Dialog.Title>Add a new user</Dialog.Title>
<Dialog.Text className="mb-8">
Enter a username to create a new user.
{' '}
{magic
? (
<>
Since Magic DNS is enabled, machines will be
accessible via
{' '}
<Code>
[machine].
.
{magic}
</Code>
.
</>
)
: undefined}
Enter a username to create a new user.{' '}
{magic ? (
<>
Since Magic DNS is enabled, machines will be accessible via{' '}
<Code>[machine]. .{magic}</Code>.
</>
) : undefined}
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget)
submit(event.currentTarget);
}}
>
<input type="hidden" name="_method" value="create" />
@@ -59,16 +45,10 @@ export default function Add({ magic }: Props) {
className="my-2"
/>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
<Dialog.Action variant="confirm" onPress={close}>
Create
</Dialog.Action>
</div>
@@ -77,5 +57,5 @@ export default function Add({ magic }: Props) {
)}
</Dialog.Panel>
</Dialog>
)
);
}
+17 -32
View File
@@ -1,18 +1,18 @@
import { XIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import { XIcon } from '@primer/octicons-react';
import { Form, useSubmit } from 'react-router';
import { useState } from 'react';
import Button from '~/components/Button'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Button from '~/components/Button';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
interface Props {
username: string
username: string;
}
export default function Remove({ username }: Props) {
const submit = useSubmit()
const dialogState = useState(false)
const submit = useSubmit();
const dialogState = useState(false);
return (
<>
@@ -25,41 +25,26 @@ export default function Remove({ username }: Props) {
</Button>
<Dialog control={dialogState}>
<Dialog.Panel control={dialogState}>
{close => (
{(close) => (
<>
<Dialog.Title>
Delete
{' '}
{username}
?
</Dialog.Title>
<Dialog.Title>Delete {username}?</Dialog.Title>
<Dialog.Text className="mb-8">
Are you sure you want to delete
{' '}
{username}
?
{' '}
A deleted user cannot be recovered.
Are you sure you want to delete {username}? A deleted user
cannot be recovered.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget)
submit(event.currentTarget);
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="username" value={username} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
<Dialog.Action variant="confirm" onPress={close}>
Delete
</Dialog.Action>
</div>
@@ -69,5 +54,5 @@ export default function Remove({ username }: Props) {
</Dialog.Panel>
</Dialog>
</>
)
);
}
+18 -31
View File
@@ -1,20 +1,20 @@
import { PencilIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import { PencilIcon } from '@primer/octicons-react';
import { Form, useSubmit } from 'react-router';
import { useState } from 'react';
import Button from '~/components/Button'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import Button from '~/components/Button';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
interface Props {
username: string
magic?: string
username: string;
magic?: string;
}
export default function Rename({ username, magic }: Props) {
const submit = useSubmit()
const dialogState = useState(false)
const [newName, setNewName] = useState(username)
const submit = useSubmit();
const dialogState = useState(false);
const [newName, setNewName] = useState(username);
return (
<>
@@ -27,23 +27,16 @@ export default function Rename({ username, magic }: Props) {
</Button>
<Dialog control={dialogState}>
<Dialog.Panel control={dialogState}>
{close => (
{(close) => (
<>
<Dialog.Title>
Rename
{' '}
{username}
?
</Dialog.Title>
<Dialog.Title>Rename {username}?</Dialog.Title>
<Dialog.Text className="mb-8">
Enter a new username for
{' '}
{username}
Enter a new username for {username}
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget)
submit(event.currentTarget);
}}
>
<input type="hidden" name="_method" value="rename" />
@@ -56,16 +49,10 @@ export default function Rename({ username, magic }: Props) {
className="my-2"
/>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
<Dialog.Action variant="confirm" onPress={close}>
Rename
</Dialog.Action>
</div>
@@ -75,5 +62,5 @@ export default function Rename({ username, magic }: Props) {
</Dialog.Panel>
</Dialog>
</>
)
);
}
+136 -156
View File
@@ -1,48 +1,48 @@
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
import { PersonIcon } from '@primer/octicons-react'
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { useActionData, useLoaderData, useSubmit } from '@remix-run/react'
import { useEffect, useState } from 'react'
import { ClientOnly } from 'remix-utils/client-only'
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
import { PersonIcon } from '@primer/octicons-react';
import { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useActionData, useLoaderData, useSubmit } from 'react-router';
import { useEffect, useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { Machine, User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import { send } from '~/utils/res'
import Attribute from '~/components/Attribute';
import Card from '~/components/Card';
import StatusCircle from '~/components/StatusCircle';
import { toast } from '~/components/Toaster';
import { Machine, User } from '~/types';
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale';
import { del, post, pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions';
import { useLiveData } from '~/utils/useLiveData';
import { send } from '~/utils/res';
import Auth from './components/auth'
import Oidc from './components/oidc'
import Remove from './dialogs/remove'
import Rename from './dialogs/rename'
import Auth from './components/auth';
import Oidc from './components/oidc';
import Remove from './dialogs/remove';
import Rename from './dialogs/rename';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const session = await getSession(request.headers.get('Cookie'));
const [machines, apiUsers] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
])
]);
const users = apiUsers.users.map(user => ({
const users = apiUsers.users.map((user) => ({
...user,
machines: machines.nodes.filter(machine => machine.user.id === user.id),
}))
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
}));
const context = await loadContext()
let magic: string | undefined
const context = await loadContext();
let magic: string | undefined;
if (context.config.read) {
const config = await loadConfig()
const config = await loadConfig();
if (config.dns.magic_dns) {
magic = config.dns.base_domain
magic = config.dns.base_domain;
}
}
@@ -50,125 +50,115 @@ export async function loader({ request }: LoaderFunctionArgs) {
oidc: context.oidc,
magic,
users,
}
};
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send({ message: 'Unauthorized' }, 401)
return send({ message: 'Unauthorized' }, 401);
}
const data = await request.formData()
const data = await request.formData();
if (!data.has('_method')) {
return send({ message: 'No method provided' }, 400)
return send({ message: 'No method provided' }, 400);
}
const method = String(data.get('_method'))
const method = String(data.get('_method'));
switch (method) {
case 'create': {
if (!data.has('username')) {
return send({ message: 'No name provided' }, 400)
return send({ message: 'No name provided' }, 400);
}
const username = String(data.get('username'))
const username = String(data.get('username'));
await post('v1/user', session.get('hsApiKey')!, {
name: username,
})
});
return { message: `User ${username} created` }
return { message: `User ${username} created` };
}
case 'delete': {
if (!data.has('username')) {
return send({ message: 'No name provided' }, 400)
return send({ message: 'No name provided' }, 400);
}
const username = String(data.get('username'))
await del(`v1/user/${username}`, session.get('hsApiKey')!)
return { message: `User ${username} deleted` }
const username = String(data.get('username'));
await del(`v1/user/${username}`, session.get('hsApiKey')!);
return { message: `User ${username} deleted` };
}
case 'rename': {
if (!data.has('old') || !data.has('new')) {
return send({ message: 'No old or new name provided' }, 400)
return send({ message: 'No old or new name provided' }, 400);
}
const old = String(data.get('old'))
const newName = String(data.get('new'))
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!)
return { message: `User ${old} renamed to ${newName}` }
const old = String(data.get('old'));
const newName = String(data.get('new'));
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!);
return { message: `User ${old} renamed to ${newName}` };
}
case 'move': {
if (!data.has('id') || !data.has('to') || !data.has('name')) {
return send({ message: 'No ID or destination provided' }, 400)
return send({ message: 'No ID or destination provided' }, 400);
}
const id = String(data.get('id'))
const to = String(data.get('to'))
const name = String(data.get('name'))
const id = String(data.get('id'));
const to = String(data.get('to'));
const name = String(data.get('name'));
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
return { message: `Moved ${name} to ${to}` }
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!);
return { message: `Moved ${name} to ${to}` };
} catch {
return send({ message: `Failed to move ${name} to ${to}` }, 500)
return send({ message: `Failed to move ${name} to ${to}` }, 500);
}
}
default: {
return send({ message: 'Invalid method' }, 400)
return send({ message: 'Invalid method' }, 400);
}
}
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const [users, setUsers] = useState(data.users)
const actionData = useActionData<typeof action>()
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>();
const [users, setUsers] = useState(data.users);
const actionData = useActionData<typeof action>();
useLiveData({ interval: 3000 });
useEffect(() => {
if (!actionData) {
return
return;
}
toast(actionData.message)
toast(actionData.message);
if (actionData.message.startsWith('Failed')) {
setUsers(data.users)
setUsers(data.users);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionData])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionData]);
useEffect(() => {
setUsers(data.users)
}, [data.users])
setUsers(data.users);
}, [data.users]);
return (
<>
<h1 className="text-2xl font-medium mb-1.5">
Users
</h1>
<h1 className="text-2xl font-medium mb-1.5">Users</h1>
<p className="mb-8 text-md">
Manage the users in your network and their permissions.
Tip: You can drag machines between users to change ownership.
Manage the users in your network and their permissions. Tip: You can
drag machines between users to change ownership.
</p>
{data.oidc
? (
<Oidc
oidc={data.oidc}
magic={data.magic}
/>
)
: (
<Auth magic={data.magic} />
)}
<ClientOnly fallback={
<Users users={users} />
}
>
{data.oidc ? (
<Oidc oidc={data.oidc} magic={data.magic} />
) : (
<Auth magic={data.magic} />
)}
<ClientOnly fallback={<Users users={users} />}>
{() => (
<InteractiveUsers
users={users}
@@ -178,92 +168,86 @@ export default function Page() {
)}
</ClientOnly>
</>
)
);
}
type UserMachine = User & { machines: Machine[] }
type UserMachine = User & { machines: Machine[] };
interface UserProps {
users: UserMachine[]
setUsers?: (users: UserMachine[]) => void
magic?: string
users: UserMachine[];
setUsers?: (users: UserMachine[]) => void;
magic?: string;
}
function Users({ users, magic }: UserProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map(user => (
<UserCard
key={user.id}
user={user}
magic={magic}
/>
{users.map((user) => (
<UserCard key={user.id} user={user} magic={magic} />
))}
</div>
)
);
}
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
const submit = useSubmit()
const submit = useSubmit();
return (
<DndContext onDragEnd={(event) => {
const { over, active } = event
if (!over) {
return
}
<DndContext
onDragEnd={(event) => {
const { over, active } = event;
if (!over) {
return;
}
// Update the UI optimistically
const newUsers = new Array<UserMachine>()
const reference = active.data as DataRef<Machine>
if (!reference.current) {
return
}
// Update the UI optimistically
const newUsers = new Array<UserMachine>();
const reference = active.data as DataRef<Machine>;
if (!reference.current) {
return;
}
// Ignore if the user is unchanged
if (reference.current.user.name === over.id) {
return
}
// Ignore if the user is unchanged
if (reference.current.user.name === over.id) {
return;
}
for (const user of users) {
newUsers.push({
...user,
machines: over.id === user.name
? [...user.machines, reference.current]
: user.machines.filter(m => m.id !== active.id),
})
}
for (const user of users) {
newUsers.push({
...user,
machines:
over.id === user.name
? [...user.machines, reference.current]
: user.machines.filter((m) => m.id !== active.id),
});
}
setUsers?.(newUsers)
const data = new FormData()
data.append('_method', 'move')
data.append('id', active.id.toString())
data.append('to', over.id.toString())
data.append('name', reference.current.givenName)
setUsers?.(newUsers);
const data = new FormData();
data.append('_method', 'move');
data.append('id', active.id.toString());
data.append('to', over.id.toString());
data.append('name', reference.current.givenName);
submit(data, {
method: 'POST',
})
}}
submit(data, {
method: 'POST',
});
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map(user => (
<UserCard
key={user.id}
user={user}
magic={magic}
/>
{users.map((user) => (
<UserCard key={user.id} user={user} magic={magic} />
))}
</div>
</DndContext>
)
);
}
function MachineChip({ machine }: { readonly machine: Machine }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: machine.id,
data: machine,
})
});
return (
<div
@@ -287,18 +271,18 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
value={machine.ipAddresses[0]}
/>
</div>
)
);
}
interface CardProps {
user: UserMachine
magic?: string
user: UserMachine;
magic?: string;
}
function UserCard({ user, magic }: CardProps) {
const { isOver, setNodeRef } = useDroppable({
id: user.name,
})
});
return (
<div ref={setNodeRef}>
@@ -312,25 +296,21 @@ function UserCard({ user, magic }: CardProps) {
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<PersonIcon className="w-6 h-6" />
<span className="text-lg font-mono">
{user.name}
</span>
<span className="text-lg font-mono">{user.name}</span>
</div>
<div className="flex items-center gap-2">
<Rename username={user.name} magic={magic} />
{user.machines.length === 0
? (
<Remove username={user.name} />
)
: undefined}
{user.machines.length === 0 ? (
<Remove username={user.name} />
) : undefined}
</div>
</div>
<div className="mt-4">
{user.machines.map(machine => (
{user.machines.map((machine) => (
<MachineChip key={machine.id} machine={machine} />
))}
</div>
</Card>
</div>
)
);
}
+15 -12
View File
@@ -1,23 +1,26 @@
import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull } from '~/utils/headscale'
import { data } from '@remix-run/node'
import log from '~/utils/log'
import { loadContext } from '~/utils/config/headplane';
import { HeadscaleError, pull } from '~/utils/headscale';
import { data } from 'react-router';
import log from '~/utils/log';
export async function loader() {
const context = await loadContext()
const context = await loadContext();
try {
// Doesn't matter, we just need a 401
await pull('v1/', 'wrongkey')
await pull('v1/', 'wrongkey');
} catch (e) {
if (!(e instanceof HeadscaleError)) {
log.debug('Healthz', 'Headscale is not reachable')
return data({
status: 'NOT OK',
error: e.message
}, { status: 500 })
log.debug('Healthz', 'Headscale is not reachable');
return data(
{
status: 'NOT OK',
error: e.message,
},
{ status: 500 },
);
}
}
return { status: 'OK' }
return { status: 'OK' };
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from '@remix-run/node'
import { redirect } from 'react-router';
export async function loader() {
return redirect('/machines')
return redirect('/machines');
}