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