client/web: restructure api mutations into hook

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>
This commit is contained in:
Sonia Appasamy
2023-12-06 00:26:34 -05:00
committed by Sonia Appasamy
parent 9fd29f15c7
commit 97f8577ad2
17 changed files with 485 additions and 433 deletions
+10 -36
View File
@@ -1,44 +1,18 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useEffect, useMemo, useState } from "react"
import { apiFetch } from "src/api"
import { NodeData } from "src/hooks/node-data"
export type ExitNode = {
ID: string
Name: string
Location?: ExitNodeLocation
Online?: boolean
}
type ExitNodeLocation = {
Country: string
CountryCode: CountryCode
City: string
CityCode: CityCode
Priority: number
}
type CountryCode = string
type CityCode = string
export type ExitNodeGroup = {
id: string
name?: string
nodes: ExitNode[]
}
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, setData] = useState<ExitNode[]>([])
useEffect(() => {
apiFetch<ExitNode[]>("/exit-nodes", "GET")
.then((r) => setData(r))
.catch((err) => {
alert("Failed operation: " + err.message)
})
}, [])
const { data } = useSWR<ExitNode[]>("/exit-nodes")
const { tailnetNodesSorted, locationNodesMap } = useMemo(() => {
// First going through exit nodes and splitting them into two groups:
-243
View File
@@ -1,243 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useMemo, useState } from "react"
import { apiFetch, incrementMetric, setUnraidCsrfToken } from "src/api"
import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes"
import { VersionInfo } from "src/hooks/self-update"
import { assertNever } from "src/utils/util"
import useSWR from "swr"
export type NodeData = {
Profile: UserProfile
Status: NodeState
DeviceName: string
OS: string
IPv4: string
IPv6: string
ID: string
KeyExpiry: string
KeyExpired: boolean
UsingExitNode?: ExitNode
AdvertisingExitNode: boolean
AdvertisedRoutes?: SubnetRoute[]
TUNMode: boolean
IsSynology: boolean
DSMVersion: number
IsUnraid: boolean
UnraidToken: string
IPNVersion: string
ClientVersion?: VersionInfo
URLPrefix: string
DomainName: string
TailnetName: string
IsTagged: boolean
Tags: string[]
RunningSSHServer: boolean
ControlAdminURL: string
LicensesURL: string
Features: { [key in Feature]: boolean } // value is true if given feature is available on this client
ACLAllowsAnyIncomingTraffic: boolean
}
type NodeState =
| "NoState"
| "NeedsLogin"
| "NeedsMachineAuth"
| "Stopped"
| "Starting"
| "Running"
export type UserProfile = {
LoginName: string
DisplayName: string
ProfilePicURL: string
}
export type SubnetRoute = {
Route: string
Approved: boolean
}
export type Feature =
| "advertise-exit-node"
| "advertise-routes"
| "use-exit-node"
| "ssh"
| "auto-update"
export const featureDescription = (f: Feature) => {
switch (f) {
case "advertise-exit-node":
return "Advertising as an exit node"
case "advertise-routes":
return "Advertising subnet routes"
case "use-exit-node":
return "Using an exit node"
case "ssh":
return "Running a Tailscale SSH server"
case "auto-update":
return "Auto updating client versions"
default:
assertNever(f)
}
}
/**
* NodeUpdaters provides a set of mutation functions for a node.
*
* These functions handle both making the requested change, as well as
* refreshing the app's node data state upon completion to reflect any
* relevant changes in the UI.
*/
export type NodeUpdaters = {
/**
* patchPrefs updates node preferences.
* Only provided preferences will be updated.
* Similar to running the tailscale set command in the CLI.
*/
patchPrefs: (d: PrefsPATCHData) => Promise<void>
/**
* postExitNode updates the node's status as either using or
* running as an exit node.
*/
postExitNode: (d: ExitNode) => Promise<void>
/**
* postSubnetRoutes updates the node's advertised subnet routes.
*/
postSubnetRoutes: (d: string[]) => Promise<void>
}
type PrefsPATCHData = {
RunSSHSet?: boolean
RunSSH?: boolean
}
type RoutesPOSTData = {
UseExitNode?: string
AdvertiseExitNode?: boolean
AdvertiseRoutes?: string[]
}
// useNodeData returns basic data about the current node.
export default function useNodeData() {
const { data, mutate } = useSWR<NodeData>("/data")
const [isPosting, setIsPosting] = useState<boolean>(false)
useEffect(
() => setUnraidCsrfToken(data?.IsUnraid ? data.UnraidToken : undefined),
[data]
)
const prefsPATCH = useCallback(
(d: PrefsPATCHData) => {
setIsPosting(true)
if (data) {
const optimisticUpdates = data
if (d.RunSSHSet) {
optimisticUpdates.RunningSSHServer = Boolean(d.RunSSH)
}
// Reflect the pref change immediatley on the frontend,
// then make the prefs PATCH. If the request fails,
// data will be updated to it's previous value in
// onComplete below.
mutate(optimisticUpdates, false)
}
const onComplete = () => {
setIsPosting(false)
mutate() // refresh data after PATCH finishes
}
const updateMetrics = () => {
// only update metrics if values have changed
if (data?.RunningSSHServer !== d.RunSSH) {
incrementMetric(
d.RunSSH ? "web_client_ssh_enable" : "web_client_ssh_disable"
)
}
}
return apiFetch("/local/v0/prefs", "PATCH", d)
.then(() => {
updateMetrics()
onComplete()
})
.catch((err) => {
onComplete()
alert("Failed to update prefs")
throw err
})
},
[data, mutate]
)
const routesPOST = useCallback(
(d: RoutesPOSTData) => {
setIsPosting(true)
const onComplete = () => {
setIsPosting(false)
mutate() // refresh data after POST finishes
}
const updateMetrics = () => {
// only update metrics if values have changed
if (data?.AdvertisingExitNode !== d.AdvertiseExitNode) {
incrementMetric(
d.AdvertiseExitNode
? "web_client_advertise_exitnode_enable"
: "web_client_advertise_exitnode_disable"
)
}
// useExitNode is the ID of the exit node to use
if (data?.UsingExitNode?.ID !== d.UseExitNode) {
incrementMetric(
d.UseExitNode
? "web_client_use_exitnode_enable"
: "web_client_use_exitnode_disable"
)
}
}
return apiFetch("/routes", "POST", d)
.then(() => {
updateMetrics()
onComplete()
})
.catch((err) => {
onComplete()
alert("Failed to update routes")
throw err
})
},
[mutate, data?.AdvertisingExitNode, data?.UsingExitNode?.ID]
)
const nodeUpdaters: NodeUpdaters = useMemo(
() => ({
patchPrefs: prefsPATCH,
postExitNode: (node) =>
routesPOST({
AdvertiseExitNode: node.ID === runAsExitNode.ID,
UseExitNode:
node.ID === noExitNode.ID || node.ID === runAsExitNode.ID
? undefined
: node.ID,
AdvertiseRoutes: data?.AdvertisedRoutes?.map((r) => r.Route), // unchanged
}),
postSubnetRoutes: (routes) =>
routesPOST({
AdvertiseRoutes: routes,
AdvertiseExitNode: data?.AdvertisingExitNode, // unchanged
UseExitNode: data?.UsingExitNode?.ID, // unchanged
}).then(() => incrementMetric("web_client_advertise_routes_change")),
}),
[
data?.AdvertisingExitNode,
data?.AdvertisedRoutes,
data?.UsingExitNode?.ID,
prefsPATCH,
routesPOST,
]
)
return { data, refreshData: mutate, nodeUpdaters, isPosting }
}
+1 -7
View File
@@ -3,13 +3,7 @@
import { useCallback, useEffect, useState } from "react"
import { apiFetch } from "src/api"
// this type is deserialized from tailcfg.ClientVersion,
// so it should not include fields not included in that type.
export type VersionInfo = {
RunningLatest: boolean
LatestVersion?: string
}
import { VersionInfo } from "src/types"
// see ipnstate.UpdateProgress
export type UpdateProgress = {