202 lines
4.7 KiB
TypeScript
202 lines
4.7 KiB
TypeScript
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
|
|
import { useMemo } from 'react';
|
|
import { Link } from 'react-router';
|
|
import Chip from '~/components/Chip';
|
|
import Menu from '~/components/Menu';
|
|
import StatusCircle from '~/components/StatusCircle';
|
|
import type { HostInfo, Machine, Route, User } from '~/types';
|
|
import cn from '~/utils/cn';
|
|
import * as hinfo from '~/utils/host-info';
|
|
|
|
import toast from '~/utils/toast';
|
|
import MenuOptions from './menu';
|
|
|
|
interface Props {
|
|
machine: Machine;
|
|
routes: Route[];
|
|
users: User[];
|
|
isAgent?: boolean;
|
|
magic?: string;
|
|
stats?: HostInfo;
|
|
isDisabled?: boolean;
|
|
}
|
|
|
|
export default function MachineRow({
|
|
machine,
|
|
routes,
|
|
users,
|
|
isAgent,
|
|
magic,
|
|
stats,
|
|
isDisabled,
|
|
}: 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 tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
|
|
|
|
if (expired) {
|
|
tags.unshift('Expired');
|
|
}
|
|
|
|
const prefix = magic?.startsWith('[user]')
|
|
? magic.replace('[user]', machine.user.name)
|
|
: magic;
|
|
|
|
// This is much easier with Object.groupBy but it's too new for us
|
|
const { exit, subnet, subnetApproved } = routes.reduce<{
|
|
exit: Route[];
|
|
subnetApproved: Route[];
|
|
subnet: Route[];
|
|
}>(
|
|
(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;
|
|
}
|
|
|
|
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 (exitEnabled) {
|
|
tags.unshift('Exit Node');
|
|
}
|
|
|
|
if (subnetApproved.length > 0) {
|
|
tags.unshift('Subnets');
|
|
}
|
|
|
|
if (isAgent) {
|
|
tags.unshift('Headplane Agent');
|
|
}
|
|
|
|
const ipOptions = useMemo(() => {
|
|
if (magic) {
|
|
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
|
|
}
|
|
|
|
return machine.ipAddresses;
|
|
}, [magic, machine.ipAddresses]);
|
|
|
|
return (
|
|
<tr
|
|
key={machine.id}
|
|
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
|
>
|
|
<td className="pl-0.5 py-2 focus-within:ring">
|
|
<Link
|
|
to={`/machines/${machine.id}`}
|
|
className={cn('group/link h-full focus:outline-none')}
|
|
>
|
|
<p
|
|
className={cn(
|
|
'font-semibold leading-snug',
|
|
'group-hover/link:text-blue-600',
|
|
'group-hover/link:dark:text-blue-400',
|
|
)}
|
|
>
|
|
{machine.givenName}
|
|
</p>
|
|
<p className="text-sm font-mono opacity-50">{machine.name}</p>
|
|
<div className="flex gap-1 mt-1">
|
|
{tags.map((tag) => (
|
|
<Chip key={tag} text={tag} />
|
|
))}
|
|
</div>
|
|
</Link>
|
|
</td>
|
|
<td className="py-2">
|
|
<div className="flex items-center gap-x-1">
|
|
{machine.ipAddresses[0]}
|
|
<Menu placement="bottom end">
|
|
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
|
<ChevronDownIcon className="w-4 h-4" />
|
|
</Menu.IconButton>
|
|
<Menu.Panel
|
|
onAction={async (key) => {
|
|
await navigator.clipboard.writeText(key.toString());
|
|
toast('Copied IP address to clipboard');
|
|
}}
|
|
>
|
|
<Menu.Section>
|
|
{ipOptions.map((ip) => (
|
|
<Menu.Item key={ip} textValue={ip}>
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-between',
|
|
'text-sm w-full gap-x-6',
|
|
)}
|
|
>
|
|
{ip}
|
|
<CopyIcon className="w-3 h-3" />
|
|
</div>
|
|
</Menu.Item>
|
|
))}
|
|
</Menu.Section>
|
|
</Menu.Panel>
|
|
</Menu>
|
|
</div>
|
|
</td>
|
|
{/* We pass undefined when agents are not enabled */}
|
|
{isAgent !== undefined ? (
|
|
<td className="py-2">
|
|
{stats !== undefined ? (
|
|
<>
|
|
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
|
|
<p className="text-sm opacity-50 max-w-48 truncate">
|
|
{hinfo.getOSInfo(stats)}
|
|
</p>
|
|
</>
|
|
) : (
|
|
<p className="text-sm opacity-50">Unknown</p>
|
|
)}
|
|
</td>
|
|
) : undefined}
|
|
<td className="py-2">
|
|
<span
|
|
className={cn(
|
|
'flex items-center gap-x-1 text-sm',
|
|
'text-headplane-600 dark:text-headplane-300',
|
|
)}
|
|
>
|
|
<StatusCircle
|
|
isOnline={machine.online && !expired}
|
|
className="w-4 h-4"
|
|
/>
|
|
<p suppressHydrationWarning>
|
|
{machine.online && !expired
|
|
? 'Connected'
|
|
: new Date(machine.lastSeen).toLocaleString()}
|
|
</p>
|
|
</span>
|
|
</td>
|
|
<td className="py-2 pr-0.5">
|
|
<MenuOptions
|
|
machine={machine}
|
|
routes={routes}
|
|
users={users}
|
|
magic={magic}
|
|
isDisabled={isDisabled}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|