mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
Merge pull request #433 from kronenpj/Correct-Tags-API-Change-#2993
Address tags incompatiblity introduced in 0.28.0-beta.2
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
|
||||
+160
-106
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
|
||||
|
||||
const ipOptions = useMemo(() => {
|
||||
if (magic) {
|
||||
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
||||
}
|
||||
const ipOptions = useMemo(() => {
|
||||
if (magic) {
|
||||
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
||||
}
|
||||
|
||||
return node.ipAddresses;
|
||||
}, [magic, node.ipAddresses]);
|
||||
return node.ipAddresses;
|
||||
}, [magic, node.ipAddresses]);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||
key={node.id}
|
||||
>
|
||||
<td className="pl-0.5 py-2 focus-within:ring-3">
|
||||
<Link
|
||||
className={cn('group/link h-full focus:outline-hidden')}
|
||||
to={`/machines/${node.id}`}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'font-semibold leading-snug',
|
||||
'group-hover/link:text-blue-600',
|
||||
'dark:group-hover/link:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{node.givenName}
|
||||
</p>
|
||||
<p className="text-sm opacity-50">
|
||||
{node.user.name ||
|
||||
node.user.displayName ||
|
||||
node.user.email ||
|
||||
node.user.id}
|
||||
</p>
|
||||
<div className="flex gap-1 flex-wrap mt-1.5">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{node.validTags.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
{node.ipAddresses[0]}
|
||||
<Menu placement="bottom end">
|
||||
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel
|
||||
onAction={async (key) => {
|
||||
await navigator.clipboard.writeText(key.toString());
|
||||
toast('Copied IP address to clipboard');
|
||||
}}
|
||||
>
|
||||
<Menu.Section>
|
||||
{ipOptions.map((ip) => (
|
||||
<Menu.Item key={ip} textValue={ip}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between',
|
||||
'text-sm w-full gap-x-6',
|
||||
)}
|
||||
>
|
||||
{ip}
|
||||
<Copy className="w-3 h-3" />
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
{/* We pass undefined when agents are not enabled */}
|
||||
{isAgent !== undefined ? (
|
||||
<td className="py-2">
|
||||
{node.hostInfo !== undefined ? (
|
||||
<>
|
||||
<p className="leading-snug">
|
||||
{hinfo.getTSVersion(node.hostInfo)}
|
||||
</p>
|
||||
<p className="text-sm opacity-50 max-w-48 truncate">
|
||||
{hinfo.getOSInfo(node.hostInfo)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm opacity-50">Unknown</p>
|
||||
)}
|
||||
</td>
|
||||
) : undefined}
|
||||
<td className="py-2">
|
||||
<div className="flex items-start gap-x-1">
|
||||
<StatusCircle
|
||||
className="w-4 h-4 mt-0.5"
|
||||
isOnline={node.online && !node.expired}
|
||||
/>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-headplane-600 dark:text-headplane-300',
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{node.online && !node.expired
|
||||
? 'Connected'
|
||||
: 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>
|
||||
);
|
||||
return (
|
||||
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={node.id}>
|
||||
<td className="py-2 pl-0.5 focus-within:ring-3">
|
||||
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold leading-snug",
|
||||
"group-hover/link:text-blue-600",
|
||||
"dark:group-hover/link:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{node.givenName}
|
||||
</p>
|
||||
<p className="text-sm opacity-50">
|
||||
{node.user.name || node.user.displayName || node.user.email || node.user.id}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{node.tags?.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
{node.ipAddresses[0]}
|
||||
<Menu placement="bottom end">
|
||||
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel
|
||||
onAction={async (key) => {
|
||||
await navigator.clipboard.writeText(key.toString());
|
||||
toast("Copied IP address to clipboard");
|
||||
}}
|
||||
>
|
||||
<Menu.Section>
|
||||
{ipOptions.map((ip) => (
|
||||
<Menu.Item key={ip} textValue={ip}>
|
||||
<div
|
||||
className={cn("flex items-center justify-between", "text-sm w-full gap-x-6")}
|
||||
>
|
||||
{ip}
|
||||
<Copy className="h-3 w-3" />
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
{/* We pass undefined when agents are not enabled */}
|
||||
{isAgent !== undefined ? (
|
||||
<td className="py-2">
|
||||
{node.hostInfo !== undefined ? (
|
||||
<>
|
||||
<p className="leading-snug">{hinfo.getTSVersion(node.hostInfo)}</p>
|
||||
<p className="max-w-48 truncate text-sm opacity-50">
|
||||
{hinfo.getOSInfo(node.hostInfo)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm opacity-50">Unknown</p>
|
||||
)}
|
||||
</td>
|
||||
) : undefined}
|
||||
<td className="py-2">
|
||||
<div className="flex items-start gap-x-1">
|
||||
<StatusCircle className="mt-0.5 h-4 w-4" isOnline={node.online && !node.expired} />
|
||||
<div>
|
||||
<p
|
||||
className={cn("text-sm", "text-headplane-600 dark:text-headplane-300")}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{node.online && !node.expired
|
||||
? "Connected"
|
||||
: 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) {
|
||||
const uiTags: string[] = [];
|
||||
if (node.expired) {
|
||||
uiTags.push('expired');
|
||||
}
|
||||
const uiTags: string[] = [];
|
||||
if (node.expired) {
|
||||
uiTags.push("expired");
|
||||
}
|
||||
|
||||
if (node.expiry === null) {
|
||||
uiTags.push('no-expiry');
|
||||
}
|
||||
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.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.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 (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
|
||||
uiTags.push("tailscale-ssh");
|
||||
}
|
||||
|
||||
if (isAgent === true) {
|
||||
uiTags.push('headplane-agent');
|
||||
}
|
||||
if (isAgent === true) {
|
||||
uiTags.push("headplane-agent");
|
||||
}
|
||||
|
||||
return uiTags;
|
||||
return uiTags;
|
||||
}
|
||||
|
||||
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
|
||||
return uiTags.map((tag) => {
|
||||
switch (tag) {
|
||||
case 'exit-approved':
|
||||
case 'exit-waiting':
|
||||
return <ExitNodeTag isEnabled={tag === 'exit-approved'} key={tag} />;
|
||||
return uiTags.map((tag) => {
|
||||
switch (tag) {
|
||||
case "exit-approved":
|
||||
case "exit-waiting":
|
||||
return <ExitNodeTag isEnabled={tag === "exit-approved"} key={tag} />;
|
||||
|
||||
case 'subnet-approved':
|
||||
case 'subnet-waiting':
|
||||
return <SubnetTag isEnabled={tag === 'subnet-approved'} key={tag} />;
|
||||
case "subnet-approved":
|
||||
case "subnet-waiting":
|
||||
return <SubnetTag isEnabled={tag === "subnet-approved"} key={tag} />;
|
||||
|
||||
case 'expired':
|
||||
case 'no-expiry':
|
||||
return (
|
||||
<ExpiryTag
|
||||
expiry={node.expiry ?? undefined}
|
||||
key={tag}
|
||||
variant={tag}
|
||||
/>
|
||||
);
|
||||
case "expired":
|
||||
case "no-expiry":
|
||||
return <ExpiryTag expiry={node.expiry ?? undefined} key={tag} variant={tag} />;
|
||||
|
||||
case 'tailscale-ssh':
|
||||
return <TailscaleSSHTag key={tag} />;
|
||||
case "tailscale-ssh":
|
||||
return <TailscaleSSHTag key={tag} />;
|
||||
|
||||
case 'headplane-agent':
|
||||
return <HeadplaneAgentTag key={tag} />;
|
||||
case "headplane-agent":
|
||||
return <HeadplaneAgentTag key={tag} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
ACL tags can be used to reference machines in your ACL policies. See
|
||||
the{' '}
|
||||
<Link
|
||||
name="Tailscale documentation"
|
||||
to="https://tailscale.com/kb/1068/acl-tags"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>{' '}
|
||||
for more information.
|
||||
</Dialog.Text>
|
||||
<input name="action_id" type="hidden" value="update_tags" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<input name="tags" type="hidden" value={tags.join(',')} />
|
||||
<TableList className="mt-4">
|
||||
{tags.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<TagsIcon />
|
||||
<p className="font-semibold">No tags are set on this machine</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
tags.map((item) => (
|
||||
<TableList.Item className="font-mono" id={item} key={item}>
|
||||
{item}
|
||||
<Button
|
||||
className="rounded-md p-0.5"
|
||||
onPress={() => {
|
||||
setTags(tags.filter((tag) => tag !== item));
|
||||
}}
|
||||
>
|
||||
<X className="p-1" />
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))
|
||||
)}
|
||||
</TableList>
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
ACL tags can be used to reference machines in your ACL policies. See the{" "}
|
||||
<Link name="Tailscale documentation" to="https://tailscale.com/kb/1068/acl-tags">
|
||||
Tailscale documentation
|
||||
</Link>{" "}
|
||||
for more information.
|
||||
</Dialog.Text>
|
||||
<input name="action_id" type="hidden" value="update_tags" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<input name="tags" type="hidden" value={tags.join(",")} />
|
||||
<TableList className="mt-4">
|
||||
{tags.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<TagsIcon />
|
||||
<p className="font-semibold">No tags are set on this machine</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
tags.map((item) => (
|
||||
<TableList.Item className="font-mono" id={item} key={item}>
|
||||
{item}
|
||||
<Button
|
||||
className="rounded-md p-0.5"
|
||||
onPress={() => {
|
||||
setTags(tags.filter((tag) => tag !== item));
|
||||
}}
|
||||
>
|
||||
<X className="p-1" />
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))
|
||||
)}
|
||||
</TableList>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Select
|
||||
allowsCustomValue
|
||||
aria-label="Add a tag"
|
||||
className="w-full"
|
||||
inputValue={tag}
|
||||
isInvalid={tag.length > 0 && tagIsInvalid}
|
||||
onInputChange={setTag}
|
||||
placeholder="tag:example"
|
||||
>
|
||||
{validNodeTags.map((nodeTag) => {
|
||||
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
|
||||
})}
|
||||
</Select>
|
||||
<Button
|
||||
className={cn(
|
||||
'rounded-md p-1',
|
||||
tagIsInvalid && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={tagIsInvalid}
|
||||
onPress={() => {
|
||||
setTags([...tags, tag]);
|
||||
setTag('tag:');
|
||||
}}
|
||||
>
|
||||
<Plus className="p-1" size={30} />
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Select
|
||||
allowsCustomValue
|
||||
aria-label="Add a tag"
|
||||
className="w-full"
|
||||
inputValue={tag}
|
||||
isInvalid={tag.length > 0 && tagIsInvalid}
|
||||
onInputChange={setTag}
|
||||
placeholder="tag:example"
|
||||
>
|
||||
{validNodeTags.map((nodeTag) => {
|
||||
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
|
||||
})}
|
||||
</Select>
|
||||
<Button
|
||||
className={cn("rounded-md p-1", tagIsInvalid && "opacity-50 cursor-not-allowed")}
|
||||
isDisabled={tagIsInvalid}
|
||||
onPress={() => {
|
||||
setTags([...tags, tag]);
|
||||
setTag("tag:");
|
||||
}}
|
||||
>
|
||||
<Plus className="p-1" size={30} />
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
+329
-384
@@ -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 (
|
||||
<div>
|
||||
<p className="mb-8 text-md">
|
||||
<RemixLink className="font-medium" to="/machines">
|
||||
All Machines
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span>
|
||||
{node.givenName}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
'flex justify-between items-center pb-2',
|
||||
'border-b border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-baseline gap-x-4 text-sm">
|
||||
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||||
<StatusCircle className="w-4 h-4" isOnline={node.online} />
|
||||
</span>
|
||||
<MenuOptions
|
||||
existingTags={existingTags}
|
||||
isFullButton
|
||||
magic={magic}
|
||||
node={node}
|
||||
users={users}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 mb-4">
|
||||
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
||||
<span className="text-sm text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Managed by
|
||||
<Tooltip>
|
||||
<Info className="p-1" />
|
||||
<Tooltip.Body>
|
||||
By default, a machine’s permissions match its creator’s.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="flex items-center gap-x-2.5 mt-1">
|
||||
<UserCircle />
|
||||
{node.user.name ||
|
||||
node.user.displayName ||
|
||||
node.user.email ||
|
||||
node.user.id}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 pl-4">
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
||||
Status
|
||||
</p>
|
||||
<div className="flex gap-1 mt-1 mb-8">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{tags.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
|
||||
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p>
|
||||
Subnets let you expose physical network routes onto Tailscale.{' '}
|
||||
<Link
|
||||
name="Tailscale Subnets Documentation"
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
<Button onPress={() => setShowRouting(true)}>Review</Button>
|
||||
</div>
|
||||
<Card
|
||||
className={cn(
|
||||
'w-full max-w-full grid sm:grid-cols-2',
|
||||
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
|
||||
)}
|
||||
variant="flat"
|
||||
>
|
||||
<div>
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Approved
|
||||
<Tooltip>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<Tooltip.Body>
|
||||
Traffic to these routes are being routed through this machine.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
)}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Awaiting Approval
|
||||
<Tooltip>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<Tooltip.Body>
|
||||
This machine is advertising these routes, but they must be
|
||||
approved before traffic will be routed to them.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
)}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Exit Node
|
||||
<Tooltip>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<Tooltip.Body>
|
||||
Whether this machine can act as an exit node for your tailnet.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.exitRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : node.customRouting.exitApproved ? (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-700" />
|
||||
Allowed
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CircleSlash className="w-3.5 h-3.5 text-red-700" />
|
||||
Awaiting Approval
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
)}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<h2 className="text-xl font-medium">Machine Details</h2>
|
||||
<p className="mb-4">
|
||||
Information about this machine’s network. Used to debug connection
|
||||
issues.
|
||||
</p>
|
||||
<Card
|
||||
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12"
|
||||
variant="flat"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Attribute
|
||||
name="Creator"
|
||||
value={
|
||||
node.user.name ||
|
||||
node.user.displayName ||
|
||||
node.user.email ||
|
||||
node.user.id
|
||||
}
|
||||
/>
|
||||
<Attribute name="Machine name" value={node.givenName} />
|
||||
<Attribute
|
||||
name="OS hostname"
|
||||
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
||||
value={node.name}
|
||||
/>
|
||||
{stats ? (
|
||||
<>
|
||||
<Attribute name="OS" value={getOSInfo(stats)} />
|
||||
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
|
||||
</>
|
||||
) : undefined}
|
||||
<Attribute
|
||||
name="ID"
|
||||
tooltip="ID for this machine. Used in the Headscale API."
|
||||
value={node.id}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Node key"
|
||||
tooltip="Public key which uniquely identifies this machine."
|
||||
value={node.nodeKey}
|
||||
/>
|
||||
<Attribute
|
||||
name="Created"
|
||||
value={new Date(node.createdAt).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Last Seen"
|
||||
value={
|
||||
node.online
|
||||
? 'Connected'
|
||||
: new Date(node.lastSeen).toLocaleString()
|
||||
}
|
||||
/>
|
||||
<Attribute
|
||||
name="Key expiry"
|
||||
value={
|
||||
node.expiry !== null
|
||||
? new Date(node.expiry).toLocaleString()
|
||||
: 'Never'
|
||||
}
|
||||
/>
|
||||
{magic ? (
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Domain"
|
||||
value={`${node.givenName}.${magic}`}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="uppercase text-sm font-semibold opacity-75">
|
||||
Addresses
|
||||
</p>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Tailscale IPv4"
|
||||
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
||||
value={getIpv4Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Tailscale IPv6"
|
||||
tooltip="This machine’s 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 machine’s 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>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<p className="text-md mb-8">
|
||||
<RemixLink className="font-medium" to="/machines">
|
||||
All Machines
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span>
|
||||
{node.givenName}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between items-center pb-2",
|
||||
"border-b border-headplane-100 dark:border-headplane-800",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-baseline gap-x-4 text-sm">
|
||||
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||||
<StatusCircle className="h-4 w-4" isOnline={node.online} />
|
||||
</span>
|
||||
<MenuOptions
|
||||
existingTags={existingTags}
|
||||
isFullButton
|
||||
magic={magic}
|
||||
node={node}
|
||||
users={users}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex gap-1">
|
||||
<div className="border-headplane-100 dark:border-headplane-800 border-r p-2 pr-4">
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1 text-sm">
|
||||
Managed by
|
||||
<Tooltip>
|
||||
<Info className="p-1" />
|
||||
<Tooltip.Body>By default, a machine’s permissions match its creator’s.</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1 flex items-center gap-x-2.5">
|
||||
<UserCircle />
|
||||
{node.user.name || node.user.displayName || node.user.email || node.user.id}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 pl-4">
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-sm">Status</p>
|
||||
<div className="mt-1 mb-8 flex gap-1">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{tags.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
|
||||
<h2 className="mt-8 text-xl font-medium">Subnets & Routing</h2>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p>
|
||||
Subnets let you expose physical network routes onto Tailscale.{" "}
|
||||
<Link name="Tailscale Subnets Documentation" to="https://tailscale.com/kb/1019/subnets">
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
<Button onPress={() => setShowRouting(true)}>Review</Button>
|
||||
</div>
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full max-w-full grid sm:grid-cols-2",
|
||||
"md:grid-cols-4 gap-8 mr-2 text-sm mb-8",
|
||||
)}
|
||||
variant="flat"
|
||||
>
|
||||
<div>
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Approved
|
||||
<Tooltip>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
<Tooltip.Body>
|
||||
Traffic to these routes are being routed through this machine.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Awaiting Approval
|
||||
<Tooltip>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
<Tooltip.Body>
|
||||
This machine is advertising these routes, but they must be approved before traffic
|
||||
will be routed to them.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Exit Node
|
||||
<Tooltip>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
<Tooltip.Body>
|
||||
Whether this machine can act as an exit node for your tailnet.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.exitRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : node.customRouting.exitApproved ? (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-700" />
|
||||
Allowed
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CircleSlash className="h-3.5 w-3.5 text-red-700" />
|
||||
Awaiting Approval
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<h2 className="text-xl font-medium">Machine Details</h2>
|
||||
<p className="mb-4">
|
||||
Information about this machine’s network. Used to debug connection issues.
|
||||
</p>
|
||||
<Card
|
||||
className="grid w-full max-w-full grid-cols-1 gap-y-2 sm:gap-x-12 lg:grid-cols-2"
|
||||
variant="flat"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Attribute
|
||||
name="Creator"
|
||||
value={node.user.name || node.user.displayName || node.user.email || node.user.id}
|
||||
/>
|
||||
<Attribute name="Machine name" value={node.givenName} />
|
||||
<Attribute
|
||||
name="OS hostname"
|
||||
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
||||
value={node.name}
|
||||
/>
|
||||
{stats ? (
|
||||
<>
|
||||
<Attribute name="OS" value={getOSInfo(stats)} />
|
||||
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
|
||||
</>
|
||||
) : undefined}
|
||||
<Attribute
|
||||
name="ID"
|
||||
tooltip="ID for this machine. Used in the Headscale API."
|
||||
value={node.id}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Node key"
|
||||
tooltip="Public key which uniquely identifies this machine."
|
||||
value={node.nodeKey}
|
||||
/>
|
||||
<Attribute name="Created" value={new Date(node.createdAt).toLocaleString()} />
|
||||
<Attribute
|
||||
name="Last Seen"
|
||||
value={node.online ? "Connected" : new Date(node.lastSeen).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Key expiry"
|
||||
value={node.expiry !== null ? new Date(node.expiry).toLocaleString() : "Never"}
|
||||
/>
|
||||
{magic ? (
|
||||
<Attribute isCopyable name="Domain" value={`${node.givenName}.${magic}`} />
|
||||
) : undefined}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-semibold uppercase opacity-75">Addresses</p>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Tailscale IPv4"
|
||||
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
||||
value={getIpv4Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Tailscale IPv6"
|
||||
tooltip="This machine’s 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="mt-4 text-sm font-semibold uppercase opacity-75">Client Connectivity</p>
|
||||
<Attribute
|
||||
name="Varies"
|
||||
tooltip="Whether the machine is behind a difficult NAT that varies the machine’s 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[]) {
|
||||
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 "—";
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
/**
|
||||
* Retrieves all nodes (machines) from the Headscale instance.
|
||||
*
|
||||
* @returns An array of `Machine` objects representing the nodes.
|
||||
*/
|
||||
getNodes(): Promise<Machine[]>;
|
||||
/**
|
||||
* Retrieves all nodes (machines) from the Headscale instance.
|
||||
*
|
||||
* @returns An array of `Machine` objects representing the nodes.
|
||||
*/
|
||||
getNodes(): Promise<Machine[]>;
|
||||
|
||||
/**
|
||||
* 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<Machine>;
|
||||
/**
|
||||
* 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<Machine>;
|
||||
|
||||
/**
|
||||
* Deletes a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to delete.
|
||||
*/
|
||||
deleteNode(id: string): Promise<void>;
|
||||
/**
|
||||
* Deletes a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to delete.
|
||||
*/
|
||||
deleteNode(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 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<Machine>;
|
||||
/**
|
||||
* 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<Machine>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* Expires a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to expire.
|
||||
*/
|
||||
expireNode(id: string): Promise<void>;
|
||||
/**
|
||||
* Expires a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to expire.
|
||||
*/
|
||||
expireNode(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
|
||||
getNodes: async () => {
|
||||
const { nodes } = await client.apiFetch<{ nodes: Machine[] }>(
|
||||
'GET',
|
||||
'v1/node',
|
||||
apiKey,
|
||||
);
|
||||
getNodes: async () => {
|
||||
const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey);
|
||||
|
||||
return nodes;
|
||||
},
|
||||
return nodes.map((node) => normalizeTags(client, node));
|
||||
},
|
||||
|
||||
getNode: async (nodeId) => {
|
||||
const { node } = await client.apiFetch<{ node: Machine }>(
|
||||
'GET',
|
||||
`v1/node/${nodeId}`,
|
||||
apiKey,
|
||||
);
|
||||
getNode: async (nodeId) => {
|
||||
const { node } = await client.apiFetch<{ node: RawMachine }>(
|
||||
"GET",
|
||||
`v1/node/${nodeId}`,
|
||||
apiKey,
|
||||
);
|
||||
|
||||
return node;
|
||||
},
|
||||
return normalizeTags(client, node);
|
||||
},
|
||||
|
||||
deleteNode: async (nodeId) => {
|
||||
await client.apiFetch<void>('DELETE', `v1/node/${nodeId}`, apiKey);
|
||||
},
|
||||
deleteNode: async (nodeId) => {
|
||||
await client.apiFetch<void>("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,
|
||||
},
|
||||
);
|
||||
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 node;
|
||||
},
|
||||
return normalizeTags(client, node);
|
||||
},
|
||||
|
||||
approveNodeRoutes: async (nodeId, routes) => {
|
||||
await client.apiFetch<void>(
|
||||
'POST',
|
||||
`v1/node/${nodeId}/approve_routes`,
|
||||
apiKey,
|
||||
{ routes },
|
||||
);
|
||||
},
|
||||
approveNodeRoutes: async (nodeId, routes) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/approve_routes`, apiKey, { routes });
|
||||
},
|
||||
|
||||
expireNode: async (nodeId) => {
|
||||
await client.apiFetch<void>('POST', `v1/node/${nodeId}/expire`, apiKey);
|
||||
},
|
||||
expireNode: async (nodeId) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/expire`, apiKey);
|
||||
},
|
||||
|
||||
renameNode: async (nodeId, newName) => {
|
||||
await client.apiFetch<void>(
|
||||
'POST',
|
||||
`v1/node/${nodeId}/rename/${newName}`,
|
||||
apiKey,
|
||||
);
|
||||
},
|
||||
renameNode: async (nodeId, newName) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey);
|
||||
},
|
||||
|
||||
setNodeTags: async (nodeId, tags) => {
|
||||
await client.apiFetch<void>('POST', `v1/node/${nodeId}/tags`, apiKey, {
|
||||
tags,
|
||||
});
|
||||
},
|
||||
setNodeTags: async (nodeId, tags) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/tags`, apiKey, {
|
||||
tags,
|
||||
});
|
||||
},
|
||||
|
||||
setNodeUser: async (nodeId, user) => {
|
||||
await client.apiFetch<void>('POST', `v1/node/${nodeId}/user`, apiKey, {
|
||||
user,
|
||||
});
|
||||
},
|
||||
setNodeUser: async (nodeId, user) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/user`, apiKey, {
|
||||
user,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
+25
-27
@@ -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[];
|
||||
}
|
||||
|
||||
+39
-54
@@ -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<string, HostInfo> | undefined,
|
||||
nodes: Machine[],
|
||||
stats?: Record<string, HostInfo> | 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();
|
||||
}
|
||||
|
||||
@@ -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 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 body = (await res.body.json()) as OpenAPIV2.Document;
|
||||
return hashOpenApiDocument(body);
|
||||
}
|
||||
|
||||
async function collectCanonicalizedFamilies(
|
||||
newHashes: readonly (readonly [string, Record<string, string>])[],
|
||||
newHashes: readonly (readonly [string, Record<string, string>])[],
|
||||
) {
|
||||
const canonicalizedFamilies: Record<string, string[]> = {};
|
||||
for (const [version, hashes] of newHashes) {
|
||||
const signature = JSON.stringify(hashes);
|
||||
let canonical: string | null = null;
|
||||
const canonicalizedFamilies: Record<string, string[]> = {};
|
||||
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],
|
||||
);
|
||||
for (const existingCanonical of Object.keys(canonicalizedFamilies)) {
|
||||
const existingSignature = JSON.stringify(
|
||||
newHashes.find(([v]) => v === existingCanonical)![1],
|
||||
);
|
||||
|
||||
if (existingSignature === signature) {
|
||||
canonical = existingCanonical;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (existingSignature === signature) {
|
||||
canonical = existingCanonical;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!canonical) {
|
||||
canonicalizedFamilies[version] = [version];
|
||||
continue;
|
||||
}
|
||||
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];
|
||||
}
|
||||
}
|
||||
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' }),
|
||||
);
|
||||
}
|
||||
for (const [canonical, family] of Object.entries(canonicalizedFamilies)) {
|
||||
canonicalizedFamilies[canonical] = family.sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
return canonicalizedFamilies;
|
||||
return canonicalizedFamilies;
|
||||
}
|
||||
|
||||
async function writeHashes(hashes: Record<string, Record<string, string>>) {
|
||||
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<string, string[]>) {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user