mirror of
https://github.com/tale/headplane.git
synced 2026-08-06 12:17:41 +00:00
feat: switch machine apis to new runtime api client
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { isRouteErrorResponse, useRouteError } from 'react-router';
|
||||
import ResponseError from '~/server/headscale/api/error';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import cn from '~/utils/cn';
|
||||
import Card from './Card';
|
||||
import Code from './Code';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type LoaderFunctionArgs, Outlet, redirect } from 'react-router';
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { pruneEphemeralNodes } from '~/server/db/pruner';
|
||||
import ResponseError from '~/server/headscale/api/error';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import log from '~/utils/log';
|
||||
|
||||
export async function loader({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api/error';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { data400, data403 } from '~/utils/res';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api/error';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { data403 } from '~/utils/res';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ActionFunctionArgs, data, redirect } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api/error';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import { Key } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
|
||||
|
||||
@@ -64,7 +64,10 @@ export default function MachineRow({
|
||||
{node.givenName}
|
||||
</p>
|
||||
<p className="text-sm opacity-50">
|
||||
{node.user.name || node.user.displayName || node.user.email || node.user.id}
|
||||
{node.user.name ||
|
||||
node.user.displayName ||
|
||||
node.user.email ||
|
||||
node.user.id}
|
||||
</p>
|
||||
<div className="flex gap-1 flex-wrap mt-1.5">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import Move from '../dialogs/move';
|
||||
import Rename from '../dialogs/rename';
|
||||
import Routes from '../dialogs/routes';
|
||||
import Tags from '../dialogs/tags';
|
||||
|
||||
interface MenuProps {
|
||||
node: PopulatedNode;
|
||||
users: User[];
|
||||
@@ -36,8 +37,8 @@ export default function MachineMenu({
|
||||
<div className="flex items-center justify-end px-4 gap-1.5">
|
||||
{modal === 'remove' && (
|
||||
<Delete
|
||||
machine={node}
|
||||
isOpen={modal === 'remove'}
|
||||
machine={node}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
@@ -45,19 +46,19 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'move' && (
|
||||
<Move
|
||||
machine={node}
|
||||
users={users}
|
||||
isOpen={modal === 'move'}
|
||||
machine={node}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
users={users}
|
||||
/>
|
||||
)}
|
||||
{modal === 'rename' && (
|
||||
<Rename
|
||||
isOpen={modal === 'rename'}
|
||||
machine={node}
|
||||
magic={magic}
|
||||
isOpen={modal === 'rename'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
@@ -65,8 +66,8 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'routes' && (
|
||||
<Routes
|
||||
node={node}
|
||||
isOpen={modal === 'routes'}
|
||||
node={node}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
@@ -74,8 +75,8 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'tags' && (
|
||||
<Tags
|
||||
machine={node}
|
||||
isOpen={modal === 'tags'}
|
||||
machine={node}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
@@ -83,8 +84,8 @@ export default function MachineMenu({
|
||||
)}
|
||||
{node.expired && modal === 'expire' ? undefined : (
|
||||
<Expire
|
||||
machine={node}
|
||||
isOpen={modal === 'expire'}
|
||||
machine={node}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
@@ -95,33 +96,23 @@ export default function MachineMenu({
|
||||
isFullButton ? (
|
||||
<Button
|
||||
className="flex items-center gap-x-2"
|
||||
variant="heavy"
|
||||
onPress={() => {
|
||||
// We need to use JS to open the SSH URL
|
||||
// in a new WINDOW since href can only
|
||||
// do a new TAB.
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.name}`,
|
||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer,width=800,height=600',
|
||||
);
|
||||
}}
|
||||
variant="heavy"
|
||||
>
|
||||
<SquareTerminal className="h-5" />
|
||||
<p>SSH</p>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onPress={() => {
|
||||
// We need to use JS to open the SSH URL
|
||||
// in a new WINDOW since href can only
|
||||
// do a new TAB.
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.name}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer,width=800,height=600',
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
'py-0.5 w-fit bg-transparent border-transparent',
|
||||
'border group-hover:border-headplane-200',
|
||||
@@ -129,6 +120,16 @@ export default function MachineMenu({
|
||||
'opacity-0 pointer-events-none group-hover:opacity-100',
|
||||
'group-hover:pointer-events-auto',
|
||||
)}
|
||||
onPress={() => {
|
||||
// We need to use JS to open the SSH URL
|
||||
// in a new WINDOW since href can only
|
||||
// do a new TAB.
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer,width=800,height=600',
|
||||
);
|
||||
}}
|
||||
>
|
||||
SSH
|
||||
</Button>
|
||||
@@ -142,19 +143,19 @@ export default function MachineMenu({
|
||||
</Menu.Button>
|
||||
) : (
|
||||
<Menu.IconButton
|
||||
label="Machine Options"
|
||||
className={cn(
|
||||
'py-0.5 w-10 bg-transparent border-transparent',
|
||||
'border group-hover:border-headplane-200',
|
||||
'dark:group-hover:border-headplane-700',
|
||||
)}
|
||||
label="Machine Options"
|
||||
>
|
||||
<Ellipsis className="h-5" />
|
||||
</Menu.IconButton>
|
||||
)}
|
||||
<Menu.Panel
|
||||
onAction={(key) => setModal(key as Modal)}
|
||||
disabledKeys={node.expired ? ['expire'] : []}
|
||||
onAction={(key) => setModal(key as Modal)}
|
||||
>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="rename">Edit machine name</Menu.Item>
|
||||
|
||||
@@ -14,16 +14,16 @@ export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel
|
||||
variant="destructive"
|
||||
onSubmit={() => navigate('/machines')}
|
||||
variant="destructive"
|
||||
>
|
||||
<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.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="delete" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<input name="action_id" type="hidden" value="delete" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -20,21 +20,23 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||
<Dialog.Text>
|
||||
The owner of the machine is the user associated with it.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="reassign" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<input type="hidden" name="user_id" value={userId?.toString()} />
|
||||
<input name="action_id" type="hidden" value="reassign" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<input name="user_id" type="hidden" value={userId?.toString()} />
|
||||
<Select
|
||||
defaultSelectedKey={machine.user.id}
|
||||
isRequired
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
defaultSelectedKey={machine.user.id}
|
||||
onSelectionChange={(key) => {
|
||||
setUserId(key);
|
||||
}}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -30,14 +30,14 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
<Code isCopyable>tailscale up --login-server={data.server}</Code> on
|
||||
your device.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="register" />
|
||||
<input name="action_id" type="hidden" value="register" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Machine Key"
|
||||
placeholder="AbCd..."
|
||||
validationBehavior="native"
|
||||
name="register_key"
|
||||
onChange={setMkey}
|
||||
placeholder="AbCd..."
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Select
|
||||
isRequired
|
||||
@@ -46,12 +46,14 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
<Menu isDisabled={data.isDisabled} disabledKeys={data.disabledKeys}>
|
||||
<Menu disabledKeys={data.disabledKeys} isDisabled={data.isDisabled}>
|
||||
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
||||
<Menu.Panel
|
||||
onAction={(key) => {
|
||||
|
||||
@@ -27,16 +27,15 @@ export default function Rename({
|
||||
This name is shown in the admin panel, in Tailscale clients, and used
|
||||
when generating MagicDNS names.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="rename" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<input name="action_id" type="hidden" value="rename" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<Input
|
||||
defaultValue={machine.givenName}
|
||||
isRequired
|
||||
label="Machine name"
|
||||
placeholder="Machine name"
|
||||
validationBehavior="native"
|
||||
name="name"
|
||||
defaultValue={machine.givenName}
|
||||
onChange={setName}
|
||||
placeholder="Machine name"
|
||||
validate={(value) => {
|
||||
if (value.length === 0) {
|
||||
return 'Cannot be empty';
|
||||
@@ -66,6 +65,7 @@ export default function Rename({
|
||||
return 'Cannot contain consecutive hyphens';
|
||||
}
|
||||
}}
|
||||
validationBehavior="native"
|
||||
/>
|
||||
{magic ? (
|
||||
name.length > 0 && name !== machine.givenName ? (
|
||||
|
||||
@@ -30,8 +30,8 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
|
||||
Connect to devices you can't install Tailscale on by advertising
|
||||
IP ranges as subnet routes.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
name="Tailscale Subnets Documentation"
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
@@ -70,8 +70,8 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
|
||||
<Dialog.Text>
|
||||
Allow your network to route internet traffic through this machine.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1103/exit-nodes"
|
||||
name="Tailscale Exit-node Documentation"
|
||||
to="https://tailscale.com/kb/1103/exit-nodes"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
|
||||
@@ -26,16 +26,16 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
||||
ACL tags can be used to reference machines in your ACL policies. See
|
||||
the{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1068/acl-tags"
|
||||
name="Tailscale documentation"
|
||||
to="https://tailscale.com/kb/1068/acl-tags"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>{' '}
|
||||
for more information.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="update_tags" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||
<input name="action_id" type="hidden" value="update_tags" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<input name="tags" type="hidden" value={tags.join(',')} />
|
||||
<TableList className="mt-4">
|
||||
{tags.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
@@ -44,7 +44,7 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
||||
</TableList.Item>
|
||||
) : (
|
||||
tags.map((item) => (
|
||||
<TableList.Item className="font-mono" key={item} id={item}>
|
||||
<TableList.Item className="font-mono" id={item} key={item}>
|
||||
{item}
|
||||
<Button
|
||||
className="rounded-md p-0.5"
|
||||
@@ -66,14 +66,14 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
labelHidden
|
||||
label="Add a tag"
|
||||
placeholder="tag:example"
|
||||
onChange={setTag}
|
||||
className={cn(
|
||||
'border-none font-mono p-0',
|
||||
'rounded-none focus:ring-0 w-full',
|
||||
)}
|
||||
label="Add a tag"
|
||||
labelHidden
|
||||
onChange={setTag}
|
||||
placeholder="tag:example"
|
||||
/>
|
||||
<Button
|
||||
className={cn(
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { type ActionFunctionArgs, data, redirect } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { data, redirect } from 'react-router';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { Machine } from '~/types';
|
||||
import type { Route } from './+types/machine';
|
||||
|
||||
export async function machineAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
@@ -14,7 +10,7 @@ export async function machineAction({
|
||||
);
|
||||
|
||||
const formData = await request.formData();
|
||||
const apiKey = session.api_key;
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
@@ -31,7 +27,22 @@ export async function machineAction({
|
||||
});
|
||||
}
|
||||
|
||||
return registerMachine(formData, apiKey, context);
|
||||
const registrationKey = formData.get('register_key')?.toString();
|
||||
if (!registrationKey) {
|
||||
throw data('Missing `register_key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get('user')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const node = await api.registerNode(user, registrationKey);
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
|
||||
// Check if the user has permission to manage this machine
|
||||
@@ -42,12 +53,7 @@ export async function machineAction({
|
||||
});
|
||||
}
|
||||
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const node = nodes.find((node) => node.id === nodeId);
|
||||
const node = await api.getNode(nodeId);
|
||||
if (!node) {
|
||||
throw data(`Machine with ID ${nodeId} not found`, {
|
||||
status: 404,
|
||||
@@ -65,27 +71,103 @@ export async function machineAction({
|
||||
|
||||
switch (action) {
|
||||
case 'rename': {
|
||||
return renameMachine(formData, apiKey, nodeId, context);
|
||||
const newName = formData.get('name')?.toString();
|
||||
if (!newName) {
|
||||
throw data('Missing `name` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const name = String(formData.get('name'));
|
||||
await api.renameNode(nodeId, name);
|
||||
return { message: 'Machine renamed' };
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
return deleteMachine(apiKey, nodeId, context);
|
||||
await api.deleteNode(nodeId);
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
case 'expire': {
|
||||
return expireMachine(apiKey, nodeId, context);
|
||||
await api.expireNode(nodeId);
|
||||
return { message: 'Machine expired' };
|
||||
}
|
||||
|
||||
case 'update_tags': {
|
||||
return updateTags(formData, apiKey, nodeId, context);
|
||||
const tags = formData.get('tags')?.toString().split(',') ?? [];
|
||||
if (tags.length === 0) {
|
||||
throw data('Missing `tags` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.setNodeTags(
|
||||
nodeId,
|
||||
tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
|
||||
);
|
||||
return { message: 'Tags updated' };
|
||||
}
|
||||
|
||||
case 'update_routes': {
|
||||
return updateRoutes(formData, apiKey, nodeId, context);
|
||||
const newApproved = node.approvedRoutes;
|
||||
const routes = formData.get('routes')?.toString();
|
||||
if (!routes) {
|
||||
throw data('Missing `routes` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const allRoutes = routes.split(',').map((route) => route.trim());
|
||||
if (allRoutes.length === 0) {
|
||||
throw data('No routes provided to update', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const enabled = formData.get('enabled')?.toString();
|
||||
if (enabled === undefined) {
|
||||
throw data('Missing `enabled` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (enabled === 'true') {
|
||||
for (const route of allRoutes) {
|
||||
// If already approved, skip, otherwise add to approved
|
||||
if (newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newApproved.push(route);
|
||||
}
|
||||
} else {
|
||||
for (const route of allRoutes) {
|
||||
// If not approved, skip, otherwise remove from approved
|
||||
if (!newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = newApproved.indexOf(route);
|
||||
if (index > -1) {
|
||||
newApproved.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await api.approveNodeRoutes(nodeId, newApproved);
|
||||
return { message: 'Routes updated' };
|
||||
}
|
||||
|
||||
case 'reassign': {
|
||||
return reassignMachine(formData, apiKey, nodeId, context);
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.setNodeUser(nodeId, user);
|
||||
return { message: 'Machine reassigned' };
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -94,173 +176,3 @@ export async function machineAction({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function registerMachine(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const registrationKey = formData.get('register_key')?.toString();
|
||||
if (!registrationKey) {
|
||||
throw data('Missing `register_key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get('user')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('user', user);
|
||||
qp.append('key', registrationKey);
|
||||
const url = `v1/node/register?${qp.toString()}`;
|
||||
const { node } = await context.client.post<{ node: Machine }>(url, apiKey, {
|
||||
user,
|
||||
key: registrationKey,
|
||||
});
|
||||
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
|
||||
async function renameMachine(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const newName = formData.get('name')?.toString();
|
||||
if (!newName) {
|
||||
throw data('Missing `name` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const name = String(formData.get('name'));
|
||||
await context.client.post(`v1/node/${nodeId}/rename/${name}`, apiKey);
|
||||
return { message: 'Machine renamed' };
|
||||
}
|
||||
|
||||
async function deleteMachine(
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
await context.client.delete(`v1/node/${nodeId}`, apiKey);
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
async function expireMachine(
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
await context.client.post(`v1/node/${nodeId}/expire`, apiKey);
|
||||
return { message: 'Machine expired' };
|
||||
}
|
||||
|
||||
async function updateTags(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const tags = formData.get('tags')?.toString().split(',') ?? [];
|
||||
if (tags.length === 0) {
|
||||
throw data('Missing `tags` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.client.post(`v1/node/${nodeId}/tags`, apiKey, {
|
||||
tags: tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
|
||||
});
|
||||
|
||||
return { message: 'Tags updated' };
|
||||
}
|
||||
|
||||
async function updateRoutes(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const { node } = await context.client.get<{ node: Machine }>(
|
||||
`v1/node/${nodeId}`,
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const newApproved = node.approvedRoutes;
|
||||
const routes = formData.get('routes')?.toString();
|
||||
if (!routes) {
|
||||
throw data('Missing `routes` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const allRoutes = routes.split(',').map((route) => route.trim());
|
||||
if (allRoutes.length === 0) {
|
||||
throw data('No routes provided to update', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const enabled = formData.get('enabled')?.toString();
|
||||
if (enabled === undefined) {
|
||||
throw data('Missing `enabled` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (enabled === 'true') {
|
||||
for (const route of allRoutes) {
|
||||
// If already approved, skip, otherwise add to approved
|
||||
if (newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newApproved.push(route);
|
||||
}
|
||||
} else {
|
||||
for (const route of allRoutes) {
|
||||
// If not approved, skip, otherwise remove from approved
|
||||
if (!newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = newApproved.indexOf(route);
|
||||
if (index > -1) {
|
||||
newApproved.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await context.client.post(`v1/node/${nodeId}/approve_routes`, apiKey, {
|
||||
routes: newApproved,
|
||||
});
|
||||
|
||||
return { message: 'Routes updated' };
|
||||
}
|
||||
|
||||
async function reassignMachine(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.client.post(`v1/node/${nodeId}/user`, apiKey, {
|
||||
user,
|
||||
});
|
||||
|
||||
return { message: 'Machine reassigned' };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { Link as RemixLink, useLoaderData } from 'react-router';
|
||||
import { data, Link as RemixLink, useLoaderData } from 'react-router';
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
@@ -9,26 +8,25 @@ import Chip from '~/components/Chip';
|
||||
import Link from '~/components/Link';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { getOSInfo, getTSVersion } from '~/utils/host-info';
|
||||
import { mapNodes } from '~/utils/node-info';
|
||||
import type { Route } from './+types/machine';
|
||||
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
|
||||
import MenuOptions from './components/menu';
|
||||
import Routes from './dialogs/routes';
|
||||
import { machineAction } from './machine-actions';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
params,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (!params.id) {
|
||||
throw new Error('No machine ID provided');
|
||||
}
|
||||
|
||||
if (params.id.endsWith('.ico')) {
|
||||
throw data(null, { status: 204 });
|
||||
}
|
||||
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
@@ -36,33 +34,29 @@ export async function loader({
|
||||
}
|
||||
}
|
||||
|
||||
const [machine, { users }] = await Promise.all([
|
||||
context.client.get<{ node: Machine }>(
|
||||
`v1/node/${params.id}`,
|
||||
session.api_key,
|
||||
),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const [node, users] = await Promise.all([
|
||||
api.getNode(params.id),
|
||||
api.getUsers(),
|
||||
]);
|
||||
|
||||
const lookup = await context.agents?.lookup([machine.node.nodeKey]);
|
||||
const [node] = mapNodes([machine.node], lookup);
|
||||
const lookup = await context.agents?.lookup([node.nodeKey]);
|
||||
const [enhancedNode] = mapNodes([node], lookup);
|
||||
const tags = Array.from(
|
||||
new Set([...node.validTags, ...node.forcedTags]),
|
||||
).sort();
|
||||
|
||||
return {
|
||||
node,
|
||||
node: enhancedNode,
|
||||
tags,
|
||||
users,
|
||||
magic,
|
||||
agent: context.agents?.agentID(),
|
||||
stats: lookup?.[node.nodeKey],
|
||||
stats: lookup?.[enhancedNode.nodeKey],
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs) {
|
||||
return machineAction(request);
|
||||
}
|
||||
export const action = machineAction;
|
||||
|
||||
export default function Page() {
|
||||
const { node, tags, magic, users, agent, stats } =
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import cn from '~/utils/cn';
|
||||
import { mapNodes } from '~/utils/node-info';
|
||||
import type { Route } from './+types/overview';
|
||||
import MachineRow from './components/machine-row';
|
||||
import NewMachine from './dialogs/new';
|
||||
import { machineAction } from './machine-actions';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.user;
|
||||
if (!user) {
|
||||
@@ -69,9 +65,7 @@ export async function loader({
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs) {
|
||||
return machineAction(request);
|
||||
}
|
||||
export const action = machineAction;
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
|
||||
import { data } from 'react-router';
|
||||
import { Agent, Dispatcher, errors, request } from 'undici';
|
||||
import log from '~/utils/log';
|
||||
import ResponseError from './api/error';
|
||||
import ResponseError from './api/response-error';
|
||||
|
||||
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
|
||||
const keys = Object.keys(error as Record<string, unknown>);
|
||||
@@ -144,6 +144,9 @@ export async function createApiClient(base: string, certPath?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the new RuntimeApiClient instead.
|
||||
*/
|
||||
export class ApiClient {
|
||||
private agent: Agent;
|
||||
private base: string;
|
||||
@@ -153,7 +156,7 @@ export class ApiClient {
|
||||
this.base = base;
|
||||
}
|
||||
|
||||
private async defaultFetch(
|
||||
async defaultFetch(
|
||||
url: string,
|
||||
options?: Partial<Dispatcher.RequestOptions>,
|
||||
) {
|
||||
@@ -178,6 +181,9 @@ export class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the new RuntimeApiClient instead.
|
||||
*/
|
||||
async healthcheck() {
|
||||
try {
|
||||
const res = await request(new URL('/health', this.base), {
|
||||
@@ -195,6 +201,9 @@ export class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the new RuntimeApiClient instead.
|
||||
*/
|
||||
async get<T = unknown>(url: string, key: string) {
|
||||
const res = await this.defaultFetch(`/api/${url}`, {
|
||||
headers: {
|
||||
@@ -204,12 +213,19 @@ export class ApiClient {
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug('api', 'GET %s failed with status %d', url, res.statusCode);
|
||||
throw new ResponseError(res.statusCode, await res.body.text());
|
||||
throw new ResponseError(
|
||||
res.statusCode,
|
||||
await res.body.text(),
|
||||
`GET ${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the new RuntimeApiClient instead.
|
||||
*/
|
||||
async post<T = unknown>(url: string, key: string, body?: unknown) {
|
||||
const res = await this.defaultFetch(`/api/${url}`, {
|
||||
method: 'POST',
|
||||
@@ -221,12 +237,19 @@ export class ApiClient {
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug('api', 'POST %s failed with status %d', url, res.statusCode);
|
||||
throw new ResponseError(res.statusCode, await res.body.text());
|
||||
throw new ResponseError(
|
||||
res.statusCode,
|
||||
await res.body.text(),
|
||||
`POST ${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the new RuntimeApiClient instead.
|
||||
*/
|
||||
async put<T = unknown>(url: string, key: string, body?: unknown) {
|
||||
const res = await this.defaultFetch(`/api/${url}`, {
|
||||
method: 'PUT',
|
||||
@@ -238,12 +261,19 @@ export class ApiClient {
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug('api', 'PUT %s failed with status %d', url, res.statusCode);
|
||||
throw new ResponseError(res.statusCode, await res.body.text());
|
||||
throw new ResponseError(
|
||||
res.statusCode,
|
||||
await res.body.text(),
|
||||
`PUT ${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the new RuntimeApiClient instead.
|
||||
*/
|
||||
async delete<T = unknown>(url: string, key: string) {
|
||||
const res = await this.defaultFetch(`/api/${url}`, {
|
||||
method: 'DELETE',
|
||||
@@ -254,7 +284,11 @@ export class ApiClient {
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug('api', 'DELETE %s failed with status %d', url, res.statusCode);
|
||||
throw new ResponseError(res.statusCode, await res.body.text());
|
||||
throw new ResponseError(
|
||||
res.statusCode,
|
||||
await res.body.text(),
|
||||
`DELETE ${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
import { data } from 'react-router';
|
||||
import { errors } from 'undici';
|
||||
|
||||
// Represents an error that occurred during a response
|
||||
// Thrown when status codes are >= 400
|
||||
export default class ResponseError extends Error {
|
||||
status: number;
|
||||
response: string;
|
||||
responseObject?: Record<string, unknown>;
|
||||
|
||||
constructor(status: number, response: string, requestUrl: string) {
|
||||
super(`${requestUrl}: status ${status} - ${response}`);
|
||||
this.name = 'ResponseError';
|
||||
this.status = status;
|
||||
this.response = response;
|
||||
|
||||
try {
|
||||
// Try to parse the response as JSON to get a response object
|
||||
this.responseObject = JSON.parse(response);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
|
||||
if (typeof error !== 'object' || error === null) {
|
||||
return false;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HeadscaleApiInterface } from '../api';
|
||||
import type { HeadscaleApiInterface } from '../api';
|
||||
|
||||
/**
|
||||
* Creates a strongly-typed group factory for a given endpoint interface.
|
||||
|
||||
@@ -3,14 +3,12 @@ import { readFile } from 'node:fs/promises';
|
||||
import { dereference } from '@readme/openapi-parser';
|
||||
import type { OpenAPIV2 } from 'openapi-types';
|
||||
import { Agent, type Dispatcher, request } from 'undici';
|
||||
import type { Key, Machine, PreAuthKey, User } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import endpointSets, { RuntimeApiClient } from './endpoints';
|
||||
import ResponseError, { friendlyError } from './error';
|
||||
import { friendlyError } from './error';
|
||||
import ResponseError from './response-error';
|
||||
import { detectApiVersion, isAtLeast, type Version } from './version';
|
||||
|
||||
export type RuntimeApiClient2 = {};
|
||||
|
||||
/**
|
||||
* A low-level composed interface for interacting with the Headscale API.
|
||||
* This interface provides direct access to the underlying Undici agent
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Represents an error that occurred during a response
|
||||
// Thrown when status codes are >= 400
|
||||
export default class ResponseError extends Error {
|
||||
status: number;
|
||||
response: string;
|
||||
responseObject?: Record<string, unknown>;
|
||||
|
||||
constructor(status: number, response: string, requestUrl: string) {
|
||||
super(`${requestUrl}: status ${status} - ${response}`);
|
||||
this.name = 'ResponseError';
|
||||
this.status = status;
|
||||
this.response = response;
|
||||
|
||||
try {
|
||||
// Try to parse the response as JSON to get a response object
|
||||
this.responseObject = JSON.parse(response);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -28,7 +28,7 @@ in
|
||||
|
||||
pnpmDeps = pnpm_10.fetchDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
hash = "sha256-KniPa9RMQAy5TY7H/TmETQNjqnuJv/kdFuRQdIoE/gQ=";
|
||||
hash = "sha256-O2t2n0m3pRXqoaw5ym8zlYMLsftV6MIpAqaCRYdTnSg=";
|
||||
fetcherVersion = 1;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user