feat: redo machine tagging system
This commit is contained in:
parent
6ace244401
commit
0c7e2e49f5
@ -11,6 +11,8 @@
|
|||||||
- Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)).
|
- Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)).
|
||||||
- The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)).
|
- The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)).
|
||||||
- The machine actions backend has been reworked to better handle errors and provide more information to the user (closes [#185](https://github.com/tale/headplane/issues/185)).
|
- The machine actions backend has been reworked to better handle errors and provide more information to the user (closes [#185](https://github.com/tale/headplane/issues/185)).
|
||||||
|
- Machine tags now show states when waiting for subnet or exit node approval and when expiry is disabled.
|
||||||
|
- Expiry status on the UI was incorrectly showing as never due to changes in the Headscale API.
|
||||||
|
|
||||||
### 0.5.10 (April 4, 2025)
|
### 0.5.10 (April 4, 2025)
|
||||||
- Fix an issue where other preferences to skip onboarding affected every user.
|
- Fix an issue where other preferences to skip onboarding affected every user.
|
||||||
|
|||||||
@ -17,10 +17,10 @@ export default function Chip({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs px-2 py-0.5 rounded-full',
|
'h-5 text-xs py-0.5 px-1 rounded-md',
|
||||||
'text-headplane-700 dark:text-headplane-100',
|
'text-headplane-700 dark:text-headplane-100',
|
||||||
'bg-headplane-100 dark:bg-headplane-700',
|
'bg-headplane-100 dark:bg-headplane-700',
|
||||||
leftIcon || rightIcon ? 'inline-flex items-center gap-x-1' : '',
|
'inline-flex items-center gap-x-1',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
32
app/components/tags/ExitNode.tsx
Normal file
32
app/components/tags/ExitNode.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import cn from '~/utils/cn';
|
||||||
|
import Chip from '../Chip';
|
||||||
|
import Tooltip from '../Tooltip';
|
||||||
|
|
||||||
|
export interface ExitNodeTagProps {
|
||||||
|
isEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<Chip
|
||||||
|
text="Exit Node"
|
||||||
|
className={cn(
|
||||||
|
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
)}
|
||||||
|
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||||
|
/>
|
||||||
|
<Tooltip.Body>
|
||||||
|
{isEnabled ? (
|
||||||
|
<>This machine is acting as an exit node.</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
This machine is requesting to be used as an exit node. Review this
|
||||||
|
from the "Edit route settings..." option in the machine's menu.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
app/components/tags/Expiry.tsx
Normal file
42
app/components/tags/Expiry.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import Chip from '../Chip';
|
||||||
|
import Tooltip from '../Tooltip';
|
||||||
|
|
||||||
|
export interface ExpiryTagProps {
|
||||||
|
variant: 'expired' | 'no-expiry';
|
||||||
|
expiry?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<Chip
|
||||||
|
text={
|
||||||
|
variant === 'expired'
|
||||||
|
? `Expired ${formatter.format(new Date(expiry!))}`
|
||||||
|
: 'No expiry'
|
||||||
|
}
|
||||||
|
className="bg-headplane-200 text-headplane-800 dark:bg-headplane-800 dark:text-headplane-200"
|
||||||
|
/>
|
||||||
|
<Tooltip.Body>
|
||||||
|
{variant === 'expired' ? (
|
||||||
|
<>
|
||||||
|
This machine is expired and will not be able to connect to the
|
||||||
|
network. Re-authenticate with Tailscale on the machine to re-enable
|
||||||
|
it.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
This machine has key expiry disabled and will never need to
|
||||||
|
re-authenticate.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/components/tags/HeadplaneAgent.tsx
Normal file
20
app/components/tags/HeadplaneAgent.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import cn from '~/utils/cn';
|
||||||
|
import Chip from '../Chip';
|
||||||
|
import Tooltip from '../Tooltip';
|
||||||
|
|
||||||
|
export function HeadplaneAgentTag() {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<Chip
|
||||||
|
text="Headplane Agent"
|
||||||
|
className={cn(
|
||||||
|
'bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Tooltip.Body>
|
||||||
|
This machine is running the Headplane agent, which allows it to provide
|
||||||
|
host information in the web UI.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
app/components/tags/Subnet.tsx
Normal file
32
app/components/tags/Subnet.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import cn from '~/utils/cn';
|
||||||
|
import Chip from '../Chip';
|
||||||
|
import Tooltip from '../Tooltip';
|
||||||
|
|
||||||
|
export interface SubnetTagProps {
|
||||||
|
isEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubnetTag({ isEnabled }: SubnetTagProps) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<Chip
|
||||||
|
text="Subnets"
|
||||||
|
className={cn(
|
||||||
|
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
)}
|
||||||
|
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||||
|
/>
|
||||||
|
<Tooltip.Body>
|
||||||
|
{isEnabled ? (
|
||||||
|
<>This machine advertises subnet routes.</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
This machine has unadvertised subnet routes. Review this from the
|
||||||
|
"Edit route settings..." option in the machine's menu.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,105 +4,54 @@ import { Link } from 'react-router';
|
|||||||
import Chip from '~/components/Chip';
|
import Chip from '~/components/Chip';
|
||||||
import Menu from '~/components/Menu';
|
import Menu from '~/components/Menu';
|
||||||
import StatusCircle from '~/components/StatusCircle';
|
import StatusCircle from '~/components/StatusCircle';
|
||||||
import type { HostInfo, Machine, Route, User } from '~/types';
|
import type { User } from '~/types';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
import * as hinfo from '~/utils/host-info';
|
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 toast from '~/utils/toast';
|
||||||
import MenuOptions from './menu';
|
import MenuOptions from './menu';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
machine: Machine;
|
node: PopulatedNode;
|
||||||
routes: Route[];
|
|
||||||
users: User[];
|
users: User[];
|
||||||
isAgent?: boolean;
|
isAgent?: boolean;
|
||||||
magic?: string;
|
magic?: string;
|
||||||
stats?: HostInfo;
|
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MachineRow({
|
export default function MachineRow({
|
||||||
machine,
|
node,
|
||||||
routes,
|
|
||||||
users,
|
users,
|
||||||
isAgent,
|
isAgent,
|
||||||
magic,
|
magic,
|
||||||
stats,
|
|
||||||
isDisabled,
|
isDisabled,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const expired =
|
const uiTags = useMemo(() => {
|
||||||
machine.expiry === '0001-01-01 00:00:00' ||
|
const tags = uiTagsForNode(node, isAgent);
|
||||||
machine.expiry === '0001-01-01T00:00:00Z' ||
|
return tags;
|
||||||
machine.expiry === null
|
}, [node, isAgent]);
|
||||||
? 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(() => {
|
const ipOptions = useMemo(() => {
|
||||||
if (magic) {
|
if (magic) {
|
||||||
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
|
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
return machine.ipAddresses;
|
return node.ipAddresses;
|
||||||
}, [magic, machine.ipAddresses]);
|
}, [magic, node.ipAddresses]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={machine.id}
|
key={node.id}
|
||||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||||
>
|
>
|
||||||
<td className="pl-0.5 py-2 focus-within:ring">
|
<td className="pl-0.5 py-2 focus-within:ring">
|
||||||
<Link
|
<Link
|
||||||
to={`/machines/${machine.id}`}
|
to={`/machines/${node.id}`}
|
||||||
className={cn('group/link h-full focus:outline-none')}
|
className={cn('group/link h-full focus:outline-none')}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
@ -112,11 +61,12 @@ export default function MachineRow({
|
|||||||
'group-hover/link:dark:text-blue-400',
|
'group-hover/link:dark:text-blue-400',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{machine.givenName}
|
{node.givenName}
|
||||||
</p>
|
</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">
|
<div className="flex gap-1 mt-1">
|
||||||
{tags.map((tag) => (
|
{mapTagsToComponents(node, uiTags)}
|
||||||
|
{node.validTags.map((tag) => (
|
||||||
<Chip key={tag} text={tag} />
|
<Chip key={tag} text={tag} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -124,7 +74,7 @@ export default function MachineRow({
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
<div className="flex items-center gap-x-1">
|
<div className="flex items-center gap-x-1">
|
||||||
{machine.ipAddresses[0]}
|
{node.ipAddresses[0]}
|
||||||
<Menu placement="bottom end">
|
<Menu placement="bottom end">
|
||||||
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
||||||
<ChevronDownIcon className="w-4 h-4" />
|
<ChevronDownIcon className="w-4 h-4" />
|
||||||
@ -157,11 +107,13 @@ export default function MachineRow({
|
|||||||
{/* We pass undefined when agents are not enabled */}
|
{/* We pass undefined when agents are not enabled */}
|
||||||
{isAgent !== undefined ? (
|
{isAgent !== undefined ? (
|
||||||
<td className="py-2">
|
<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">
|
<p className="text-sm opacity-50 max-w-48 truncate">
|
||||||
{hinfo.getOSInfo(stats)}
|
{hinfo.getOSInfo(node.hostInfo)}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -177,20 +129,19 @@ export default function MachineRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<StatusCircle
|
<StatusCircle
|
||||||
isOnline={machine.online && !expired}
|
isOnline={node.online && !node.expired}
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<p suppressHydrationWarning>
|
<p suppressHydrationWarning>
|
||||||
{machine.online && !expired
|
{node.online && !node.expired
|
||||||
? 'Connected'
|
? 'Connected'
|
||||||
: new Date(machine.lastSeen).toLocaleString()}
|
: new Date(node.lastSeen).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-0.5">
|
<td className="py-2 pr-0.5">
|
||||||
<MenuOptions
|
<MenuOptions
|
||||||
machine={machine}
|
node={node}
|
||||||
routes={routes}
|
|
||||||
users={users}
|
users={users}
|
||||||
magic={magic}
|
magic={magic}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
@ -199,3 +150,64 @@ export default function MachineRow({
|
|||||||
</tr>
|
</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 { Cog, Ellipsis } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Menu from '~/components/Menu';
|
import Menu from '~/components/Menu';
|
||||||
import type { Machine, Route, User } from '~/types';
|
import type { User } from '~/types';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
|
import { PopulatedNode } from '~/utils/node-info';
|
||||||
import Delete from '../dialogs/delete';
|
import Delete from '../dialogs/delete';
|
||||||
import Expire from '../dialogs/expire';
|
import Expire from '../dialogs/expire';
|
||||||
import Move from '../dialogs/move';
|
import Move from '../dialogs/move';
|
||||||
@ -11,8 +12,7 @@ import Routes from '../dialogs/routes';
|
|||||||
import Tags from '../dialogs/tags';
|
import Tags from '../dialogs/tags';
|
||||||
|
|
||||||
interface MenuProps {
|
interface MenuProps {
|
||||||
machine: Machine;
|
node: PopulatedNode;
|
||||||
routes: Route[];
|
|
||||||
users: User[];
|
users: User[];
|
||||||
magic?: string;
|
magic?: string;
|
||||||
isFullButton?: boolean;
|
isFullButton?: boolean;
|
||||||
@ -22,27 +22,18 @@ interface MenuProps {
|
|||||||
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
||||||
|
|
||||||
export default function MachineMenu({
|
export default function MachineMenu({
|
||||||
machine,
|
node,
|
||||||
routes,
|
|
||||||
magic,
|
magic,
|
||||||
users,
|
users,
|
||||||
isFullButton,
|
isFullButton,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
}: MenuProps) {
|
}: MenuProps) {
|
||||||
const [modal, setModal] = useState<Modal>(null);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{modal === 'remove' && (
|
{modal === 'remove' && (
|
||||||
<Delete
|
<Delete
|
||||||
machine={machine}
|
machine={node}
|
||||||
isOpen={modal === 'remove'}
|
isOpen={modal === 'remove'}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
@ -51,7 +42,7 @@ export default function MachineMenu({
|
|||||||
)}
|
)}
|
||||||
{modal === 'move' && (
|
{modal === 'move' && (
|
||||||
<Move
|
<Move
|
||||||
machine={machine}
|
machine={node}
|
||||||
users={users}
|
users={users}
|
||||||
isOpen={modal === 'move'}
|
isOpen={modal === 'move'}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
@ -61,7 +52,7 @@ export default function MachineMenu({
|
|||||||
)}
|
)}
|
||||||
{modal === 'rename' && (
|
{modal === 'rename' && (
|
||||||
<Rename
|
<Rename
|
||||||
machine={machine}
|
machine={node}
|
||||||
magic={magic}
|
magic={magic}
|
||||||
isOpen={modal === 'rename'}
|
isOpen={modal === 'rename'}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
@ -71,8 +62,7 @@ export default function MachineMenu({
|
|||||||
)}
|
)}
|
||||||
{modal === 'routes' && (
|
{modal === 'routes' && (
|
||||||
<Routes
|
<Routes
|
||||||
machine={machine}
|
node={node}
|
||||||
routes={routes}
|
|
||||||
isOpen={modal === 'routes'}
|
isOpen={modal === 'routes'}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
@ -81,16 +71,16 @@ export default function MachineMenu({
|
|||||||
)}
|
)}
|
||||||
{modal === 'tags' && (
|
{modal === 'tags' && (
|
||||||
<Tags
|
<Tags
|
||||||
machine={machine}
|
machine={node}
|
||||||
isOpen={modal === 'tags'}
|
isOpen={modal === 'tags'}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{expired && modal === 'expire' ? undefined : (
|
{node.expired && modal === 'expire' ? undefined : (
|
||||||
<Expire
|
<Expire
|
||||||
machine={machine}
|
machine={node}
|
||||||
isOpen={modal === 'expire'}
|
isOpen={modal === 'expire'}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
@ -116,7 +106,10 @@ export default function MachineMenu({
|
|||||||
<Ellipsis className="h-5" />
|
<Ellipsis className="h-5" />
|
||||||
</Menu.IconButton>
|
</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.Section>
|
||||||
<Menu.Item key="rename">Edit machine name</Menu.Item>
|
<Menu.Item key="rename">Edit machine name</Menu.Item>
|
||||||
<Menu.Item key="routes">Edit route settings</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.Item key="move">Change owner</Menu.Item>
|
||||||
</Menu.Section>
|
</Menu.Section>
|
||||||
<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">
|
<Menu.Item key="remove" textValue="Remove">
|
||||||
<p className="text-red-500 dark:text-red-400">Remove</p>
|
<p className="text-red-500 dark:text-red-400">Remove</p>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@ -1,55 +1,30 @@
|
|||||||
import { GlobeLock, RouteOff } from 'lucide-react';
|
import { GlobeLock, RouteOff } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useFetcher } from 'react-router';
|
import { useFetcher } from 'react-router';
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
import Switch from '~/components/Switch';
|
import Switch from '~/components/Switch';
|
||||||
import TableList from '~/components/TableList';
|
import TableList from '~/components/TableList';
|
||||||
import type { Machine, Route } from '~/types';
|
import { PopulatedNode } from '~/utils/node-info';
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
interface RoutesProps {
|
interface RoutesProps {
|
||||||
machine: Machine;
|
node: PopulatedNode;
|
||||||
routes: Route[];
|
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Support deleting routes
|
// TODO: Support deleting routes
|
||||||
export default function Routes({
|
export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
|
||||||
machine,
|
|
||||||
routes,
|
|
||||||
isOpen,
|
|
||||||
setIsOpen,
|
|
||||||
}: RoutesProps) {
|
|
||||||
const fetcher = useFetcher();
|
const fetcher = useFetcher();
|
||||||
|
|
||||||
// This is much easier with Object.groupBy but it's too new for us
|
const subnets = [
|
||||||
const { exit, subnet } = routes.reduce<{
|
...node.customRouting.subnetApprovedRoutes,
|
||||||
exit: Route[];
|
...node.customRouting.subnetWaitingRoutes,
|
||||||
subnet: Route[];
|
];
|
||||||
}>(
|
|
||||||
(acc, route) => {
|
|
||||||
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
|
||||||
acc.exit.push(route);
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.subnet.push(route);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ exit: [], subnet: [] },
|
|
||||||
);
|
|
||||||
|
|
||||||
const exitEnabled = useMemo(() => {
|
|
||||||
if (exit.length !== 2) return false;
|
|
||||||
return exit[0].enabled && exit[1].enabled;
|
|
||||||
}, [exit]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel variant="unactionable">
|
<Dialog.Panel variant="unactionable">
|
||||||
<Dialog.Title>Edit route settings of {machine.givenName}</Dialog.Title>
|
<Dialog.Title>Edit route settings of {node.givenName}</Dialog.Title>
|
||||||
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
|
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
Connect to devices you can't install Tailscale on by advertising
|
Connect to devices you can't install Tailscale on by advertising
|
||||||
@ -62,7 +37,7 @@ export default function Routes({
|
|||||||
</Link>
|
</Link>
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<TableList className="mt-4">
|
<TableList className="mt-4">
|
||||||
{subnet.length === 0 ? (
|
{subnets.length === 0 ? (
|
||||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||||
<RouteOff />
|
<RouteOff />
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
@ -70,7 +45,7 @@ export default function Routes({
|
|||||||
</p>
|
</p>
|
||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{subnet.map((route) => (
|
{subnets.map((route) => (
|
||||||
<TableList.Item key={route.id}>
|
<TableList.Item key={route.id}>
|
||||||
<p>{route.prefix}</p>
|
<p>{route.prefix}</p>
|
||||||
<Switch
|
<Switch
|
||||||
@ -79,7 +54,7 @@ export default function Routes({
|
|||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set('action_id', 'update_routes');
|
form.set('action_id', 'update_routes');
|
||||||
form.set('node_id', machine.id);
|
form.set('node_id', node.id);
|
||||||
form.set('routes', [route.id].join(','));
|
form.set('routes', [route.id].join(','));
|
||||||
|
|
||||||
form.set('enabled', String(checked));
|
form.set('enabled', String(checked));
|
||||||
@ -102,7 +77,7 @@ export default function Routes({
|
|||||||
</Link>
|
</Link>
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<TableList className="mt-4">
|
<TableList className="mt-4">
|
||||||
{exit.length === 0 ? (
|
{node.customRouting.exitRoutes.length === 0 ? (
|
||||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||||
<GlobeLock />
|
<GlobeLock />
|
||||||
<p className="font-semibold">This machine is not an exit node</p>
|
<p className="font-semibold">This machine is not an exit node</p>
|
||||||
@ -111,13 +86,18 @@ export default function Routes({
|
|||||||
<TableList.Item>
|
<TableList.Item>
|
||||||
<p>Use as exit node</p>
|
<p>Use as exit node</p>
|
||||||
<Switch
|
<Switch
|
||||||
defaultSelected={exitEnabled}
|
defaultSelected={node.customRouting.exitApproved}
|
||||||
label="Enabled"
|
label="Enabled"
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set('action_id', 'update_routes');
|
form.set('action_id', 'update_routes');
|
||||||
form.set('node_id', machine.id);
|
form.set('node_id', node.id);
|
||||||
form.set('routes', exit.map((route) => route.id).join(','));
|
form.set(
|
||||||
|
'routes',
|
||||||
|
node.customRouting.exitRoutes
|
||||||
|
.map((route) => route.id)
|
||||||
|
.join(','),
|
||||||
|
);
|
||||||
|
|
||||||
form.set('enabled', String(checked));
|
form.set('enabled', String(checked));
|
||||||
fetcher.submit(form, {
|
fetcher.submit(form, {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||||
import { Link as RemixLink, useLoaderData } from 'react-router';
|
import { Link as RemixLink, useLoaderData } from 'react-router';
|
||||||
|
import { mapTag } from 'yaml/util';
|
||||||
import Attribute from '~/components/Attribute';
|
import Attribute from '~/components/Attribute';
|
||||||
import Button from '~/components/Button';
|
import Button from '~/components/Button';
|
||||||
import Card from '~/components/Card';
|
import Card from '~/components/Card';
|
||||||
@ -12,6 +13,8 @@ import Tooltip from '~/components/Tooltip';
|
|||||||
import type { LoadContext } from '~/server';
|
import type { LoadContext } from '~/server';
|
||||||
import type { Machine, Route, User } from '~/types';
|
import type { Machine, Route, User } from '~/types';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
|
import { mapNodes } from '~/utils/node-info';
|
||||||
|
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
|
||||||
import MenuOptions from './components/menu';
|
import MenuOptions from './components/menu';
|
||||||
import Routes from './dialogs/routes';
|
import Routes from './dialogs/routes';
|
||||||
import { machineAction } from './machine-actions';
|
import { machineAction } from './machine-actions';
|
||||||
@ -33,7 +36,7 @@ export async function loader({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [machine, routes, users] = await Promise.all([
|
const [machine, { routes }, { users }] = await Promise.all([
|
||||||
context.client.get<{ node: Machine }>(
|
context.client.get<{ node: Machine }>(
|
||||||
`v1/node/${params.id}`,
|
`v1/node/${params.id}`,
|
||||||
session.get('api_key')!,
|
session.get('api_key')!,
|
||||||
@ -45,16 +48,13 @@ export async function loader({
|
|||||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const [node] = mapNodes([machine.node], routes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
machine: machine.node,
|
node,
|
||||||
routes: routes.routes.filter((route) => route.node.id === params.id),
|
users,
|
||||||
users: users.users,
|
|
||||||
magic,
|
magic,
|
||||||
// TODO: Fix agent
|
agent: context.agents?.agentID(),
|
||||||
agent: false,
|
|
||||||
// agent: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()].includes(
|
|
||||||
// machine.node.id,
|
|
||||||
// ),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,62 +63,13 @@ export async function action(request: ActionFunctionArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { machine, magic, routes, users, agent } =
|
const { node, magic, users, agent } = useLoaderData<typeof loader>();
|
||||||
useLoaderData<typeof loader>();
|
|
||||||
const [showRouting, setShowRouting] = useState(false);
|
const [showRouting, setShowRouting] = useState(false);
|
||||||
|
|
||||||
const expired =
|
const uiTags = useMemo(() => {
|
||||||
machine.expiry === '0001-01-01 00:00:00' ||
|
const tags = uiTagsForNode(node, agent === node.nodeKey);
|
||||||
machine.expiry === '0001-01-01T00:00:00Z' ||
|
return tags;
|
||||||
machine.expiry === null
|
}, [node, agent]);
|
||||||
? false
|
|
||||||
: new Date(machine.expiry).getTime() < Date.now();
|
|
||||||
|
|
||||||
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
|
|
||||||
|
|
||||||
if (expired) {
|
|
||||||
tags.unshift('Expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agent) {
|
|
||||||
tags.unshift('Headplane Agent');
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is much easier with Object.groupBy but it's too new for us
|
|
||||||
const { exit, subnet, subnetApproved } = routes.reduce<{
|
|
||||||
exit: Route[];
|
|
||||||
subnet: Route[];
|
|
||||||
subnetApproved: 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -127,7 +78,7 @@ export default function Page() {
|
|||||||
All Machines
|
All Machines
|
||||||
</RemixLink>
|
</RemixLink>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
{machine.givenName}
|
{node.givenName}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -136,17 +87,10 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-baseline gap-x-4 text-sm">
|
<span className="flex items-baseline gap-x-4 text-sm">
|
||||||
<h1 className="text-2xl font-medium">{machine.givenName}</h1>
|
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||||||
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
|
<StatusCircle isOnline={node.online} className="w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
|
<MenuOptions isFullButton node={node} users={users} magic={magic} />
|
||||||
<MenuOptions
|
|
||||||
isFullButton
|
|
||||||
machine={machine}
|
|
||||||
routes={routes}
|
|
||||||
users={users}
|
|
||||||
magic={magic}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 mb-4">
|
<div className="flex gap-1 mb-4">
|
||||||
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
||||||
@ -161,29 +105,23 @@ export default function Page() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-x-2.5 mt-1">
|
<div className="flex items-center gap-x-2.5 mt-1">
|
||||||
<UserCircle />
|
<UserCircle />
|
||||||
{machine.user.name}
|
{node.user.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{tags.length > 0 ? (
|
<div className="p-2 pl-4">
|
||||||
<div className="p-2 pl-4">
|
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
||||||
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
Status
|
||||||
Status
|
</p>
|
||||||
</p>
|
<div className="flex gap-1 mt-1 mb-8">
|
||||||
<div className="flex gap-1 mt-1 mb-8">
|
{mapTagsToComponents(node, uiTags)}
|
||||||
{tags.map((tag) => (
|
{node.validTags.map((tag) => (
|
||||||
<Chip key={tag} text={tag} />
|
<Chip key={tag} text={tag} />
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
|
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
|
||||||
<Routes
|
<Routes node={node} isOpen={showRouting} setIsOpen={setShowRouting} />
|
||||||
machine={machine}
|
|
||||||
routes={routes}
|
|
||||||
isOpen={showRouting}
|
|
||||||
setIsOpen={setShowRouting}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<p>
|
<p>
|
||||||
Subnets let you expose physical network routes onto Tailscale.{' '}
|
Subnets let you expose physical network routes onto Tailscale.{' '}
|
||||||
@ -214,11 +152,11 @@ export default function Page() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{subnetApproved.length === 0 ? (
|
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
|
||||||
<span className="opacity-50">—</span>
|
<span className="opacity-50">—</span>
|
||||||
) : (
|
) : (
|
||||||
<ul className="leading-normal">
|
<ul className="leading-normal">
|
||||||
{subnetApproved.map((route) => (
|
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
||||||
<li key={route.id}>{route.prefix}</li>
|
<li key={route.id}>{route.prefix}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -246,11 +184,11 @@ export default function Page() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{subnet.length === 0 ? (
|
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
|
||||||
<span className="opacity-50">—</span>
|
<span className="opacity-50">—</span>
|
||||||
) : (
|
) : (
|
||||||
<ul className="leading-normal">
|
<ul className="leading-normal">
|
||||||
{subnet.map((route) => (
|
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
||||||
<li key={route.id}>{route.prefix}</li>
|
<li key={route.id}>{route.prefix}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -277,9 +215,9 @@ export default function Page() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{exit.length === 0 ? (
|
{node.customRouting.exitRoutes.length === 0 ? (
|
||||||
<span className="opacity-50">—</span>
|
<span className="opacity-50">—</span>
|
||||||
) : exitEnabled ? (
|
) : node.customRouting.exitApproved ? (
|
||||||
<span className="flex items-center gap-x-1">
|
<span className="flex items-center gap-x-1">
|
||||||
<CheckCircle className="w-3.5 h-3.5 text-green-700" />
|
<CheckCircle className="w-3.5 h-3.5 text-green-700" />
|
||||||
Allowed
|
Allowed
|
||||||
@ -304,31 +242,35 @@ export default function Page() {
|
|||||||
</Card>
|
</Card>
|
||||||
<h2 className="text-xl font-medium mb-4">Machine Details</h2>
|
<h2 className="text-xl font-medium mb-4">Machine Details</h2>
|
||||||
<Card variant="flat" className="w-full max-w-full">
|
<Card variant="flat" className="w-full max-w-full">
|
||||||
<Attribute name="Creator" value={machine.user.name} />
|
<Attribute name="Creator" value={node.user.name} />
|
||||||
<Attribute name="Node ID" value={machine.id} />
|
<Attribute name="Node ID" value={node.id} />
|
||||||
<Attribute name="Node Name" value={machine.givenName} />
|
<Attribute name="Node Name" value={node.givenName} />
|
||||||
<Attribute name="Hostname" value={machine.name} />
|
<Attribute name="Hostname" value={node.name} />
|
||||||
<Attribute isCopyable name="Node Key" value={machine.nodeKey} />
|
<Attribute isCopyable name="Node Key" value={node.nodeKey} />
|
||||||
<Attribute
|
<Attribute
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
name="Created"
|
name="Created"
|
||||||
value={new Date(machine.createdAt).toLocaleString()}
|
value={new Date(node.createdAt).toLocaleString()}
|
||||||
/>
|
/>
|
||||||
<Attribute
|
<Attribute
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
name="Last Seen"
|
name="Last Seen"
|
||||||
value={new Date(machine.lastSeen).toLocaleString()}
|
value={new Date(node.lastSeen).toLocaleString()}
|
||||||
/>
|
/>
|
||||||
<Attribute
|
<Attribute
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
name="Expiry"
|
name="Expiry"
|
||||||
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
|
value={
|
||||||
|
node.expiry !== null
|
||||||
|
? new Date(node.expiry).toLocaleString()
|
||||||
|
: 'Never'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{magic ? (
|
{magic ? (
|
||||||
<Attribute
|
<Attribute
|
||||||
isCopyable
|
isCopyable
|
||||||
name="Domain"
|
name="Domain"
|
||||||
value={`${machine.givenName}.${magic}`}
|
value={`${node.givenName}.${magic}`}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type { LoadContext } from '~/server';
|
|||||||
import { Capabilities } from '~/server/web/roles';
|
import { Capabilities } from '~/server/web/roles';
|
||||||
import type { Machine, Route, User } from '~/types';
|
import type { Machine, Route, User } from '~/types';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
|
import { mapNodes } from '~/utils/node-info';
|
||||||
import MachineRow from './components/machine-row';
|
import MachineRow from './components/machine-row';
|
||||||
import NewMachine from './dialogs/new';
|
import NewMachine from './dialogs/new';
|
||||||
import { machineAction } from './machine-actions';
|
import { machineAction } from './machine-actions';
|
||||||
@ -40,7 +41,7 @@ export async function loader({
|
|||||||
Capabilities.write_machines,
|
Capabilities.write_machines,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [machines, routes, users] = await Promise.all([
|
const [{ nodes }, { routes }, { users }] = await Promise.all([
|
||||||
context.client.get<{ nodes: Machine[] }>(
|
context.client.get<{ nodes: Machine[] }>(
|
||||||
'v1/node',
|
'v1/node',
|
||||||
session.get('api_key')!,
|
session.get('api_key')!,
|
||||||
@ -59,17 +60,18 @@ export async function loader({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
|
||||||
|
const populatedNodes = mapNodes(nodes, routes, stats);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodes: machines.nodes,
|
populatedNodes,
|
||||||
routes: routes.routes,
|
nodes,
|
||||||
users: users.users,
|
routes,
|
||||||
|
users,
|
||||||
magic,
|
magic,
|
||||||
server: context.config.headscale.url,
|
server: context.config.headscale.url,
|
||||||
publicServer: context.config.headscale.public_url,
|
publicServer: context.config.headscale.public_url,
|
||||||
agent: context.agents?.agentID(),
|
agent: context.agents?.agentID(),
|
||||||
stats: await context.agents?.lookup(
|
|
||||||
machines.nodes.map((node) => node.nodeKey),
|
|
||||||
),
|
|
||||||
writable: writablePermission,
|
writable: writablePermission,
|
||||||
preAuth: await context.sessions.check(
|
preAuth: await context.sessions.check(
|
||||||
request,
|
request,
|
||||||
@ -143,19 +145,13 @@ export default function Page() {
|
|||||||
'border-t border-headplane-100 dark:border-headplane-800',
|
'border-t border-headplane-100 dark:border-headplane-800',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{data.nodes.map((machine) => (
|
{data.populatedNodes.map((machine) => (
|
||||||
<MachineRow
|
<MachineRow
|
||||||
key={machine.id}
|
key={machine.id}
|
||||||
machine={machine}
|
node={machine}
|
||||||
routes={data.routes.filter(
|
|
||||||
(route) => route.node.id === machine.id,
|
|
||||||
)}
|
|
||||||
users={data.users}
|
users={data.users}
|
||||||
magic={data.magic}
|
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.agent === machine.nodeKey}
|
isAgent={data.agent === machine.nodeKey}
|
||||||
stats={data.stats?.[machine.nodeKey]}
|
|
||||||
isDisabled={
|
isDisabled={
|
||||||
data.writable
|
data.writable
|
||||||
? false // If the user has write permissions, they can edit all machines
|
? false // If the user has write permissions, they can edit all machines
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export interface Machine {
|
|||||||
|
|
||||||
user: User;
|
user: User;
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
expiry: string;
|
expiry: string | null;
|
||||||
|
|
||||||
preAuthKey?: unknown; // TODO
|
preAuthKey?: unknown; // TODO
|
||||||
|
|
||||||
|
|||||||
58
app/utils/node-info.ts
Normal file
58
app/utils/node-info.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { HostInfo, Machine, Route } from '~/types';
|
||||||
|
|
||||||
|
export interface PopulatedNode extends Machine {
|
||||||
|
routes: Route[];
|
||||||
|
hostInfo?: HostInfo;
|
||||||
|
expired: boolean;
|
||||||
|
customRouting: {
|
||||||
|
exitRoutes: Route[];
|
||||||
|
exitApproved: boolean;
|
||||||
|
subnetApprovedRoutes: Route[];
|
||||||
|
subnetWaitingRoutes: Route[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapNodes(
|
||||||
|
nodes: Machine[],
|
||||||
|
routes: Route[],
|
||||||
|
stats?: Record<string, HostInfo> | undefined,
|
||||||
|
): PopulatedNode[] {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
const nodeRoutes = routes.filter((route) => route.node.id === node.id);
|
||||||
|
const customRouting = nodeRoutes.reduce<PopulatedNode['customRouting']>(
|
||||||
|
(acc, route) => {
|
||||||
|
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||||
|
acc.exitRoutes.push(route);
|
||||||
|
if (route.enabled) {
|
||||||
|
acc.exitApproved = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (route.enabled) {
|
||||||
|
acc.subnetApprovedRoutes.push(route);
|
||||||
|
} else {
|
||||||
|
acc.subnetWaitingRoutes.push(route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exitRoutes: [],
|
||||||
|
exitApproved: false,
|
||||||
|
subnetApprovedRoutes: [],
|
||||||
|
subnetWaitingRoutes: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
routes: nodeRoutes,
|
||||||
|
hostInfo: stats?.[node.nodeKey],
|
||||||
|
customRouting,
|
||||||
|
expired:
|
||||||
|
node.expiry === null
|
||||||
|
? false
|
||||||
|
: new Date(node.expiry).getTime() < Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user