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