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
+5 -3
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"]
} }
+160 -106
View File
@@ -1,108 +1,162 @@
{ {
"0.26.0": { "0.26.0": {
"GET /api/v1/apikey": "efe31b6dc980e158", "GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312", "POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17", "POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1", "DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6", "POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20", "GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001", "POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4", "POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784", "POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922", "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0", "GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388", "PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e", "GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58", "POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17", "GET /api/v1/user": "228831b58ccc5a17",
"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.26.1": { "0.26.1": {
"GET /api/v1/apikey": "efe31b6dc980e158", "GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312", "POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17", "POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1", "DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6", "POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20", "GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001", "POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4", "POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784", "POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922", "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0", "GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388", "PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e", "GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58", "POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17", "GET /api/v1/user": "228831b58ccc5a17",
"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.27.0": { "0.27.0": {
"GET /api/v1/apikey": "efe31b6dc980e158", "GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312", "POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17", "POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1", "DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6", "POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f", "GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20", "GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001", "POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4", "POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784", "POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922", "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0", "GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388", "PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e", "GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58", "POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17", "GET /api/v1/user": "228831b58ccc5a17",
"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.27.1": { "0.27.1": {
"GET /api/v1/apikey": "efe31b6dc980e158", "GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312", "POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17", "POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1", "DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6", "POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f", "GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20", "GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001", "POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4", "POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae", "POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922", "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0", "GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388", "PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e", "GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58", "POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17", "GET /api/v1/user": "228831b58ccc5a17",
"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"
}
} }
+184 -207
View File
@@ -1,234 +1,211 @@
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;
users: User[]; users: User[];
isAgent?: boolean; isAgent?: boolean;
magic?: string; magic?: string;
isDisabled?: boolean; isDisabled?: boolean;
existingTags?: string[]; existingTags?: string[];
} }
export default function MachineRow({ export default function MachineRow({
node, node,
users, users,
isAgent, isAgent,
magic, magic,
isDisabled, isDisabled,
existingTags, existingTags,
}: Props) { }: Props) {
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]); const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
const ipOptions = useMemo(() => { const ipOptions = useMemo(() => {
if (magic) { if (magic) {
return [...node.ipAddresses, `${node.givenName}.${magic}`]; return [...node.ipAddresses, `${node.givenName}.${magic}`];
} }
return node.ipAddresses; return node.ipAddresses;
}, [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}`}>
> <p
<td className="pl-0.5 py-2 focus-within:ring-3"> className={cn(
<Link "font-semibold leading-snug",
className={cn('group/link h-full focus:outline-hidden')} "group-hover/link:text-blue-600",
to={`/machines/${node.id}`} "dark:group-hover/link:text-blue-400",
> )}
<p >
className={cn( {node.givenName}
'font-semibold leading-snug', </p>
'group-hover/link:text-blue-600', <p className="text-sm opacity-50">
'dark:group-hover/link:text-blue-400', {node.user.name || node.user.displayName || node.user.email || node.user.id}
)} </p>
> <div className="mt-1.5 flex flex-wrap gap-1">
{node.givenName} {mapTagsToComponents(node, uiTags)}
</p> {node.tags?.map((tag) => (
<p className="text-sm opacity-50"> <Chip key={tag} text={tag} />
{node.user.name || ))}
node.user.displayName || </div>
node.user.email || </Link>
node.user.id} </td>
</p> <td className="py-2">
<div className="flex gap-1 flex-wrap mt-1.5"> <div className="flex items-center gap-x-1">
{mapTagsToComponents(node, uiTags)} {node.ipAddresses[0]}
{node.validTags.map((tag) => ( <Menu placement="bottom end">
<Chip key={tag} text={tag} /> <Menu.IconButton className="bg-transparent" label="IP Addresses">
))} <ChevronDown className="h-4 w-4" />
</div> </Menu.IconButton>
</Link> <Menu.Panel
</td> onAction={async (key) => {
<td className="py-2"> await navigator.clipboard.writeText(key.toString());
<div className="flex items-center gap-x-1"> toast("Copied IP address to clipboard");
{node.ipAddresses[0]} }}
<Menu placement="bottom end"> >
<Menu.IconButton className="bg-transparent" label="IP Addresses"> <Menu.Section>
<ChevronDown className="w-4 h-4" /> {ipOptions.map((ip) => (
</Menu.IconButton> <Menu.Item key={ip} textValue={ip}>
<Menu.Panel <div
onAction={async (key) => { className={cn("flex items-center justify-between", "text-sm w-full gap-x-6")}
await navigator.clipboard.writeText(key.toString()); >
toast('Copied IP address to clipboard'); {ip}
}} <Copy className="h-3 w-3" />
> </div>
<Menu.Section> </Menu.Item>
{ipOptions.map((ip) => ( ))}
<Menu.Item key={ip} textValue={ip}> </Menu.Section>
<div </Menu.Panel>
className={cn( </Menu>
'flex items-center justify-between', </div>
'text-sm w-full gap-x-6', </td>
)} {/* We pass undefined when agents are not enabled */}
> {isAgent !== undefined ? (
{ip} <td className="py-2">
<Copy className="w-3 h-3" /> {node.hostInfo !== undefined ? (
</div> <>
</Menu.Item> <p className="leading-snug">{hinfo.getTSVersion(node.hostInfo)}</p>
))} <p className="max-w-48 truncate text-sm opacity-50">
</Menu.Section> {hinfo.getOSInfo(node.hostInfo)}
</Menu.Panel> </p>
</Menu> </>
</div> ) : (
</td> <p className="text-sm opacity-50">Unknown</p>
{/* We pass undefined when agents are not enabled */} )}
{isAgent !== undefined ? ( </td>
<td className="py-2"> ) : undefined}
{node.hostInfo !== undefined ? ( <td className="py-2">
<> <div className="flex items-start gap-x-1">
<p className="leading-snug"> <StatusCircle className="mt-0.5 h-4 w-4" isOnline={node.online && !node.expired} />
{hinfo.getTSVersion(node.hostInfo)} <div>
</p> <p
<p className="text-sm opacity-50 max-w-48 truncate"> className={cn("text-sm", "text-headplane-600 dark:text-headplane-300")}
{hinfo.getOSInfo(node.hostInfo)} suppressHydrationWarning
</p> >
</> {node.online && !node.expired
) : ( ? "Connected"
<p className="text-sm opacity-50">Unknown</p> : new Date(node.lastSeen).toLocaleString()}
)} </p>
</td> {!(node.online && !node.expired) && (
) : undefined} <p className="text-xs opacity-50" suppressHydrationWarning>
<td className="py-2"> {formatTimeDelta(new Date(node.lastSeen))}
<div className="flex items-start gap-x-1"> </p>
<StatusCircle )}
className="w-4 h-4 mt-0.5" </div>
isOnline={node.online && !node.expired} </div>
/> </td>
<div> <td className="py-2 pr-0.5">
<p <MenuOptions
className={cn( existingTags={existingTags}
'text-sm', isDisabled={isDisabled}
'text-headplane-600 dark:text-headplane-300', magic={magic}
)} node={node}
suppressHydrationWarning users={users}
> />
{node.online && !node.expired </td>
? 'Connected' </tr>
: new Date(node.lastSeen).toLocaleString()} );
</p>
{!(node.online && !node.expired) && (
<p className="text-xs opacity-50" suppressHydrationWarning>
{formatTimeDelta(new Date(node.lastSeen))}
</p>
)}
</div>
</div>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
existingTags={existingTags}
isDisabled={isDisabled}
magic={magic}
node={node}
users={users}
/>
</td>
</tr>
);
} }
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;
} }
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:
return null; return null;
} }
}); });
} }
+88 -98
View File
@@ -1,106 +1,96 @@
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;
isOpen: boolean; isOpen: boolean;
setIsOpen: (isOpen: boolean) => void; setIsOpen: (isOpen: boolean) => void;
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, const tagIsInvalid = useMemo(() => {
existingTags, return tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag);
}: TagsProps) { }, [tag, tags]);
const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('tag:');
const tagIsInvalid = useMemo(() => {
return tag.length === 0 || !tag.startsWith('tag:') || tags.includes(tag);
}, [tag, tags]);
const validNodeTags = useMemo(() => { const validNodeTags = useMemo(() => {
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || []; return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
}, [tags]); }, [tags]);
return ( return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}> <Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<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 Tailscale documentation
name="Tailscale documentation" </Link>{" "}
to="https://tailscale.com/kb/1068/acl-tags" for more information.
> </Dialog.Text>
Tailscale documentation <input name="action_id" type="hidden" value="update_tags" />
</Link>{' '} <input name="node_id" type="hidden" value={machine.id} />
for more information. <input name="tags" type="hidden" value={tags.join(",")} />
</Dialog.Text> <TableList className="mt-4">
<input name="action_id" type="hidden" value="update_tags" /> {tags.length === 0 ? (
<input name="node_id" type="hidden" value={machine.id} /> <TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<input name="tags" type="hidden" value={tags.join(',')} /> <TagsIcon />
<TableList className="mt-4"> <p className="font-semibold">No tags are set on this machine</p>
{tags.length === 0 ? ( </TableList.Item>
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70"> ) : (
<TagsIcon /> tags.map((item) => (
<p className="font-semibold">No tags are set on this machine</p> <TableList.Item className="font-mono" id={item} key={item}>
</TableList.Item> {item}
) : ( <Button
tags.map((item) => ( className="rounded-md p-0.5"
<TableList.Item className="font-mono" id={item} key={item}> onPress={() => {
{item} setTags(tags.filter((tag) => tag !== item));
<Button }}
className="rounded-md p-0.5" >
onPress={() => { <X className="p-1" />
setTags(tags.filter((tag) => tag !== item)); </Button>
}} </TableList.Item>
> ))
<X className="p-1" /> )}
</Button> </TableList>
</TableList.Item>
))
)}
</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"
className="w-full" className="w-full"
inputValue={tag} inputValue={tag}
isInvalid={tag.length > 0 && tagIsInvalid} isInvalid={tag.length > 0 && tagIsInvalid}
onInputChange={setTag} onInputChange={setTag}
placeholder="tag:example" placeholder="tag:example"
> >
{validNodeTags.map((nodeTag) => { {validNodeTags.map((nodeTag) => {
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>; return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
})} })}
</Select> </Select>
<Button <Button
className={cn( className={cn("rounded-md p-1", tagIsInvalid && "opacity-50 cursor-not-allowed")}
'rounded-md p-1', isDisabled={tagIsInvalid}
tagIsInvalid && 'opacity-50 cursor-not-allowed', onPress={() => {
)} setTags([...tags, tag]);
isDisabled={tagIsInvalid} setTag("tag:");
onPress={() => { }}
setTags([...tags, tag]); >
setTag('tag:'); <Plus className="p-1" size={30} />
}} </Button>
> </div>
<Plus className="p-1" size={30} /> </Dialog.Panel>
</Button> </Dialog>
</div> );
</Dialog.Panel>
</Dialog>
);
} }
+329 -384
View File
@@ -1,408 +1,353 @@
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 });
} }
let magic: string | undefined; let magic: string | undefined;
if (context.hs.readable()) { if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) { if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain; magic = context.hs.c.dns.base_domain;
} }
} }
const api = context.hsApi.getRuntimeClient(session.api_key); const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]); const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
const node = nodes.find((node) => node.id === params.id); const node = nodes.find((node) => node.id === params.id);
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,
tags, tags,
users, users,
magic, magic,
agent: context.agents?.agentID(), agent: context.agents?.agentID(),
stats: lookup?.[enhancedNode.nodeKey], stats: lookup?.[enhancedNode.nodeKey],
existingTags: sortNodeTags(nodes), existingTags: sortNodeTags(nodes),
}; };
} }
export const action = machineAction; export const action = machineAction;
export default function Page({ export default function Page({
loaderData: { node, tags, users, magic, agent, stats, existingTags }, loaderData: { node, tags, users, magic, agent, stats, existingTags },
}: Route.ComponentProps) { }: Route.ComponentProps) {
const [showRouting, setShowRouting] = useState(false); const [showRouting, setShowRouting] = useState(false);
const uiTags = useMemo(() => { const uiTags = useMemo(() => {
const tags = uiTagsForNode(node, agent === node.nodeKey); const tags = uiTagsForNode(node, agent === node.nodeKey);
return tags; return tags;
}, [node, agent]); }, [node, agent]);
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>
<span className="mx-2">/</span> <span className="mx-2">/</span>
{node.givenName} {node.givenName}
</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}
isFullButton isFullButton
magic={magic} magic={magic}
node={node} node={node}
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>
</Tooltip.Body> </span>
</Tooltip> <div className="mt-1 flex items-center gap-x-2.5">
</span> <UserCircle />
<div className="flex items-center gap-x-2.5 mt-1"> {node.user.name || node.user.displayName || node.user.email || node.user.id}
<UserCircle /> </div>
{node.user.name || </div>
node.user.displayName || <div className="p-2 pl-4">
node.user.email || <p className="text-headplane-600 dark:text-headplane-300 text-sm">Status</p>
node.user.id} <div className="mt-1 mb-8 flex gap-1">
</div> {mapTagsToComponents(node, uiTags)}
</div> {tags.map((tag) => (
<div className="p-2 pl-4"> <Chip key={tag} text={tag} />
<p className="text-sm text-headplane-600 dark:text-headplane-300"> ))}
Status </div>
</p> </div>
<div className="flex gap-1 mt-1 mb-8"> </div>
{mapTagsToComponents(node, uiTags)} <Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
{tags.map((tag) => ( <h2 className="mt-8 text-xl font-medium">Subnets & Routing</h2>
<Chip key={tag} text={tag} /> <div className="mb-4 flex items-center justify-between">
))} <p>
</div> Subnets let you expose physical network routes onto Tailscale.{" "}
</div> <Link name="Tailscale Subnets Documentation" to="https://tailscale.com/kb/1019/subnets">
</div> Learn More
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} /> </Link>
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2> </p>
<div className="flex items-center justify-between mb-4"> <Button onPress={() => setShowRouting(true)}>Review</Button>
<p> </div>
Subnets let you expose physical network routes onto Tailscale.{' '} <Card
<Link className={cn(
name="Tailscale Subnets Documentation" "w-full max-w-full grid sm:grid-cols-2",
to="https://tailscale.com/kb/1019/subnets" "md:grid-cols-4 gap-8 mr-2 text-sm mb-8",
> )}
Learn More variant="flat"
</Link> >
</p> <div>
<Button onPress={() => setShowRouting(true)}>Review</Button> <span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
</div> Approved
<Card <Tooltip>
className={cn( <Info className="h-3.5 w-3.5" />
'w-full max-w-full grid sm:grid-cols-2', <Tooltip.Body>
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8', Traffic to these routes are being routed through this machine.
)} </Tooltip.Body>
variant="flat" </Tooltip>
> </span>
<div> <div className="mt-1">
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1"> {node.customRouting.subnetApprovedRoutes.length === 0 ? (
Approved <span className="opacity-50"></span>
<Tooltip> ) : (
<Info className="w-3.5 h-3.5" /> <ul className="leading-normal">
<Tooltip.Body> {node.customRouting.subnetApprovedRoutes.map((route) => (
Traffic to these routes are being routed through this machine. <li key={route}>{route}</li>
</Tooltip.Body> ))}
</Tooltip> </ul>
</span> )}
<div className="mt-1"> </div>
{node.customRouting.subnetApprovedRoutes.length === 0 ? ( <Button
<span className="opacity-50"></span> className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
) : ( onPress={() => setShowRouting(true)}
<ul className="leading-normal"> >
{node.customRouting.subnetApprovedRoutes.map((route) => ( Edit
<li key={route}>{route}</li> </Button>
))} </div>
</ul> <div>
)} <span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
</div> Awaiting Approval
<Button <Tooltip>
className={cn( <Info className="h-3.5 w-3.5" />
'px-1.5 py-0.5 rounded-md mt-1.5', <Tooltip.Body>
'text-blue-500 dark:text-blue-400', This machine is advertising these routes, but they must be approved before traffic
)} will be routed to them.
onPress={() => setShowRouting(true)} </Tooltip.Body>
> </Tooltip>
Edit </span>
</Button> <div className="mt-1">
</div> {node.customRouting.subnetWaitingRoutes.length === 0 ? (
<div> <span className="opacity-50"></span>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1"> ) : (
Awaiting Approval <ul className="leading-normal">
<Tooltip> {node.customRouting.subnetWaitingRoutes.map((route) => (
<Info className="w-3.5 h-3.5" /> <li key={route}>{route}</li>
<Tooltip.Body> ))}
This machine is advertising these routes, but they must be </ul>
approved before traffic will be routed to them. )}
</Tooltip.Body> </div>
</Tooltip> <Button
</span> className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
<div className="mt-1"> onPress={() => setShowRouting(true)}
{node.customRouting.subnetWaitingRoutes.length === 0 ? ( >
<span className="opacity-50"></span> Edit
) : ( </Button>
<ul className="leading-normal"> </div>
{node.customRouting.subnetWaitingRoutes.map((route) => ( <div>
<li key={route}>{route}</li> <span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
))} Exit Node
</ul> <Tooltip>
)} <Info className="h-3.5 w-3.5" />
</div> <Tooltip.Body>
<Button Whether this machine can act as an exit node for your tailnet.
className={cn( </Tooltip.Body>
'px-1.5 py-0.5 rounded-md mt-1.5', </Tooltip>
'text-blue-500 dark:text-blue-400', </span>
)} <div className="mt-1">
onPress={() => setShowRouting(true)} {node.customRouting.exitRoutes.length === 0 ? (
> <span className="opacity-50"></span>
Edit ) : node.customRouting.exitApproved ? (
</Button> <span className="flex items-center gap-x-1">
</div> <CheckCircle className="h-3.5 w-3.5 text-green-700" />
<div> Allowed
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1"> </span>
Exit Node ) : (
<Tooltip> <span className="flex items-center gap-x-1">
<Info className="w-3.5 h-3.5" /> <CircleSlash className="h-3.5 w-3.5 text-red-700" />
<Tooltip.Body> Awaiting Approval
Whether this machine can act as an exit node for your tailnet. </span>
</Tooltip.Body> )}
</Tooltip> </div>
</span> <Button
<div className="mt-1"> className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
{node.customRouting.exitRoutes.length === 0 ? ( onPress={() => setShowRouting(true)}
<span className="opacity-50"></span> >
) : node.customRouting.exitApproved ? ( Edit
<span className="flex items-center gap-x-1"> </Button>
<CheckCircle className="w-3.5 h-3.5 text-green-700" /> </div>
Allowed </Card>
</span> <h2 className="text-xl font-medium">Machine Details</h2>
) : ( <p className="mb-4">
<span className="flex items-center gap-x-1"> Information about this machines network. Used to debug connection issues.
<CircleSlash className="w-3.5 h-3.5 text-red-700" /> </p>
Awaiting Approval <Card
</span> className="grid w-full max-w-full grid-cols-1 gap-y-2 sm:gap-x-12 lg:grid-cols-2"
)} variant="flat"
</div> >
<Button <div className="flex flex-col gap-1">
className={cn( <Attribute
'px-1.5 py-0.5 rounded-md mt-1.5', name="Creator"
'text-blue-500 dark:text-blue-400', value={node.user.name || node.user.displayName || node.user.email || node.user.id}
)} />
onPress={() => setShowRouting(true)} <Attribute name="Machine name" value={node.givenName} />
> <Attribute
Edit name="OS hostname"
</Button> tooltip="OS hostname is published by the machines operating system and is used as the default name for the machine."
</div> value={node.name}
</Card> />
<h2 className="text-xl font-medium">Machine Details</h2> {stats ? (
<p className="mb-4"> <>
Information about this machines network. Used to debug connection <Attribute name="OS" value={getOSInfo(stats)} />
issues. <Attribute name="Tailscale version" value={getTSVersion(stats)} />
</p> </>
<Card ) : undefined}
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12" <Attribute
variant="flat" name="ID"
> tooltip="ID for this machine. Used in the Headscale API."
<div className="flex flex-col gap-1"> value={node.id}
<Attribute />
name="Creator" <Attribute
value={ isCopyable
node.user.name || name="Node key"
node.user.displayName || tooltip="Public key which uniquely identifies this machine."
node.user.email || value={node.nodeKey}
node.user.id />
} <Attribute name="Created" value={new Date(node.createdAt).toLocaleString()} />
/> <Attribute
<Attribute name="Machine name" value={node.givenName} /> name="Last Seen"
<Attribute value={node.online ? "Connected" : new Date(node.lastSeen).toLocaleString()}
name="OS hostname" />
tooltip="OS hostname is published by the machines operating system and is used as the default name for the machine." <Attribute
value={node.name} name="Key expiry"
/> value={node.expiry !== null ? new Date(node.expiry).toLocaleString() : "Never"}
{stats ? ( />
<> {magic ? (
<Attribute name="OS" value={getOSInfo(stats)} /> <Attribute isCopyable name="Domain" value={`${node.givenName}.${magic}`} />
<Attribute name="Tailscale version" value={getTSVersion(stats)} /> ) : undefined}
</> </div>
) : undefined} <div className="flex flex-col gap-1">
<Attribute <p className="text-sm font-semibold uppercase opacity-75">Addresses</p>
name="ID" <Attribute
tooltip="ID for this machine. Used in the Headscale API." isCopyable
value={node.id} name="Tailscale IPv4"
/> tooltip="This machines IPv4 address within your tailnet (your private Tailscale network)."
<Attribute value={getIpv4Address(node.ipAddresses)}
isCopyable />
name="Node key" <Attribute
tooltip="Public key which uniquely identifies this machine." isCopyable
value={node.nodeKey} name="Tailscale IPv6"
/> tooltip="This machines IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
<Attribute value={getIpv6Address(node.ipAddresses)}
name="Created" />
value={new Date(node.createdAt).toLocaleString()} <Attribute
/> isCopyable
<Attribute name="Short domain"
name="Last Seen" tooltip="Users of your tailnet can use this DNS short name to access this machine."
value={ value={node.givenName}
node.online />
? 'Connected' {magic ? (
: new Date(node.lastSeen).toLocaleString() <Attribute
} isCopyable
/> name="Full domain"
<Attribute tooltip="Users of your tailnet can use this DNS name to access this machine."
name="Key expiry" value={`${node.givenName}.${magic}`}
value={ />
node.expiry !== null ) : undefined}
? new Date(node.expiry).toLocaleString() {stats ? (
: 'Never' <>
} <p className="mt-4 text-sm font-semibold uppercase opacity-75">Client Connectivity</p>
/> <Attribute
{magic ? ( name="Varies"
<Attribute tooltip="Whether the machine is behind a difficult NAT that varies the machines IP address depending on the destination."
isCopyable value={stats.NetInfo?.MappingVariesByDestIP ? "Yes" : "No"}
name="Domain" />
value={`${node.givenName}.${magic}`} <Attribute
/> name="Hairpinning"
) : undefined} tooltip="Whether the machine needs to traverse NATs with hairpinning."
</div> value={stats.NetInfo?.HairPinning ? "Yes" : "No"}
<div className="flex flex-col gap-1"> />
<p className="uppercase text-sm font-semibold opacity-75"> <Attribute name="IPv6" value={stats.NetInfo?.WorkingIPv6 ? "Yes" : "No"} />
Addresses <Attribute name="UDP" value={stats.NetInfo?.WorkingUDP ? "Yes" : "No"} />
</p> <Attribute name="UPnP" value={stats.NetInfo?.UPnP ? "Yes" : "No"} />
<Attribute <Attribute name="PCP" value={stats.NetInfo?.PCP ? "Yes" : "No"} />
isCopyable <Attribute name="NAT-PMP" value={stats.NetInfo?.PMP ? "Yes" : "No"} />
name="Tailscale IPv4" </>
tooltip="This machines IPv4 address within your tailnet (your private Tailscale network)." ) : undefined}
value={getIpv4Address(node.ipAddresses)} </div>
/> </Card>
<Attribute </div>
isCopyable );
name="Tailscale IPv6"
tooltip="This machines IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
value={getIpv6Address(node.ipAddresses)}
/>
<Attribute
isCopyable
name="Short domain"
tooltip="Users of your tailnet can use this DNS short name to access this machine."
value={node.givenName}
/>
{magic ? (
<Attribute
isCopyable
name="Full domain"
tooltip="Users of your tailnet can use this DNS name to access this machine."
value={`${node.givenName}.${magic}`}
/>
) : undefined}
{stats ? (
<>
<p className="uppercase text-sm font-semibold opacity-75 mt-4">
Client Connectivity
</p>
<Attribute
name="Varies"
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'}
/>
<Attribute
name="Hairpinning"
tooltip="Whether the machine needs to traverse NATs with hairpinning."
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'}
/>
</>
) : undefined}
</div>
</Card>
</div>
);
} }
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 "—";
} }
+139 -125
View File
@@ -1,150 +1,164 @@
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 {
/** /**
* Retrieves all nodes (machines) from the Headscale instance. * Retrieves all nodes (machines) from the Headscale instance.
* *
* @returns An array of `Machine` objects representing the nodes. * @returns An array of `Machine` objects representing the nodes.
*/ */
getNodes(): Promise<Machine[]>; getNodes(): Promise<Machine[]>;
/** /**
* Retrieves a specific node (machine) by its ID. * Retrieves a specific node (machine) by its ID.
* *
* @param id The ID of the node to retrieve. * @param id The ID of the node to retrieve.
* @returns A `Machine` object representing the node. * @returns A `Machine` object representing the node.
*/ */
getNode(id: string): Promise<Machine>; getNode(id: string): Promise<Machine>;
/** /**
* Deletes a specific node (machine) by its ID. * Deletes a specific node (machine) by its ID.
* *
* @param id The ID of the node to delete. * @param id The ID of the node to delete.
*/ */
deleteNode(id: string): Promise<void>; deleteNode(id: string): Promise<void>;
/** /**
* Registers a new node (machine) with the given user and key. * Registers a new node (machine) with the given user and key.
* *
* @param user The user to associate with the node. * @param user The user to associate with the node.
* @param key The registration key for the node. * @param key The registration key for the node.
* @returns A `Machine` object representing the newly registered node. * @returns A `Machine` object representing the newly registered node.
*/ */
registerNode(user: string, key: string): Promise<Machine>; registerNode(user: string, key: string): Promise<Machine>;
/** /**
* Approves routes for a specific node (machine) by its ID. * Approves routes for a specific node (machine) by its ID.
* *
* @param id The ID of the node. * @param id The ID of the node.
* @param routes An array of routes to approve for the node. * @param routes An array of routes to approve for the node.
*/ */
approveNodeRoutes(id: string, routes: string[]): Promise<void>; approveNodeRoutes(id: string, routes: string[]): Promise<void>;
/** /**
* Expires a specific node (machine) by its ID. * Expires a specific node (machine) by its ID.
* *
* @param id The ID of the node to expire. * @param id The ID of the node to expire.
*/ */
expireNode(id: string): Promise<void>; expireNode(id: string): Promise<void>;
/** /**
* Renames a specific node (machine) by its ID. * Renames a specific node (machine) by its ID.
* *
* @param id The ID of the node to rename. * @param id The ID of the node to rename.
* @param newName The new name for the node. * @param newName The new name for the node.
*/ */
renameNode(id: string, newName: string): Promise<void>; renameNode(id: string, newName: string): Promise<void>;
/** /**
* Sets tags for a specific node (machine) by its ID. * Sets tags for a specific node (machine) by its ID.
* *
* @param id The ID of the node. * @param id The ID of the node.
* @param tags An array of tags to set for the node. * @param tags An array of tags to set for the node.
*/ */
setNodeTags(id: string, tags: string[]): Promise<void>; setNodeTags(id: string, tags: string[]): Promise<void>;
/** /**
* Sets the user for a specific node (machine) by its ID. * Sets the user for a specific node (machine) by its ID.
* *
* @param id The ID of the node. * @param id The ID of the node.
* @param user The user to set for the node. * @param user The user to set for the node.
*/ */
setNodeUser(id: string, user: string): Promise<void>; setNodeUser(id: string, user: string): Promise<void>;
} }
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,
{ {
user, user,
key, key,
}, },
); );
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,
}); });
}, },
})); }));
+25 -27
View File
@@ -1,35 +1,33 @@
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;
machineKey: string; machineKey: string;
nodeKey: string; nodeKey: string;
discoKey: string; discoKey: string;
ipAddresses: string[]; ipAddresses: string[];
name: string; name: string;
user: User; user: User;
lastSeen: string; lastSeen: string;
expiry: string | null; expiry: string | null;
preAuthKey?: PreAuthKey; preAuthKey?: PreAuthKey;
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[]; givenName: string;
validTags: string[]; online: boolean;
givenName: string;
online: boolean;
// Added in Headscale 0.26+ // Added in Headscale 0.26+
approvedRoutes: string[]; approvedRoutes: string[];
availableRoutes: string[]; availableRoutes: string[];
subnetRoutes: string[]; subnetRoutes: string[];
} }
+39 -54
View File
@@ -1,65 +1,50 @@
import { HostInfo, Machine } from '~/types'; import { HostInfo, Machine } from "~/types";
export interface PopulatedNode extends Machine { export interface PopulatedNode extends Machine {
routes: string[]; routes: string[];
hostInfo?: HostInfo; hostInfo?: HostInfo;
expired: boolean; expired: boolean;
customRouting: { customRouting: {
exitRoutes: string[]; exitRoutes: string[];
exitApproved: boolean; exitApproved: boolean;
subnetApprovedRoutes: string[]; subnetApprovedRoutes: string[];
subnetWaitingRoutes: string[]; subnetWaitingRoutes: string[];
}; };
} }
export function mapNodes( export function mapNodes(
nodes: Machine[], nodes: Machine[],
stats?: Record<string, HostInfo> | undefined, stats?: Record<string, HostInfo> | undefined,
): 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"),
), subnetApprovedRoutes: node.approvedRoutes.filter(
exitApproved: node.approvedRoutes.some( (route) =>
(route) => route === '::/0' || route === '0.0.0.0/0', route !== "::/0" && route !== "0.0.0.0/0" && node.availableRoutes.includes(route),
), ),
subnetApprovedRoutes: node.approvedRoutes.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.availableRoutes.includes(route), } satisfies PopulatedNode["customRouting"];
),
subnetWaitingRoutes: node.availableRoutes.filter(
(route) =>
route !== '::/0' &&
route !== '0.0.0.0/0' &&
!node.approvedRoutes.includes(route),
),
} satisfies PopulatedNode['customRouting'];
return { return {
...node, ...node,
routes: Array.from(new Set(node.availableRoutes)), routes: Array.from(new Set(node.availableRoutes)),
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(),
}; };
}); });
} }
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();
} }
+70 -66
View File
@@ -1,98 +1,102 @@
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);
} }
const body = (await res.body.json()) as OpenAPIV2.Document; const body = (await res.body.json()) as OpenAPIV2.Document;
return hashOpenApiDocument(body); return hashOpenApiDocument(body);
} }
async function collectCanonicalizedFamilies( async function collectCanonicalizedFamilies(
newHashes: readonly (readonly [string, Record<string, string>])[], newHashes: readonly (readonly [string, Record<string, string>])[],
) { ) {
const canonicalizedFamilies: Record<string, string[]> = {}; const canonicalizedFamilies: Record<string, string[]> = {};
for (const [version, hashes] of newHashes) { for (const [version, hashes] of newHashes) {
const signature = JSON.stringify(hashes); const signature = JSON.stringify(hashes);
let canonical: string | null = null; let canonical: string | null = null;
for (const existingCanonical of Object.keys(canonicalizedFamilies)) { for (const existingCanonical of Object.keys(canonicalizedFamilies)) {
const existingSignature = JSON.stringify( const existingSignature = JSON.stringify(
newHashes.find(([v]) => v === existingCanonical)![1], newHashes.find(([v]) => v === existingCanonical)![1],
); );
if (existingSignature === signature) { if (existingSignature === signature) {
canonical = existingCanonical; canonical = existingCanonical;
break; break;
} }
} }
if (!canonical) { if (!canonical) {
canonicalizedFamilies[version] = [version]; canonicalizedFamilies[version] = [version];
continue; continue;
} }
canonicalizedFamilies[canonical].push(version); canonicalizedFamilies[canonical].push(version);
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];
delete canonicalizedFamilies[canonical]; delete canonicalizedFamilies[canonical];
} }
} }
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" }),
); );
} }
return canonicalizedFamilies; return canonicalizedFamilies;
} }
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(
Object.entries(SPEC_MAP).map(async ([version, specUrl]) => { Object.entries(SPEC_MAP).map(async ([version, specUrl]) => {
const hashes = await hashOpenApiOperations(specUrl); const hashes = await hashOpenApiOperations(specUrl);
return [version, hashes] as const; return [version, hashes] as const;
}), }),
); );
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);