chore: switch to react-router v7

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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