Address tags incompatiblity introduced in 0.28.0-beta.2
This commit is contained in:
Aarnav Tale
2026-02-05 22:31:35 -05:00
committed by GitHub
9 changed files with 1039 additions and 1070 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
{ {
"0.26.1": ["0.26.0", "0.26.1"], "0.26.1": ["0.26.0", "0.26.1"],
"0.27.0": ["0.27.0"], "0.27.0": ["0.27.0"],
"0.27.1": ["0.27.1"] "0.27.1": ["0.27.1"],
"0.28.0-beta.1": ["0.28.0-beta.1"],
"0.28.0-beta.2": ["0.28.0-beta.2"]
} }
+54
View File
@@ -104,5 +104,59 @@
"POST /api/v1/user": "a4e1d889d7962da5", "POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884", "DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7" "POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.28.0-beta.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"DELETE /api/v1/preauthkey": "fa2975a185782e5d",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.28.0-beta.2": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "8428b44e3a821e9e",
"DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
} }
} }
+58 -81
View File
@@ -1,21 +1,24 @@
import { ChevronDown, Copy } from 'lucide-react'; import { ChevronDown, Copy } from "lucide-react";
import { useMemo } from 'react'; import { useMemo } from "react";
import { Link } from 'react-router'; import { Link } from "react-router";
import Chip from '~/components/Chip';
import Menu from '~/components/Menu'; import type { User } from "~/types";
import StatusCircle from '~/components/StatusCircle';
import { ExitNodeTag } from '~/components/tags/ExitNode'; import Chip from "~/components/Chip";
import { ExpiryTag } from '~/components/tags/Expiry'; import Menu from "~/components/Menu";
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent'; import StatusCircle from "~/components/StatusCircle";
import { SubnetTag } from '~/components/tags/Subnet'; import { ExitNodeTag } from "~/components/tags/ExitNode";
import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH'; import { ExpiryTag } from "~/components/tags/Expiry";
import type { User } from '~/types'; import { HeadplaneAgentTag } from "~/components/tags/HeadplaneAgent";
import cn from '~/utils/cn'; import { SubnetTag } from "~/components/tags/Subnet";
import * as hinfo from '~/utils/host-info'; import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
import { PopulatedNode } from '~/utils/node-info'; import cn from "~/utils/cn";
import { formatTimeDelta } from '~/utils/time'; import * as hinfo from "~/utils/host-info";
import toast from '~/utils/toast'; import { PopulatedNode } from "~/utils/node-info";
import MenuOptions from './menu'; import { formatTimeDelta } from "~/utils/time";
import toast from "~/utils/toast";
import MenuOptions from "./menu";
interface Props { interface Props {
node: PopulatedNode; node: PopulatedNode;
@@ -45,33 +48,24 @@ export default function MachineRow({
}, [magic, node.ipAddresses]); }, [magic, node.ipAddresses]);
return ( return (
<tr <tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={node.id}>
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" <td className="py-2 pl-0.5 focus-within:ring-3">
key={node.id} <Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
>
<td className="pl-0.5 py-2 focus-within:ring-3">
<Link
className={cn('group/link h-full focus:outline-hidden')}
to={`/machines/${node.id}`}
>
<p <p
className={cn( className={cn(
'font-semibold leading-snug', "font-semibold leading-snug",
'group-hover/link:text-blue-600', "group-hover/link:text-blue-600",
'dark:group-hover/link:text-blue-400', "dark:group-hover/link:text-blue-400",
)} )}
> >
{node.givenName} {node.givenName}
</p> </p>
<p className="text-sm opacity-50"> <p className="text-sm opacity-50">
{node.user.name || {node.user.name || node.user.displayName || node.user.email || node.user.id}
node.user.displayName ||
node.user.email ||
node.user.id}
</p> </p>
<div className="flex gap-1 flex-wrap mt-1.5"> <div className="mt-1.5 flex flex-wrap gap-1">
{mapTagsToComponents(node, uiTags)} {mapTagsToComponents(node, uiTags)}
{node.validTags.map((tag) => ( {node.tags?.map((tag) => (
<Chip key={tag} text={tag} /> <Chip key={tag} text={tag} />
))} ))}
</div> </div>
@@ -82,25 +76,22 @@ export default function MachineRow({
{node.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">
<ChevronDown className="w-4 h-4" /> <ChevronDown className="h-4 w-4" />
</Menu.IconButton> </Menu.IconButton>
<Menu.Panel <Menu.Panel
onAction={async (key) => { onAction={async (key) => {
await navigator.clipboard.writeText(key.toString()); await navigator.clipboard.writeText(key.toString());
toast('Copied IP address to clipboard'); toast("Copied IP address to clipboard");
}} }}
> >
<Menu.Section> <Menu.Section>
{ipOptions.map((ip) => ( {ipOptions.map((ip) => (
<Menu.Item key={ip} textValue={ip}> <Menu.Item key={ip} textValue={ip}>
<div <div
className={cn( className={cn("flex items-center justify-between", "text-sm w-full gap-x-6")}
'flex items-center justify-between',
'text-sm w-full gap-x-6',
)}
> >
{ip} {ip}
<Copy className="w-3 h-3" /> <Copy className="h-3 w-3" />
</div> </div>
</Menu.Item> </Menu.Item>
))} ))}
@@ -114,10 +105,8 @@ export default function MachineRow({
<td className="py-2"> <td className="py-2">
{node.hostInfo !== undefined ? ( {node.hostInfo !== undefined ? (
<> <>
<p className="leading-snug"> <p className="leading-snug">{hinfo.getTSVersion(node.hostInfo)}</p>
{hinfo.getTSVersion(node.hostInfo)} <p className="max-w-48 truncate text-sm opacity-50">
</p>
<p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(node.hostInfo)} {hinfo.getOSInfo(node.hostInfo)}
</p> </p>
</> </>
@@ -128,20 +117,14 @@ export default function MachineRow({
) : undefined} ) : undefined}
<td className="py-2"> <td className="py-2">
<div className="flex items-start gap-x-1"> <div className="flex items-start gap-x-1">
<StatusCircle <StatusCircle className="mt-0.5 h-4 w-4" isOnline={node.online && !node.expired} />
className="w-4 h-4 mt-0.5"
isOnline={node.online && !node.expired}
/>
<div> <div>
<p <p
className={cn( className={cn("text-sm", "text-headplane-600 dark:text-headplane-300")}
'text-sm',
'text-headplane-600 dark:text-headplane-300',
)}
suppressHydrationWarning suppressHydrationWarning
> >
{node.online && !node.expired {node.online && !node.expired
? 'Connected' ? "Connected"
: new Date(node.lastSeen).toLocaleString()} : new Date(node.lastSeen).toLocaleString()}
</p> </p>
{!(node.online && !node.expired) && ( {!(node.online && !node.expired) && (
@@ -168,33 +151,33 @@ export default function MachineRow({
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) { export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
const uiTags: string[] = []; const uiTags: string[] = [];
if (node.expired) { if (node.expired) {
uiTags.push('expired'); uiTags.push("expired");
} }
if (node.expiry === null) { if (node.expiry === null) {
uiTags.push('no-expiry'); uiTags.push("no-expiry");
} }
if (node.customRouting.exitRoutes.length > 0) { if (node.customRouting.exitRoutes.length > 0) {
if (node.customRouting.exitApproved) { if (node.customRouting.exitApproved) {
uiTags.push('exit-approved'); uiTags.push("exit-approved");
} else { } else {
uiTags.push('exit-waiting'); uiTags.push("exit-waiting");
} }
} }
if (node.customRouting.subnetWaitingRoutes.length > 0) { if (node.customRouting.subnetWaitingRoutes.length > 0) {
uiTags.push('subnet-waiting'); uiTags.push("subnet-waiting");
} else if (node.customRouting.subnetApprovedRoutes.length > 0) { } else if (node.customRouting.subnetApprovedRoutes.length > 0) {
uiTags.push('subnet-approved'); uiTags.push("subnet-approved");
} }
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) { if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
uiTags.push('tailscale-ssh'); uiTags.push("tailscale-ssh");
} }
if (isAgent === true) { if (isAgent === true) {
uiTags.push('headplane-agent'); uiTags.push("headplane-agent");
} }
return uiTags; return uiTags;
@@ -203,28 +186,22 @@ export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) { export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
return uiTags.map((tag) => { return uiTags.map((tag) => {
switch (tag) { switch (tag) {
case 'exit-approved': case "exit-approved":
case 'exit-waiting': case "exit-waiting":
return <ExitNodeTag isEnabled={tag === 'exit-approved'} key={tag} />; return <ExitNodeTag isEnabled={tag === "exit-approved"} key={tag} />;
case 'subnet-approved': case "subnet-approved":
case 'subnet-waiting': case "subnet-waiting":
return <SubnetTag isEnabled={tag === 'subnet-approved'} key={tag} />; return <SubnetTag isEnabled={tag === "subnet-approved"} key={tag} />;
case 'expired': case "expired":
case 'no-expiry': case "no-expiry":
return ( return <ExpiryTag expiry={node.expiry ?? undefined} key={tag} variant={tag} />;
<ExpiryTag
expiry={node.expiry ?? undefined}
key={tag}
variant={tag}
/>
);
case 'tailscale-ssh': case "tailscale-ssh":
return <TailscaleSSHTag key={tag} />; return <TailscaleSSHTag key={tag} />;
case 'headplane-agent': case "headplane-agent":
return <HeadplaneAgentTag key={tag} />; return <HeadplaneAgentTag key={tag} />;
default: default:
+22 -32
View File
@@ -1,12 +1,14 @@
import { Plus, TagsIcon, X } from 'lucide-react'; import { Plus, TagsIcon, X } from "lucide-react";
import { useMemo, useState } from 'react'; import { useMemo, useState } from "react";
import Button from '~/components/Button';
import Dialog from '~/components/Dialog'; import type { Machine } from "~/types";
import Link from '~/components/Link';
import Select from '~/components/Select'; import Button from "~/components/Button";
import TableList from '~/components/TableList'; import Dialog from "~/components/Dialog";
import type { Machine } from '~/types'; import Link from "~/components/Link";
import cn from '~/utils/cn'; import Select from "~/components/Select";
import TableList from "~/components/TableList";
import cn from "~/utils/cn";
interface TagsProps { interface TagsProps {
machine: Machine; machine: Machine;
@@ -15,16 +17,11 @@ interface TagsProps {
existingTags?: string[]; existingTags?: string[];
} }
export default function Tags({ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsProps) {
machine, const [tags, setTags] = useState([...machine.tags]);
isOpen, const [tag, setTag] = useState("tag:");
setIsOpen,
existingTags,
}: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('tag:');
const tagIsInvalid = useMemo(() => { const tagIsInvalid = useMemo(() => {
return tag.length === 0 || !tag.startsWith('tag:') || tags.includes(tag); return tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag);
}, [tag, tags]); }, [tag, tags]);
const validNodeTags = useMemo(() => { const validNodeTags = useMemo(() => {
@@ -36,19 +33,15 @@ export default function Tags({
<Dialog.Panel> <Dialog.Panel>
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title> <Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text> <Dialog.Text>
ACL tags can be used to reference machines in your ACL policies. See ACL tags can be used to reference machines in your ACL policies. See the{" "}
the{' '} <Link name="Tailscale documentation" to="https://tailscale.com/kb/1068/acl-tags">
<Link
name="Tailscale documentation"
to="https://tailscale.com/kb/1068/acl-tags"
>
Tailscale documentation Tailscale documentation
</Link>{' '} </Link>{" "}
for more information. for more information.
</Dialog.Text> </Dialog.Text>
<input name="action_id" type="hidden" value="update_tags" /> <input name="action_id" type="hidden" value="update_tags" />
<input name="node_id" type="hidden" value={machine.id} /> <input name="node_id" type="hidden" value={machine.id} />
<input name="tags" type="hidden" value={tags.join(',')} /> <input name="tags" type="hidden" value={tags.join(",")} />
<TableList className="mt-4"> <TableList className="mt-4">
{tags.length === 0 ? ( {tags.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">
@@ -72,7 +65,7 @@ export default function Tags({
)} )}
</TableList> </TableList>
<div className="flex items-center gap-2 mt-2"> <div className="mt-2 flex items-center gap-2">
<Select <Select
allowsCustomValue allowsCustomValue
aria-label="Add a tag" aria-label="Add a tag"
@@ -87,14 +80,11 @@ export default function Tags({
})} })}
</Select> </Select>
<Button <Button
className={cn( className={cn("rounded-md p-1", tagIsInvalid && "opacity-50 cursor-not-allowed")}
'rounded-md p-1',
tagIsInvalid && 'opacity-50 cursor-not-allowed',
)}
isDisabled={tagIsInvalid} isDisabled={tagIsInvalid}
onPress={() => { onPress={() => {
setTags([...tags, tag]); setTags([...tags, tag]);
setTag('tag:'); setTag("tag:");
}} }}
> >
<Plus className="p-1" size={30} /> <Plus className="p-1" size={30} />
+72 -127
View File
@@ -1,29 +1,32 @@
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react'; import { CheckCircle, CircleSlash, Info, UserCircle } from "lucide-react";
import { useMemo, useState } from 'react'; import { useMemo, useState } from "react";
import { data, Link as RemixLink } from 'react-router'; import { data, Link as RemixLink } from "react-router";
import Attribute from '~/components/Attribute';
import Button from '~/components/Button'; import Attribute from "~/components/Attribute";
import Card from '~/components/Card'; import Button from "~/components/Button";
import Chip from '~/components/Chip'; import Card from "~/components/Card";
import Link from '~/components/Link'; import Chip from "~/components/Chip";
import StatusCircle from '~/components/StatusCircle'; import Link from "~/components/Link";
import Tooltip from '~/components/Tooltip'; import StatusCircle from "~/components/StatusCircle";
import cn from '~/utils/cn'; import Tooltip from "~/components/Tooltip";
import { getOSInfo, getTSVersion } from '~/utils/host-info'; import cn from "~/utils/cn";
import { mapNodes, sortNodeTags } from '~/utils/node-info'; import { getOSInfo, getTSVersion } from "~/utils/host-info";
import type { Route } from './+types/machine'; import { mapNodes, sortNodeTags } from "~/utils/node-info";
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
import MenuOptions from './components/menu'; import type { Route } from "./+types/machine";
import Routes from './dialogs/routes';
import { machineAction } from './machine-actions'; import { mapTagsToComponents, uiTagsForNode } from "./components/machine-row";
import MenuOptions from "./components/menu";
import Routes from "./dialogs/routes";
import { machineAction } from "./machine-actions";
export async function loader({ request, params, context }: Route.LoaderArgs) { export async function loader({ request, params, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
if (!params.id) { if (!params.id) {
throw new Error('No machine ID provided'); throw new Error("No machine ID provided");
} }
if (params.id.endsWith('.ico')) { if (params.id.endsWith(".ico")) {
throw data(null, { status: 204 }); throw data(null, { status: 204 });
} }
@@ -40,9 +43,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
const lookup = await context.agents?.lookup([node.nodeKey]); const lookup = await context.agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup); const [enhancedNode] = mapNodes([node], lookup);
const tags = Array.from( const tags = [...node.tags].sort();
new Set([...node.validTags, ...node.forcedTags]),
).sort();
return { return {
node: enhancedNode, node: enhancedNode,
@@ -69,7 +70,7 @@ export default function Page({
return ( return (
<div> <div>
<p className="mb-8 text-md"> <p className="text-md mb-8">
<RemixLink className="font-medium" to="/machines"> <RemixLink className="font-medium" to="/machines">
All Machines All Machines
</RemixLink> </RemixLink>
@@ -78,13 +79,13 @@ export default function Page({
</p> </p>
<div <div
className={cn( className={cn(
'flex justify-between items-center pb-2', "flex justify-between items-center pb-2",
'border-b border-headplane-100 dark:border-headplane-800', "border-b border-headplane-100 dark:border-headplane-800",
)} )}
> >
<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">{node.givenName}</h1> <h1 className="text-2xl font-medium">{node.givenName}</h1>
<StatusCircle className="w-4 h-4" isOnline={node.online} /> <StatusCircle className="h-4 w-4" isOnline={node.online} />
</span> </span>
<MenuOptions <MenuOptions
existingTags={existingTags} existingTags={existingTags}
@@ -94,30 +95,23 @@ export default function Page({
users={users} users={users}
/> />
</div> </div>
<div className="flex gap-1 mb-4"> <div className="mb-4 flex gap-1">
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4"> <div className="border-headplane-100 dark:border-headplane-800 border-r p-2 pr-4">
<span className="text-sm text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1"> <span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1 text-sm">
Managed by Managed by
<Tooltip> <Tooltip>
<Info className="p-1" /> <Info className="p-1" />
<Tooltip.Body> <Tooltip.Body>By default, a machines permissions match its creators.</Tooltip.Body>
By default, a machines permissions match its creators.
</Tooltip.Body>
</Tooltip> </Tooltip>
</span> </span>
<div className="flex items-center gap-x-2.5 mt-1"> <div className="mt-1 flex items-center gap-x-2.5">
<UserCircle /> <UserCircle />
{node.user.name || {node.user.name || node.user.displayName || node.user.email || node.user.id}
node.user.displayName ||
node.user.email ||
node.user.id}
</div> </div>
</div> </div>
<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-headplane-600 dark:text-headplane-300 text-sm">Status</p>
Status <div className="mt-1 mb-8 flex gap-1">
</p>
<div className="flex gap-1 mt-1 mb-8">
{mapTagsToComponents(node, uiTags)} {mapTagsToComponents(node, uiTags)}
{tags.map((tag) => ( {tags.map((tag) => (
<Chip key={tag} text={tag} /> <Chip key={tag} text={tag} />
@@ -126,14 +120,11 @@ export default function Page({
</div> </div>
</div> </div>
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} /> <Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2> <h2 className="mt-8 text-xl font-medium">Subnets & Routing</h2>
<div className="flex items-center justify-between mb-4"> <div className="mb-4 flex items-center justify-between">
<p> <p>
Subnets let you expose physical network routes onto Tailscale.{' '} Subnets let you expose physical network routes onto Tailscale.{" "}
<Link <Link name="Tailscale Subnets Documentation" to="https://tailscale.com/kb/1019/subnets">
name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
>
Learn More Learn More
</Link> </Link>
</p> </p>
@@ -141,8 +132,8 @@ export default function Page({
</div> </div>
<Card <Card
className={cn( className={cn(
'w-full max-w-full grid sm:grid-cols-2', "w-full max-w-full grid sm:grid-cols-2",
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8', "md:grid-cols-4 gap-8 mr-2 text-sm mb-8",
)} )}
variant="flat" variant="flat"
> >
@@ -150,7 +141,7 @@ export default function Page({
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1"> <span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Approved Approved
<Tooltip> <Tooltip>
<Info className="w-3.5 h-3.5" /> <Info className="h-3.5 w-3.5" />
<Tooltip.Body> <Tooltip.Body>
Traffic to these routes are being routed through this machine. Traffic to these routes are being routed through this machine.
</Tooltip.Body> </Tooltip.Body>
@@ -168,10 +159,7 @@ export default function Page({
)} )}
</div> </div>
<Button <Button
className={cn( className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
onPress={() => setShowRouting(true)} onPress={() => setShowRouting(true)}
> >
Edit Edit
@@ -181,10 +169,10 @@ export default function Page({
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1"> <span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Awaiting Approval Awaiting Approval
<Tooltip> <Tooltip>
<Info className="w-3.5 h-3.5" /> <Info className="h-3.5 w-3.5" />
<Tooltip.Body> <Tooltip.Body>
This machine is advertising these routes, but they must be This machine is advertising these routes, but they must be approved before traffic
approved before traffic will be routed to them. will be routed to them.
</Tooltip.Body> </Tooltip.Body>
</Tooltip> </Tooltip>
</span> </span>
@@ -200,10 +188,7 @@ export default function Page({
)} )}
</div> </div>
<Button <Button
className={cn( className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
onPress={() => setShowRouting(true)} onPress={() => setShowRouting(true)}
> >
Edit Edit
@@ -213,7 +198,7 @@ export default function Page({
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1"> <span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Exit Node Exit Node
<Tooltip> <Tooltip>
<Info className="w-3.5 h-3.5" /> <Info className="h-3.5 w-3.5" />
<Tooltip.Body> <Tooltip.Body>
Whether this machine can act as an exit node for your tailnet. Whether this machine can act as an exit node for your tailnet.
</Tooltip.Body> </Tooltip.Body>
@@ -224,21 +209,18 @@ export default function Page({
<span className="opacity-50"></span> <span className="opacity-50"></span>
) : node.customRouting.exitApproved ? ( ) : 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="h-3.5 w-3.5 text-green-700" />
Allowed Allowed
</span> </span>
) : ( ) : (
<span className="flex items-center gap-x-1"> <span className="flex items-center gap-x-1">
<CircleSlash className="w-3.5 h-3.5 text-red-700" /> <CircleSlash className="h-3.5 w-3.5 text-red-700" />
Awaiting Approval Awaiting Approval
</span> </span>
)} )}
</div> </div>
<Button <Button
className={cn( className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
onPress={() => setShowRouting(true)} onPress={() => setShowRouting(true)}
> >
Edit Edit
@@ -247,22 +229,16 @@ export default function Page({
</Card> </Card>
<h2 className="text-xl font-medium">Machine Details</h2> <h2 className="text-xl font-medium">Machine Details</h2>
<p className="mb-4"> <p className="mb-4">
Information about this machines network. Used to debug connection Information about this machines network. Used to debug connection issues.
issues.
</p> </p>
<Card <Card
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12" className="grid w-full max-w-full grid-cols-1 gap-y-2 sm:gap-x-12 lg:grid-cols-2"
variant="flat" variant="flat"
> >
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Attribute <Attribute
name="Creator" name="Creator"
value={ value={node.user.name || node.user.displayName || node.user.email || node.user.id}
node.user.name ||
node.user.displayName ||
node.user.email ||
node.user.id
}
/> />
<Attribute name="Machine name" value={node.givenName} /> <Attribute name="Machine name" value={node.givenName} />
<Attribute <Attribute
@@ -287,38 +263,21 @@ export default function Page({
tooltip="Public key which uniquely identifies this machine." tooltip="Public key which uniquely identifies this machine."
value={node.nodeKey} value={node.nodeKey}
/> />
<Attribute <Attribute name="Created" value={new Date(node.createdAt).toLocaleString()} />
name="Created"
value={new Date(node.createdAt).toLocaleString()}
/>
<Attribute <Attribute
name="Last Seen" name="Last Seen"
value={ value={node.online ? "Connected" : new Date(node.lastSeen).toLocaleString()}
node.online
? 'Connected'
: new Date(node.lastSeen).toLocaleString()
}
/> />
<Attribute <Attribute
name="Key expiry" name="Key expiry"
value={ value={node.expiry !== null ? new Date(node.expiry).toLocaleString() : "Never"}
node.expiry !== null
? new Date(node.expiry).toLocaleString()
: 'Never'
}
/> />
{magic ? ( {magic ? (
<Attribute <Attribute isCopyable name="Domain" value={`${node.givenName}.${magic}`} />
isCopyable
name="Domain"
value={`${node.givenName}.${magic}`}
/>
) : undefined} ) : undefined}
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="uppercase text-sm font-semibold opacity-75"> <p className="text-sm font-semibold uppercase opacity-75">Addresses</p>
Addresses
</p>
<Attribute <Attribute
isCopyable isCopyable
name="Tailscale IPv4" name="Tailscale IPv4"
@@ -347,36 +306,22 @@ export default function Page({
) : undefined} ) : undefined}
{stats ? ( {stats ? (
<> <>
<p className="uppercase text-sm font-semibold opacity-75 mt-4"> <p className="mt-4 text-sm font-semibold uppercase opacity-75">Client Connectivity</p>
Client Connectivity
</p>
<Attribute <Attribute
name="Varies" name="Varies"
tooltip="Whether the machine is behind a difficult NAT that varies the machines IP address depending on the destination." tooltip="Whether the machine is behind a difficult NAT that varies the machines IP address depending on the destination."
value={stats.NetInfo?.MappingVariesByDestIP ? 'Yes' : 'No'} value={stats.NetInfo?.MappingVariesByDestIP ? "Yes" : "No"}
/> />
<Attribute <Attribute
name="Hairpinning" name="Hairpinning"
tooltip="Whether the machine needs to traverse NATs with hairpinning." tooltip="Whether the machine needs to traverse NATs with hairpinning."
value={stats.NetInfo?.HairPinning ? 'Yes' : 'No'} value={stats.NetInfo?.HairPinning ? "Yes" : "No"}
/>
<Attribute
name="IPv6"
value={stats.NetInfo?.WorkingIPv6 ? 'Yes' : 'No'}
/>
<Attribute
name="UDP"
value={stats.NetInfo?.WorkingUDP ? 'Yes' : 'No'}
/>
<Attribute
name="UPnP"
value={stats.NetInfo?.UPnP ? 'Yes' : 'No'}
/>
<Attribute name="PCP" value={stats.NetInfo?.PCP ? 'Yes' : 'No'} />
<Attribute
name="NAT-PMP"
value={stats.NetInfo?.PMP ? 'Yes' : 'No'}
/> />
<Attribute name="IPv6" value={stats.NetInfo?.WorkingIPv6 ? "Yes" : "No"} />
<Attribute name="UDP" value={stats.NetInfo?.WorkingUDP ? "Yes" : "No"} />
<Attribute name="UPnP" value={stats.NetInfo?.UPnP ? "Yes" : "No"} />
<Attribute name="PCP" value={stats.NetInfo?.PCP ? "Yes" : "No"} />
<Attribute name="NAT-PMP" value={stats.NetInfo?.PMP ? "Yes" : "No"} />
</> </>
) : undefined} ) : undefined}
</div> </div>
@@ -387,22 +332,22 @@ export default function Page({
function getIpv4Address(addresses: string[]) { function getIpv4Address(addresses: string[]) {
for (const address of addresses) { for (const address of addresses) {
if (address.startsWith('100.')) { if (address.startsWith("100.")) {
// Return the first CGNAT address // Return the first CGNAT address
return address; return address;
} }
} }
return '—'; return "—";
} }
function getIpv6Address(addresses: string[]) { function getIpv6Address(addresses: string[]) {
for (const address of addresses) { for (const address of addresses) {
if (address.startsWith('fd')) { if (address.startsWith("fd")) {
// Return the first IPv6 address // Return the first IPv6 address
return address; return address;
} }
} }
return '—'; return "—";
} }
+45 -31
View File
@@ -1,5 +1,32 @@
import type { Machine } from '~/types'; import type { Machine } from "~/types";
import { defineApiEndpoints } from '../factory';
import type { HeadscaleApiInterface } from "..";
import { defineApiEndpoints } from "../factory";
interface RawMachine extends Omit<Machine, "tags"> {
tags?: string[];
forcedTags?: string[];
validTags?: string[];
invalidTags?: string[];
}
/**
* Normalizes the tags of a RawMachine based on the Headscale version.
*
* @param client The Headscale API client helper.
* @param node The RawMachine object to normalize.
* @returns A Machine object with normalized tags.
*/
function normalizeTags(client: HeadscaleApiInterface["clientHelpers"], node: RawMachine): Machine {
if (client.isAtleast("0.28.0-beta.1")) {
return { ...node, tags: node.tags ?? [] } as Machine;
}
const tags = Array.from(new Set([...(node.forcedTags ?? []), ...(node.validTags ?? [])]));
return { ...node, tags } as Machine;
}
export interface NodeEndpoints { export interface NodeEndpoints {
/** /**
@@ -75,35 +102,31 @@ export interface NodeEndpoints {
export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({ export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
getNodes: async () => { getNodes: async () => {
const { nodes } = await client.apiFetch<{ nodes: Machine[] }>( const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey);
'GET',
'v1/node',
apiKey,
);
return nodes; return nodes.map((node) => normalizeTags(client, node));
}, },
getNode: async (nodeId) => { getNode: async (nodeId) => {
const { node } = await client.apiFetch<{ node: Machine }>( const { node } = await client.apiFetch<{ node: RawMachine }>(
'GET', "GET",
`v1/node/${nodeId}`, `v1/node/${nodeId}`,
apiKey, apiKey,
); );
return node; return normalizeTags(client, node);
}, },
deleteNode: async (nodeId) => { deleteNode: async (nodeId) => {
await client.apiFetch<void>('DELETE', `v1/node/${nodeId}`, apiKey); await client.apiFetch<void>("DELETE", `v1/node/${nodeId}`, apiKey);
}, },
registerNode: async (user, key) => { registerNode: async (user, key) => {
const qp = new URLSearchParams(); const qp = new URLSearchParams();
qp.append('user', user); qp.append("user", user);
qp.append('key', key); qp.append("key", key);
const { node } = await client.apiFetch<{ node: Machine }>( const { node } = await client.apiFetch<{ node: RawMachine }>(
'POST', "POST",
`v1/node/register?${qp.toString()}`, `v1/node/register?${qp.toString()}`,
apiKey, apiKey,
{ {
@@ -112,38 +135,29 @@ export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
}, },
); );
return node; return normalizeTags(client, node);
}, },
approveNodeRoutes: async (nodeId, routes) => { approveNodeRoutes: async (nodeId, routes) => {
await client.apiFetch<void>( await client.apiFetch<void>("POST", `v1/node/${nodeId}/approve_routes`, apiKey, { routes });
'POST',
`v1/node/${nodeId}/approve_routes`,
apiKey,
{ routes },
);
}, },
expireNode: async (nodeId) => { expireNode: async (nodeId) => {
await client.apiFetch<void>('POST', `v1/node/${nodeId}/expire`, apiKey); await client.apiFetch<void>("POST", `v1/node/${nodeId}/expire`, apiKey);
}, },
renameNode: async (nodeId, newName) => { renameNode: async (nodeId, newName) => {
await client.apiFetch<void>( await client.apiFetch<void>("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey);
'POST',
`v1/node/${nodeId}/rename/${newName}`,
apiKey,
);
}, },
setNodeTags: async (nodeId, tags) => { setNodeTags: async (nodeId, tags) => {
await client.apiFetch<void>('POST', `v1/node/${nodeId}/tags`, apiKey, { await client.apiFetch<void>("POST", `v1/node/${nodeId}/tags`, apiKey, {
tags, tags,
}); });
}, },
setNodeUser: async (nodeId, user) => { setNodeUser: async (nodeId, user) => {
await client.apiFetch<void>('POST', `v1/node/${nodeId}/user`, apiKey, { await client.apiFetch<void>("POST", `v1/node/${nodeId}/user`, apiKey, {
user, user,
}); });
}, },
+7 -9
View File
@@ -1,5 +1,5 @@
import type { PreAuthKey } from './PreAuthKey'; import type { PreAuthKey } from "./PreAuthKey";
import type { User } from './User'; import type { User } from "./User";
export interface Machine { export interface Machine {
id: string; id: string;
@@ -17,14 +17,12 @@ export interface Machine {
createdAt: string; createdAt: string;
registerMethod: registerMethod:
| 'REGISTER_METHOD_UNSPECIFIED' | "REGISTER_METHOD_UNSPECIFIED"
| 'REGISTER_METHOD_AUTH_KEY' | "REGISTER_METHOD_AUTH_KEY"
| 'REGISTER_METHOD_CLI' | "REGISTER_METHOD_CLI"
| 'REGISTER_METHOD_OIDC'; | "REGISTER_METHOD_OIDC";
forcedTags: string[]; tags: string[];
invalidTags: string[];
validTags: string[];
givenName: string; givenName: string;
online: boolean; online: boolean;
+9 -24
View File
@@ -1,4 +1,4 @@
import { HostInfo, Machine } from '~/types'; import { HostInfo, Machine } from "~/types";
export interface PopulatedNode extends Machine { export interface PopulatedNode extends Machine {
routes: string[]; routes: string[];
@@ -18,25 +18,17 @@ export function mapNodes(
): PopulatedNode[] { ): PopulatedNode[] {
return nodes.map((node) => { return nodes.map((node) => {
const customRouting = { const customRouting = {
exitRoutes: node.availableRoutes.filter( exitRoutes: node.availableRoutes.filter((route) => route === "::/0" || route === "0.0.0.0/0"),
(route) => route === '::/0' || route === '0.0.0.0/0', exitApproved: node.approvedRoutes.some((route) => route === "::/0" || route === "0.0.0.0/0"),
),
exitApproved: node.approvedRoutes.some(
(route) => route === '::/0' || route === '0.0.0.0/0',
),
subnetApprovedRoutes: node.approvedRoutes.filter( subnetApprovedRoutes: node.approvedRoutes.filter(
(route) => (route) =>
route !== '::/0' && route !== "::/0" && route !== "0.0.0.0/0" && node.availableRoutes.includes(route),
route !== '0.0.0.0/0' &&
node.availableRoutes.includes(route),
), ),
subnetWaitingRoutes: node.availableRoutes.filter( subnetWaitingRoutes: node.availableRoutes.filter(
(route) => (route) =>
route !== '::/0' && route !== "::/0" && route !== "0.0.0.0/0" && !node.approvedRoutes.includes(route),
route !== '0.0.0.0/0' &&
!node.approvedRoutes.includes(route),
), ),
} satisfies PopulatedNode['customRouting']; } satisfies PopulatedNode["customRouting"];
return { return {
...node, ...node,
@@ -44,8 +36,8 @@ export function mapNodes(
hostInfo: stats?.[node.nodeKey], hostInfo: stats?.[node.nodeKey],
customRouting, customRouting,
expired: expired:
node.expiry === '0001-01-01 00:00:00' || node.expiry === "0001-01-01 00:00:00" ||
node.expiry === '0001-01-01T00:00:00Z' || node.expiry === "0001-01-01T00:00:00Z" ||
node.expiry === null node.expiry === null
? false ? false
: new Date(node.expiry).getTime() < Date.now(), : new Date(node.expiry).getTime() < Date.now(),
@@ -54,12 +46,5 @@ export function mapNodes(
} }
export function sortNodeTags(nodes: Machine[]): string[] { export function sortNodeTags(nodes: Machine[]): string[] {
return Array.from( return Array.from(new Set(nodes.flatMap((node) => node.tags))).sort();
new Set(
nodes.flatMap(({ validTags, forcedTags }) => [
...validTags,
...forcedTags,
]),
),
).sort();
} }
+22 -18
View File
@@ -1,27 +1,31 @@
import { writeFile } from 'node:fs/promises'; import type { OpenAPIV2 } from "openapi-types";
import { resolve } from 'node:path';
import { cwd } from 'node:process';
import type { OpenAPIV2 } from 'openapi-types';
import { request } from 'undici';
import { hashOpenApiDocument } from '~/server/headscale/api/hasher';
const HASH_FILE_LOCATION = 'app/openapi-operation-hashes.json'; import { writeFile } from "node:fs/promises";
const CANONICAL_LOCATION = 'app/openapi-canonical-families.json'; import { resolve } from "node:path";
import { cwd } from "node:process";
import { request } from "undici";
import { hashOpenApiDocument } from "~/server/headscale/api/hasher";
const HASH_FILE_LOCATION = "app/openapi-operation-hashes.json";
const CANONICAL_LOCATION = "app/openapi-canonical-families.json";
const SPEC_MAP = { const SPEC_MAP = {
// '0.25.0': '/v0.25.0/gen/openapiv2/headscale/v1/headscale.swagger.json', // '0.25.0': '/v0.25.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
// '0.25.1': '/v0.25.1/gen/openapiv2/headscale/v1/headscale.swagger.json', // '0.25.1': '/v0.25.1/gen/openapiv2/headscale/v1/headscale.swagger.json',
'0.26.0': '/v0.26.0/gen/openapiv2/headscale/v1/headscale.swagger.json', "0.26.0": "/v0.26.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
'0.26.1': '/v0.26.1/gen/openapiv2/headscale/v1/headscale.swagger.json', "0.26.1": "/v0.26.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
'0.27.0': '/v0.27.0/gen/openapiv2/headscale/v1/headscale.swagger.json', "0.27.0": "/v0.27.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
'0.27.1': '/v0.27.1/gen/openapiv2/headscale/v1/headscale.swagger.json', "0.27.1": "/v0.27.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.28.0-beta.1": "/v0.28.0-beta.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.28.0-beta.2": "/v0.28.0-beta.2/gen/openapiv2/headscale/v1/headscale.swagger.json",
} as const; } as const;
async function hashOpenApiOperations(specUrl: string) { async function hashOpenApiOperations(specUrl: string) {
const url = `https://raw.githubusercontent.com/juanfont/headscale${specUrl}`; const url = `https://raw.githubusercontent.com/juanfont/headscale${specUrl}`;
const res = await request(url); const res = await request(url);
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
console.error('Failed to fetch OpenAPI spec:', res.statusCode); console.error("Failed to fetch OpenAPI spec:", res.statusCode);
process.exit(1); process.exit(1);
} }
@@ -57,7 +61,7 @@ async function collectCanonicalizedFamilies(
if ( if (
version.localeCompare(canonical, undefined, { version.localeCompare(canonical, undefined, {
numeric: true, numeric: true,
sensitivity: 'base', sensitivity: "base",
}) > 0 }) > 0
) { ) {
canonicalizedFamilies[version] = canonicalizedFamilies[canonical]; canonicalizedFamilies[version] = canonicalizedFamilies[canonical];
@@ -67,7 +71,7 @@ async function collectCanonicalizedFamilies(
for (const [canonical, family] of Object.entries(canonicalizedFamilies)) { for (const [canonical, family] of Object.entries(canonicalizedFamilies)) {
canonicalizedFamilies[canonical] = family.sort((a, b) => canonicalizedFamilies[canonical] = family.sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }), a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }),
); );
} }
@@ -76,12 +80,12 @@ async function collectCanonicalizedFamilies(
async function writeHashes(hashes: Record<string, Record<string, string>>) { async function writeHashes(hashes: Record<string, Record<string, string>>) {
const path = resolve(cwd(), HASH_FILE_LOCATION); const path = resolve(cwd(), HASH_FILE_LOCATION);
await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, 'utf-8'); await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, "utf-8");
} }
async function writeCanonicalizedFamilies(families: Record<string, string[]>) { async function writeCanonicalizedFamilies(families: Record<string, string[]>) {
const path = resolve(cwd(), CANONICAL_LOCATION); const path = resolve(cwd(), CANONICAL_LOCATION);
await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, 'utf-8'); await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, "utf-8");
} }
const newHashes = await Promise.all( const newHashes = await Promise.all(
@@ -93,6 +97,6 @@ const newHashes = await Promise.all(
const canonicalizedFamilies = await collectCanonicalizedFamilies(newHashes); const canonicalizedFamilies = await collectCanonicalizedFamilies(newHashes);
console.log('Writing new OpenAPI operation hashes to file'); console.log("Writing new OpenAPI operation hashes to file");
await writeHashes(Object.fromEntries(newHashes)); await writeHashes(Object.fromEntries(newHashes));
await writeCanonicalizedFamilies(canonicalizedFamilies); await writeCanonicalizedFamilies(canonicalizedFamilies);