From 97f8577ad28c6c9fcc68cdade89a6b51adfb67d4 Mon Sep 17 00:00:00 2001 From: Sonia Appasamy Date: Wed, 6 Dec 2023 00:26:34 -0500 Subject: [PATCH] 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 --- client/web/src/api.ts | 263 +++++++++++++++++- client/web/src/components/app.tsx | 60 ++-- .../web/src/components/control-components.tsx | 2 +- .../web/src/components/exit-node-selector.tsx | 16 +- client/web/src/components/login-toggle.tsx | 2 +- .../web/src/components/update-available.tsx | 2 +- .../components/views/device-details-view.tsx | 15 +- client/web/src/components/views/home-view.tsx | 11 +- .../web/src/components/views/login-view.tsx | 58 ++-- client/web/src/components/views/ssh-view.tsx | 16 +- .../components/views/subnet-router-view.tsx | 32 ++- .../src/components/views/updating-view.tsx | 7 +- client/web/src/hooks/exit-nodes.ts | 46 +-- client/web/src/hooks/node-data.ts | 243 ---------------- client/web/src/hooks/self-update.ts | 8 +- client/web/src/types.ts | 112 ++++++++ client/web/web.go | 25 +- 17 files changed, 485 insertions(+), 433 deletions(-) delete mode 100644 client/web/src/hooks/node-data.ts create mode 100644 client/web/src/types.ts diff --git a/client/web/src/api.ts b/client/web/src/api.ts index 410a71d5e..7b0425d5f 100644 --- a/client/web/src/api.ts +++ b/client/web/src/api.ts @@ -1,23 +1,260 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -import { SWRConfiguration } from "swr" +import { useCallback } from "react" +import useToaster from "src/hooks/toaster" +import { ExitNode, NodeData, SubnetRoute } from "src/types" +import { assertNever } from "src/utils/util" +import { MutatorOptions, SWRConfiguration, useSWRConfig } from "swr" +import { noExitNode, runAsExitNode } from "./hooks/exit-nodes" export const swrConfig: SWRConfiguration = { fetcher: (url: string) => apiFetch(url, "GET"), onError: (err, _) => console.error(err), } +type APIType = + | { action: "up"; data: TailscaleUpData } + | { action: "logout" } + | { action: "new-auth-session"; data: AuthSessionNewData } + | { action: "update-prefs"; data: LocalPrefsData } + | { action: "update-routes"; data: SubnetRoute[] } + | { action: "update-exit-node"; data: ExitNode } + +/** + * POST /api/up data + */ +type TailscaleUpData = { + Reauthenticate?: boolean // force reauthentication + ControlURL?: string + AuthKey?: string +} + +/** + * GET /api/auth/session/new data + */ +type AuthSessionNewData = { + authUrl: string +} + +/** + * PATCH /api/local/v0/prefs data + */ +type LocalPrefsData = { + RunSSHSet?: boolean + RunSSH?: boolean +} + +/** + * POST /api/routes data + */ +type RoutesData = { + SetExitNode?: boolean + SetRoutes?: boolean + UseExitNode?: string + AdvertiseExitNode?: boolean + AdvertiseRoutes?: string[] +} + +/** + * useAPI hook returns an api handler that can execute api calls + * throughout the web client UI. + */ +export function useAPI() { + const toaster = useToaster() + const { mutate } = useSWRConfig() // allows for global mutation + + const handlePostError = useCallback( + (toast?: string) => (err: Error) => { + console.error(err) + toast && toaster.show({ variant: "danger", message: toast }) + throw err + }, + [toaster] + ) + + /** + * optimisticMutate wraps the SWR `mutate` function to apply some + * type-awareness with the following behavior: + * + * 1. `optimisticData` update is applied immediately on FetchDataType + * throughout the web client UI. + * + * 2. `fetch` data mutation runs. + * + * 3. On completion, FetchDataType is revalidated to exactly reflect the + * updated server state. + * + * The `key` argument is the useSWR key associated with the MutateDataType. + * All `useSWR(key)` consumers throughout the UI will see updates reflected. + */ + const optimisticMutate = useCallback( + ( + key: string, + fetch: Promise, + optimisticData: (current: MutateDataType) => MutateDataType + ): Promise => { + const options: MutatorOptions = { + /** + * populateCache is meant for use when the remote request returns back + * the updated data directly. i.e. When FetchDataType is the same as + * MutateDataType. Most of our data manipulation requests return a 200 + * with empty data on success. We turn off populateCache so that the + * cache only gets updated after completion of the remote reqeust when + * the revalidation step runs. + */ + populateCache: false, + optimisticData, + } + return mutate(key, fetch, options) + }, + [mutate] + ) + + const api = useCallback( + (t: APIType) => { + switch (t.action) { + /** + * "up" handles authenticating the machine to tailnet. + */ + case "up": + return apiFetch<{ url?: string }>("/up", "POST", t.data) + .then((d) => d.url && window.open(d.url, "_blank")) // "up" login step + .then(() => incrementMetric("web_client_node_connect")) + .then(() => mutate("/data")) + .catch(handlePostError("Failed to login")) + + /** + * "logout" handles logging the node out of tailscale, effectively + * expiring its node key. + */ + case "logout": + // For logout, must increment metric before running api call, + // as tailscaled will be unreachable after the call completes. + incrementMetric("web_client_node_disconnect") + return apiFetch("/local/v0/logout", "POST").catch( + handlePostError("Failed to logout") + ) + + /** + * "new-auth-session" handles creating a new check mode session to + * authorize the viewing user to manage the node via the web client. + */ + case "new-auth-session": + return apiFetch("/auth/session/new", "GET").catch( + handlePostError("Failed to create new session") + ) + + /** + * "update-prefs" handles setting the node's tailscale prefs. + */ + case "update-prefs": { + return optimisticMutate( + "/data", + apiFetch("/local/v0/prefs", "PATCH", t.data), + (old) => ({ + ...old, + RunningSSHServer: t.data.RunSSHSet + ? Boolean(t.data.RunSSH) + : old.RunningSSHServer, + }) + ) + .then( + () => + t.data.RunSSHSet && + incrementMetric( + t.data.RunSSH + ? "web_client_ssh_enable" + : "web_client_ssh_disable" + ) + ) + .catch(handlePostError("Failed to update node preference")) + } + + /** + * "update-routes" handles setting the node's advertised routes. + */ + case "update-routes": { + const body: RoutesData = { + SetRoutes: true, + AdvertiseRoutes: t.data.map((r) => r.Route), + } + return optimisticMutate( + "/data", + apiFetch("/routes", "POST", body), + (old) => ({ ...old, AdvertisedRoutes: t.data }) + ) + .then(() => incrementMetric("web_client_advertise_routes_change")) + .catch(handlePostError("Failed to update routes")) + } + + /** + * "update-exit-node" handles updating the node's state as either + * running as an exit node or using another node as an exit node. + */ + case "update-exit-node": { + const id = t.data.ID + const body: RoutesData = { + SetExitNode: true, + } + if (id !== noExitNode.ID && id !== runAsExitNode.ID) { + body.UseExitNode = id + } else if (id === runAsExitNode.ID) { + body.AdvertiseExitNode = true + } + const metrics: MetricName[] = [] + return optimisticMutate( + "/data", + apiFetch("/routes", "POST", body), + (old) => { + // Only update metrics whose values have changed. + if (old.AdvertisingExitNode !== Boolean(body.AdvertiseExitNode)) { + metrics.push( + body.AdvertiseExitNode + ? "web_client_advertise_exitnode_enable" + : "web_client_advertise_exitnode_disable" + ) + } + if (Boolean(old.UsingExitNode) !== Boolean(body.UseExitNode)) { + metrics.push( + body.UseExitNode + ? "web_client_use_exitnode_enable" + : "web_client_use_exitnode_disable" + ) + } + return { + ...old, + UsingExitNode: Boolean(body.UseExitNode) ? t.data : undefined, + AdvertisingExitNode: Boolean(body.AdvertiseExitNode), + } + } + ) + .then(() => metrics.forEach((m) => incrementMetric(m))) + .catch(handlePostError("Failed to update exit node")) + } + + default: + assertNever(t) + } + }, + [handlePostError, mutate, optimisticMutate] + ) + + return api +} + let csrfToken: string let synoToken: string | undefined // required for synology API requests let unraidCsrfToken: string | undefined // required for unraid POST requests (#8062) -// apiFetch wraps the standard JS fetch function with csrf header -// management and param additions specific to the web client. -// -// apiFetch adds the `api` prefix to the request URL, -// so endpoint should be provided without the `api` prefix -// (i.e. provide `/data` rather than `api/data`). +/** + * apiFetch wraps the standard JS fetch function with csrf header + * management and param additions specific to the web client. + * + * apiFetch adds the `api` prefix to the request URL, + * so endpoint should be provided without the `api` prefix + * (i.e. provide `/data` rather than `api/data`). + */ export function apiFetch( endpoint: string, method: "GET" | "POST" | "PATCH", @@ -73,6 +310,10 @@ export function apiFetch( return r.json() } }) + .then((r) => { + r?.UnraidToken && setUnraidCsrfToken(r.UnraidToken) + return r + }) } function updateCsrfToken(r: Response) { @@ -86,12 +327,14 @@ export function setSynoToken(token?: string) { synoToken = token } -export function setUnraidCsrfToken(token?: string) { +function setUnraidCsrfToken(token?: string) { unraidCsrfToken = token } -// incrementMetric hits the client metrics local API endpoint to -// increment the given counter metric by one. +/** + * incrementMetric hits the client metrics local API endpoint to + * increment the given counter metric by one. + */ export function incrementMetric(metricName: MetricName) { const postData: MetricsPOSTData[] = [ { diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index 58769913b..20761a4e4 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -1,7 +1,7 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -import React, { useEffect } from "react" +import React from "react" import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg" import LoginToggle from "src/components/login-toggle" import DeviceDetailsView from "src/components/views/device-details-view" @@ -11,12 +11,9 @@ import SSHView from "src/components/views/ssh-view" import SubnetRouterView from "src/components/views/subnet-router-view" import { UpdatingView } from "src/components/views/updating-view" import useAuth, { AuthResponse } from "src/hooks/auth" -import useNodeData, { - Feature, - featureDescription, - NodeData, -} from "src/hooks/node-data" +import { Feature, featureDescription, NodeData } from "src/types" import LoadingDots from "src/ui/loading-dots" +import useSWR from "swr" import { Link, Route, Router, Switch, useLocation } from "wouter" export default function App() { @@ -40,53 +37,38 @@ function WebClient({ auth: AuthResponse newSession: () => Promise }) { - const { data, refreshData, nodeUpdaters } = useNodeData() - useEffect(() => { - refreshData() - }, [auth, refreshData]) + const { data: node } = useSWR("/data") - return !data ? ( + return !node ? ( - ) : data.Status === "NeedsLogin" || - data.Status === "NoState" || - data.Status === "Stopped" ? ( + ) : node.Status === "NeedsLogin" || + node.Status === "NoState" || + node.Status === "Stopped" ? ( // Client not on a tailnet, render login. - + ) : ( // Otherwise render the new web client. <> - -
+ +
- + - + - - + + - - + + {/* TODO */}Share local content - + @@ -111,7 +93,7 @@ function FeatureRoute({ children, }: { path: string - node: NodeData // TODO: once we have swr, just call useNodeData within FeatureView + node: NodeData feature: Feature children: React.ReactNode }) { diff --git a/client/web/src/components/control-components.tsx b/client/web/src/components/control-components.tsx index d961e0684..ffb0a2999 100644 --- a/client/web/src/components/control-components.tsx +++ b/client/web/src/components/control-components.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BSD-3-Clause import React from "react" -import { NodeData } from "src/hooks/node-data" +import { NodeData } from "src/types" /** * AdminContainer renders its contents only if the node's control diff --git a/client/web/src/components/exit-node-selector.tsx b/client/web/src/components/exit-node-selector.tsx index 3199ad923..aa8eef018 100644 --- a/client/web/src/components/exit-node-selector.tsx +++ b/client/web/src/components/exit-node-selector.tsx @@ -2,32 +2,32 @@ // SPDX-License-Identifier: BSD-3-Clause import cx from "classnames" -import React, { useCallback, useMemo, useRef, useState } from "react" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useAPI } from "src/api" import { ReactComponent as Check } from "src/assets/icons/check.svg" import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg" import useExitNodes, { - ExitNode, noExitNode, runAsExitNode, trimDNSSuffix, } from "src/hooks/exit-nodes" -import { NodeData, NodeUpdaters } from "src/hooks/node-data" +import { ExitNode, NodeData } from "src/types" import Popover from "src/ui/popover" import SearchInput from "src/ui/search-input" export default function ExitNodeSelector({ className, node, - nodeUpdaters, disabled, }: { className?: string node: NodeData - nodeUpdaters: NodeUpdaters disabled?: boolean }) { + const api = useAPI() const [open, setOpen] = useState(false) const [selected, setSelected] = useState(toSelectedExitNode(node)) + useEffect(() => setSelected(toSelectedExitNode(node)), [node]) const handleSelect = useCallback( (n: ExitNode) => { @@ -35,11 +35,9 @@ export default function ExitNodeSelector({ if (n.ID === selected.ID) { return // no update } - const old = selected - setSelected(n) // optimistic UI update - nodeUpdaters.postExitNode(n).catch(() => setSelected(old)) + api({ action: "update-exit-node", data: n }) }, - [nodeUpdaters, selected] + [api, selected] ) const [ diff --git a/client/web/src/components/login-toggle.tsx b/client/web/src/components/login-toggle.tsx index fe0811f14..011aa2f8b 100644 --- a/client/web/src/components/login-toggle.tsx +++ b/client/web/src/components/login-toggle.tsx @@ -7,7 +7,7 @@ import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg import { ReactComponent as Eye } from "src/assets/icons/eye.svg" import { ReactComponent as User } from "src/assets/icons/user.svg" import { AuthResponse, AuthType } from "src/hooks/auth" -import { NodeData } from "src/hooks/node-data" +import { NodeData } from "src/types" import Button from "src/ui/button" import Popover from "src/ui/popover" import ProfilePic from "src/ui/profile-pic" diff --git a/client/web/src/components/update-available.tsx b/client/web/src/components/update-available.tsx index 51105876f..f0a284bb3 100644 --- a/client/web/src/components/update-available.tsx +++ b/client/web/src/components/update-available.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BSD-3-Clause import React from "react" -import { VersionInfo } from "src/hooks/self-update" +import { VersionInfo } from "src/types" import Button from "src/ui/button" import { useLocation } from "wouter" diff --git a/client/web/src/components/views/device-details-view.tsx b/client/web/src/components/views/device-details-view.tsx index 83f431a4a..eb54d9b77 100644 --- a/client/web/src/components/views/device-details-view.tsx +++ b/client/web/src/components/views/device-details-view.tsx @@ -3,12 +3,12 @@ import cx from "classnames" import React from "react" -import { apiFetch, incrementMetric } from "src/api" +import { useAPI } from "src/api" import ACLTag from "src/components/acl-tag" import * as Control from "src/components/control-components" import NiceIP from "src/components/nice-ip" import { UpdateAvailableNotification } from "src/components/update-available" -import { NodeData } from "src/hooks/node-data" +import { NodeData } from "src/types" import Button from "src/ui/button" import QuickCopy from "src/ui/quick-copy" import { useLocation } from "wouter" @@ -20,6 +20,7 @@ export default function DeviceDetailsView({ readonly: boolean node: NodeData }) { + const api = useAPI() const [, setLocation] = useLocation() return ( @@ -40,13 +41,9 @@ export default function DeviceDetailsView({ {!readonly && ( diff --git a/client/web/src/components/views/home-view.tsx b/client/web/src/components/views/home-view.tsx index 97eb9ebb2..26ec466d5 100644 --- a/client/web/src/components/views/home-view.tsx +++ b/client/web/src/components/views/home-view.tsx @@ -8,18 +8,16 @@ import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg" import { ReactComponent as Machine } from "src/assets/icons/machine.svg" import AddressCard from "src/components/address-copy-card" import ExitNodeSelector from "src/components/exit-node-selector" -import { NodeData, NodeUpdaters } from "src/hooks/node-data" +import { NodeData } from "src/types" import { pluralize } from "src/utils/util" import { Link, useLocation } from "wouter" export default function HomeView({ readonly, node, - nodeUpdaters, }: { readonly: boolean node: NodeData - nodeUpdaters: NodeUpdaters }) { const [allSubnetRoutes, pendingSubnetRoutes] = useMemo( () => [ @@ -62,12 +60,7 @@ export default function HomeView({ {(node.Features["advertise-exit-node"] || node.Features["use-exit-node"]) && ( - + )} void -}) { +export default function LoginView({ data }: { data: NodeData }) { + const api = useAPI() const [controlURL, setControlURL] = useState("") const [authKey, setAuthKey] = useState("") - const login = useCallback( - (opt: TailscaleUpOptions) => { - tailscaleUp(opt).then(() => { - incrementMetric("web_client_node_connect") - refreshData() - }) - }, - [refreshData] - ) - return (
@@ -45,7 +30,7 @@ export default function LoginView({