diff options
| author | Sonia Appasamy <sonia@tailscale.com> | 2023-12-05 15:06:59 -0500 |
|---|---|---|
| committer | Sonia Appasamy <sonia@tailscale.com> | 2023-12-05 15:06:59 -0500 |
| commit | c7c59d910c0d5c62c3c6c450f8bb7ec93951220d (patch) | |
| tree | 6d7af0fa36de35324548be428d7847365e199eb9 | |
| parent | c27aa9e7ff6d906cacfa5e1f5a583e4dabf4eb1e (diff) | |
| download | tailscale-soniaappasamy/use-swr.tar.xz tailscale-soniaappasamy/use-swr.zip | |
| -rw-r--r-- | client/web/package.json | 1 | ||||
| -rw-r--r-- | client/web/src/api.ts | 80 | ||||
| -rw-r--r-- | client/web/src/components/app.tsx | 33 | ||||
| -rw-r--r-- | client/web/src/components/views/ssh-view.tsx | 1 | ||||
| -rw-r--r-- | client/web/src/hooks/auth.ts | 66 | ||||
| -rw-r--r-- | client/web/src/hooks/exit-nodes.ts | 31 | ||||
| -rw-r--r-- | client/web/src/hooks/node-data.ts | 64 | ||||
| -rw-r--r-- | client/web/src/index.tsx | 17 | ||||
| -rw-r--r-- | client/web/yarn.lock | 15 |
9 files changed, 167 insertions, 141 deletions
diff --git a/client/web/package.json b/client/web/package.json index 81494e4d4..dc317b757 100644 --- a/client/web/package.json +++ b/client/web/package.json @@ -13,6 +13,7 @@ "classnames": "^2.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "swr": "^2.2.4", "wouter": "^2.11.0" }, "devDependencies": { diff --git a/client/web/src/api.ts b/client/web/src/api.ts index b6c0c4415..5114197d8 100644 --- a/client/web/src/api.ts +++ b/client/web/src/api.ts @@ -11,14 +11,12 @@ let unraidCsrfToken: string | undefined // required for unraid POST requests (#8 // 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( +export function apiFetch<T>( endpoint: string, - method: "GET" | "POST" | "PATCH", - body?: any, - params?: Record<string, string> -): Promise<Response> { + init?: RequestInit | undefined +): Promise<T> { const urlParams = new URLSearchParams(window.location.search) - const nextParams = new URLSearchParams(params) + const nextParams = new URLSearchParams() if (synoToken) { nextParams.set("SynoToken", synoToken) } else { @@ -31,36 +29,43 @@ export function apiFetch( const url = `api${endpoint}${search ? `?${search}` : ""}` var contentType: string - if (unraidCsrfToken && method === "POST") { + if (unraidCsrfToken && init?.method === "POST") { const params = new URLSearchParams() params.append("csrf_token", unraidCsrfToken) - if (body) { - params.append("ts_data", JSON.stringify(body)) + if (init.body) { + params.append("ts_data", init.body.toString()) } - body = params.toString() + init.body = params.toString() contentType = "application/x-www-form-urlencoded;charset=UTF-8" } else { - body = body ? JSON.stringify(body) : undefined contentType = "application/json" } return fetch(url, { - method: method, + ...init, headers: { Accept: "application/json", "Content-Type": contentType, "X-CSRF-Token": csrfToken, }, - body, - }).then((r) => { - updateCsrfToken(r) - if (!r.ok) { - return r.text().then((err) => { - throw new Error(err) - }) - } - return r }) + .then((r) => { + updateCsrfToken(r) + if (!r.ok) { + return r.text().then((err) => { + throw new Error(err) + }) + } + return r + }) + .then((r) => r.json()) + .then((r) => { + // TODO: MAYBE SET USING TOKEN HEADER + if (r.IsUnraid && r.UnraidToken) { + setUnraidCsrfToken(r.UnraidToken) + } + return r + }) } function updateCsrfToken(r: Response) { @@ -77,3 +82,36 @@ export function setSynoToken(token?: string) { export function setUnraidCsrfToken(token?: string) { unraidCsrfToken = token } + +/** + * Some fetch wrappers. + */ + +export async function getAuthSessionNew(): Promise<void> { + const d = await apiFetch<{ authUrl: string }>("/auth/session/new", { + method: "GET", + }) + if (d.authUrl) { + window.open(d.authUrl, "_blank") + await apiFetch("/auth/session/wait", { method: "GET" }) + } + // todo: still need catch for these, not using swr +} + +type PatchLocalPrefsData = { + RunSSHSet?: boolean + RunSSH?: boolean +} + +export async function patchLocalPrefs(p: PatchLocalPrefsData): Promise<void> { + return apiFetch("/local/v0/prefs", { + method: "PATCH", + body: JSON.stringify(p), // todo: annoying to do this for all... + }) + // .then(onComplete) + // .catch((err) => { + // onComplete() + // alert("Failed to update prefs") + // throw err + // }) +} diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index 8fcdaf6f6..2320d07c5 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: BSD-3-Clause import React, { useEffect } from "react" +import { getAuthSessionNew } from "src/api" 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" @@ -12,29 +13,24 @@ 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, { NodeData } from "src/hooks/node-data" +import { useSWRConfig } from "swr" import { Link, Route, Router, Switch, useLocation } from "wouter" export default function App() { - const { data: auth, loading: loadingAuth, newSession } = useAuth() + const { data: auth, loading: loadingAuth } = useAuth() return ( <main className="min-w-sm max-w-lg mx-auto py-14 px-5"> {loadingAuth || !auth ? ( <div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view ) : ( - <WebClient auth={auth} newSession={newSession} /> + <WebClient auth={auth} /> )} </main> ) } -function WebClient({ - auth, - newSession, -}: { - auth: AuthResponse - newSession: () => Promise<void> -}) { +function WebClient({ auth }: { auth: AuthResponse }) { const { data, refreshData, nodeUpdaters } = useNodeData() useEffect(() => { refreshData() @@ -51,7 +47,7 @@ function WebClient({ // Otherwise render the new web client. <> <Router base={data.URLPrefix}> - <Header node={data} auth={auth} newSession={newSession} /> + <Header node={data} auth={auth} /> <Switch> <Route path="/"> <HomeView @@ -93,15 +89,8 @@ function WebClient({ ) } -function Header({ - node, - auth, - newSession, -}: { - node: NodeData - auth: AuthResponse - newSession: () => Promise<void> -}) { +function Header({ node, auth }: { node: NodeData; auth: AuthResponse }) { + const { mutate } = useSWRConfig() const [loc] = useLocation() return ( @@ -113,7 +102,11 @@ function Header({ {node.DomainName} </div> </div> - <LoginToggle node={node} auth={auth} newSession={newSession} /> + <LoginToggle + node={node} + auth={auth} + newSession={() => getAuthSessionNew().then(() => mutate("/data"))} + /> </div> {loc !== "/" && loc !== "/update" && ( <Link diff --git a/client/web/src/components/views/ssh-view.tsx b/client/web/src/components/views/ssh-view.tsx index 1a80edf99..11aaf8225 100644 --- a/client/web/src/components/views/ssh-view.tsx +++ b/client/web/src/components/views/ssh-view.tsx @@ -6,6 +6,7 @@ import * as Control from "src/components/control-components" import { NodeData, NodeUpdaters } from "src/hooks/node-data" import Toggle from "src/ui/toggle" +// todo: htis fist export default function SSHView({ readonly, node, diff --git a/client/web/src/hooks/auth.ts b/client/web/src/hooks/auth.ts index f8537681a..9b2eca15e 100644 --- a/client/web/src/hooks/auth.ts +++ b/client/web/src/hooks/auth.ts @@ -1,8 +1,9 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -import { useCallback, useEffect, useState } from "react" -import { apiFetch, setSynoToken } from "src/api" +import { useEffect } from "react" +import { getAuthSessionNew, setSynoToken } from "src/api" +import useSWR from "swr" export enum AuthType { synology = "synology", @@ -23,57 +24,29 @@ export type AuthResponse = { // useAuth reports and refreshes Tailscale auth status // for the web client. export default function useAuth() { - const [data, setData] = useState<AuthResponse>() - const [loading, setLoading] = useState<boolean>(true) + const { data, isLoading, mutate } = useSWR<AuthResponse>("/auth") - const loadAuth = useCallback(() => { - setLoading(true) - return apiFetch("/auth", "GET") - .then((r) => r.json()) - .then((d) => { - setData(d) - switch ((d as AuthResponse).authNeeded) { - case AuthType.synology: - fetch("/webman/login.cgi") - .then((r) => r.json()) - .then((a) => { - setSynoToken(a.SynoToken) - setLoading(false) - }) - break - default: - setLoading(false) - } - return d - }) - .catch((error) => { - setLoading(false) - console.error(error) - }) - }, []) - - const newSession = useCallback(() => { - return apiFetch("/auth/session/new", "GET") - .then((r) => r.json()) - .then((d) => { - if (d.authUrl) { - window.open(d.authUrl, "_blank") - return apiFetch("/auth/session/wait", "GET") - } - }) - .then(() => loadAuth()) - .catch((error) => { - console.error(error) - }) - }, [loadAuth]) + useEffect(() => { + if (data?.authNeeded === AuthType.synology) { + fetch("/webman/login.cgi") + .then((r) => r.json()) + .then((a) => { + setSynoToken(a.SynoToken) + // Refresh auth reponse once synology + // auth completed. + mutate() + }) + } + }) + // TODO useEffect(() => { loadAuth().then((d) => { if ( !d.canManageNode && new URLSearchParams(window.location.search).get("check") === "now" ) { - newSession() + getAuthSessionNew() } }) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -81,7 +54,6 @@ export default function useAuth() { return { data, - loading, - newSession, + loading: isLoading || data?.authNeeded === AuthType.synology, } } diff --git a/client/web/src/hooks/exit-nodes.ts b/client/web/src/hooks/exit-nodes.ts index 9f08e0be8..85547f5c3 100644 --- a/client/web/src/hooks/exit-nodes.ts +++ b/client/web/src/hooks/exit-nodes.ts @@ -1,8 +1,9 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -import { useEffect, useMemo, useState } from "react" -import { apiFetch } from "src/api" +import { useMemo } from "react" +import { NodeData } from "src/hooks/node-data" +import useSWR from "swr" export type ExitNode = { ID: string @@ -28,17 +29,19 @@ export type ExitNodeGroup = { nodes: ExitNode[] } -export default function useExitNodes(tailnetName: string, filter?: string) { - const [data, setData] = useState<ExitNode[]>([]) +export default function useExitNodes(filter?: string) { + const { data: node } = useSWR<NodeData>("/data") + const { data } = useSWR<ExitNode[]>("/exit-nodes") // TODO: PIPE BACK ERRORS, MAYBE SWR HAS SOMETHING GOOD + // const [data, setData] = useState<ExitNode[]>([]) - useEffect(() => { - apiFetch("/exit-nodes", "GET") - .then((r) => r.json()) - .then((r) => setData(r)) - .catch((err) => { - alert("Failed operation: " + err.message) - }) - }, []) + // useEffect(() => { + // apiFetch("/exit-nodes", "GET") + // .then((r) => r.json()) + // .then((r) => setData(r)) + // .catch((err) => { + // alert("Failed operation: " + err.message) + // }) + // }, []) const { tailnetNodesSorted, locationNodesMap } = useMemo(() => { // First going through exit nodes and splitting them into two groups: @@ -55,7 +58,7 @@ export default function useExitNodes(tailnetName: string, filter?: string) { // Only Mullvad exit nodes have locations filled. tailnetNodes.push({ ...n, - Name: trimDNSSuffix(n.Name, tailnetName), + Name: trimDNSSuffix(n.Name, node?.TailnetName || ""), }) return } @@ -70,7 +73,7 @@ export default function useExitNodes(tailnetName: string, filter?: string) { tailnetNodesSorted: tailnetNodes.sort(compareByName), locationNodesMap: locationNodes, } - }, [data, tailnetName]) + }, [data, node?.TailnetName]) const hasFilter = Boolean(filter) diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index 981ff9bcc..39e9c7cc5 100644 --- a/client/web/src/hooks/node-data.ts +++ b/client/web/src/hooks/node-data.ts @@ -1,10 +1,11 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause -import { useCallback, useEffect, useMemo, useState } from "react" -import { apiFetch, setUnraidCsrfToken } from "src/api" +import { useCallback, useMemo, useState } from "react" +import { apiFetch } from "src/api" import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes" import { VersionInfo } from "src/hooks/self-update" +import useSWR from "swr" export type NodeData = { Profile: UserProfile @@ -93,21 +94,10 @@ type RoutesPOSTData = { // useNodeData returns basic data about the current node. export default function useNodeData() { - const [data, setData] = useState<NodeData>() + const { data, mutate } = useSWR<NodeData>("/data") // TODO: USE GLOBAL MUTATE!!! + // const [data, setData] = useState<NodeData>() const [isPosting, setIsPosting] = useState<boolean>(false) - const refreshData = useCallback( - () => - apiFetch("/data", "GET") - .then((r) => r.json()) - .then((d: NodeData) => { - setData(d) - setUnraidCsrfToken(d.IsUnraid ? d.UnraidToken : undefined) - }) - .catch((error) => console.error(error)), - [setData] - ) - const prefsPATCH = useCallback( (d: PrefsPATCHData) => { setIsPosting(true) @@ -120,12 +110,12 @@ export default function useNodeData() { // then make the prefs PATCH. If the request fails, // data will be updated to it's previous value in // onComplete below. - setData(optimisticUpdates) + mutate(optimisticUpdates, { revalidate: false }) } const onComplete = () => { setIsPosting(false) - refreshData() // refresh data after PATCH finishes + mutate() // refresh data after PATCH finishes } return apiFetch("/local/v0/prefs", "PATCH", d) @@ -136,7 +126,7 @@ export default function useNodeData() { throw err }) }, - [setIsPosting, refreshData, setData, data] + [data, mutate] ) const routesPOST = useCallback( @@ -144,7 +134,7 @@ export default function useNodeData() { setIsPosting(true) const onComplete = () => { setIsPosting(false) - refreshData() // refresh data after POST finishes + mutate() // refresh data after POST finishes } return apiFetch("/routes", "POST", d) @@ -155,27 +145,27 @@ export default function useNodeData() { throw err }) }, - [setIsPosting, refreshData] + [setIsPosting, mutate] ) - useEffect( - () => { - // Initial data load. - refreshData() + // useEffect( + // () => { + // // Initial data load. + // refreshData() - // Refresh on browser tab focus. - const onVisibilityChange = () => { - document.visibilityState === "visible" && refreshData() - } - window.addEventListener("visibilitychange", onVisibilityChange) - return () => { - // Cleanup browser tab listener. - window.removeEventListener("visibilitychange", onVisibilityChange) - } - }, - // Run once. - [refreshData] - ) + // // Refresh on browser tab focus. + // const onVisibilityChange = () => { + // document.visibilityState === "visible" && refreshData() + // } + // window.addEventListener("visibilitychange", onVisibilityChange) + // return () => { + // // Cleanup browser tab listener. + // window.removeEventListener("visibilitychange", onVisibilityChange) + // } + // }, + // // Run once. + // [refreshData] + // ) const nodeUpdaters: NodeUpdaters = useMemo( () => ({ diff --git a/client/web/src/index.tsx b/client/web/src/index.tsx index ef5a65a44..9f252d5b0 100644 --- a/client/web/src/index.tsx +++ b/client/web/src/index.tsx @@ -11,6 +11,8 @@ import React from "react" import { createRoot } from "react-dom/client" import App from "src/components/app" +import { SWRConfig } from "swr" +import { apiFetch } from "./api" declare var window: any // This is used to determine if the react client is built. @@ -25,6 +27,19 @@ const root = createRoot(rootEl) root.render( <React.StrictMode> - <App /> + <SWRConfig + value={{ + fetcher: apiFetch, + onError: (err, _) => { + // TODO: toast on error instead? + if (err.message) { + alert(`Request failed: ${err.message}`) + } + console.error(err) + }, + }} + > + <App /> + </SWRConfig> </React.StrictMode> ) diff --git a/client/web/yarn.lock b/client/web/yarn.lock index efcabd8ab..c699f2e8d 100644 --- a/client/web/yarn.lock +++ b/client/web/yarn.lock @@ -2587,6 +2587,11 @@ classnames@^2.3.1: resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== +client-only@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -4701,6 +4706,14 @@ svg-parser@^2.0.4: resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== +swr@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.2.4.tgz#03ec4c56019902fbdc904d78544bd7a9a6fa3f07" + integrity sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ== + dependencies: + client-only "^0.0.1" + use-sync-external-store "^1.2.0" + tailwindcss@^3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf" @@ -4953,7 +4966,7 @@ use-sidecar@^1.1.2: detect-node-es "^1.1.0" tslib "^2.0.0" -use-sync-external-store@^1.0.0: +use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== |
