mirror of
https://github.com/tailscale/tailscale.git
synced 2025-09-30 10:01:27 +02:00
This commit makes some restructural changes to how we handle api posting from the web client frontend. Now that we're using SWR, we have less of a need for hooks like useNodeData that return a useSWR response alongside some mutation callbacks. SWR makes it easy to mutate throughout the UI without needing access to the original data state in order to reflect updates. So, we can fetch data without having to tie it to post callbacks that have to be passed around through components. In an effort to consolidate our posting endpoints, and make it easier to add more api handlers cleanly in the future, this change introduces a new `useAPI` hook that returns a single `api` callback that can make any changes from any component in the UI. The hook itself handles using SWR to mutate the relevant data keys, which get globally reflected throughout the UI. As a concurrent cleanup, node types are also moved to their own types.ts file, to consolidate data types across the app. Updates #10261 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
205 lines
6.1 KiB
TypeScript
205 lines
6.1 KiB
TypeScript
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
import { useMemo } from "react"
|
|
import {
|
|
CityCode,
|
|
CountryCode,
|
|
ExitNode,
|
|
ExitNodeLocation,
|
|
NodeData,
|
|
} from "src/types"
|
|
import useSWR from "swr"
|
|
|
|
export default function useExitNodes(node: NodeData, filter?: string) {
|
|
const { data } = useSWR<ExitNode[]>("/exit-nodes")
|
|
|
|
const { tailnetNodesSorted, locationNodesMap } = useMemo(() => {
|
|
// First going through exit nodes and splitting them into two groups:
|
|
// 1. tailnetNodes: exit nodes advertised by tailnet's own nodes
|
|
// 2. locationNodes: exit nodes advertised by non-tailnet Mullvad nodes
|
|
let tailnetNodes: ExitNode[] = []
|
|
const locationNodes = new Map<CountryCode, Map<CityCode, ExitNode[]>>()
|
|
|
|
if (!node.Features["use-exit-node"]) {
|
|
// early-return
|
|
return {
|
|
tailnetNodesSorted: tailnetNodes,
|
|
locationNodesMap: locationNodes,
|
|
}
|
|
}
|
|
|
|
data?.forEach((n) => {
|
|
const loc = n.Location
|
|
if (!loc) {
|
|
// 2023-11-15: Currently, if the node doesn't have
|
|
// location information, it is owned by the tailnet.
|
|
// Only Mullvad exit nodes have locations filled.
|
|
tailnetNodes.push({
|
|
...n,
|
|
Name: trimDNSSuffix(n.Name, node.TailnetName),
|
|
})
|
|
return
|
|
}
|
|
const countryNodes =
|
|
locationNodes.get(loc.CountryCode) || new Map<CityCode, ExitNode[]>()
|
|
const cityNodes = countryNodes.get(loc.CityCode) || []
|
|
countryNodes.set(loc.CityCode, [...cityNodes, n])
|
|
locationNodes.set(loc.CountryCode, countryNodes)
|
|
})
|
|
|
|
return {
|
|
tailnetNodesSorted: tailnetNodes.sort(compareByName),
|
|
locationNodesMap: locationNodes,
|
|
}
|
|
}, [data, node.Features, node.TailnetName])
|
|
|
|
const hasFilter = Boolean(filter)
|
|
|
|
const mullvadNodesSorted = useMemo(() => {
|
|
const nodes: ExitNode[] = []
|
|
if (!node.Features["use-exit-node"]) {
|
|
return nodes // early-return
|
|
}
|
|
|
|
// addBestMatchNode adds the node with the "higest priority"
|
|
// match from a list of exit node `options` to `nodes`.
|
|
const addBestMatchNode = (
|
|
options: ExitNode[],
|
|
name: (l: ExitNodeLocation) => string
|
|
) => {
|
|
const bestNode = highestPriorityNode(options)
|
|
if (!bestNode || !bestNode.Location) {
|
|
return // not possible, doing this for type safety
|
|
}
|
|
nodes.push({
|
|
...bestNode,
|
|
Name: name(bestNode.Location),
|
|
})
|
|
}
|
|
|
|
if (!hasFilter) {
|
|
// When nothing is searched, only show a single best-matching
|
|
// exit node per-country.
|
|
//
|
|
// There's too many location-based nodes to display all of them.
|
|
locationNodesMap.forEach(
|
|
// add one node per country
|
|
(countryNodes) =>
|
|
addBestMatchNode(flattenMap(countryNodes), (l) => l.Country)
|
|
)
|
|
} else {
|
|
// Otherwise, show the best match on a city-level,
|
|
// with a "Country: Best Match" node at top.
|
|
//
|
|
// i.e. We allow for discovering cities through searching.
|
|
locationNodesMap.forEach((countryNodes) => {
|
|
countryNodes.forEach(
|
|
// add one node per city
|
|
(cityNodes) =>
|
|
addBestMatchNode(cityNodes, (l) => `${l.Country}: ${l.City}`)
|
|
)
|
|
// add the "Country: Best Match" node
|
|
addBestMatchNode(
|
|
flattenMap(countryNodes),
|
|
(l) => `${l.Country}: Best Match`
|
|
)
|
|
})
|
|
}
|
|
|
|
return nodes.sort(compareByName)
|
|
}, [hasFilter, locationNodesMap, node.Features])
|
|
|
|
// Ordered and filtered grouping of exit nodes.
|
|
const exitNodeGroups = useMemo(() => {
|
|
const filterLower = !filter ? undefined : filter.toLowerCase()
|
|
|
|
const selfGroup = {
|
|
id: "self",
|
|
name: undefined,
|
|
nodes: filter
|
|
? []
|
|
: !node.Features["advertise-exit-node"]
|
|
? [noExitNode] // don't show "runAsExitNode" option
|
|
: [noExitNode, runAsExitNode],
|
|
}
|
|
|
|
if (!node.Features["use-exit-node"]) {
|
|
return [selfGroup]
|
|
}
|
|
return [
|
|
selfGroup,
|
|
{
|
|
id: "tailnet",
|
|
nodes: filterLower
|
|
? tailnetNodesSorted.filter((n) =>
|
|
n.Name.toLowerCase().includes(filterLower)
|
|
)
|
|
: tailnetNodesSorted,
|
|
},
|
|
{
|
|
id: "mullvad",
|
|
name: "Mullvad VPN",
|
|
nodes: filterLower
|
|
? mullvadNodesSorted.filter((n) =>
|
|
n.Name.toLowerCase().includes(filterLower)
|
|
)
|
|
: mullvadNodesSorted,
|
|
},
|
|
]
|
|
}, [filter, node.Features, tailnetNodesSorted, mullvadNodesSorted])
|
|
|
|
return { data: exitNodeGroups }
|
|
}
|
|
|
|
// highestPriorityNode finds the highest priority node for use
|
|
// (the "best match" node) from a list of exit nodes.
|
|
// Nodes with equal priorities are picked between arbitrarily.
|
|
function highestPriorityNode(nodes: ExitNode[]): ExitNode | undefined {
|
|
return nodes.length === 0
|
|
? undefined
|
|
: nodes.sort(
|
|
(a, b) => (b.Location?.Priority || 0) - (a.Location?.Priority || 0)
|
|
)[0]
|
|
}
|
|
|
|
// compareName compares two exit nodes alphabetically by name.
|
|
function compareByName(a: ExitNode, b: ExitNode): number {
|
|
if (a.Location && b.Location && a.Location.Country === b.Location.Country) {
|
|
// Always put "<Country>: Best Match" node at top of country list.
|
|
if (a.Name.includes(": Best Match")) {
|
|
return -1
|
|
} else if (b.Name.includes(": Best Match")) {
|
|
return 1
|
|
}
|
|
}
|
|
return a.Name.localeCompare(b.Name)
|
|
}
|
|
|
|
function flattenMap<T, V>(m: Map<T, V[]>): V[] {
|
|
return Array.from(m.values()).reduce((prev, curr) => [...prev, ...curr])
|
|
}
|
|
|
|
// trimDNSSuffix trims the tailnet dns name from s, leaving no
|
|
// trailing dots.
|
|
//
|
|
// trimDNSSuffix("hello.ts.net", "ts.net") = "hello"
|
|
// trimDNSSuffix("hello", "ts.net") = "hello"
|
|
export function trimDNSSuffix(s: string, tailnetDNSName: string): string {
|
|
if (s.endsWith(".")) {
|
|
s = s.slice(0, -1)
|
|
}
|
|
if (s.endsWith("." + tailnetDNSName)) {
|
|
s = s.replace("." + tailnetDNSName, "")
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Neither of these are really "online", but setting this makes them selectable.
|
|
export const noExitNode: ExitNode = { ID: "NONE", Name: "None", Online: true }
|
|
export const runAsExitNode: ExitNode = {
|
|
ID: "RUNNING",
|
|
Name: "Run as exit node",
|
|
Online: true,
|
|
}
|