mirror of
https://github.com/tale/headplane.git
synced 2026-08-27 15:37:04 +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.26.1": ["0.26.0", "0.26.1"],
|
||||||
"0.27.0": ["0.27.0"],
|
"0.27.0": ["0.27.0"],
|
||||||
"0.27.1": ["0.27.1"]
|
"0.27.1": ["0.27.1"],
|
||||||
|
"0.28.0-beta.1": ["0.28.0-beta.1"],
|
||||||
|
"0.28.0-beta.2": ["0.28.0-beta.2"]
|
||||||
}
|
}
|
||||||
|
|||||||
+160
-106
@@ -1,108 +1,162 @@
|
|||||||
{
|
{
|
||||||
"0.26.0": {
|
"0.26.0": {
|
||||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||||
"GET /api/v1/user": "228831b58ccc5a17",
|
"GET /api/v1/user": "228831b58ccc5a17",
|
||||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||||
},
|
},
|
||||||
"0.26.1": {
|
"0.26.1": {
|
||||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||||
"GET /api/v1/user": "228831b58ccc5a17",
|
"GET /api/v1/user": "228831b58ccc5a17",
|
||||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||||
},
|
},
|
||||||
"0.27.0": {
|
"0.27.0": {
|
||||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||||
"GET /api/v1/user": "228831b58ccc5a17",
|
"GET /api/v1/user": "228831b58ccc5a17",
|
||||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||||
},
|
},
|
||||||
"0.27.1": {
|
"0.27.1": {
|
||||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||||
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||||
"GET /api/v1/user": "228831b58ccc5a17",
|
"GET /api/v1/user": "228831b58ccc5a17",
|
||||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||||
}
|
},
|
||||||
|
"0.28.0-beta.1": {
|
||||||
|
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||||
|
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||||
|
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||||
|
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||||
|
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||||
|
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||||
|
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||||
|
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||||
|
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||||
|
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||||
|
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||||
|
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||||
|
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||||
|
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||||
|
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||||
|
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||||
|
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||||
|
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||||
|
"DELETE /api/v1/preauthkey": "fa2975a185782e5d",
|
||||||
|
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||||
|
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||||
|
"GET /api/v1/user": "228831b58ccc5a17",
|
||||||
|
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||||
|
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||||
|
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||||
|
},
|
||||||
|
"0.28.0-beta.2": {
|
||||||
|
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||||
|
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||||
|
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||||
|
"DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2",
|
||||||
|
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||||
|
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||||
|
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||||
|
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||||
|
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||||
|
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||||
|
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||||
|
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||||
|
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||||
|
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||||
|
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||||
|
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||||
|
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||||
|
"GET /api/v1/preauthkey": "8428b44e3a821e9e",
|
||||||
|
"DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09",
|
||||||
|
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||||
|
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||||
|
"GET /api/v1/user": "228831b58ccc5a17",
|
||||||
|
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||||
|
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||||
|
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,234 +1,211 @@
|
|||||||
import { ChevronDown, Copy } from 'lucide-react';
|
import { ChevronDown, Copy } from "lucide-react";
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { Link } from 'react-router';
|
import { Link } from "react-router";
|
||||||
import Chip from '~/components/Chip';
|
|
||||||
import Menu from '~/components/Menu';
|
import type { User } from "~/types";
|
||||||
import StatusCircle from '~/components/StatusCircle';
|
|
||||||
import { ExitNodeTag } from '~/components/tags/ExitNode';
|
import Chip from "~/components/Chip";
|
||||||
import { ExpiryTag } from '~/components/tags/Expiry';
|
import Menu from "~/components/Menu";
|
||||||
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
|
import StatusCircle from "~/components/StatusCircle";
|
||||||
import { SubnetTag } from '~/components/tags/Subnet';
|
import { ExitNodeTag } from "~/components/tags/ExitNode";
|
||||||
import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH';
|
import { ExpiryTag } from "~/components/tags/Expiry";
|
||||||
import type { User } from '~/types';
|
import { HeadplaneAgentTag } from "~/components/tags/HeadplaneAgent";
|
||||||
import cn from '~/utils/cn';
|
import { SubnetTag } from "~/components/tags/Subnet";
|
||||||
import * as hinfo from '~/utils/host-info';
|
import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
|
||||||
import { PopulatedNode } from '~/utils/node-info';
|
import cn from "~/utils/cn";
|
||||||
import { formatTimeDelta } from '~/utils/time';
|
import * as hinfo from "~/utils/host-info";
|
||||||
import toast from '~/utils/toast';
|
import { PopulatedNode } from "~/utils/node-info";
|
||||||
import MenuOptions from './menu';
|
import { formatTimeDelta } from "~/utils/time";
|
||||||
|
import toast from "~/utils/toast";
|
||||||
|
|
||||||
|
import MenuOptions from "./menu";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
node: PopulatedNode;
|
node: PopulatedNode;
|
||||||
users: User[];
|
users: User[];
|
||||||
isAgent?: boolean;
|
isAgent?: boolean;
|
||||||
magic?: string;
|
magic?: string;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
existingTags?: string[];
|
existingTags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MachineRow({
|
export default function MachineRow({
|
||||||
node,
|
node,
|
||||||
users,
|
users,
|
||||||
isAgent,
|
isAgent,
|
||||||
magic,
|
magic,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
existingTags,
|
existingTags,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
|
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
|
||||||
|
|
||||||
const ipOptions = useMemo(() => {
|
const ipOptions = useMemo(() => {
|
||||||
if (magic) {
|
if (magic) {
|
||||||
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
return node.ipAddresses;
|
return node.ipAddresses;
|
||||||
}, [magic, node.ipAddresses]);
|
}, [magic, node.ipAddresses]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={node.id}>
|
||||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
<td className="py-2 pl-0.5 focus-within:ring-3">
|
||||||
key={node.id}
|
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
|
||||||
>
|
<p
|
||||||
<td className="pl-0.5 py-2 focus-within:ring-3">
|
className={cn(
|
||||||
<Link
|
"font-semibold leading-snug",
|
||||||
className={cn('group/link h-full focus:outline-hidden')}
|
"group-hover/link:text-blue-600",
|
||||||
to={`/machines/${node.id}`}
|
"dark:group-hover/link:text-blue-400",
|
||||||
>
|
)}
|
||||||
<p
|
>
|
||||||
className={cn(
|
{node.givenName}
|
||||||
'font-semibold leading-snug',
|
</p>
|
||||||
'group-hover/link:text-blue-600',
|
<p className="text-sm opacity-50">
|
||||||
'dark:group-hover/link:text-blue-400',
|
{node.user.name || node.user.displayName || node.user.email || node.user.id}
|
||||||
)}
|
</p>
|
||||||
>
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{node.givenName}
|
{mapTagsToComponents(node, uiTags)}
|
||||||
</p>
|
{node.tags?.map((tag) => (
|
||||||
<p className="text-sm opacity-50">
|
<Chip key={tag} text={tag} />
|
||||||
{node.user.name ||
|
))}
|
||||||
node.user.displayName ||
|
</div>
|
||||||
node.user.email ||
|
</Link>
|
||||||
node.user.id}
|
</td>
|
||||||
</p>
|
<td className="py-2">
|
||||||
<div className="flex gap-1 flex-wrap mt-1.5">
|
<div className="flex items-center gap-x-1">
|
||||||
{mapTagsToComponents(node, uiTags)}
|
{node.ipAddresses[0]}
|
||||||
{node.validTags.map((tag) => (
|
<Menu placement="bottom end">
|
||||||
<Chip key={tag} text={tag} />
|
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
||||||
))}
|
<ChevronDown className="h-4 w-4" />
|
||||||
</div>
|
</Menu.IconButton>
|
||||||
</Link>
|
<Menu.Panel
|
||||||
</td>
|
onAction={async (key) => {
|
||||||
<td className="py-2">
|
await navigator.clipboard.writeText(key.toString());
|
||||||
<div className="flex items-center gap-x-1">
|
toast("Copied IP address to clipboard");
|
||||||
{node.ipAddresses[0]}
|
}}
|
||||||
<Menu placement="bottom end">
|
>
|
||||||
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
<Menu.Section>
|
||||||
<ChevronDown className="w-4 h-4" />
|
{ipOptions.map((ip) => (
|
||||||
</Menu.IconButton>
|
<Menu.Item key={ip} textValue={ip}>
|
||||||
<Menu.Panel
|
<div
|
||||||
onAction={async (key) => {
|
className={cn("flex items-center justify-between", "text-sm w-full gap-x-6")}
|
||||||
await navigator.clipboard.writeText(key.toString());
|
>
|
||||||
toast('Copied IP address to clipboard');
|
{ip}
|
||||||
}}
|
<Copy className="h-3 w-3" />
|
||||||
>
|
</div>
|
||||||
<Menu.Section>
|
</Menu.Item>
|
||||||
{ipOptions.map((ip) => (
|
))}
|
||||||
<Menu.Item key={ip} textValue={ip}>
|
</Menu.Section>
|
||||||
<div
|
</Menu.Panel>
|
||||||
className={cn(
|
</Menu>
|
||||||
'flex items-center justify-between',
|
</div>
|
||||||
'text-sm w-full gap-x-6',
|
</td>
|
||||||
)}
|
{/* We pass undefined when agents are not enabled */}
|
||||||
>
|
{isAgent !== undefined ? (
|
||||||
{ip}
|
<td className="py-2">
|
||||||
<Copy className="w-3 h-3" />
|
{node.hostInfo !== undefined ? (
|
||||||
</div>
|
<>
|
||||||
</Menu.Item>
|
<p className="leading-snug">{hinfo.getTSVersion(node.hostInfo)}</p>
|
||||||
))}
|
<p className="max-w-48 truncate text-sm opacity-50">
|
||||||
</Menu.Section>
|
{hinfo.getOSInfo(node.hostInfo)}
|
||||||
</Menu.Panel>
|
</p>
|
||||||
</Menu>
|
</>
|
||||||
</div>
|
) : (
|
||||||
</td>
|
<p className="text-sm opacity-50">Unknown</p>
|
||||||
{/* We pass undefined when agents are not enabled */}
|
)}
|
||||||
{isAgent !== undefined ? (
|
</td>
|
||||||
<td className="py-2">
|
) : undefined}
|
||||||
{node.hostInfo !== undefined ? (
|
<td className="py-2">
|
||||||
<>
|
<div className="flex items-start gap-x-1">
|
||||||
<p className="leading-snug">
|
<StatusCircle className="mt-0.5 h-4 w-4" isOnline={node.online && !node.expired} />
|
||||||
{hinfo.getTSVersion(node.hostInfo)}
|
<div>
|
||||||
</p>
|
<p
|
||||||
<p className="text-sm opacity-50 max-w-48 truncate">
|
className={cn("text-sm", "text-headplane-600 dark:text-headplane-300")}
|
||||||
{hinfo.getOSInfo(node.hostInfo)}
|
suppressHydrationWarning
|
||||||
</p>
|
>
|
||||||
</>
|
{node.online && !node.expired
|
||||||
) : (
|
? "Connected"
|
||||||
<p className="text-sm opacity-50">Unknown</p>
|
: new Date(node.lastSeen).toLocaleString()}
|
||||||
)}
|
</p>
|
||||||
</td>
|
{!(node.online && !node.expired) && (
|
||||||
) : undefined}
|
<p className="text-xs opacity-50" suppressHydrationWarning>
|
||||||
<td className="py-2">
|
{formatTimeDelta(new Date(node.lastSeen))}
|
||||||
<div className="flex items-start gap-x-1">
|
</p>
|
||||||
<StatusCircle
|
)}
|
||||||
className="w-4 h-4 mt-0.5"
|
</div>
|
||||||
isOnline={node.online && !node.expired}
|
</div>
|
||||||
/>
|
</td>
|
||||||
<div>
|
<td className="py-2 pr-0.5">
|
||||||
<p
|
<MenuOptions
|
||||||
className={cn(
|
existingTags={existingTags}
|
||||||
'text-sm',
|
isDisabled={isDisabled}
|
||||||
'text-headplane-600 dark:text-headplane-300',
|
magic={magic}
|
||||||
)}
|
node={node}
|
||||||
suppressHydrationWarning
|
users={users}
|
||||||
>
|
/>
|
||||||
{node.online && !node.expired
|
</td>
|
||||||
? 'Connected'
|
</tr>
|
||||||
: new Date(node.lastSeen).toLocaleString()}
|
);
|
||||||
</p>
|
|
||||||
{!(node.online && !node.expired) && (
|
|
||||||
<p className="text-xs opacity-50" suppressHydrationWarning>
|
|
||||||
{formatTimeDelta(new Date(node.lastSeen))}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-0.5">
|
|
||||||
<MenuOptions
|
|
||||||
existingTags={existingTags}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
magic={magic}
|
|
||||||
node={node}
|
|
||||||
users={users}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
|
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
|
||||||
const uiTags: string[] = [];
|
const uiTags: string[] = [];
|
||||||
if (node.expired) {
|
if (node.expired) {
|
||||||
uiTags.push('expired');
|
uiTags.push("expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.expiry === null) {
|
if (node.expiry === null) {
|
||||||
uiTags.push('no-expiry');
|
uiTags.push("no-expiry");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.customRouting.exitRoutes.length > 0) {
|
if (node.customRouting.exitRoutes.length > 0) {
|
||||||
if (node.customRouting.exitApproved) {
|
if (node.customRouting.exitApproved) {
|
||||||
uiTags.push('exit-approved');
|
uiTags.push("exit-approved");
|
||||||
} else {
|
} else {
|
||||||
uiTags.push('exit-waiting');
|
uiTags.push("exit-waiting");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.customRouting.subnetWaitingRoutes.length > 0) {
|
if (node.customRouting.subnetWaitingRoutes.length > 0) {
|
||||||
uiTags.push('subnet-waiting');
|
uiTags.push("subnet-waiting");
|
||||||
} else if (node.customRouting.subnetApprovedRoutes.length > 0) {
|
} else if (node.customRouting.subnetApprovedRoutes.length > 0) {
|
||||||
uiTags.push('subnet-approved');
|
uiTags.push("subnet-approved");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
|
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
|
||||||
uiTags.push('tailscale-ssh');
|
uiTags.push("tailscale-ssh");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAgent === true) {
|
if (isAgent === true) {
|
||||||
uiTags.push('headplane-agent');
|
uiTags.push("headplane-agent");
|
||||||
}
|
}
|
||||||
|
|
||||||
return uiTags;
|
return uiTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
|
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
|
||||||
return uiTags.map((tag) => {
|
return uiTags.map((tag) => {
|
||||||
switch (tag) {
|
switch (tag) {
|
||||||
case 'exit-approved':
|
case "exit-approved":
|
||||||
case 'exit-waiting':
|
case "exit-waiting":
|
||||||
return <ExitNodeTag isEnabled={tag === 'exit-approved'} key={tag} />;
|
return <ExitNodeTag isEnabled={tag === "exit-approved"} key={tag} />;
|
||||||
|
|
||||||
case 'subnet-approved':
|
case "subnet-approved":
|
||||||
case 'subnet-waiting':
|
case "subnet-waiting":
|
||||||
return <SubnetTag isEnabled={tag === 'subnet-approved'} key={tag} />;
|
return <SubnetTag isEnabled={tag === "subnet-approved"} key={tag} />;
|
||||||
|
|
||||||
case 'expired':
|
case "expired":
|
||||||
case 'no-expiry':
|
case "no-expiry":
|
||||||
return (
|
return <ExpiryTag expiry={node.expiry ?? undefined} key={tag} variant={tag} />;
|
||||||
<ExpiryTag
|
|
||||||
expiry={node.expiry ?? undefined}
|
|
||||||
key={tag}
|
|
||||||
variant={tag}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'tailscale-ssh':
|
case "tailscale-ssh":
|
||||||
return <TailscaleSSHTag key={tag} />;
|
return <TailscaleSSHTag key={tag} />;
|
||||||
|
|
||||||
case 'headplane-agent':
|
case "headplane-agent":
|
||||||
return <HeadplaneAgentTag key={tag} />;
|
return <HeadplaneAgentTag key={tag} />;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +1,96 @@
|
|||||||
import { Plus, TagsIcon, X } from 'lucide-react';
|
import { Plus, TagsIcon, X } from "lucide-react";
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from "react";
|
||||||
import Button from '~/components/Button';
|
|
||||||
import Dialog from '~/components/Dialog';
|
import type { Machine } from "~/types";
|
||||||
import Link from '~/components/Link';
|
|
||||||
import Select from '~/components/Select';
|
import Button from "~/components/Button";
|
||||||
import TableList from '~/components/TableList';
|
import Dialog from "~/components/Dialog";
|
||||||
import type { Machine } from '~/types';
|
import Link from "~/components/Link";
|
||||||
import cn from '~/utils/cn';
|
import Select from "~/components/Select";
|
||||||
|
import TableList from "~/components/TableList";
|
||||||
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
interface TagsProps {
|
interface TagsProps {
|
||||||
machine: Machine;
|
machine: Machine;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
existingTags?: string[];
|
existingTags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tags({
|
export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsProps) {
|
||||||
machine,
|
const [tags, setTags] = useState([...machine.tags]);
|
||||||
isOpen,
|
const [tag, setTag] = useState("tag:");
|
||||||
setIsOpen,
|
const tagIsInvalid = useMemo(() => {
|
||||||
existingTags,
|
return tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag);
|
||||||
}: TagsProps) {
|
}, [tag, tags]);
|
||||||
const [tags, setTags] = useState(machine.forcedTags);
|
|
||||||
const [tag, setTag] = useState('tag:');
|
|
||||||
const tagIsInvalid = useMemo(() => {
|
|
||||||
return tag.length === 0 || !tag.startsWith('tag:') || tags.includes(tag);
|
|
||||||
}, [tag, tags]);
|
|
||||||
|
|
||||||
const validNodeTags = useMemo(() => {
|
const validNodeTags = useMemo(() => {
|
||||||
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
|
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
|
||||||
}, [tags]);
|
}, [tags]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel>
|
<Dialog.Panel>
|
||||||
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
ACL tags can be used to reference machines in your ACL policies. See
|
ACL tags can be used to reference machines in your ACL policies. See the{" "}
|
||||||
the{' '}
|
<Link name="Tailscale documentation" to="https://tailscale.com/kb/1068/acl-tags">
|
||||||
<Link
|
Tailscale documentation
|
||||||
name="Tailscale documentation"
|
</Link>{" "}
|
||||||
to="https://tailscale.com/kb/1068/acl-tags"
|
for more information.
|
||||||
>
|
</Dialog.Text>
|
||||||
Tailscale documentation
|
<input name="action_id" type="hidden" value="update_tags" />
|
||||||
</Link>{' '}
|
<input name="node_id" type="hidden" value={machine.id} />
|
||||||
for more information.
|
<input name="tags" type="hidden" value={tags.join(",")} />
|
||||||
</Dialog.Text>
|
<TableList className="mt-4">
|
||||||
<input name="action_id" type="hidden" value="update_tags" />
|
{tags.length === 0 ? (
|
||||||
<input name="node_id" type="hidden" value={machine.id} />
|
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||||
<input name="tags" type="hidden" value={tags.join(',')} />
|
<TagsIcon />
|
||||||
<TableList className="mt-4">
|
<p className="font-semibold">No tags are set on this machine</p>
|
||||||
{tags.length === 0 ? (
|
</TableList.Item>
|
||||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
) : (
|
||||||
<TagsIcon />
|
tags.map((item) => (
|
||||||
<p className="font-semibold">No tags are set on this machine</p>
|
<TableList.Item className="font-mono" id={item} key={item}>
|
||||||
</TableList.Item>
|
{item}
|
||||||
) : (
|
<Button
|
||||||
tags.map((item) => (
|
className="rounded-md p-0.5"
|
||||||
<TableList.Item className="font-mono" id={item} key={item}>
|
onPress={() => {
|
||||||
{item}
|
setTags(tags.filter((tag) => tag !== item));
|
||||||
<Button
|
}}
|
||||||
className="rounded-md p-0.5"
|
>
|
||||||
onPress={() => {
|
<X className="p-1" />
|
||||||
setTags(tags.filter((tag) => tag !== item));
|
</Button>
|
||||||
}}
|
</TableList.Item>
|
||||||
>
|
))
|
||||||
<X className="p-1" />
|
)}
|
||||||
</Button>
|
</TableList>
|
||||||
</TableList.Item>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableList>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
allowsCustomValue
|
allowsCustomValue
|
||||||
aria-label="Add a tag"
|
aria-label="Add a tag"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
inputValue={tag}
|
inputValue={tag}
|
||||||
isInvalid={tag.length > 0 && tagIsInvalid}
|
isInvalid={tag.length > 0 && tagIsInvalid}
|
||||||
onInputChange={setTag}
|
onInputChange={setTag}
|
||||||
placeholder="tag:example"
|
placeholder="tag:example"
|
||||||
>
|
>
|
||||||
{validNodeTags.map((nodeTag) => {
|
{validNodeTags.map((nodeTag) => {
|
||||||
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
|
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
|
||||||
})}
|
})}
|
||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn("rounded-md p-1", tagIsInvalid && "opacity-50 cursor-not-allowed")}
|
||||||
'rounded-md p-1',
|
isDisabled={tagIsInvalid}
|
||||||
tagIsInvalid && 'opacity-50 cursor-not-allowed',
|
onPress={() => {
|
||||||
)}
|
setTags([...tags, tag]);
|
||||||
isDisabled={tagIsInvalid}
|
setTag("tag:");
|
||||||
onPress={() => {
|
}}
|
||||||
setTags([...tags, tag]);
|
>
|
||||||
setTag('tag:');
|
<Plus className="p-1" size={30} />
|
||||||
}}
|
</Button>
|
||||||
>
|
</div>
|
||||||
<Plus className="p-1" size={30} />
|
</Dialog.Panel>
|
||||||
</Button>
|
</Dialog>
|
||||||
</div>
|
);
|
||||||
</Dialog.Panel>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+329
-384
@@ -1,408 +1,353 @@
|
|||||||
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
|
import { CheckCircle, CircleSlash, Info, UserCircle } from "lucide-react";
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from "react";
|
||||||
import { data, Link as RemixLink } from 'react-router';
|
import { data, Link as RemixLink } from "react-router";
|
||||||
import Attribute from '~/components/Attribute';
|
|
||||||
import Button from '~/components/Button';
|
import Attribute from "~/components/Attribute";
|
||||||
import Card from '~/components/Card';
|
import Button from "~/components/Button";
|
||||||
import Chip from '~/components/Chip';
|
import Card from "~/components/Card";
|
||||||
import Link from '~/components/Link';
|
import Chip from "~/components/Chip";
|
||||||
import StatusCircle from '~/components/StatusCircle';
|
import Link from "~/components/Link";
|
||||||
import Tooltip from '~/components/Tooltip';
|
import StatusCircle from "~/components/StatusCircle";
|
||||||
import cn from '~/utils/cn';
|
import Tooltip from "~/components/Tooltip";
|
||||||
import { getOSInfo, getTSVersion } from '~/utils/host-info';
|
import cn from "~/utils/cn";
|
||||||
import { mapNodes, sortNodeTags } from '~/utils/node-info';
|
import { getOSInfo, getTSVersion } from "~/utils/host-info";
|
||||||
import type { Route } from './+types/machine';
|
import { mapNodes, sortNodeTags } from "~/utils/node-info";
|
||||||
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
|
|
||||||
import MenuOptions from './components/menu';
|
import type { Route } from "./+types/machine";
|
||||||
import Routes from './dialogs/routes';
|
|
||||||
import { machineAction } from './machine-actions';
|
import { mapTagsToComponents, uiTagsForNode } from "./components/machine-row";
|
||||||
|
import MenuOptions from "./components/menu";
|
||||||
|
import Routes from "./dialogs/routes";
|
||||||
|
import { machineAction } from "./machine-actions";
|
||||||
|
|
||||||
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||||
const session = await context.sessions.auth(request);
|
const session = await context.sessions.auth(request);
|
||||||
if (!params.id) {
|
if (!params.id) {
|
||||||
throw new Error('No machine ID provided');
|
throw new Error("No machine ID provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.id.endsWith('.ico')) {
|
if (params.id.endsWith(".ico")) {
|
||||||
throw data(null, { status: 204 });
|
throw data(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let magic: string | undefined;
|
let magic: string | undefined;
|
||||||
if (context.hs.readable()) {
|
if (context.hs.readable()) {
|
||||||
if (context.hs.c?.dns.magic_dns) {
|
if (context.hs.c?.dns.magic_dns) {
|
||||||
magic = context.hs.c.dns.base_domain;
|
magic = context.hs.c.dns.base_domain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||||
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
|
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||||
const node = nodes.find((node) => node.id === params.id);
|
const node = nodes.find((node) => node.id === params.id);
|
||||||
|
|
||||||
const lookup = await context.agents?.lookup([node.nodeKey]);
|
const lookup = await context.agents?.lookup([node.nodeKey]);
|
||||||
const [enhancedNode] = mapNodes([node], lookup);
|
const [enhancedNode] = mapNodes([node], lookup);
|
||||||
const tags = Array.from(
|
const tags = [...node.tags].sort();
|
||||||
new Set([...node.validTags, ...node.forcedTags]),
|
|
||||||
).sort();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
node: enhancedNode,
|
node: enhancedNode,
|
||||||
tags,
|
tags,
|
||||||
users,
|
users,
|
||||||
magic,
|
magic,
|
||||||
agent: context.agents?.agentID(),
|
agent: context.agents?.agentID(),
|
||||||
stats: lookup?.[enhancedNode.nodeKey],
|
stats: lookup?.[enhancedNode.nodeKey],
|
||||||
existingTags: sortNodeTags(nodes),
|
existingTags: sortNodeTags(nodes),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const action = machineAction;
|
export const action = machineAction;
|
||||||
|
|
||||||
export default function Page({
|
export default function Page({
|
||||||
loaderData: { node, tags, users, magic, agent, stats, existingTags },
|
loaderData: { node, tags, users, magic, agent, stats, existingTags },
|
||||||
}: Route.ComponentProps) {
|
}: Route.ComponentProps) {
|
||||||
const [showRouting, setShowRouting] = useState(false);
|
const [showRouting, setShowRouting] = useState(false);
|
||||||
|
|
||||||
const uiTags = useMemo(() => {
|
const uiTags = useMemo(() => {
|
||||||
const tags = uiTagsForNode(node, agent === node.nodeKey);
|
const tags = uiTagsForNode(node, agent === node.nodeKey);
|
||||||
return tags;
|
return tags;
|
||||||
}, [node, agent]);
|
}, [node, agent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-8 text-md">
|
<p className="text-md mb-8">
|
||||||
<RemixLink className="font-medium" to="/machines">
|
<RemixLink className="font-medium" to="/machines">
|
||||||
All Machines
|
All Machines
|
||||||
</RemixLink>
|
</RemixLink>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
{node.givenName}
|
{node.givenName}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex justify-between items-center pb-2',
|
"flex justify-between items-center pb-2",
|
||||||
'border-b border-headplane-100 dark:border-headplane-800',
|
"border-b border-headplane-100 dark:border-headplane-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-baseline gap-x-4 text-sm">
|
<span className="flex items-baseline gap-x-4 text-sm">
|
||||||
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||||||
<StatusCircle className="w-4 h-4" isOnline={node.online} />
|
<StatusCircle className="h-4 w-4" isOnline={node.online} />
|
||||||
</span>
|
</span>
|
||||||
<MenuOptions
|
<MenuOptions
|
||||||
existingTags={existingTags}
|
existingTags={existingTags}
|
||||||
isFullButton
|
isFullButton
|
||||||
magic={magic}
|
magic={magic}
|
||||||
node={node}
|
node={node}
|
||||||
users={users}
|
users={users}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 mb-4">
|
<div className="mb-4 flex gap-1">
|
||||||
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
<div className="border-headplane-100 dark:border-headplane-800 border-r p-2 pr-4">
|
||||||
<span className="text-sm text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1 text-sm">
|
||||||
Managed by
|
Managed by
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Info className="p-1" />
|
<Info className="p-1" />
|
||||||
<Tooltip.Body>
|
<Tooltip.Body>By default, a machine’s permissions match its creator’s.</Tooltip.Body>
|
||||||
By default, a machine’s permissions match its creator’s.
|
</Tooltip>
|
||||||
</Tooltip.Body>
|
</span>
|
||||||
</Tooltip>
|
<div className="mt-1 flex items-center gap-x-2.5">
|
||||||
</span>
|
<UserCircle />
|
||||||
<div className="flex items-center gap-x-2.5 mt-1">
|
{node.user.name || node.user.displayName || node.user.email || node.user.id}
|
||||||
<UserCircle />
|
</div>
|
||||||
{node.user.name ||
|
</div>
|
||||||
node.user.displayName ||
|
<div className="p-2 pl-4">
|
||||||
node.user.email ||
|
<p className="text-headplane-600 dark:text-headplane-300 text-sm">Status</p>
|
||||||
node.user.id}
|
<div className="mt-1 mb-8 flex gap-1">
|
||||||
</div>
|
{mapTagsToComponents(node, uiTags)}
|
||||||
</div>
|
{tags.map((tag) => (
|
||||||
<div className="p-2 pl-4">
|
<Chip key={tag} text={tag} />
|
||||||
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
))}
|
||||||
Status
|
</div>
|
||||||
</p>
|
</div>
|
||||||
<div className="flex gap-1 mt-1 mb-8">
|
</div>
|
||||||
{mapTagsToComponents(node, uiTags)}
|
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
|
||||||
{tags.map((tag) => (
|
<h2 className="mt-8 text-xl font-medium">Subnets & Routing</h2>
|
||||||
<Chip key={tag} text={tag} />
|
<div className="mb-4 flex items-center justify-between">
|
||||||
))}
|
<p>
|
||||||
</div>
|
Subnets let you expose physical network routes onto Tailscale.{" "}
|
||||||
</div>
|
<Link name="Tailscale Subnets Documentation" to="https://tailscale.com/kb/1019/subnets">
|
||||||
</div>
|
Learn More
|
||||||
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
|
</Link>
|
||||||
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
|
</p>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<Button onPress={() => setShowRouting(true)}>Review</Button>
|
||||||
<p>
|
</div>
|
||||||
Subnets let you expose physical network routes onto Tailscale.{' '}
|
<Card
|
||||||
<Link
|
className={cn(
|
||||||
name="Tailscale Subnets Documentation"
|
"w-full max-w-full grid sm:grid-cols-2",
|
||||||
to="https://tailscale.com/kb/1019/subnets"
|
"md:grid-cols-4 gap-8 mr-2 text-sm mb-8",
|
||||||
>
|
)}
|
||||||
Learn More
|
variant="flat"
|
||||||
</Link>
|
>
|
||||||
</p>
|
<div>
|
||||||
<Button onPress={() => setShowRouting(true)}>Review</Button>
|
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||||
</div>
|
Approved
|
||||||
<Card
|
<Tooltip>
|
||||||
className={cn(
|
<Info className="h-3.5 w-3.5" />
|
||||||
'w-full max-w-full grid sm:grid-cols-2',
|
<Tooltip.Body>
|
||||||
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
|
Traffic to these routes are being routed through this machine.
|
||||||
)}
|
</Tooltip.Body>
|
||||||
variant="flat"
|
</Tooltip>
|
||||||
>
|
</span>
|
||||||
<div>
|
<div className="mt-1">
|
||||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
|
||||||
Approved
|
<span className="opacity-50">—</span>
|
||||||
<Tooltip>
|
) : (
|
||||||
<Info className="w-3.5 h-3.5" />
|
<ul className="leading-normal">
|
||||||
<Tooltip.Body>
|
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
||||||
Traffic to these routes are being routed through this machine.
|
<li key={route}>{route}</li>
|
||||||
</Tooltip.Body>
|
))}
|
||||||
</Tooltip>
|
</ul>
|
||||||
</span>
|
)}
|
||||||
<div className="mt-1">
|
</div>
|
||||||
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
|
<Button
|
||||||
<span className="opacity-50">—</span>
|
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
|
||||||
) : (
|
onPress={() => setShowRouting(true)}
|
||||||
<ul className="leading-normal">
|
>
|
||||||
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
Edit
|
||||||
<li key={route}>{route}</li>
|
</Button>
|
||||||
))}
|
</div>
|
||||||
</ul>
|
<div>
|
||||||
)}
|
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||||
</div>
|
Awaiting Approval
|
||||||
<Button
|
<Tooltip>
|
||||||
className={cn(
|
<Info className="h-3.5 w-3.5" />
|
||||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
<Tooltip.Body>
|
||||||
'text-blue-500 dark:text-blue-400',
|
This machine is advertising these routes, but they must be approved before traffic
|
||||||
)}
|
will be routed to them.
|
||||||
onPress={() => setShowRouting(true)}
|
</Tooltip.Body>
|
||||||
>
|
</Tooltip>
|
||||||
Edit
|
</span>
|
||||||
</Button>
|
<div className="mt-1">
|
||||||
</div>
|
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
|
||||||
<div>
|
<span className="opacity-50">—</span>
|
||||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
) : (
|
||||||
Awaiting Approval
|
<ul className="leading-normal">
|
||||||
<Tooltip>
|
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
||||||
<Info className="w-3.5 h-3.5" />
|
<li key={route}>{route}</li>
|
||||||
<Tooltip.Body>
|
))}
|
||||||
This machine is advertising these routes, but they must be
|
</ul>
|
||||||
approved before traffic will be routed to them.
|
)}
|
||||||
</Tooltip.Body>
|
</div>
|
||||||
</Tooltip>
|
<Button
|
||||||
</span>
|
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
|
||||||
<div className="mt-1">
|
onPress={() => setShowRouting(true)}
|
||||||
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
|
>
|
||||||
<span className="opacity-50">—</span>
|
Edit
|
||||||
) : (
|
</Button>
|
||||||
<ul className="leading-normal">
|
</div>
|
||||||
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
<div>
|
||||||
<li key={route}>{route}</li>
|
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||||
))}
|
Exit Node
|
||||||
</ul>
|
<Tooltip>
|
||||||
)}
|
<Info className="h-3.5 w-3.5" />
|
||||||
</div>
|
<Tooltip.Body>
|
||||||
<Button
|
Whether this machine can act as an exit node for your tailnet.
|
||||||
className={cn(
|
</Tooltip.Body>
|
||||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
</Tooltip>
|
||||||
'text-blue-500 dark:text-blue-400',
|
</span>
|
||||||
)}
|
<div className="mt-1">
|
||||||
onPress={() => setShowRouting(true)}
|
{node.customRouting.exitRoutes.length === 0 ? (
|
||||||
>
|
<span className="opacity-50">—</span>
|
||||||
Edit
|
) : node.customRouting.exitApproved ? (
|
||||||
</Button>
|
<span className="flex items-center gap-x-1">
|
||||||
</div>
|
<CheckCircle className="h-3.5 w-3.5 text-green-700" />
|
||||||
<div>
|
Allowed
|
||||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
</span>
|
||||||
Exit Node
|
) : (
|
||||||
<Tooltip>
|
<span className="flex items-center gap-x-1">
|
||||||
<Info className="w-3.5 h-3.5" />
|
<CircleSlash className="h-3.5 w-3.5 text-red-700" />
|
||||||
<Tooltip.Body>
|
Awaiting Approval
|
||||||
Whether this machine can act as an exit node for your tailnet.
|
</span>
|
||||||
</Tooltip.Body>
|
)}
|
||||||
</Tooltip>
|
</div>
|
||||||
</span>
|
<Button
|
||||||
<div className="mt-1">
|
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
|
||||||
{node.customRouting.exitRoutes.length === 0 ? (
|
onPress={() => setShowRouting(true)}
|
||||||
<span className="opacity-50">—</span>
|
>
|
||||||
) : node.customRouting.exitApproved ? (
|
Edit
|
||||||
<span className="flex items-center gap-x-1">
|
</Button>
|
||||||
<CheckCircle className="w-3.5 h-3.5 text-green-700" />
|
</div>
|
||||||
Allowed
|
</Card>
|
||||||
</span>
|
<h2 className="text-xl font-medium">Machine Details</h2>
|
||||||
) : (
|
<p className="mb-4">
|
||||||
<span className="flex items-center gap-x-1">
|
Information about this machine’s network. Used to debug connection issues.
|
||||||
<CircleSlash className="w-3.5 h-3.5 text-red-700" />
|
</p>
|
||||||
Awaiting Approval
|
<Card
|
||||||
</span>
|
className="grid w-full max-w-full grid-cols-1 gap-y-2 sm:gap-x-12 lg:grid-cols-2"
|
||||||
)}
|
variant="flat"
|
||||||
</div>
|
>
|
||||||
<Button
|
<div className="flex flex-col gap-1">
|
||||||
className={cn(
|
<Attribute
|
||||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
name="Creator"
|
||||||
'text-blue-500 dark:text-blue-400',
|
value={node.user.name || node.user.displayName || node.user.email || node.user.id}
|
||||||
)}
|
/>
|
||||||
onPress={() => setShowRouting(true)}
|
<Attribute name="Machine name" value={node.givenName} />
|
||||||
>
|
<Attribute
|
||||||
Edit
|
name="OS hostname"
|
||||||
</Button>
|
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
||||||
</div>
|
value={node.name}
|
||||||
</Card>
|
/>
|
||||||
<h2 className="text-xl font-medium">Machine Details</h2>
|
{stats ? (
|
||||||
<p className="mb-4">
|
<>
|
||||||
Information about this machine’s network. Used to debug connection
|
<Attribute name="OS" value={getOSInfo(stats)} />
|
||||||
issues.
|
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
|
||||||
</p>
|
</>
|
||||||
<Card
|
) : undefined}
|
||||||
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12"
|
<Attribute
|
||||||
variant="flat"
|
name="ID"
|
||||||
>
|
tooltip="ID for this machine. Used in the Headscale API."
|
||||||
<div className="flex flex-col gap-1">
|
value={node.id}
|
||||||
<Attribute
|
/>
|
||||||
name="Creator"
|
<Attribute
|
||||||
value={
|
isCopyable
|
||||||
node.user.name ||
|
name="Node key"
|
||||||
node.user.displayName ||
|
tooltip="Public key which uniquely identifies this machine."
|
||||||
node.user.email ||
|
value={node.nodeKey}
|
||||||
node.user.id
|
/>
|
||||||
}
|
<Attribute name="Created" value={new Date(node.createdAt).toLocaleString()} />
|
||||||
/>
|
<Attribute
|
||||||
<Attribute name="Machine name" value={node.givenName} />
|
name="Last Seen"
|
||||||
<Attribute
|
value={node.online ? "Connected" : new Date(node.lastSeen).toLocaleString()}
|
||||||
name="OS hostname"
|
/>
|
||||||
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
<Attribute
|
||||||
value={node.name}
|
name="Key expiry"
|
||||||
/>
|
value={node.expiry !== null ? new Date(node.expiry).toLocaleString() : "Never"}
|
||||||
{stats ? (
|
/>
|
||||||
<>
|
{magic ? (
|
||||||
<Attribute name="OS" value={getOSInfo(stats)} />
|
<Attribute isCopyable name="Domain" value={`${node.givenName}.${magic}`} />
|
||||||
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
|
) : undefined}
|
||||||
</>
|
</div>
|
||||||
) : undefined}
|
<div className="flex flex-col gap-1">
|
||||||
<Attribute
|
<p className="text-sm font-semibold uppercase opacity-75">Addresses</p>
|
||||||
name="ID"
|
<Attribute
|
||||||
tooltip="ID for this machine. Used in the Headscale API."
|
isCopyable
|
||||||
value={node.id}
|
name="Tailscale IPv4"
|
||||||
/>
|
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
||||||
<Attribute
|
value={getIpv4Address(node.ipAddresses)}
|
||||||
isCopyable
|
/>
|
||||||
name="Node key"
|
<Attribute
|
||||||
tooltip="Public key which uniquely identifies this machine."
|
isCopyable
|
||||||
value={node.nodeKey}
|
name="Tailscale IPv6"
|
||||||
/>
|
tooltip="This machine’s IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
|
||||||
<Attribute
|
value={getIpv6Address(node.ipAddresses)}
|
||||||
name="Created"
|
/>
|
||||||
value={new Date(node.createdAt).toLocaleString()}
|
<Attribute
|
||||||
/>
|
isCopyable
|
||||||
<Attribute
|
name="Short domain"
|
||||||
name="Last Seen"
|
tooltip="Users of your tailnet can use this DNS short name to access this machine."
|
||||||
value={
|
value={node.givenName}
|
||||||
node.online
|
/>
|
||||||
? 'Connected'
|
{magic ? (
|
||||||
: new Date(node.lastSeen).toLocaleString()
|
<Attribute
|
||||||
}
|
isCopyable
|
||||||
/>
|
name="Full domain"
|
||||||
<Attribute
|
tooltip="Users of your tailnet can use this DNS name to access this machine."
|
||||||
name="Key expiry"
|
value={`${node.givenName}.${magic}`}
|
||||||
value={
|
/>
|
||||||
node.expiry !== null
|
) : undefined}
|
||||||
? new Date(node.expiry).toLocaleString()
|
{stats ? (
|
||||||
: 'Never'
|
<>
|
||||||
}
|
<p className="mt-4 text-sm font-semibold uppercase opacity-75">Client Connectivity</p>
|
||||||
/>
|
<Attribute
|
||||||
{magic ? (
|
name="Varies"
|
||||||
<Attribute
|
tooltip="Whether the machine is behind a difficult NAT that varies the machine’s IP address depending on the destination."
|
||||||
isCopyable
|
value={stats.NetInfo?.MappingVariesByDestIP ? "Yes" : "No"}
|
||||||
name="Domain"
|
/>
|
||||||
value={`${node.givenName}.${magic}`}
|
<Attribute
|
||||||
/>
|
name="Hairpinning"
|
||||||
) : undefined}
|
tooltip="Whether the machine needs to traverse NATs with hairpinning."
|
||||||
</div>
|
value={stats.NetInfo?.HairPinning ? "Yes" : "No"}
|
||||||
<div className="flex flex-col gap-1">
|
/>
|
||||||
<p className="uppercase text-sm font-semibold opacity-75">
|
<Attribute name="IPv6" value={stats.NetInfo?.WorkingIPv6 ? "Yes" : "No"} />
|
||||||
Addresses
|
<Attribute name="UDP" value={stats.NetInfo?.WorkingUDP ? "Yes" : "No"} />
|
||||||
</p>
|
<Attribute name="UPnP" value={stats.NetInfo?.UPnP ? "Yes" : "No"} />
|
||||||
<Attribute
|
<Attribute name="PCP" value={stats.NetInfo?.PCP ? "Yes" : "No"} />
|
||||||
isCopyable
|
<Attribute name="NAT-PMP" value={stats.NetInfo?.PMP ? "Yes" : "No"} />
|
||||||
name="Tailscale IPv4"
|
</>
|
||||||
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
) : undefined}
|
||||||
value={getIpv4Address(node.ipAddresses)}
|
</div>
|
||||||
/>
|
</Card>
|
||||||
<Attribute
|
</div>
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIpv4Address(addresses: string[]) {
|
function getIpv4Address(addresses: string[]) {
|
||||||
for (const address of addresses) {
|
for (const address of addresses) {
|
||||||
if (address.startsWith('100.')) {
|
if (address.startsWith("100.")) {
|
||||||
// Return the first CGNAT address
|
// Return the first CGNAT address
|
||||||
return address;
|
return address;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return '—';
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIpv6Address(addresses: string[]) {
|
function getIpv6Address(addresses: string[]) {
|
||||||
for (const address of addresses) {
|
for (const address of addresses) {
|
||||||
if (address.startsWith('fd')) {
|
if (address.startsWith("fd")) {
|
||||||
// Return the first IPv6 address
|
// Return the first IPv6 address
|
||||||
return address;
|
return address;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return '—';
|
return "—";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,150 +1,164 @@
|
|||||||
import type { Machine } from '~/types';
|
import type { Machine } from "~/types";
|
||||||
import { defineApiEndpoints } from '../factory';
|
|
||||||
|
import type { HeadscaleApiInterface } from "..";
|
||||||
|
|
||||||
|
import { defineApiEndpoints } from "../factory";
|
||||||
|
|
||||||
|
interface RawMachine extends Omit<Machine, "tags"> {
|
||||||
|
tags?: string[];
|
||||||
|
forcedTags?: string[];
|
||||||
|
validTags?: string[];
|
||||||
|
invalidTags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes the tags of a RawMachine based on the Headscale version.
|
||||||
|
*
|
||||||
|
* @param client The Headscale API client helper.
|
||||||
|
* @param node The RawMachine object to normalize.
|
||||||
|
* @returns A Machine object with normalized tags.
|
||||||
|
*/
|
||||||
|
function normalizeTags(client: HeadscaleApiInterface["clientHelpers"], node: RawMachine): Machine {
|
||||||
|
if (client.isAtleast("0.28.0-beta.1")) {
|
||||||
|
return { ...node, tags: node.tags ?? [] } as Machine;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = Array.from(new Set([...(node.forcedTags ?? []), ...(node.validTags ?? [])]));
|
||||||
|
|
||||||
|
return { ...node, tags } as Machine;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NodeEndpoints {
|
export interface NodeEndpoints {
|
||||||
/**
|
/**
|
||||||
* Retrieves all nodes (machines) from the Headscale instance.
|
* Retrieves all nodes (machines) from the Headscale instance.
|
||||||
*
|
*
|
||||||
* @returns An array of `Machine` objects representing the nodes.
|
* @returns An array of `Machine` objects representing the nodes.
|
||||||
*/
|
*/
|
||||||
getNodes(): Promise<Machine[]>;
|
getNodes(): Promise<Machine[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves a specific node (machine) by its ID.
|
* Retrieves a specific node (machine) by its ID.
|
||||||
*
|
*
|
||||||
* @param id The ID of the node to retrieve.
|
* @param id The ID of the node to retrieve.
|
||||||
* @returns A `Machine` object representing the node.
|
* @returns A `Machine` object representing the node.
|
||||||
*/
|
*/
|
||||||
getNode(id: string): Promise<Machine>;
|
getNode(id: string): Promise<Machine>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a specific node (machine) by its ID.
|
* Deletes a specific node (machine) by its ID.
|
||||||
*
|
*
|
||||||
* @param id The ID of the node to delete.
|
* @param id The ID of the node to delete.
|
||||||
*/
|
*/
|
||||||
deleteNode(id: string): Promise<void>;
|
deleteNode(id: string): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a new node (machine) with the given user and key.
|
* Registers a new node (machine) with the given user and key.
|
||||||
*
|
*
|
||||||
* @param user The user to associate with the node.
|
* @param user The user to associate with the node.
|
||||||
* @param key The registration key for the node.
|
* @param key The registration key for the node.
|
||||||
* @returns A `Machine` object representing the newly registered node.
|
* @returns A `Machine` object representing the newly registered node.
|
||||||
*/
|
*/
|
||||||
registerNode(user: string, key: string): Promise<Machine>;
|
registerNode(user: string, key: string): Promise<Machine>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Approves routes for a specific node (machine) by its ID.
|
* Approves routes for a specific node (machine) by its ID.
|
||||||
*
|
*
|
||||||
* @param id The ID of the node.
|
* @param id The ID of the node.
|
||||||
* @param routes An array of routes to approve for the node.
|
* @param routes An array of routes to approve for the node.
|
||||||
*/
|
*/
|
||||||
approveNodeRoutes(id: string, routes: string[]): Promise<void>;
|
approveNodeRoutes(id: string, routes: string[]): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expires a specific node (machine) by its ID.
|
* Expires a specific node (machine) by its ID.
|
||||||
*
|
*
|
||||||
* @param id The ID of the node to expire.
|
* @param id The ID of the node to expire.
|
||||||
*/
|
*/
|
||||||
expireNode(id: string): Promise<void>;
|
expireNode(id: string): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renames a specific node (machine) by its ID.
|
* Renames a specific node (machine) by its ID.
|
||||||
*
|
*
|
||||||
* @param id The ID of the node to rename.
|
* @param id The ID of the node to rename.
|
||||||
* @param newName The new name for the node.
|
* @param newName The new name for the node.
|
||||||
*/
|
*/
|
||||||
renameNode(id: string, newName: string): Promise<void>;
|
renameNode(id: string, newName: string): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets tags for a specific node (machine) by its ID.
|
* Sets tags for a specific node (machine) by its ID.
|
||||||
*
|
*
|
||||||
* @param id The ID of the node.
|
* @param id The ID of the node.
|
||||||
* @param tags An array of tags to set for the node.
|
* @param tags An array of tags to set for the node.
|
||||||
*/
|
*/
|
||||||
setNodeTags(id: string, tags: string[]): Promise<void>;
|
setNodeTags(id: string, tags: string[]): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the user for a specific node (machine) by its ID.
|
* Sets the user for a specific node (machine) by its ID.
|
||||||
*
|
*
|
||||||
* @param id The ID of the node.
|
* @param id The ID of the node.
|
||||||
* @param user The user to set for the node.
|
* @param user The user to set for the node.
|
||||||
*/
|
*/
|
||||||
setNodeUser(id: string, user: string): Promise<void>;
|
setNodeUser(id: string, user: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
|
export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
|
||||||
getNodes: async () => {
|
getNodes: async () => {
|
||||||
const { nodes } = await client.apiFetch<{ nodes: Machine[] }>(
|
const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey);
|
||||||
'GET',
|
|
||||||
'v1/node',
|
|
||||||
apiKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
return nodes;
|
return nodes.map((node) => normalizeTags(client, node));
|
||||||
},
|
},
|
||||||
|
|
||||||
getNode: async (nodeId) => {
|
getNode: async (nodeId) => {
|
||||||
const { node } = await client.apiFetch<{ node: Machine }>(
|
const { node } = await client.apiFetch<{ node: RawMachine }>(
|
||||||
'GET',
|
"GET",
|
||||||
`v1/node/${nodeId}`,
|
`v1/node/${nodeId}`,
|
||||||
apiKey,
|
apiKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return node;
|
return normalizeTags(client, node);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteNode: async (nodeId) => {
|
deleteNode: async (nodeId) => {
|
||||||
await client.apiFetch<void>('DELETE', `v1/node/${nodeId}`, apiKey);
|
await client.apiFetch<void>("DELETE", `v1/node/${nodeId}`, apiKey);
|
||||||
},
|
},
|
||||||
|
|
||||||
registerNode: async (user, key) => {
|
registerNode: async (user, key) => {
|
||||||
const qp = new URLSearchParams();
|
const qp = new URLSearchParams();
|
||||||
qp.append('user', user);
|
qp.append("user", user);
|
||||||
qp.append('key', key);
|
qp.append("key", key);
|
||||||
const { node } = await client.apiFetch<{ node: Machine }>(
|
const { node } = await client.apiFetch<{ node: RawMachine }>(
|
||||||
'POST',
|
"POST",
|
||||||
`v1/node/register?${qp.toString()}`,
|
`v1/node/register?${qp.toString()}`,
|
||||||
apiKey,
|
apiKey,
|
||||||
{
|
{
|
||||||
user,
|
user,
|
||||||
key,
|
key,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return node;
|
return normalizeTags(client, node);
|
||||||
},
|
},
|
||||||
|
|
||||||
approveNodeRoutes: async (nodeId, routes) => {
|
approveNodeRoutes: async (nodeId, routes) => {
|
||||||
await client.apiFetch<void>(
|
await client.apiFetch<void>("POST", `v1/node/${nodeId}/approve_routes`, apiKey, { routes });
|
||||||
'POST',
|
},
|
||||||
`v1/node/${nodeId}/approve_routes`,
|
|
||||||
apiKey,
|
|
||||||
{ routes },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
expireNode: async (nodeId) => {
|
expireNode: async (nodeId) => {
|
||||||
await client.apiFetch<void>('POST', `v1/node/${nodeId}/expire`, apiKey);
|
await client.apiFetch<void>("POST", `v1/node/${nodeId}/expire`, apiKey);
|
||||||
},
|
},
|
||||||
|
|
||||||
renameNode: async (nodeId, newName) => {
|
renameNode: async (nodeId, newName) => {
|
||||||
await client.apiFetch<void>(
|
await client.apiFetch<void>("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey);
|
||||||
'POST',
|
},
|
||||||
`v1/node/${nodeId}/rename/${newName}`,
|
|
||||||
apiKey,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
setNodeTags: async (nodeId, tags) => {
|
setNodeTags: async (nodeId, tags) => {
|
||||||
await client.apiFetch<void>('POST', `v1/node/${nodeId}/tags`, apiKey, {
|
await client.apiFetch<void>("POST", `v1/node/${nodeId}/tags`, apiKey, {
|
||||||
tags,
|
tags,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setNodeUser: async (nodeId, user) => {
|
setNodeUser: async (nodeId, user) => {
|
||||||
await client.apiFetch<void>('POST', `v1/node/${nodeId}/user`, apiKey, {
|
await client.apiFetch<void>("POST", `v1/node/${nodeId}/user`, apiKey, {
|
||||||
user,
|
user,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
+25
-27
@@ -1,35 +1,33 @@
|
|||||||
import type { PreAuthKey } from './PreAuthKey';
|
import type { PreAuthKey } from "./PreAuthKey";
|
||||||
import type { User } from './User';
|
import type { User } from "./User";
|
||||||
|
|
||||||
export interface Machine {
|
export interface Machine {
|
||||||
id: string;
|
id: string;
|
||||||
machineKey: string;
|
machineKey: string;
|
||||||
nodeKey: string;
|
nodeKey: string;
|
||||||
discoKey: string;
|
discoKey: string;
|
||||||
ipAddresses: string[];
|
ipAddresses: string[];
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
user: User;
|
user: User;
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
expiry: string | null;
|
expiry: string | null;
|
||||||
|
|
||||||
preAuthKey?: PreAuthKey;
|
preAuthKey?: PreAuthKey;
|
||||||
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
registerMethod:
|
registerMethod:
|
||||||
| 'REGISTER_METHOD_UNSPECIFIED'
|
| "REGISTER_METHOD_UNSPECIFIED"
|
||||||
| 'REGISTER_METHOD_AUTH_KEY'
|
| "REGISTER_METHOD_AUTH_KEY"
|
||||||
| 'REGISTER_METHOD_CLI'
|
| "REGISTER_METHOD_CLI"
|
||||||
| 'REGISTER_METHOD_OIDC';
|
| "REGISTER_METHOD_OIDC";
|
||||||
|
|
||||||
forcedTags: string[];
|
tags: string[];
|
||||||
invalidTags: string[];
|
givenName: string;
|
||||||
validTags: string[];
|
online: boolean;
|
||||||
givenName: string;
|
|
||||||
online: boolean;
|
|
||||||
|
|
||||||
// Added in Headscale 0.26+
|
// Added in Headscale 0.26+
|
||||||
approvedRoutes: string[];
|
approvedRoutes: string[];
|
||||||
availableRoutes: string[];
|
availableRoutes: string[];
|
||||||
subnetRoutes: string[];
|
subnetRoutes: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-54
@@ -1,65 +1,50 @@
|
|||||||
import { HostInfo, Machine } from '~/types';
|
import { HostInfo, Machine } from "~/types";
|
||||||
|
|
||||||
export interface PopulatedNode extends Machine {
|
export interface PopulatedNode extends Machine {
|
||||||
routes: string[];
|
routes: string[];
|
||||||
hostInfo?: HostInfo;
|
hostInfo?: HostInfo;
|
||||||
expired: boolean;
|
expired: boolean;
|
||||||
customRouting: {
|
customRouting: {
|
||||||
exitRoutes: string[];
|
exitRoutes: string[];
|
||||||
exitApproved: boolean;
|
exitApproved: boolean;
|
||||||
subnetApprovedRoutes: string[];
|
subnetApprovedRoutes: string[];
|
||||||
subnetWaitingRoutes: string[];
|
subnetWaitingRoutes: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapNodes(
|
export function mapNodes(
|
||||||
nodes: Machine[],
|
nodes: Machine[],
|
||||||
stats?: Record<string, HostInfo> | undefined,
|
stats?: Record<string, HostInfo> | undefined,
|
||||||
): PopulatedNode[] {
|
): PopulatedNode[] {
|
||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
const customRouting = {
|
const customRouting = {
|
||||||
exitRoutes: node.availableRoutes.filter(
|
exitRoutes: node.availableRoutes.filter((route) => route === "::/0" || route === "0.0.0.0/0"),
|
||||||
(route) => route === '::/0' || route === '0.0.0.0/0',
|
exitApproved: node.approvedRoutes.some((route) => route === "::/0" || route === "0.0.0.0/0"),
|
||||||
),
|
subnetApprovedRoutes: node.approvedRoutes.filter(
|
||||||
exitApproved: node.approvedRoutes.some(
|
(route) =>
|
||||||
(route) => route === '::/0' || route === '0.0.0.0/0',
|
route !== "::/0" && route !== "0.0.0.0/0" && node.availableRoutes.includes(route),
|
||||||
),
|
),
|
||||||
subnetApprovedRoutes: node.approvedRoutes.filter(
|
subnetWaitingRoutes: node.availableRoutes.filter(
|
||||||
(route) =>
|
(route) =>
|
||||||
route !== '::/0' &&
|
route !== "::/0" && route !== "0.0.0.0/0" && !node.approvedRoutes.includes(route),
|
||||||
route !== '0.0.0.0/0' &&
|
),
|
||||||
node.availableRoutes.includes(route),
|
} satisfies PopulatedNode["customRouting"];
|
||||||
),
|
|
||||||
subnetWaitingRoutes: node.availableRoutes.filter(
|
|
||||||
(route) =>
|
|
||||||
route !== '::/0' &&
|
|
||||||
route !== '0.0.0.0/0' &&
|
|
||||||
!node.approvedRoutes.includes(route),
|
|
||||||
),
|
|
||||||
} satisfies PopulatedNode['customRouting'];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
routes: Array.from(new Set(node.availableRoutes)),
|
routes: Array.from(new Set(node.availableRoutes)),
|
||||||
hostInfo: stats?.[node.nodeKey],
|
hostInfo: stats?.[node.nodeKey],
|
||||||
customRouting,
|
customRouting,
|
||||||
expired:
|
expired:
|
||||||
node.expiry === '0001-01-01 00:00:00' ||
|
node.expiry === "0001-01-01 00:00:00" ||
|
||||||
node.expiry === '0001-01-01T00:00:00Z' ||
|
node.expiry === "0001-01-01T00:00:00Z" ||
|
||||||
node.expiry === null
|
node.expiry === null
|
||||||
? false
|
? false
|
||||||
: new Date(node.expiry).getTime() < Date.now(),
|
: new Date(node.expiry).getTime() < Date.now(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortNodeTags(nodes: Machine[]): string[] {
|
export function sortNodeTags(nodes: Machine[]): string[] {
|
||||||
return Array.from(
|
return Array.from(new Set(nodes.flatMap((node) => node.tags))).sort();
|
||||||
new Set(
|
|
||||||
nodes.flatMap(({ validTags, forcedTags }) => [
|
|
||||||
...validTags,
|
|
||||||
...forcedTags,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
).sort();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +1,102 @@
|
|||||||
import { writeFile } from 'node:fs/promises';
|
import type { OpenAPIV2 } from "openapi-types";
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import { cwd } from 'node:process';
|
|
||||||
import type { OpenAPIV2 } from 'openapi-types';
|
|
||||||
import { request } from 'undici';
|
|
||||||
import { hashOpenApiDocument } from '~/server/headscale/api/hasher';
|
|
||||||
|
|
||||||
const HASH_FILE_LOCATION = 'app/openapi-operation-hashes.json';
|
import { writeFile } from "node:fs/promises";
|
||||||
const CANONICAL_LOCATION = 'app/openapi-canonical-families.json';
|
import { resolve } from "node:path";
|
||||||
|
import { cwd } from "node:process";
|
||||||
|
import { request } from "undici";
|
||||||
|
|
||||||
|
import { hashOpenApiDocument } from "~/server/headscale/api/hasher";
|
||||||
|
|
||||||
|
const HASH_FILE_LOCATION = "app/openapi-operation-hashes.json";
|
||||||
|
const CANONICAL_LOCATION = "app/openapi-canonical-families.json";
|
||||||
|
|
||||||
const SPEC_MAP = {
|
const SPEC_MAP = {
|
||||||
// '0.25.0': '/v0.25.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
|
// '0.25.0': '/v0.25.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
|
||||||
// '0.25.1': '/v0.25.1/gen/openapiv2/headscale/v1/headscale.swagger.json',
|
// '0.25.1': '/v0.25.1/gen/openapiv2/headscale/v1/headscale.swagger.json',
|
||||||
'0.26.0': '/v0.26.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
|
"0.26.0": "/v0.26.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||||
'0.26.1': '/v0.26.1/gen/openapiv2/headscale/v1/headscale.swagger.json',
|
"0.26.1": "/v0.26.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||||
'0.27.0': '/v0.27.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
|
"0.27.0": "/v0.27.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||||
'0.27.1': '/v0.27.1/gen/openapiv2/headscale/v1/headscale.swagger.json',
|
"0.27.1": "/v0.27.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||||
|
"0.28.0-beta.1": "/v0.28.0-beta.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||||
|
"0.28.0-beta.2": "/v0.28.0-beta.2/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
async function hashOpenApiOperations(specUrl: string) {
|
async function hashOpenApiOperations(specUrl: string) {
|
||||||
const url = `https://raw.githubusercontent.com/juanfont/headscale${specUrl}`;
|
const url = `https://raw.githubusercontent.com/juanfont/headscale${specUrl}`;
|
||||||
const res = await request(url);
|
const res = await request(url);
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
console.error('Failed to fetch OpenAPI spec:', res.statusCode);
|
console.error("Failed to fetch OpenAPI spec:", res.statusCode);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await res.body.json()) as OpenAPIV2.Document;
|
const body = (await res.body.json()) as OpenAPIV2.Document;
|
||||||
return hashOpenApiDocument(body);
|
return hashOpenApiDocument(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectCanonicalizedFamilies(
|
async function collectCanonicalizedFamilies(
|
||||||
newHashes: readonly (readonly [string, Record<string, string>])[],
|
newHashes: readonly (readonly [string, Record<string, string>])[],
|
||||||
) {
|
) {
|
||||||
const canonicalizedFamilies: Record<string, string[]> = {};
|
const canonicalizedFamilies: Record<string, string[]> = {};
|
||||||
for (const [version, hashes] of newHashes) {
|
for (const [version, hashes] of newHashes) {
|
||||||
const signature = JSON.stringify(hashes);
|
const signature = JSON.stringify(hashes);
|
||||||
let canonical: string | null = null;
|
let canonical: string | null = null;
|
||||||
|
|
||||||
for (const existingCanonical of Object.keys(canonicalizedFamilies)) {
|
for (const existingCanonical of Object.keys(canonicalizedFamilies)) {
|
||||||
const existingSignature = JSON.stringify(
|
const existingSignature = JSON.stringify(
|
||||||
newHashes.find(([v]) => v === existingCanonical)![1],
|
newHashes.find(([v]) => v === existingCanonical)![1],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingSignature === signature) {
|
if (existingSignature === signature) {
|
||||||
canonical = existingCanonical;
|
canonical = existingCanonical;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canonical) {
|
if (!canonical) {
|
||||||
canonicalizedFamilies[version] = [version];
|
canonicalizedFamilies[version] = [version];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
canonicalizedFamilies[canonical].push(version);
|
canonicalizedFamilies[canonical].push(version);
|
||||||
if (
|
if (
|
||||||
version.localeCompare(canonical, undefined, {
|
version.localeCompare(canonical, undefined, {
|
||||||
numeric: true,
|
numeric: true,
|
||||||
sensitivity: 'base',
|
sensitivity: "base",
|
||||||
}) > 0
|
}) > 0
|
||||||
) {
|
) {
|
||||||
canonicalizedFamilies[version] = canonicalizedFamilies[canonical];
|
canonicalizedFamilies[version] = canonicalizedFamilies[canonical];
|
||||||
delete canonicalizedFamilies[canonical];
|
delete canonicalizedFamilies[canonical];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [canonical, family] of Object.entries(canonicalizedFamilies)) {
|
for (const [canonical, family] of Object.entries(canonicalizedFamilies)) {
|
||||||
canonicalizedFamilies[canonical] = family.sort((a, b) =>
|
canonicalizedFamilies[canonical] = family.sort((a, b) =>
|
||||||
a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }),
|
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return canonicalizedFamilies;
|
return canonicalizedFamilies;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeHashes(hashes: Record<string, Record<string, string>>) {
|
async function writeHashes(hashes: Record<string, Record<string, string>>) {
|
||||||
const path = resolve(cwd(), HASH_FILE_LOCATION);
|
const path = resolve(cwd(), HASH_FILE_LOCATION);
|
||||||
await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, 'utf-8');
|
await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeCanonicalizedFamilies(families: Record<string, string[]>) {
|
async function writeCanonicalizedFamilies(families: Record<string, string[]>) {
|
||||||
const path = resolve(cwd(), CANONICAL_LOCATION);
|
const path = resolve(cwd(), CANONICAL_LOCATION);
|
||||||
await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, 'utf-8');
|
await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
const newHashes = await Promise.all(
|
const newHashes = await Promise.all(
|
||||||
Object.entries(SPEC_MAP).map(async ([version, specUrl]) => {
|
Object.entries(SPEC_MAP).map(async ([version, specUrl]) => {
|
||||||
const hashes = await hashOpenApiOperations(specUrl);
|
const hashes = await hashOpenApiOperations(specUrl);
|
||||||
return [version, hashes] as const;
|
return [version, hashes] as const;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const canonicalizedFamilies = await collectCanonicalizedFamilies(newHashes);
|
const canonicalizedFamilies = await collectCanonicalizedFamilies(newHashes);
|
||||||
|
|
||||||
console.log('Writing new OpenAPI operation hashes to file');
|
console.log("Writing new OpenAPI operation hashes to file");
|
||||||
await writeHashes(Object.fromEntries(newHashes));
|
await writeHashes(Object.fromEntries(newHashes));
|
||||||
await writeCanonicalizedFamilies(canonicalizedFamilies);
|
await writeCanonicalizedFamilies(canonicalizedFamilies);
|
||||||
|
|||||||
Reference in New Issue
Block a user