headplane/app/routes/machines/overview.tsx

173 lines
5 KiB
TypeScript
Raw Normal View History

2024-12-31 10:30:14 +05:30
import { InfoIcon } from '@primer/octicons-react';
2024-12-31 10:31:50 +05:30
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
2024-12-31 10:30:14 +05:30
import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import { ErrorPopup } from '~/components/Error';
2024-12-31 10:30:14 +05:30
import Link from '~/components/Link';
2025-03-22 01:36:27 -04:00
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { Machine, Route, User } from '~/types';
2025-01-28 17:46:16 -05:00
import cn from '~/utils/cn';
import MachineRow from './components/machine-row';
2024-12-31 10:30:14 +05:30
import NewMachine from './dialogs/new';
import { machineAction } from './machine-actions';
2025-03-22 01:36:27 -04:00
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const user = session.get('user');
if (!user) {
throw new Error('Missing user session. Please log in again.');
}
const check = await context.sessions.check(
request,
Capabilities.read_machines,
);
if (!check) {
// Not authorized to view this page
throw new Error(
'You do not have permission to view this page. Please contact your administrator.',
);
}
const writablePermission = await context.sessions.check(
request,
Capabilities.write_machines,
);
const [machines, routes, users] = await Promise.all([
2025-03-22 01:36:27 -04:00
context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
),
context.client.get<{ routes: Route[] }>(
'v1/routes',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
2024-12-31 10:30:14 +05:30
]);
2024-12-31 10:30:14 +05:30
let magic: string | undefined;
2025-03-22 01:36:27 -04:00
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
return {
2024-05-01 02:47:45 -04:00
nodes: machines.nodes,
routes: routes.routes,
users: users.users,
magic,
2025-03-22 01:36:27 -04:00
server: context.config.headscale.url,
publicServer: context.config.headscale.public_url,
agents: context.agents?.tailnetIDs(),
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
writable: writablePermission,
preAuth: await context.sessions.check(
request,
Capabilities.generate_authkeys,
),
subject: user.subject,
2024-12-31 10:30:14 +05:30
};
}
2025-03-22 01:36:27 -04:00
export async function action(request: ActionFunctionArgs) {
return machineAction(request);
}
export default function Page() {
2024-12-31 10:30:14 +05:30
const data = useLoaderData<typeof loader>();
return (
<>
2025-02-04 17:21:03 -05:00
<div className="flex justify-between items-center mb-6">
<div className="flex flex-col w-2/3">
2025-02-04 17:21:03 -05:00
<h1 className="text-2xl font-medium mb-2">Machines</h1>
<p>
2024-12-31 10:30:14 +05:30
Manage the devices connected to your Tailnet.{' '}
<Link
to="https://tailscale.com/kb/1372/manage-devices"
name="Tailscale Manage Devices Documentation"
>
Learn more
</Link>
</p>
</div>
<NewMachine
server={data.publicServer ?? data.server}
users={data.users}
isDisabled={!data.writable}
disabledKeys={data.preAuth ? [] : ['pre-auth']}
/>
</div>
<table className="table-auto w-full rounded-lg">
2025-02-04 17:21:03 -05:00
<thead className="text-headplane-600 dark:text-headplane-300">
2025-02-01 15:28:04 -05:00
<tr className="text-left px-0.5">
2025-02-04 17:21:03 -05:00
<th className="uppercase text-xs font-bold pb-2">Name</th>
<th className="pb-2 w-1/4">
<div className="flex items-center gap-x-1">
2025-02-01 15:28:04 -05:00
<p className="uppercase text-xs font-bold">Addresses</p>
2024-12-31 10:30:14 +05:30
{data.magic ? (
2025-02-01 15:28:04 -05:00
<Tooltip>
<InfoIcon className="w-4 h-4" />
<Tooltip.Body className="font-normal">
2024-12-31 10:30:14 +05:30
Since MagicDNS is enabled, you can access devices based on
their name and also at{' '}
<Code>
[name].
{data.magic}
</Code>
2025-02-01 15:28:04 -05:00
</Tooltip.Body>
</Tooltip>
2024-12-31 10:30:14 +05:30
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{data.agents !== undefined ? (
<th className="uppercase text-xs font-bold pb-2">Version</th>
) : undefined}
2025-02-04 17:21:03 -05:00
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr>
</thead>
2024-12-31 10:30:14 +05:30
<tbody
className={cn(
2025-02-04 17:21:03 -05:00
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
2024-12-31 10:30:14 +05:30
)}
>
2024-12-31 10:30:14 +05:30
{data.nodes.map((machine) => (
<MachineRow
key={machine.id}
machine={machine}
2024-12-31 10:30:14 +05:30
routes={data.routes.filter(
(route) => route.node.id === machine.id,
)}
users={data.users}
magic={data.magic}
// If we pass undefined, the column will not be rendered
// This is useful for when there are no agents configured
isAgent={data.agents?.includes(machine.id)}
stats={data.stats?.[machine.nodeKey]}
isDisabled={
data.writable
? false // If the user has write permissions, they can edit all machines
: machine.user.providerId?.split('/').pop() !== data.subject
}
/>
))}
</tbody>
</table>
</>
2024-12-31 10:30:14 +05:30
);
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}