feat: add ssh buttons in the ui using hostinfo checks

This commit is contained in:
Aarnav Tale
2025-06-18 11:56:27 -04:00
parent b34a3f0ca1
commit 1e86b0e95b
5 changed files with 97 additions and 22 deletions
+21
View File
@@ -0,0 +1,21 @@
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export function TailscaleSSHTag() {
return (
<Tooltip>
<Chip
text="Tailscale SSH"
className={cn(
'bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500',
)}
/>
<Tooltip.Body>
This machine advertises Tailscale SSH, which allows you to authenticate
SSH credentials using your Tailscale account and via the Headplane web
UI.
</Tooltip.Body>
</Tooltip>
);
}
@@ -12,6 +12,7 @@ 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 { TailscaleSSHTag } from '~/components/tags/TailscaleSSH';
import { PopulatedNode } from '~/utils/node-info';
import toast from '~/utils/toast';
import MenuOptions from './menu';
@@ -177,6 +178,10 @@ export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
uiTags.push('subnet-approved');
}
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
uiTags.push('tailscale-ssh');
}
if (isAgent === true) {
uiTags.push('headplane-agent');
}
@@ -205,6 +210,9 @@ export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
/>
);
case 'tailscale-ssh':
return <TailscaleSSHTag />;
case 'headplane-agent':
return <HeadplaneAgentTag />;
+66 -16
View File
@@ -1,4 +1,4 @@
import { Cog, Ellipsis } from 'lucide-react';
import { Cog, Ellipsis, SquareTerminal } from 'lucide-react';
import { useState } from 'react';
import Menu from '~/components/Menu';
import type { User } from '~/types';
@@ -10,7 +10,6 @@ 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[];
@@ -29,6 +28,9 @@ export default function MachineMenu({
isDisabled,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
const supportsTailscaleSSH =
node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;
return (
<>
{modal === 'remove' && (
@@ -90,21 +92,69 @@ export default function MachineMenu({
<Menu isDisabled={isDisabled}>
{isFullButton ? (
<Menu.Button className="flex items-center gap-x-2">
<Cog className="h-5" />
<p>Machine Settings</p>
</Menu.Button>
<div className="flex items-center justify-end gap-1.5 pr-4">
{supportsTailscaleSSH ? (
<Menu.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(
// TODO: Use the actual real username lol
`${__PREFIX__}/ssh?hostname=${node.name}&username=tale`,
'_blank',
'noopener,noreferrer,width=800,height=600',
);
}}
>
<SquareTerminal className="h-5" />
<p>SSH</p>
</Menu.Button>
) : undefined}
<Menu.Button className="flex items-center gap-x-2">
<Cog className="h-5" />
<p>Machine Settings</p>
</Menu.Button>
</div>
) : (
<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',
)}
>
<Ellipsis className="h-5" />
</Menu.IconButton>
<div className="flex items-center justify-end gap-1.5 pr-4">
{supportsTailscaleSSH ? (
<Menu.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(
// TODO: Use the actual real username lol
`${__PREFIX__}/ssh?hostname=${node.name}&username=tale`,
'_blank',
'noopener,noreferrer,width=800,height=600',
);
}}
className={cn(
'py-0.5 w-fit bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
'opacity-0 pointer-events-none group-hover:opacity-100',
'group-hover:pointer-events-auto',
)}
>
SSH
</Menu.Button>
) : undefined}
<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',
)}
>
<Ellipsis className="h-5" />
</Menu.IconButton>
</div>
)}
<Menu.Panel
onAction={(key) => setModal(key as Modal)}
-1
View File
@@ -2,7 +2,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 { mapTag } from 'yaml/util';
import Attribute from '~/components/Attribute';
import Button from '~/components/Button';
import Card from '~/components/Card';
+2 -5
View File
@@ -71,11 +71,8 @@ export async function loader({
const qp = new URL(request.url).searchParams;
const username = qp.get('username') || undefined;
const hostname = qp.get('hostname') || undefined;
const port = qp.get('port')
? Number.parseInt(qp.get('port')!, 10)
: undefined;
if (!username || !hostname || !port) {
throw data('Missing required parameters: username, hostname, port', 400);
if (!username || !hostname) {
throw data('Missing required parameters: username, hostname', 400);
}
// TODO: Verify the Host headers to ensure CORS friendly
// TODO: Check if the URL actually resolves correctly