mirror of https://github.com/tailscale/tailscale/
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>pull/10501/head
parent
9fd29f15c7
commit
97f8577ad2
@ -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 }
|
|
||||||
}
|
|
@ -0,0 +1,112 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
import { assertNever } from "src/utils/util"
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 ExitNode = {
|
||||||
|
ID: string
|
||||||
|
Name: string
|
||||||
|
Location?: ExitNodeLocation
|
||||||
|
Online?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExitNodeLocation = {
|
||||||
|
Country: string
|
||||||
|
CountryCode: CountryCode
|
||||||
|
City: string
|
||||||
|
CityCode: CityCode
|
||||||
|
Priority: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CountryCode = string
|
||||||
|
export type CityCode = string
|
||||||
|
|
||||||
|
export type ExitNodeGroup = {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
nodes: ExitNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VersionInfo type is deserialized from tailcfg.ClientVersion,
|
||||||
|
* so it should not include fields not included in that type.
|
||||||
|
*/
|
||||||
|
export type VersionInfo = {
|
||||||
|
RunningLatest: boolean
|
||||||
|
LatestVersion?: string
|
||||||
|
}
|
Loading…
Reference in New Issue