mirror of
https://github.com/tale/headplane.git
synced 2026-08-31 17:28:14 +00:00
feat: redo machine tagging system
This commit is contained in:
@@ -4,105 +4,54 @@ 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 type { User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import * as hinfo from '~/utils/host-info';
|
||||
|
||||
import { ExitNodeTag } from '~/components/tags/ExitNode';
|
||||
import { ExpiryTag } from '~/components/tags/Expiry';
|
||||
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
|
||||
import { SubnetTag } from '~/components/tags/Subnet';
|
||||
import { PopulatedNode } from '~/utils/node-info';
|
||||
import toast from '~/utils/toast';
|
||||
import MenuOptions from './menu';
|
||||
|
||||
interface Props {
|
||||
machine: Machine;
|
||||
routes: Route[];
|
||||
node: PopulatedNode;
|
||||
users: User[];
|
||||
isAgent?: boolean;
|
||||
magic?: string;
|
||||
stats?: HostInfo;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function MachineRow({
|
||||
machine,
|
||||
routes,
|
||||
node,
|
||||
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 uiTags = useMemo(() => {
|
||||
const tags = uiTagsForNode(node, isAgent);
|
||||
return tags;
|
||||
}, [node, isAgent]);
|
||||
|
||||
const ipOptions = useMemo(() => {
|
||||
if (magic) {
|
||||
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
|
||||
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
||||
}
|
||||
|
||||
return machine.ipAddresses;
|
||||
}, [magic, machine.ipAddresses]);
|
||||
return node.ipAddresses;
|
||||
}, [magic, node.ipAddresses]);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={machine.id}
|
||||
key={node.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}`}
|
||||
to={`/machines/${node.id}`}
|
||||
className={cn('group/link h-full focus:outline-none')}
|
||||
>
|
||||
<p
|
||||
@@ -112,11 +61,12 @@ export default function MachineRow({
|
||||
'group-hover/link:dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{machine.givenName}
|
||||
{node.givenName}
|
||||
</p>
|
||||
<p className="text-sm font-mono opacity-50">{machine.name}</p>
|
||||
<p className="text-sm font-mono opacity-50">{node.name}</p>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{tags.map((tag) => (
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{node.validTags.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
@@ -124,7 +74,7 @@ export default function MachineRow({
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
{machine.ipAddresses[0]}
|
||||
{node.ipAddresses[0]}
|
||||
<Menu placement="bottom end">
|
||||
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
@@ -157,11 +107,13 @@ export default function MachineRow({
|
||||
{/* We pass undefined when agents are not enabled */}
|
||||
{isAgent !== undefined ? (
|
||||
<td className="py-2">
|
||||
{stats !== undefined ? (
|
||||
{node.hostInfo !== undefined ? (
|
||||
<>
|
||||
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
|
||||
<p className="leading-snug">
|
||||
{hinfo.getTSVersion(node.hostInfo)}
|
||||
</p>
|
||||
<p className="text-sm opacity-50 max-w-48 truncate">
|
||||
{hinfo.getOSInfo(stats)}
|
||||
{hinfo.getOSInfo(node.hostInfo)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
@@ -177,20 +129,19 @@ export default function MachineRow({
|
||||
)}
|
||||
>
|
||||
<StatusCircle
|
||||
isOnline={machine.online && !expired}
|
||||
isOnline={node.online && !node.expired}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<p suppressHydrationWarning>
|
||||
{machine.online && !expired
|
||||
{node.online && !node.expired
|
||||
? 'Connected'
|
||||
: new Date(machine.lastSeen).toLocaleString()}
|
||||
: new Date(node.lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
node={node}
|
||||
users={users}
|
||||
magic={magic}
|
||||
isDisabled={isDisabled}
|
||||
@@ -199,3 +150,64 @@ export default function MachineRow({
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
|
||||
const uiTags: string[] = [];
|
||||
if (node.expired) {
|
||||
uiTags.push('expired');
|
||||
}
|
||||
|
||||
if (node.expiry === null) {
|
||||
uiTags.push('no-expiry');
|
||||
}
|
||||
|
||||
if (node.customRouting.exitRoutes.length > 0) {
|
||||
if (node.customRouting.exitApproved) {
|
||||
uiTags.push('exit-approved');
|
||||
} else {
|
||||
uiTags.push('exit-waiting');
|
||||
}
|
||||
}
|
||||
|
||||
if (node.customRouting.subnetWaitingRoutes.length > 0) {
|
||||
uiTags.push('subnet-waiting');
|
||||
} else if (node.customRouting.subnetApprovedRoutes.length > 0) {
|
||||
uiTags.push('subnet-approved');
|
||||
}
|
||||
|
||||
if (isAgent === true) {
|
||||
uiTags.push('headplane-agent');
|
||||
}
|
||||
|
||||
return uiTags;
|
||||
}
|
||||
|
||||
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
|
||||
return uiTags.map((tag) => {
|
||||
switch (tag) {
|
||||
case 'exit-approved':
|
||||
case 'exit-waiting':
|
||||
return <ExitNodeTag key={tag} isEnabled={tag === 'exit-approved'} />;
|
||||
|
||||
case 'subnet-approved':
|
||||
case 'subnet-waiting':
|
||||
return <SubnetTag key={tag} isEnabled={tag === 'subnet-approved'} />;
|
||||
|
||||
case 'expired':
|
||||
case 'no-expiry':
|
||||
return (
|
||||
<ExpiryTag
|
||||
key={tag}
|
||||
variant={tag}
|
||||
expiry={node.expiry ?? undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'headplane-agent':
|
||||
return <HeadplaneAgentTag />;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Cog, Ellipsis } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import Menu from '~/components/Menu';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import type { User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { PopulatedNode } from '~/utils/node-info';
|
||||
import Delete from '../dialogs/delete';
|
||||
import Expire from '../dialogs/expire';
|
||||
import Move from '../dialogs/move';
|
||||
@@ -11,8 +12,7 @@ import Routes from '../dialogs/routes';
|
||||
import Tags from '../dialogs/tags';
|
||||
|
||||
interface MenuProps {
|
||||
machine: Machine;
|
||||
routes: Route[];
|
||||
node: PopulatedNode;
|
||||
users: User[];
|
||||
magic?: string;
|
||||
isFullButton?: boolean;
|
||||
@@ -22,27 +22,18 @@ interface MenuProps {
|
||||
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
||||
|
||||
export default function MachineMenu({
|
||||
machine,
|
||||
routes,
|
||||
node,
|
||||
magic,
|
||||
users,
|
||||
isFullButton,
|
||||
isDisabled,
|
||||
}: MenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
|
||||
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 (
|
||||
<>
|
||||
{modal === 'remove' && (
|
||||
<Delete
|
||||
machine={machine}
|
||||
machine={node}
|
||||
isOpen={modal === 'remove'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
@@ -51,7 +42,7 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'move' && (
|
||||
<Move
|
||||
machine={machine}
|
||||
machine={node}
|
||||
users={users}
|
||||
isOpen={modal === 'move'}
|
||||
setIsOpen={(isOpen) => {
|
||||
@@ -61,7 +52,7 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'rename' && (
|
||||
<Rename
|
||||
machine={machine}
|
||||
machine={node}
|
||||
magic={magic}
|
||||
isOpen={modal === 'rename'}
|
||||
setIsOpen={(isOpen) => {
|
||||
@@ -71,8 +62,7 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'routes' && (
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
node={node}
|
||||
isOpen={modal === 'routes'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
@@ -81,16 +71,16 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'tags' && (
|
||||
<Tags
|
||||
machine={machine}
|
||||
machine={node}
|
||||
isOpen={modal === 'tags'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{expired && modal === 'expire' ? undefined : (
|
||||
{node.expired && modal === 'expire' ? undefined : (
|
||||
<Expire
|
||||
machine={machine}
|
||||
machine={node}
|
||||
isOpen={modal === 'expire'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
@@ -116,7 +106,10 @@ export default function MachineMenu({
|
||||
<Ellipsis className="h-5" />
|
||||
</Menu.IconButton>
|
||||
)}
|
||||
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
|
||||
<Menu.Panel
|
||||
onAction={(key) => setModal(key as Modal)}
|
||||
disabledKeys={node.expired ? ['expire'] : []}
|
||||
>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="rename">Edit machine name</Menu.Item>
|
||||
<Menu.Item key="routes">Edit route settings</Menu.Item>
|
||||
@@ -124,13 +117,9 @@ export default function MachineMenu({
|
||||
<Menu.Item key="move">Change owner</Menu.Item>
|
||||
</Menu.Section>
|
||||
<Menu.Section>
|
||||
{expired ? (
|
||||
<></>
|
||||
) : (
|
||||
<Menu.Item key="expire" textValue="Expire">
|
||||
<p className="text-red-500 dark:text-red-400">Expire</p>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key="expire" textValue="Expire">
|
||||
<p className="text-red-500 dark:text-red-400">Expire</p>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="remove" textValue="Remove">
|
||||
<p className="text-red-500 dark:text-red-400">Remove</p>
|
||||
</Menu.Item>
|
||||
|
||||
Reference in New Issue
Block a user