diff --git a/app/openapi-canonical-families.json b/app/openapi-canonical-families.json
index 4c95fe2e..ed10aa66 100644
--- a/app/openapi-canonical-families.json
+++ b/app/openapi-canonical-families.json
@@ -1,5 +1,7 @@
{
- "0.26.1": ["0.26.0", "0.26.1"],
- "0.27.0": ["0.27.0"],
- "0.27.1": ["0.27.1"]
+ "0.26.1": ["0.26.0", "0.26.1"],
+ "0.27.0": ["0.27.0"],
+ "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"]
}
diff --git a/app/openapi-operation-hashes.json b/app/openapi-operation-hashes.json
index d1312dd3..65004057 100644
--- a/app/openapi-operation-hashes.json
+++ b/app/openapi-operation-hashes.json
@@ -1,108 +1,162 @@
{
- "0.26.0": {
- "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/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": "ac9ffcd6243a9784",
- "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
- "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
- "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
- "GET /api/v1/policy": "d6c639be304cd3c0",
- "PUT /api/v1/policy": "6cbe80bde771a388",
- "GET /api/v1/preauthkey": "14db6a04f90d7a7e",
- "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.26.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/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": "ac9ffcd6243a9784",
- "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
- "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
- "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
- "GET /api/v1/policy": "d6c639be304cd3c0",
- "PUT /api/v1/policy": "6cbe80bde771a388",
- "GET /api/v1/preauthkey": "14db6a04f90d7a7e",
- "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.27.0": {
- "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": "ac9ffcd6243a9784",
- "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
- "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
- "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
- "GET /api/v1/policy": "d6c639be304cd3c0",
- "PUT /api/v1/policy": "6cbe80bde771a388",
- "GET /api/v1/preauthkey": "14db6a04f90d7a7e",
- "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.27.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",
- "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
- "GET /api/v1/policy": "d6c639be304cd3c0",
- "PUT /api/v1/policy": "6cbe80bde771a388",
- "GET /api/v1/preauthkey": "14db6a04f90d7a7e",
- "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.26.0": {
+ "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/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": "ac9ffcd6243a9784",
+ "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
+ "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
+ "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
+ "GET /api/v1/policy": "d6c639be304cd3c0",
+ "PUT /api/v1/policy": "6cbe80bde771a388",
+ "GET /api/v1/preauthkey": "14db6a04f90d7a7e",
+ "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.26.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/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": "ac9ffcd6243a9784",
+ "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
+ "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
+ "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
+ "GET /api/v1/policy": "d6c639be304cd3c0",
+ "PUT /api/v1/policy": "6cbe80bde771a388",
+ "GET /api/v1/preauthkey": "14db6a04f90d7a7e",
+ "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.27.0": {
+ "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": "ac9ffcd6243a9784",
+ "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
+ "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
+ "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
+ "GET /api/v1/policy": "d6c639be304cd3c0",
+ "PUT /api/v1/policy": "6cbe80bde771a388",
+ "GET /api/v1/preauthkey": "14db6a04f90d7a7e",
+ "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.27.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",
+ "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
+ "GET /api/v1/policy": "d6c639be304cd3c0",
+ "PUT /api/v1/policy": "6cbe80bde771a388",
+ "GET /api/v1/preauthkey": "14db6a04f90d7a7e",
+ "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.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"
+ }
}
diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx
index 4d2da34b..9fae3d35 100644
--- a/app/routes/machines/components/machine-row.tsx
+++ b/app/routes/machines/components/machine-row.tsx
@@ -1,234 +1,211 @@
-import { ChevronDown, Copy } from 'lucide-react';
-import { useMemo } from 'react';
-import { Link } from 'react-router';
-import Chip from '~/components/Chip';
-import Menu from '~/components/Menu';
-import StatusCircle from '~/components/StatusCircle';
-import { ExitNodeTag } from '~/components/tags/ExitNode';
-import { ExpiryTag } from '~/components/tags/Expiry';
-import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
-import { SubnetTag } from '~/components/tags/Subnet';
-import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH';
-import type { User } from '~/types';
-import cn from '~/utils/cn';
-import * as hinfo from '~/utils/host-info';
-import { PopulatedNode } from '~/utils/node-info';
-import { formatTimeDelta } from '~/utils/time';
-import toast from '~/utils/toast';
-import MenuOptions from './menu';
+import { ChevronDown, Copy } from "lucide-react";
+import { useMemo } from "react";
+import { Link } from "react-router";
+
+import type { User } from "~/types";
+
+import Chip from "~/components/Chip";
+import Menu from "~/components/Menu";
+import StatusCircle from "~/components/StatusCircle";
+import { ExitNodeTag } from "~/components/tags/ExitNode";
+import { ExpiryTag } from "~/components/tags/Expiry";
+import { HeadplaneAgentTag } from "~/components/tags/HeadplaneAgent";
+import { SubnetTag } from "~/components/tags/Subnet";
+import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
+import cn from "~/utils/cn";
+import * as hinfo from "~/utils/host-info";
+import { PopulatedNode } from "~/utils/node-info";
+import { formatTimeDelta } from "~/utils/time";
+import toast from "~/utils/toast";
+
+import MenuOptions from "./menu";
interface Props {
- node: PopulatedNode;
- users: User[];
- isAgent?: boolean;
- magic?: string;
- isDisabled?: boolean;
- existingTags?: string[];
+ node: PopulatedNode;
+ users: User[];
+ isAgent?: boolean;
+ magic?: string;
+ isDisabled?: boolean;
+ existingTags?: string[];
}
export default function MachineRow({
- node,
- users,
- isAgent,
- magic,
- isDisabled,
- existingTags,
+ node,
+ users,
+ isAgent,
+ magic,
+ isDisabled,
+ existingTags,
}: Props) {
- const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
-
- const ipOptions = useMemo(() => {
- if (magic) {
- return [...node.ipAddresses, `${node.givenName}.${magic}`];
- }
-
- return node.ipAddresses;
- }, [magic, node.ipAddresses]);
-
- return (
-
- |
-
-
- {node.givenName}
-
-
- {node.user.name ||
- node.user.displayName ||
- node.user.email ||
- node.user.id}
-
-
- {mapTagsToComponents(node, uiTags)}
- {node.validTags.map((tag) => (
-
- ))}
-
-
- |
-
-
- {node.ipAddresses[0]}
-
-
- |
- {/* We pass undefined when agents are not enabled */}
- {isAgent !== undefined ? (
-
- {node.hostInfo !== undefined ? (
- <>
-
- {hinfo.getTSVersion(node.hostInfo)}
-
-
- {hinfo.getOSInfo(node.hostInfo)}
-
- >
- ) : (
- Unknown
- )}
- |
- ) : undefined}
-
-
-
-
-
- {node.online && !node.expired
- ? 'Connected'
- : new Date(node.lastSeen).toLocaleString()}
-
- {!(node.online && !node.expired) && (
-
- {formatTimeDelta(new Date(node.lastSeen))}
-
- )}
-
-
- |
-
-
- |
-
- );
+ const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
+
+ const ipOptions = useMemo(() => {
+ if (magic) {
+ return [...node.ipAddresses, `${node.givenName}.${magic}`];
+ }
+
+ return node.ipAddresses;
+ }, [magic, node.ipAddresses]);
+
+ return (
+
+ |
+
+
+ {node.givenName}
+
+
+ {node.user.name || node.user.displayName || node.user.email || node.user.id}
+
+
+ {mapTagsToComponents(node, uiTags)}
+ {node.tags?.map((tag) => (
+
+ ))}
+
+
+ |
+
+
+ {node.ipAddresses[0]}
+
+
+ |
+ {/* We pass undefined when agents are not enabled */}
+ {isAgent !== undefined ? (
+
+ {node.hostInfo !== undefined ? (
+ <>
+ {hinfo.getTSVersion(node.hostInfo)}
+
+ {hinfo.getOSInfo(node.hostInfo)}
+
+ >
+ ) : (
+ Unknown
+ )}
+ |
+ ) : undefined}
+
+
+
+
+
+ {node.online && !node.expired
+ ? "Connected"
+ : new Date(node.lastSeen).toLocaleString()}
+
+ {!(node.online && !node.expired) && (
+
+ {formatTimeDelta(new Date(node.lastSeen))}
+
+ )}
+
+
+ |
+
+
+ |
+
+ );
}
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
- const uiTags: string[] = [];
- if (node.expired) {
- uiTags.push('expired');
- }
-
- if (node.expiry === null) {
- uiTags.push('no-expiry');
- }
-
- if (node.customRouting.exitRoutes.length > 0) {
- if (node.customRouting.exitApproved) {
- uiTags.push('exit-approved');
- } else {
- uiTags.push('exit-waiting');
- }
- }
-
- if (node.customRouting.subnetWaitingRoutes.length > 0) {
- uiTags.push('subnet-waiting');
- } else if (node.customRouting.subnetApprovedRoutes.length > 0) {
- uiTags.push('subnet-approved');
- }
-
- if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
- uiTags.push('tailscale-ssh');
- }
-
- if (isAgent === true) {
- uiTags.push('headplane-agent');
- }
-
- return uiTags;
+ const uiTags: string[] = [];
+ if (node.expired) {
+ uiTags.push("expired");
+ }
+
+ if (node.expiry === null) {
+ uiTags.push("no-expiry");
+ }
+
+ if (node.customRouting.exitRoutes.length > 0) {
+ if (node.customRouting.exitApproved) {
+ uiTags.push("exit-approved");
+ } else {
+ uiTags.push("exit-waiting");
+ }
+ }
+
+ if (node.customRouting.subnetWaitingRoutes.length > 0) {
+ uiTags.push("subnet-waiting");
+ } else if (node.customRouting.subnetApprovedRoutes.length > 0) {
+ uiTags.push("subnet-approved");
+ }
+
+ if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
+ uiTags.push("tailscale-ssh");
+ }
+
+ if (isAgent === true) {
+ uiTags.push("headplane-agent");
+ }
+
+ return uiTags;
}
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
- return uiTags.map((tag) => {
- switch (tag) {
- case 'exit-approved':
- case 'exit-waiting':
- return ;
-
- case 'subnet-approved':
- case 'subnet-waiting':
- return ;
-
- case 'expired':
- case 'no-expiry':
- return (
-
- );
-
- case 'tailscale-ssh':
- return ;
-
- case 'headplane-agent':
- return ;
-
- default:
- return null;
- }
- });
+ return uiTags.map((tag) => {
+ switch (tag) {
+ case "exit-approved":
+ case "exit-waiting":
+ return ;
+
+ case "subnet-approved":
+ case "subnet-waiting":
+ return ;
+
+ case "expired":
+ case "no-expiry":
+ return ;
+
+ case "tailscale-ssh":
+ return ;
+
+ case "headplane-agent":
+ return ;
+
+ default:
+ return null;
+ }
+ });
}
diff --git a/app/routes/machines/dialogs/tags.tsx b/app/routes/machines/dialogs/tags.tsx
index bbc280f5..24aba817 100644
--- a/app/routes/machines/dialogs/tags.tsx
+++ b/app/routes/machines/dialogs/tags.tsx
@@ -1,106 +1,96 @@
-import { Plus, TagsIcon, X } from 'lucide-react';
-import { useMemo, useState } from 'react';
-import Button from '~/components/Button';
-import Dialog from '~/components/Dialog';
-import Link from '~/components/Link';
-import Select from '~/components/Select';
-import TableList from '~/components/TableList';
-import type { Machine } from '~/types';
-import cn from '~/utils/cn';
+import { Plus, TagsIcon, X } from "lucide-react";
+import { useMemo, useState } from "react";
+
+import type { Machine } from "~/types";
+
+import Button from "~/components/Button";
+import Dialog from "~/components/Dialog";
+import Link from "~/components/Link";
+import Select from "~/components/Select";
+import TableList from "~/components/TableList";
+import cn from "~/utils/cn";
interface TagsProps {
- machine: Machine;
- isOpen: boolean;
- setIsOpen: (isOpen: boolean) => void;
- existingTags?: string[];
+ machine: Machine;
+ isOpen: boolean;
+ setIsOpen: (isOpen: boolean) => void;
+ existingTags?: string[];
}
-export default function Tags({
- machine,
- isOpen,
- setIsOpen,
- existingTags,
-}: TagsProps) {
- 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]);
+export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsProps) {
+ const [tags, setTags] = useState([...machine.tags]);
+ const [tag, setTag] = useState("tag:");
+ const tagIsInvalid = useMemo(() => {
+ return tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag);
+ }, [tag, tags]);
- const validNodeTags = useMemo(() => {
- return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
- }, [tags]);
+ const validNodeTags = useMemo(() => {
+ return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
+ }, [tags]);
- return (
-
+ );
}
diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx
index 0b751f00..db47ec16 100644
--- a/app/routes/machines/machine.tsx
+++ b/app/routes/machines/machine.tsx
@@ -1,408 +1,353 @@
-import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
-import { useMemo, useState } from 'react';
-import { data, Link as RemixLink } from 'react-router';
-import Attribute from '~/components/Attribute';
-import Button from '~/components/Button';
-import Card from '~/components/Card';
-import Chip from '~/components/Chip';
-import Link from '~/components/Link';
-import StatusCircle from '~/components/StatusCircle';
-import Tooltip from '~/components/Tooltip';
-import cn from '~/utils/cn';
-import { getOSInfo, getTSVersion } from '~/utils/host-info';
-import { mapNodes, sortNodeTags } from '~/utils/node-info';
-import type { Route } from './+types/machine';
-import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
-import MenuOptions from './components/menu';
-import Routes from './dialogs/routes';
-import { machineAction } from './machine-actions';
+import { CheckCircle, CircleSlash, Info, UserCircle } from "lucide-react";
+import { useMemo, useState } from "react";
+import { data, Link as RemixLink } from "react-router";
+
+import Attribute from "~/components/Attribute";
+import Button from "~/components/Button";
+import Card from "~/components/Card";
+import Chip from "~/components/Chip";
+import Link from "~/components/Link";
+import StatusCircle from "~/components/StatusCircle";
+import Tooltip from "~/components/Tooltip";
+import cn from "~/utils/cn";
+import { getOSInfo, getTSVersion } from "~/utils/host-info";
+import { mapNodes, sortNodeTags } from "~/utils/node-info";
+
+import type { Route } from "./+types/machine";
+
+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) {
- const session = await context.sessions.auth(request);
- if (!params.id) {
- throw new Error('No machine ID provided');
- }
+ const session = await context.sessions.auth(request);
+ if (!params.id) {
+ throw new Error("No machine ID provided");
+ }
- if (params.id.endsWith('.ico')) {
- throw data(null, { status: 204 });
- }
+ if (params.id.endsWith(".ico")) {
+ throw data(null, { status: 204 });
+ }
- let magic: string | undefined;
- if (context.hs.readable()) {
- if (context.hs.c?.dns.magic_dns) {
- magic = context.hs.c.dns.base_domain;
- }
- }
+ let magic: string | undefined;
+ if (context.hs.readable()) {
+ if (context.hs.c?.dns.magic_dns) {
+ magic = context.hs.c.dns.base_domain;
+ }
+ }
- const api = context.hsApi.getRuntimeClient(session.api_key);
- const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
- const node = nodes.find((node) => node.id === params.id);
+ const api = context.hsApi.getRuntimeClient(session.api_key);
+ const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
+ const node = nodes.find((node) => node.id === params.id);
- const lookup = await context.agents?.lookup([node.nodeKey]);
- const [enhancedNode] = mapNodes([node], lookup);
- const tags = Array.from(
- new Set([...node.validTags, ...node.forcedTags]),
- ).sort();
+ const lookup = await context.agents?.lookup([node.nodeKey]);
+ const [enhancedNode] = mapNodes([node], lookup);
+ const tags = [...node.tags].sort();
- return {
- node: enhancedNode,
- tags,
- users,
- magic,
- agent: context.agents?.agentID(),
- stats: lookup?.[enhancedNode.nodeKey],
- existingTags: sortNodeTags(nodes),
- };
+ return {
+ node: enhancedNode,
+ tags,
+ users,
+ magic,
+ agent: context.agents?.agentID(),
+ stats: lookup?.[enhancedNode.nodeKey],
+ existingTags: sortNodeTags(nodes),
+ };
}
export const action = machineAction;
export default function Page({
- loaderData: { node, tags, users, magic, agent, stats, existingTags },
+ loaderData: { node, tags, users, magic, agent, stats, existingTags },
}: Route.ComponentProps) {
- const [showRouting, setShowRouting] = useState(false);
+ const [showRouting, setShowRouting] = useState(false);
- const uiTags = useMemo(() => {
- const tags = uiTagsForNode(node, agent === node.nodeKey);
- return tags;
- }, [node, agent]);
+ const uiTags = useMemo(() => {
+ const tags = uiTagsForNode(node, agent === node.nodeKey);
+ return tags;
+ }, [node, agent]);
- return (
-
-
-
- All Machines
-
- /
- {node.givenName}
-
-
-
- {node.givenName}
-
-
-
-
-
-
-
- Managed by
-
-
-
- By default, a machine’s permissions match its creator’s.
-
-
-
-
-
- {node.user.name ||
- node.user.displayName ||
- node.user.email ||
- node.user.id}
-
-
-
-
- Status
-
-
- {mapTagsToComponents(node, uiTags)}
- {tags.map((tag) => (
-
- ))}
-
-
-
-
-
Subnets & Routing
-
-
- Subnets let you expose physical network routes onto Tailscale.{' '}
-
- Learn More
-
-
-
-
-
-
-
- Approved
-
-
-
- Traffic to these routes are being routed through this machine.
-
-
-
-
- {node.customRouting.subnetApprovedRoutes.length === 0 ? (
-
—
- ) : (
-
- {node.customRouting.subnetApprovedRoutes.map((route) => (
- - {route}
- ))}
-
- )}
-
-
-
-
-
- Awaiting Approval
-
-
-
- This machine is advertising these routes, but they must be
- approved before traffic will be routed to them.
-
-
-
-
- {node.customRouting.subnetWaitingRoutes.length === 0 ? (
-
—
- ) : (
-
- {node.customRouting.subnetWaitingRoutes.map((route) => (
- - {route}
- ))}
-
- )}
-
-
-
-
-
- Exit Node
-
-
-
- Whether this machine can act as an exit node for your tailnet.
-
-
-
-
- {node.customRouting.exitRoutes.length === 0 ? (
- —
- ) : node.customRouting.exitApproved ? (
-
-
- Allowed
-
- ) : (
-
-
- Awaiting Approval
-
- )}
-
-
-
-
-
Machine Details
-
- Information about this machine’s network. Used to debug connection
- issues.
-
-
-
-
-
-
- {stats ? (
- <>
-
-
- >
- ) : undefined}
-
-
-
-
-
- {magic ? (
-
- ) : undefined}
-
-
-
- Addresses
-
-
-
-
- {magic ? (
-
- ) : undefined}
- {stats ? (
- <>
-
- Client Connectivity
-
-
-
-
-
-
-
-
- >
- ) : undefined}
-
-
-
- );
+ return (
+
+
+
+ All Machines
+
+ /
+ {node.givenName}
+
+
+
+ {node.givenName}
+
+
+
+
+
+
+
+ Managed by
+
+
+ By default, a machine’s permissions match its creator’s.
+
+
+
+
+ {node.user.name || node.user.displayName || node.user.email || node.user.id}
+
+
+
+
Status
+
+ {mapTagsToComponents(node, uiTags)}
+ {tags.map((tag) => (
+
+ ))}
+
+
+
+
+
Subnets & Routing
+
+
+ Subnets let you expose physical network routes onto Tailscale.{" "}
+
+ Learn More
+
+
+
+
+
+
+
+ Approved
+
+
+
+ Traffic to these routes are being routed through this machine.
+
+
+
+
+ {node.customRouting.subnetApprovedRoutes.length === 0 ? (
+
—
+ ) : (
+
+ {node.customRouting.subnetApprovedRoutes.map((route) => (
+ - {route}
+ ))}
+
+ )}
+
+
+
+
+
+ Awaiting Approval
+
+
+
+ This machine is advertising these routes, but they must be approved before traffic
+ will be routed to them.
+
+
+
+
+ {node.customRouting.subnetWaitingRoutes.length === 0 ? (
+
—
+ ) : (
+
+ {node.customRouting.subnetWaitingRoutes.map((route) => (
+ - {route}
+ ))}
+
+ )}
+
+
+
+
+
+ Exit Node
+
+
+
+ Whether this machine can act as an exit node for your tailnet.
+
+
+
+
+ {node.customRouting.exitRoutes.length === 0 ? (
+ —
+ ) : node.customRouting.exitApproved ? (
+
+
+ Allowed
+
+ ) : (
+
+
+ Awaiting Approval
+
+ )}
+
+
+
+
+
Machine Details
+
+ Information about this machine’s network. Used to debug connection issues.
+
+
+
+
+
+
+ {stats ? (
+ <>
+
+
+ >
+ ) : undefined}
+
+
+
+
+
+ {magic ? (
+
+ ) : undefined}
+
+
+
Addresses
+
+
+
+ {magic ? (
+
+ ) : undefined}
+ {stats ? (
+ <>
+
Client Connectivity
+
+
+
+
+
+
+
+ >
+ ) : undefined}
+
+
+
+ );
}
function getIpv4Address(addresses: string[]) {
- for (const address of addresses) {
- if (address.startsWith('100.')) {
- // Return the first CGNAT address
- return address;
- }
- }
+ for (const address of addresses) {
+ if (address.startsWith("100.")) {
+ // Return the first CGNAT address
+ return address;
+ }
+ }
- return '—';
+ return "—";
}
function getIpv6Address(addresses: string[]) {
- for (const address of addresses) {
- if (address.startsWith('fd')) {
- // Return the first IPv6 address
- return address;
- }
- }
+ for (const address of addresses) {
+ if (address.startsWith("fd")) {
+ // Return the first IPv6 address
+ return address;
+ }
+ }
- return '—';
+ return "—";
}
diff --git a/app/server/headscale/api/endpoints/nodes.ts b/app/server/headscale/api/endpoints/nodes.ts
index 6be7db76..fe6c03ef 100644
--- a/app/server/headscale/api/endpoints/nodes.ts
+++ b/app/server/headscale/api/endpoints/nodes.ts
@@ -1,150 +1,164 @@
-import type { Machine } from '~/types';
-import { defineApiEndpoints } from '../factory';
+import type { Machine } from "~/types";
+
+import type { HeadscaleApiInterface } from "..";
+
+import { defineApiEndpoints } from "../factory";
+
+interface RawMachine extends Omit {
+ 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 {
- /**
- * Retrieves all nodes (machines) from the Headscale instance.
- *
- * @returns An array of `Machine` objects representing the nodes.
- */
- getNodes(): Promise;
-
- /**
- * Retrieves a specific node (machine) by its ID.
- *
- * @param id The ID of the node to retrieve.
- * @returns A `Machine` object representing the node.
- */
- getNode(id: string): Promise;
-
- /**
- * Deletes a specific node (machine) by its ID.
- *
- * @param id The ID of the node to delete.
- */
- deleteNode(id: string): Promise;
-
- /**
- * Registers a new node (machine) with the given user and key.
- *
- * @param user The user to associate with the node.
- * @param key The registration key for the node.
- * @returns A `Machine` object representing the newly registered node.
- */
- registerNode(user: string, key: string): Promise;
-
- /**
- * Approves routes for a specific node (machine) by its ID.
- *
- * @param id The ID of the node.
- * @param routes An array of routes to approve for the node.
- */
- approveNodeRoutes(id: string, routes: string[]): Promise;
-
- /**
- * Expires a specific node (machine) by its ID.
- *
- * @param id The ID of the node to expire.
- */
- expireNode(id: string): Promise;
-
- /**
- * Renames a specific node (machine) by its ID.
- *
- * @param id The ID of the node to rename.
- * @param newName The new name for the node.
- */
- renameNode(id: string, newName: string): Promise;
-
- /**
- * Sets tags for a specific node (machine) by its ID.
- *
- * @param id The ID of the node.
- * @param tags An array of tags to set for the node.
- */
- setNodeTags(id: string, tags: string[]): Promise;
-
- /**
- * Sets the user for a specific node (machine) by its ID.
- *
- * @param id The ID of the node.
- * @param user The user to set for the node.
- */
- setNodeUser(id: string, user: string): Promise;
+ /**
+ * Retrieves all nodes (machines) from the Headscale instance.
+ *
+ * @returns An array of `Machine` objects representing the nodes.
+ */
+ getNodes(): Promise;
+
+ /**
+ * Retrieves a specific node (machine) by its ID.
+ *
+ * @param id The ID of the node to retrieve.
+ * @returns A `Machine` object representing the node.
+ */
+ getNode(id: string): Promise;
+
+ /**
+ * Deletes a specific node (machine) by its ID.
+ *
+ * @param id The ID of the node to delete.
+ */
+ deleteNode(id: string): Promise;
+
+ /**
+ * Registers a new node (machine) with the given user and key.
+ *
+ * @param user The user to associate with the node.
+ * @param key The registration key for the node.
+ * @returns A `Machine` object representing the newly registered node.
+ */
+ registerNode(user: string, key: string): Promise;
+
+ /**
+ * Approves routes for a specific node (machine) by its ID.
+ *
+ * @param id The ID of the node.
+ * @param routes An array of routes to approve for the node.
+ */
+ approveNodeRoutes(id: string, routes: string[]): Promise;
+
+ /**
+ * Expires a specific node (machine) by its ID.
+ *
+ * @param id The ID of the node to expire.
+ */
+ expireNode(id: string): Promise;
+
+ /**
+ * Renames a specific node (machine) by its ID.
+ *
+ * @param id The ID of the node to rename.
+ * @param newName The new name for the node.
+ */
+ renameNode(id: string, newName: string): Promise;
+
+ /**
+ * Sets tags for a specific node (machine) by its ID.
+ *
+ * @param id The ID of the node.
+ * @param tags An array of tags to set for the node.
+ */
+ setNodeTags(id: string, tags: string[]): Promise;
+
+ /**
+ * Sets the user for a specific node (machine) by its ID.
+ *
+ * @param id The ID of the node.
+ * @param user The user to set for the node.
+ */
+ setNodeUser(id: string, user: string): Promise;
}
export default defineApiEndpoints((client, apiKey) => ({
- getNodes: async () => {
- const { nodes } = await client.apiFetch<{ nodes: Machine[] }>(
- 'GET',
- 'v1/node',
- apiKey,
- );
-
- return nodes;
- },
-
- getNode: async (nodeId) => {
- const { node } = await client.apiFetch<{ node: Machine }>(
- 'GET',
- `v1/node/${nodeId}`,
- apiKey,
- );
-
- return node;
- },
-
- deleteNode: async (nodeId) => {
- await client.apiFetch('DELETE', `v1/node/${nodeId}`, apiKey);
- },
-
- registerNode: async (user, key) => {
- const qp = new URLSearchParams();
- qp.append('user', user);
- qp.append('key', key);
- const { node } = await client.apiFetch<{ node: Machine }>(
- 'POST',
- `v1/node/register?${qp.toString()}`,
- apiKey,
- {
- user,
- key,
- },
- );
-
- return node;
- },
-
- approveNodeRoutes: async (nodeId, routes) => {
- await client.apiFetch(
- 'POST',
- `v1/node/${nodeId}/approve_routes`,
- apiKey,
- { routes },
- );
- },
-
- expireNode: async (nodeId) => {
- await client.apiFetch('POST', `v1/node/${nodeId}/expire`, apiKey);
- },
-
- renameNode: async (nodeId, newName) => {
- await client.apiFetch(
- 'POST',
- `v1/node/${nodeId}/rename/${newName}`,
- apiKey,
- );
- },
-
- setNodeTags: async (nodeId, tags) => {
- await client.apiFetch('POST', `v1/node/${nodeId}/tags`, apiKey, {
- tags,
- });
- },
-
- setNodeUser: async (nodeId, user) => {
- await client.apiFetch('POST', `v1/node/${nodeId}/user`, apiKey, {
- user,
- });
- },
+ getNodes: async () => {
+ const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey);
+
+ return nodes.map((node) => normalizeTags(client, node));
+ },
+
+ getNode: async (nodeId) => {
+ const { node } = await client.apiFetch<{ node: RawMachine }>(
+ "GET",
+ `v1/node/${nodeId}`,
+ apiKey,
+ );
+
+ return normalizeTags(client, node);
+ },
+
+ deleteNode: async (nodeId) => {
+ await client.apiFetch("DELETE", `v1/node/${nodeId}`, apiKey);
+ },
+
+ registerNode: async (user, key) => {
+ const qp = new URLSearchParams();
+ qp.append("user", user);
+ qp.append("key", key);
+ const { node } = await client.apiFetch<{ node: RawMachine }>(
+ "POST",
+ `v1/node/register?${qp.toString()}`,
+ apiKey,
+ {
+ user,
+ key,
+ },
+ );
+
+ return normalizeTags(client, node);
+ },
+
+ approveNodeRoutes: async (nodeId, routes) => {
+ await client.apiFetch("POST", `v1/node/${nodeId}/approve_routes`, apiKey, { routes });
+ },
+
+ expireNode: async (nodeId) => {
+ await client.apiFetch("POST", `v1/node/${nodeId}/expire`, apiKey);
+ },
+
+ renameNode: async (nodeId, newName) => {
+ await client.apiFetch("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey);
+ },
+
+ setNodeTags: async (nodeId, tags) => {
+ await client.apiFetch("POST", `v1/node/${nodeId}/tags`, apiKey, {
+ tags,
+ });
+ },
+
+ setNodeUser: async (nodeId, user) => {
+ await client.apiFetch("POST", `v1/node/${nodeId}/user`, apiKey, {
+ user,
+ });
+ },
}));
diff --git a/app/types/Machine.ts b/app/types/Machine.ts
index 5d3e27d9..c644310c 100644
--- a/app/types/Machine.ts
+++ b/app/types/Machine.ts
@@ -1,35 +1,33 @@
-import type { PreAuthKey } from './PreAuthKey';
-import type { User } from './User';
+import type { PreAuthKey } from "./PreAuthKey";
+import type { User } from "./User";
export interface Machine {
- id: string;
- machineKey: string;
- nodeKey: string;
- discoKey: string;
- ipAddresses: string[];
- name: string;
+ id: string;
+ machineKey: string;
+ nodeKey: string;
+ discoKey: string;
+ ipAddresses: string[];
+ name: string;
- user: User;
- lastSeen: string;
- expiry: string | null;
+ user: User;
+ lastSeen: string;
+ expiry: string | null;
- preAuthKey?: PreAuthKey;
+ preAuthKey?: PreAuthKey;
- createdAt: string;
- registerMethod:
- | 'REGISTER_METHOD_UNSPECIFIED'
- | 'REGISTER_METHOD_AUTH_KEY'
- | 'REGISTER_METHOD_CLI'
- | 'REGISTER_METHOD_OIDC';
+ createdAt: string;
+ registerMethod:
+ | "REGISTER_METHOD_UNSPECIFIED"
+ | "REGISTER_METHOD_AUTH_KEY"
+ | "REGISTER_METHOD_CLI"
+ | "REGISTER_METHOD_OIDC";
- forcedTags: string[];
- invalidTags: string[];
- validTags: string[];
- givenName: string;
- online: boolean;
+ tags: string[];
+ givenName: string;
+ online: boolean;
- // Added in Headscale 0.26+
- approvedRoutes: string[];
- availableRoutes: string[];
- subnetRoutes: string[];
+ // Added in Headscale 0.26+
+ approvedRoutes: string[];
+ availableRoutes: string[];
+ subnetRoutes: string[];
}
diff --git a/app/utils/node-info.ts b/app/utils/node-info.ts
index 3e54f3c4..f26d096d 100644
--- a/app/utils/node-info.ts
+++ b/app/utils/node-info.ts
@@ -1,65 +1,50 @@
-import { HostInfo, Machine } from '~/types';
+import { HostInfo, Machine } from "~/types";
export interface PopulatedNode extends Machine {
- routes: string[];
- hostInfo?: HostInfo;
- expired: boolean;
- customRouting: {
- exitRoutes: string[];
- exitApproved: boolean;
- subnetApprovedRoutes: string[];
- subnetWaitingRoutes: string[];
- };
+ routes: string[];
+ hostInfo?: HostInfo;
+ expired: boolean;
+ customRouting: {
+ exitRoutes: string[];
+ exitApproved: boolean;
+ subnetApprovedRoutes: string[];
+ subnetWaitingRoutes: string[];
+ };
}
export function mapNodes(
- nodes: Machine[],
- stats?: Record | undefined,
+ nodes: Machine[],
+ stats?: Record | undefined,
): PopulatedNode[] {
- return nodes.map((node) => {
- const customRouting = {
- exitRoutes: node.availableRoutes.filter(
- (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(
- (route) =>
- route !== '::/0' &&
- route !== '0.0.0.0/0' &&
- node.availableRoutes.includes(route),
- ),
- subnetWaitingRoutes: node.availableRoutes.filter(
- (route) =>
- route !== '::/0' &&
- route !== '0.0.0.0/0' &&
- !node.approvedRoutes.includes(route),
- ),
- } satisfies PopulatedNode['customRouting'];
+ return nodes.map((node) => {
+ const customRouting = {
+ exitRoutes: node.availableRoutes.filter((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(
+ (route) =>
+ route !== "::/0" && route !== "0.0.0.0/0" && node.availableRoutes.includes(route),
+ ),
+ subnetWaitingRoutes: node.availableRoutes.filter(
+ (route) =>
+ route !== "::/0" && route !== "0.0.0.0/0" && !node.approvedRoutes.includes(route),
+ ),
+ } satisfies PopulatedNode["customRouting"];
- return {
- ...node,
- routes: Array.from(new Set(node.availableRoutes)),
- hostInfo: stats?.[node.nodeKey],
- customRouting,
- expired:
- node.expiry === '0001-01-01 00:00:00' ||
- node.expiry === '0001-01-01T00:00:00Z' ||
- node.expiry === null
- ? false
- : new Date(node.expiry).getTime() < Date.now(),
- };
- });
+ return {
+ ...node,
+ routes: Array.from(new Set(node.availableRoutes)),
+ hostInfo: stats?.[node.nodeKey],
+ customRouting,
+ expired:
+ node.expiry === "0001-01-01 00:00:00" ||
+ node.expiry === "0001-01-01T00:00:00Z" ||
+ node.expiry === null
+ ? false
+ : new Date(node.expiry).getTime() < Date.now(),
+ };
+ });
}
export function sortNodeTags(nodes: Machine[]): string[] {
- return Array.from(
- new Set(
- nodes.flatMap(({ validTags, forcedTags }) => [
- ...validTags,
- ...forcedTags,
- ]),
- ),
- ).sort();
+ return Array.from(new Set(nodes.flatMap((node) => node.tags))).sort();
}
diff --git a/tests/generate-openapi-hashes.ts b/tests/generate-openapi-hashes.ts
index 9a270848..a04c31e1 100644
--- a/tests/generate-openapi-hashes.ts
+++ b/tests/generate-openapi-hashes.ts
@@ -1,98 +1,102 @@
-import { writeFile } from 'node:fs/promises';
-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';
+import type { OpenAPIV2 } from "openapi-types";
-const HASH_FILE_LOCATION = 'app/openapi-operation-hashes.json';
-const CANONICAL_LOCATION = 'app/openapi-canonical-families.json';
+import { writeFile } from "node:fs/promises";
+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 = {
- // '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.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.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.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.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.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.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;
async function hashOpenApiOperations(specUrl: string) {
- const url = `https://raw.githubusercontent.com/juanfont/headscale${specUrl}`;
- const res = await request(url);
- if (res.statusCode !== 200) {
- console.error('Failed to fetch OpenAPI spec:', res.statusCode);
- process.exit(1);
- }
-
- const body = (await res.body.json()) as OpenAPIV2.Document;
- return hashOpenApiDocument(body);
+ const url = `https://raw.githubusercontent.com/juanfont/headscale${specUrl}`;
+ const res = await request(url);
+ if (res.statusCode !== 200) {
+ console.error("Failed to fetch OpenAPI spec:", res.statusCode);
+ process.exit(1);
+ }
+
+ const body = (await res.body.json()) as OpenAPIV2.Document;
+ return hashOpenApiDocument(body);
}
async function collectCanonicalizedFamilies(
- newHashes: readonly (readonly [string, Record])[],
+ newHashes: readonly (readonly [string, Record])[],
) {
- const canonicalizedFamilies: Record = {};
- for (const [version, hashes] of newHashes) {
- const signature = JSON.stringify(hashes);
- let canonical: string | null = null;
-
- for (const existingCanonical of Object.keys(canonicalizedFamilies)) {
- const existingSignature = JSON.stringify(
- newHashes.find(([v]) => v === existingCanonical)![1],
- );
-
- if (existingSignature === signature) {
- canonical = existingCanonical;
- break;
- }
- }
-
- if (!canonical) {
- canonicalizedFamilies[version] = [version];
- continue;
- }
-
- canonicalizedFamilies[canonical].push(version);
- if (
- version.localeCompare(canonical, undefined, {
- numeric: true,
- sensitivity: 'base',
- }) > 0
- ) {
- canonicalizedFamilies[version] = canonicalizedFamilies[canonical];
- delete canonicalizedFamilies[canonical];
- }
- }
-
- for (const [canonical, family] of Object.entries(canonicalizedFamilies)) {
- canonicalizedFamilies[canonical] = family.sort((a, b) =>
- a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }),
- );
- }
-
- return canonicalizedFamilies;
+ const canonicalizedFamilies: Record = {};
+ for (const [version, hashes] of newHashes) {
+ const signature = JSON.stringify(hashes);
+ let canonical: string | null = null;
+
+ for (const existingCanonical of Object.keys(canonicalizedFamilies)) {
+ const existingSignature = JSON.stringify(
+ newHashes.find(([v]) => v === existingCanonical)![1],
+ );
+
+ if (existingSignature === signature) {
+ canonical = existingCanonical;
+ break;
+ }
+ }
+
+ if (!canonical) {
+ canonicalizedFamilies[version] = [version];
+ continue;
+ }
+
+ canonicalizedFamilies[canonical].push(version);
+ if (
+ version.localeCompare(canonical, undefined, {
+ numeric: true,
+ sensitivity: "base",
+ }) > 0
+ ) {
+ canonicalizedFamilies[version] = canonicalizedFamilies[canonical];
+ delete canonicalizedFamilies[canonical];
+ }
+ }
+
+ for (const [canonical, family] of Object.entries(canonicalizedFamilies)) {
+ canonicalizedFamilies[canonical] = family.sort((a, b) =>
+ a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }),
+ );
+ }
+
+ return canonicalizedFamilies;
}
async function writeHashes(hashes: Record>) {
- const path = resolve(cwd(), HASH_FILE_LOCATION);
- await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, 'utf-8');
+ const path = resolve(cwd(), HASH_FILE_LOCATION);
+ await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, "utf-8");
}
async function writeCanonicalizedFamilies(families: Record) {
- const path = resolve(cwd(), CANONICAL_LOCATION);
- await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, 'utf-8');
+ const path = resolve(cwd(), CANONICAL_LOCATION);
+ await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, "utf-8");
}
const newHashes = await Promise.all(
- Object.entries(SPEC_MAP).map(async ([version, specUrl]) => {
- const hashes = await hashOpenApiOperations(specUrl);
- return [version, hashes] as const;
- }),
+ Object.entries(SPEC_MAP).map(async ([version, specUrl]) => {
+ const hashes = await hashOpenApiOperations(specUrl);
+ return [version, hashes] as const;
+ }),
);
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 writeCanonicalizedFamilies(canonicalizedFamilies);