feat: switch machine apis to new runtime api client

This commit is contained in:
Aarnav Tale
2025-11-04 22:40:23 -05:00
parent a68aedc297
commit d68737e410
22 changed files with 250 additions and 311 deletions
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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)}
+22 -21
View File
@@ -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>
+3 -3
View File
@@ -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>
);
+8 -6
View File
@@ -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>
+7 -5
View File
@@ -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) => {
+5 -5
View File
@@ -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 ? (
+2 -2
View File
@@ -30,8 +30,8 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
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"
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>
+9 -9
View File
@@ -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(
+103 -191
View File
@@ -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' };
}
+16 -22
View File
@@ -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 } =
+3 -9
View File
@@ -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>();
+40 -6
View File
@@ -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>;
-20
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { HeadscaleApiInterface } from '../api';
import type { HeadscaleApiInterface } from '../api';
/**
* Creates a strongly-typed group factory for a given endpoint interface.
+2 -4
View File
@@ -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
View File
@@ -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;
};