client/web: start using swr for some fetching

Adds swr to the web client, and starts by using it from the
useNodeData hook.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
Sonia Appasamy 2023-12-05 18:03:05 -05:00 committed by Sonia Appasamy
parent 014ae98297
commit 95655405b8
9 changed files with 70 additions and 68 deletions

View File

@ -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",
"zustand": "^4.4.7"
},

View File

@ -1,6 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { SWRConfiguration } from "swr"
export const swrConfig: SWRConfiguration = {
fetcher: (url: string) => apiFetch(url, "GET"),
onError: (err, _) => console.error(err),
}
let csrfToken: string
let synoToken: string | undefined // required for synology API requests
let unraidCsrfToken: string | undefined // required for unraid POST requests (#8062)
@ -11,14 +18,13 @@ 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> {
body?: any
): 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 {
@ -51,16 +57,22 @@ export function apiFetch(
"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
body: body,
})
.then((r) => {
updateCsrfToken(r)
if (!r.ok) {
return r.text().then((err) => {
throw new Error(err)
})
}
return r
})
.then((r) => {
if (r.headers.get("Content-Type") === "application/json") {
return r.json()
}
})
}
function updateCsrfToken(r: Response) {

View File

@ -146,8 +146,7 @@ type TailscaleUpOptions = {
}
function tailscaleUp(options: TailscaleUpOptions) {
return apiFetch("/up", "POST", options)
.then((r) => r.json())
return apiFetch<{ url?: string }>("/up", "POST", options)
.then((d) => {
d.url && window.open(d.url, "_blank")
})

View File

@ -28,11 +28,10 @@ export default function useAuth() {
const loadAuth = useCallback(() => {
setLoading(true)
return apiFetch("/auth", "GET")
.then((r) => r.json())
return apiFetch<AuthResponse>("/auth", "GET")
.then((d) => {
setData(d)
switch ((d as AuthResponse).authNeeded) {
switch (d.authNeeded) {
case AuthType.synology:
fetch("/webman/login.cgi")
.then((r) => r.json())
@ -53,15 +52,16 @@ export default function useAuth() {
}, [])
const newSession = useCallback(() => {
return apiFetch("/auth/session/new", "GET")
.then((r) => r.json())
return apiFetch<{ authUrl?: string }>("/auth/session/new", "GET")
.then((d) => {
if (d.authUrl) {
window.open(d.authUrl, "_blank")
return apiFetch("/auth/session/wait", "GET")
}
})
.then(() => loadAuth())
.then(() => {
loadAuth()
})
.catch((error) => {
console.error(error)
})
@ -70,7 +70,7 @@ export default function useAuth() {
useEffect(() => {
loadAuth().then((d) => {
if (
!d.canManageNode &&
!d?.canManageNode &&
new URLSearchParams(window.location.search).get("check") === "now"
) {
newSession()

View File

@ -33,8 +33,7 @@ export default function useExitNodes(node: NodeData, filter?: string) {
const [data, setData] = useState<ExitNode[]>([])
useEffect(() => {
apiFetch("/exit-nodes", "GET")
.then((r) => r.json())
apiFetch<ExitNode[]>("/exit-nodes", "GET")
.then((r) => setData(r))
.catch((err) => {
alert("Failed operation: " + err.message)

View File

@ -6,6 +6,7 @@ import { apiFetch, incrementMetric, setUnraidCsrfToken } from "src/api"
import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes"
import { VersionInfo } from "src/hooks/self-update"
import { assertNever } from "src/utils/util"
import useSWR from "swr"
export type NodeData = {
Profile: UserProfile
@ -120,19 +121,12 @@ 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")
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]
useEffect(
() => setUnraidCsrfToken(data?.IsUnraid ? data.UnraidToken : undefined),
[data]
)
const prefsPATCH = useCallback(
@ -147,12 +141,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, false)
}
const onComplete = () => {
setIsPosting(false)
refreshData() // refresh data after PATCH finishes
mutate() // refresh data after PATCH finishes
}
return apiFetch("/local/v0/prefs", "PATCH", d)
@ -163,7 +157,7 @@ export default function useNodeData() {
throw err
})
},
[setIsPosting, refreshData, setData, data]
[data, mutate]
)
const routesPOST = useCallback(
@ -171,7 +165,7 @@ export default function useNodeData() {
setIsPosting(true)
const onComplete = () => {
setIsPosting(false)
refreshData() // refresh data after POST finishes
mutate() // refresh data after POST finishes
}
const updateMetrics = () => {
// only update metrics if values have changed
@ -195,26 +189,7 @@ export default function useNodeData() {
throw err
})
},
[setIsPosting, refreshData, data?.AdvertisingExitNode]
)
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]
[mutate, data?.AdvertisingExitNode]
)
const nodeUpdaters: NodeUpdaters = useMemo(
@ -245,5 +220,5 @@ export default function useNodeData() {
]
)
return { data, refreshData, nodeUpdaters, isPosting }
return { data, refreshData: mutate, nodeUpdaters, isPosting }
}

View File

@ -61,9 +61,8 @@ export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) {
let timer: NodeJS.Timeout | undefined
function poll() {
apiFetch("/local/v0/update/progress", "GET")
.then((res) => res.json())
.then((res: UpdateProgress[]) => {
apiFetch<UpdateProgress[]>("/local/v0/update/progress", "GET")
.then((res) => {
// res contains a list of UpdateProgresses that is strictly increasing
// in size, so updateMessagesRead keeps track (across calls of poll())
// of how many of those we have already read. This is why it is not

View File

@ -10,8 +10,10 @@
import React from "react"
import { createRoot } from "react-dom/client"
import { swrConfig } from "src/api"
import App from "src/components/app"
import ToastProvider from "src/ui/toaster"
import { SWRConfig } from "swr"
declare var window: any
// This is used to determine if the react client is built.
@ -26,8 +28,10 @@ const root = createRoot(rootEl)
root.render(
<React.StrictMode>
<ToastProvider>
<App />
</ToastProvider>
<SWRConfig value={swrConfig}>
<ToastProvider>
<App />
</ToastProvider>
</SWRConfig>
</React.StrictMode>
)

View File

@ -2599,6 +2599,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"
@ -4872,6 +4877,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"
symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
@ -5159,7 +5172,7 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0:
use-sync-external-store@1.2.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==