soniaappasamy/use-swr
Sonia Appasamy 6 months ago
parent c27aa9e7ff
commit c7c59d910c
No known key found for this signature in database

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

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

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

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

@ -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 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])
const { data, isLoading, mutate } = useSWR<AuthResponse>("/auth")
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,
}
}

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

@ -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()
// 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]
)
// 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(
() => ({

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

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

Loading…
Cancel
Save