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({