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

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