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

View File

@@ -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<void>
}) {
const { data, refreshData, nodeUpdaters } = useNodeData()
useEffect(() => {
refreshData()
}, [auth, refreshData])
const { data: node } = useSWR<NodeData>("/data")
return !data ? (
return !node ? (
<LoadingView />
) : 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.
<LoginView data={data} refreshData={refreshData} />
<LoginView data={node} />
) : (
// Otherwise render the new web client.
<>
<Router base={data.URLPrefix}>
<Header node={data} auth={auth} newSession={newSession} />
<Router base={node.URLPrefix}>
<Header node={node} auth={auth} newSession={newSession} />
<Switch>
<Route path="/">
<HomeView
readonly={!auth.canManageNode}
node={data}
nodeUpdaters={nodeUpdaters}
/>
<HomeView readonly={!auth.canManageNode} node={node} />
</Route>
<Route path="/details">
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
<DeviceDetailsView readonly={!auth.canManageNode} node={node} />
</Route>
<FeatureRoute path="/subnets" feature="advertise-routes" node={data}>
<SubnetRouterView
readonly={!auth.canManageNode}
node={data}
nodeUpdaters={nodeUpdaters}
/>
<FeatureRoute path="/subnets" feature="advertise-routes" node={node}>
<SubnetRouterView readonly={!auth.canManageNode} node={node} />
</FeatureRoute>
<FeatureRoute path="/ssh" feature="ssh" node={data}>
<SSHView
readonly={!auth.canManageNode}
node={data}
nodeUpdaters={nodeUpdaters}
/>
<FeatureRoute path="/ssh" feature="ssh" node={node}>
<SSHView readonly={!auth.canManageNode} node={node} />
</FeatureRoute>
<Route path="/serve">{/* TODO */}Share local content</Route>
<FeatureRoute path="/update" feature="auto-update" node={data}>
<FeatureRoute path="/update" feature="auto-update" node={node}>
<UpdatingView
versionInfo={data.ClientVersion}
currentVersion={data.IPNVersion}
versionInfo={node.ClientVersion}
currentVersion={node.IPNVersion}
/>
</FeatureRoute>
<Route>
@@ -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
}) {

View File

@@ -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

View File

@@ -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<boolean>(false)
const [selected, setSelected] = useState<ExitNode>(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 [

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 && (
<Button
sizeVariant="small"
onClick={() => {
// increment metrics before logout as we don't gracefully handle disconnect currently
incrementMetric("web_client_node_disconnect")
apiFetch("/local/v0/logout", "POST")
.then(() => setLocation("/"))
.catch((err) => alert("Logout failed: " + err.message))
}}
onClick={() =>
api({ action: "logout" }).then(() => setLocation("/"))
}
>
Disconnect
</Button>

View File

@@ -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({
</div>
{(node.Features["advertise-exit-node"] ||
node.Features["use-exit-node"]) && (
<ExitNodeSelector
className="mb-5"
node={node}
nodeUpdaters={nodeUpdaters}
disabled={readonly}
/>
<ExitNodeSelector className="mb-5" node={node} disabled={readonly} />
)}
<Link
className="link font-medium"

View File

@@ -1,10 +1,10 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import React, { useCallback, useState } from "react"
import { apiFetch, incrementMetric } from "src/api"
import React, { useState } from "react"
import { useAPI } from "src/api"
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
import { NodeData } from "src/hooks/node-data"
import { NodeData } from "src/types"
import Button from "src/ui/button"
import Collapsible from "src/ui/collapsible"
import Input from "src/ui/input"
@@ -13,26 +13,11 @@ import Input from "src/ui/input"
* LoginView is rendered when the client is not authenticated
* to a tailnet.
*/
export default function LoginView({
data,
refreshData,
}: {
data: NodeData
refreshData: () => void
}) {
export default function LoginView({ data }: { data: NodeData }) {
const api = useAPI()
const [controlURL, setControlURL] = useState<string>("")
const [authKey, setAuthKey] = useState<string>("")
const login = useCallback(
(opt: TailscaleUpOptions) => {
tailscaleUp(opt).then(() => {
incrementMetric("web_client_node_connect")
refreshData()
})
},
[refreshData]
)
return (
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
<TailscaleIcon className="my-2 mb-8" />
@@ -45,7 +30,7 @@ export default function LoginView({
</p>
</div>
<Button
onClick={() => login({})}
onClick={() => api({ action: "up", data: {} })}
className="w-full mb-4"
intent="primary"
>
@@ -70,7 +55,9 @@ export default function LoginView({
</p>
</div>
<Button
onClick={() => login({ Reauthenticate: true })}
onClick={() =>
api({ action: "up", data: { Reauthenticate: true } })
}
className="w-full mb-4"
intent="primary"
>
@@ -97,10 +84,13 @@ export default function LoginView({
</div>
<Button
onClick={() =>
login({
Reauthenticate: true,
ControlURL: controlURL,
AuthKey: authKey,
api({
action: "up",
data: {
Reauthenticate: true,
ControlURL: controlURL,
AuthKey: authKey,
},
})
}
className="w-full mb-4"
@@ -141,19 +131,3 @@ export default function LoginView({
</div>
)
}
type TailscaleUpOptions = {
Reauthenticate?: boolean // force reauthentication
ControlURL?: string
AuthKey?: string
}
function tailscaleUp(options: TailscaleUpOptions) {
return apiFetch<{ url?: string }>("/up", "POST", options)
.then((d) => {
d.url && window.open(d.url, "_blank")
})
.catch((e) => {
console.error("Failed to login:", e)
})
}

View File

@@ -2,20 +2,21 @@
// SPDX-License-Identifier: BSD-3-Clause
import React from "react"
import { useAPI } from "src/api"
import * as Control from "src/components/control-components"
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import { NodeData } from "src/types"
import Card from "src/ui/card"
import Toggle from "src/ui/toggle"
export default function SSHView({
readonly,
node,
nodeUpdaters,
}: {
readonly: boolean
node: NodeData
nodeUpdaters: NodeUpdaters
}) {
const api = useAPI()
return (
<>
<h1 className="mb-1">Tailscale SSH server</h1>
@@ -36,9 +37,12 @@ export default function SSHView({
<Toggle
checked={node.RunningSSHServer}
onChange={() =>
nodeUpdaters.patchPrefs({
RunSSHSet: true,
RunSSH: !node.RunningSSHServer,
api({
action: "update-prefs",
data: {
RunSSHSet: true,
RunSSH: !node.RunningSSHServer,
},
})
}
disabled={readonly}

View File

@@ -2,11 +2,12 @@
// SPDX-License-Identifier: BSD-3-Clause
import React, { useCallback, useMemo, useState } from "react"
import { useAPI } from "src/api"
import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg"
import { ReactComponent as Clock } from "src/assets/icons/clock.svg"
import { ReactComponent as Plus } from "src/assets/icons/plus.svg"
import * as Control from "src/components/control-components"
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import { NodeData } from "src/types"
import Button from "src/ui/button"
import Card from "src/ui/card"
import EmptyState from "src/ui/empty-state"
@@ -15,12 +16,11 @@ import Input from "src/ui/input"
export default function SubnetRouterView({
readonly,
node,
nodeUpdaters,
}: {
readonly: boolean
node: NodeData
nodeUpdaters: NodeUpdaters
}) {
const api = useAPI()
const [advertisedRoutes, hasRoutes, hasUnapprovedRoutes] = useMemo(() => {
const routes = node.AdvertisedRoutes || []
return [routes, routes.length > 0, routes.find((r) => !r.Approved)]
@@ -70,12 +70,15 @@ export default function SubnetRouterView({
<Button
intent="primary"
onClick={() =>
nodeUpdaters
.postSubnetRoutes([
...advertisedRoutes.map((r) => r.Route),
...inputText.split(","),
])
.then(resetInput)
api({
action: "update-routes",
data: [
...advertisedRoutes,
...inputText
.split(",")
.map((r) => ({ Route: r, Approved: false })),
],
}).then(resetInput)
}
disabled={!inputText}
>
@@ -124,11 +127,12 @@ export default function SubnetRouterView({
<Button
sizeVariant="small"
onClick={() =>
nodeUpdaters.postSubnetRoutes(
advertisedRoutes
.map((it) => it.Route)
.filter((it) => it !== r.Route)
)
api({
action: "update-routes",
data: advertisedRoutes.filter(
(it) => it.Route !== r.Route
),
})
}
>
Stop advertising

View File

@@ -5,11 +5,8 @@ import React from "react"
import { ReactComponent as CheckCircleIcon } from "src/assets/icons/check-circle.svg"
import { ReactComponent as XCircleIcon } from "src/assets/icons/x-circle.svg"
import { ChangelogText } from "src/components/update-available"
import {
UpdateState,
useInstallUpdate,
VersionInfo,
} from "src/hooks/self-update"
import { UpdateState, useInstallUpdate } from "src/hooks/self-update"
import { VersionInfo } from "src/types"
import Button from "src/ui/button"
import Spinner from "src/ui/spinner"
import { useLocation } from "wouter"