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>
pull/10462/head
Sonia Appasamy 12 months ago committed by Sonia Appasamy
parent 014ae98297
commit 95655405b8

@ -13,6 +13,7 @@
"classnames": "^2.3.1", "classnames": "^2.3.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"swr": "^2.2.4",
"wouter": "^2.11.0", "wouter": "^2.11.0",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },

@ -1,6 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // 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 csrfToken: string
let synoToken: string | undefined // required for synology API requests let synoToken: string | undefined // required for synology API requests
let unraidCsrfToken: string | undefined // required for unraid POST requests (#8062) 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, // apiFetch adds the `api` prefix to the request URL,
// so endpoint should be provided without the `api` prefix // so endpoint should be provided without the `api` prefix
// (i.e. provide `/data` rather than `api/data`). // (i.e. provide `/data` rather than `api/data`).
export function apiFetch( export function apiFetch<T>(
endpoint: string, endpoint: string,
method: "GET" | "POST" | "PATCH", method: "GET" | "POST" | "PATCH",
body?: any, body?: any
params?: Record<string, string> ): Promise<T> {
): Promise<Response> {
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
const nextParams = new URLSearchParams(params) const nextParams = new URLSearchParams()
if (synoToken) { if (synoToken) {
nextParams.set("SynoToken", synoToken) nextParams.set("SynoToken", synoToken)
} else { } else {
@ -51,8 +57,9 @@ export function apiFetch(
"Content-Type": contentType, "Content-Type": contentType,
"X-CSRF-Token": csrfToken, "X-CSRF-Token": csrfToken,
}, },
body, body: body,
}).then((r) => { })
.then((r) => {
updateCsrfToken(r) updateCsrfToken(r)
if (!r.ok) { if (!r.ok) {
return r.text().then((err) => { return r.text().then((err) => {
@ -61,6 +68,11 @@ export function apiFetch(
} }
return r return r
}) })
.then((r) => {
if (r.headers.get("Content-Type") === "application/json") {
return r.json()
}
})
} }
function updateCsrfToken(r: Response) { function updateCsrfToken(r: Response) {

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

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

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

@ -6,6 +6,7 @@ import { apiFetch, incrementMetric, setUnraidCsrfToken } from "src/api"
import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes" import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes"
import { VersionInfo } from "src/hooks/self-update" import { VersionInfo } from "src/hooks/self-update"
import { assertNever } from "src/utils/util" import { assertNever } from "src/utils/util"
import useSWR from "swr"
export type NodeData = { export type NodeData = {
Profile: UserProfile Profile: UserProfile
@ -120,19 +121,12 @@ type RoutesPOSTData = {
// useNodeData returns basic data about the current node. // useNodeData returns basic data about the current node.
export default function useNodeData() { export default function useNodeData() {
const [data, setData] = useState<NodeData>() const { data, mutate } = useSWR<NodeData>("/data")
const [isPosting, setIsPosting] = useState<boolean>(false) const [isPosting, setIsPosting] = useState<boolean>(false)
const refreshData = useCallback( useEffect(
() => () => setUnraidCsrfToken(data?.IsUnraid ? data.UnraidToken : undefined),
apiFetch("/data", "GET") [data]
.then((r) => r.json())
.then((d: NodeData) => {
setData(d)
setUnraidCsrfToken(d.IsUnraid ? d.UnraidToken : undefined)
})
.catch((error) => console.error(error)),
[setData]
) )
const prefsPATCH = useCallback( const prefsPATCH = useCallback(
@ -147,12 +141,12 @@ export default function useNodeData() {
// then make the prefs PATCH. If the request fails, // then make the prefs PATCH. If the request fails,
// data will be updated to it's previous value in // data will be updated to it's previous value in
// onComplete below. // onComplete below.
setData(optimisticUpdates) mutate(optimisticUpdates, false)
} }
const onComplete = () => { const onComplete = () => {
setIsPosting(false) setIsPosting(false)
refreshData() // refresh data after PATCH finishes mutate() // refresh data after PATCH finishes
} }
return apiFetch("/local/v0/prefs", "PATCH", d) return apiFetch("/local/v0/prefs", "PATCH", d)
@ -163,7 +157,7 @@ export default function useNodeData() {
throw err throw err
}) })
}, },
[setIsPosting, refreshData, setData, data] [data, mutate]
) )
const routesPOST = useCallback( const routesPOST = useCallback(
@ -171,7 +165,7 @@ export default function useNodeData() {
setIsPosting(true) setIsPosting(true)
const onComplete = () => { const onComplete = () => {
setIsPosting(false) setIsPosting(false)
refreshData() // refresh data after POST finishes mutate() // refresh data after POST finishes
} }
const updateMetrics = () => { const updateMetrics = () => {
// only update metrics if values have changed // only update metrics if values have changed
@ -195,26 +189,7 @@ export default function useNodeData() {
throw err throw err
}) })
}, },
[setIsPosting, refreshData, data?.AdvertisingExitNode] [mutate, 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]
) )
const nodeUpdaters: NodeUpdaters = useMemo( const nodeUpdaters: NodeUpdaters = useMemo(
@ -245,5 +220,5 @@ export default function useNodeData() {
] ]
) )
return { data, refreshData, nodeUpdaters, isPosting } return { data, refreshData: mutate, nodeUpdaters, isPosting }
} }

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

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

@ -2599,6 +2599,11 @@ classnames@^2.3.1:
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== 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: color-convert@^1.9.0:
version "1.9.3" version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" 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" resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5"
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== 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: symbol-tree@^3.2.4:
version "3.2.4" version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" 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" detect-node-es "^1.1.0"
tslib "^2.0.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" version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" 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== integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==

Loading…
Cancel
Save