mirror of
https://github.com/tailscale/tailscale.git
synced 2025-01-07 16:17:41 +00:00
client/web: restructure api mutations into hook
This commit makes some restructural changes to how we handle api posting from the web client frontend. Now that we're using SWR, we have less of a need for hooks like useNodeData that return a useSWR response alongside some mutation callbacks. SWR makes it easy to mutate throughout the UI without needing access to the original data state in order to reflect updates. So, we can fetch data without having to tie it to post callbacks that have to be passed around through components. In an effort to consolidate our posting endpoints, and make it easier to add more api handlers cleanly in the future, this change introduces a new `useAPI` hook that returns a single `api` callback that can make any changes from any component in the UI. The hook itself handles using SWR to mutate the relevant data keys, which get globally reflected throughout the UI. As a concurrent cleanup, node types are also moved to their own types.ts file, to consolidate data types across the app. Updates #10261 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
parent
9fd29f15c7
commit
97f8577ad2
@ -1,23 +1,260 @@
|
|||||||
// 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"
|
import { useCallback } from "react"
|
||||||
|
import useToaster from "src/hooks/toaster"
|
||||||
|
import { ExitNode, NodeData, SubnetRoute } from "src/types"
|
||||||
|
import { assertNever } from "src/utils/util"
|
||||||
|
import { MutatorOptions, SWRConfiguration, useSWRConfig } from "swr"
|
||||||
|
import { noExitNode, runAsExitNode } from "./hooks/exit-nodes"
|
||||||
|
|
||||||
export const swrConfig: SWRConfiguration = {
|
export const swrConfig: SWRConfiguration = {
|
||||||
fetcher: (url: string) => apiFetch(url, "GET"),
|
fetcher: (url: string) => apiFetch(url, "GET"),
|
||||||
onError: (err, _) => console.error(err),
|
onError: (err, _) => console.error(err),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type APIType =
|
||||||
|
| { action: "up"; data: TailscaleUpData }
|
||||||
|
| { action: "logout" }
|
||||||
|
| { action: "new-auth-session"; data: AuthSessionNewData }
|
||||||
|
| { action: "update-prefs"; data: LocalPrefsData }
|
||||||
|
| { action: "update-routes"; data: SubnetRoute[] }
|
||||||
|
| { action: "update-exit-node"; data: ExitNode }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/up data
|
||||||
|
*/
|
||||||
|
type TailscaleUpData = {
|
||||||
|
Reauthenticate?: boolean // force reauthentication
|
||||||
|
ControlURL?: string
|
||||||
|
AuthKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/auth/session/new data
|
||||||
|
*/
|
||||||
|
type AuthSessionNewData = {
|
||||||
|
authUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/local/v0/prefs data
|
||||||
|
*/
|
||||||
|
type LocalPrefsData = {
|
||||||
|
RunSSHSet?: boolean
|
||||||
|
RunSSH?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/routes data
|
||||||
|
*/
|
||||||
|
type RoutesData = {
|
||||||
|
SetExitNode?: boolean
|
||||||
|
SetRoutes?: boolean
|
||||||
|
UseExitNode?: string
|
||||||
|
AdvertiseExitNode?: boolean
|
||||||
|
AdvertiseRoutes?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useAPI hook returns an api handler that can execute api calls
|
||||||
|
* throughout the web client UI.
|
||||||
|
*/
|
||||||
|
export function useAPI() {
|
||||||
|
const toaster = useToaster()
|
||||||
|
const { mutate } = useSWRConfig() // allows for global mutation
|
||||||
|
|
||||||
|
const handlePostError = useCallback(
|
||||||
|
(toast?: string) => (err: Error) => {
|
||||||
|
console.error(err)
|
||||||
|
toast && toaster.show({ variant: "danger", message: toast })
|
||||||
|
throw err
|
||||||
|
},
|
||||||
|
[toaster]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* optimisticMutate wraps the SWR `mutate` function to apply some
|
||||||
|
* type-awareness with the following behavior:
|
||||||
|
*
|
||||||
|
* 1. `optimisticData` update is applied immediately on FetchDataType
|
||||||
|
* throughout the web client UI.
|
||||||
|
*
|
||||||
|
* 2. `fetch` data mutation runs.
|
||||||
|
*
|
||||||
|
* 3. On completion, FetchDataType is revalidated to exactly reflect the
|
||||||
|
* updated server state.
|
||||||
|
*
|
||||||
|
* The `key` argument is the useSWR key associated with the MutateDataType.
|
||||||
|
* All `useSWR(key)` consumers throughout the UI will see updates reflected.
|
||||||
|
*/
|
||||||
|
const optimisticMutate = useCallback(
|
||||||
|
<MutateDataType, FetchDataType = any>(
|
||||||
|
key: string,
|
||||||
|
fetch: Promise<FetchDataType>,
|
||||||
|
optimisticData: (current: MutateDataType) => MutateDataType
|
||||||
|
): Promise<FetchDataType | undefined> => {
|
||||||
|
const options: MutatorOptions = {
|
||||||
|
/**
|
||||||
|
* populateCache is meant for use when the remote request returns back
|
||||||
|
* the updated data directly. i.e. When FetchDataType is the same as
|
||||||
|
* MutateDataType. Most of our data manipulation requests return a 200
|
||||||
|
* with empty data on success. We turn off populateCache so that the
|
||||||
|
* cache only gets updated after completion of the remote reqeust when
|
||||||
|
* the revalidation step runs.
|
||||||
|
*/
|
||||||
|
populateCache: false,
|
||||||
|
optimisticData,
|
||||||
|
}
|
||||||
|
return mutate(key, fetch, options)
|
||||||
|
},
|
||||||
|
[mutate]
|
||||||
|
)
|
||||||
|
|
||||||
|
const api = useCallback(
|
||||||
|
(t: APIType) => {
|
||||||
|
switch (t.action) {
|
||||||
|
/**
|
||||||
|
* "up" handles authenticating the machine to tailnet.
|
||||||
|
*/
|
||||||
|
case "up":
|
||||||
|
return apiFetch<{ url?: string }>("/up", "POST", t.data)
|
||||||
|
.then((d) => d.url && window.open(d.url, "_blank")) // "up" login step
|
||||||
|
.then(() => incrementMetric("web_client_node_connect"))
|
||||||
|
.then(() => mutate("/data"))
|
||||||
|
.catch(handlePostError("Failed to login"))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "logout" handles logging the node out of tailscale, effectively
|
||||||
|
* expiring its node key.
|
||||||
|
*/
|
||||||
|
case "logout":
|
||||||
|
// For logout, must increment metric before running api call,
|
||||||
|
// as tailscaled will be unreachable after the call completes.
|
||||||
|
incrementMetric("web_client_node_disconnect")
|
||||||
|
return apiFetch("/local/v0/logout", "POST").catch(
|
||||||
|
handlePostError("Failed to logout")
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "new-auth-session" handles creating a new check mode session to
|
||||||
|
* authorize the viewing user to manage the node via the web client.
|
||||||
|
*/
|
||||||
|
case "new-auth-session":
|
||||||
|
return apiFetch<AuthSessionNewData>("/auth/session/new", "GET").catch(
|
||||||
|
handlePostError("Failed to create new session")
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "update-prefs" handles setting the node's tailscale prefs.
|
||||||
|
*/
|
||||||
|
case "update-prefs": {
|
||||||
|
return optimisticMutate<NodeData>(
|
||||||
|
"/data",
|
||||||
|
apiFetch<LocalPrefsData>("/local/v0/prefs", "PATCH", t.data),
|
||||||
|
(old) => ({
|
||||||
|
...old,
|
||||||
|
RunningSSHServer: t.data.RunSSHSet
|
||||||
|
? Boolean(t.data.RunSSH)
|
||||||
|
: old.RunningSSHServer,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() =>
|
||||||
|
t.data.RunSSHSet &&
|
||||||
|
incrementMetric(
|
||||||
|
t.data.RunSSH
|
||||||
|
? "web_client_ssh_enable"
|
||||||
|
: "web_client_ssh_disable"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.catch(handlePostError("Failed to update node preference"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "update-routes" handles setting the node's advertised routes.
|
||||||
|
*/
|
||||||
|
case "update-routes": {
|
||||||
|
const body: RoutesData = {
|
||||||
|
SetRoutes: true,
|
||||||
|
AdvertiseRoutes: t.data.map((r) => r.Route),
|
||||||
|
}
|
||||||
|
return optimisticMutate<NodeData>(
|
||||||
|
"/data",
|
||||||
|
apiFetch<void>("/routes", "POST", body),
|
||||||
|
(old) => ({ ...old, AdvertisedRoutes: t.data })
|
||||||
|
)
|
||||||
|
.then(() => incrementMetric("web_client_advertise_routes_change"))
|
||||||
|
.catch(handlePostError("Failed to update routes"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "update-exit-node" handles updating the node's state as either
|
||||||
|
* running as an exit node or using another node as an exit node.
|
||||||
|
*/
|
||||||
|
case "update-exit-node": {
|
||||||
|
const id = t.data.ID
|
||||||
|
const body: RoutesData = {
|
||||||
|
SetExitNode: true,
|
||||||
|
}
|
||||||
|
if (id !== noExitNode.ID && id !== runAsExitNode.ID) {
|
||||||
|
body.UseExitNode = id
|
||||||
|
} else if (id === runAsExitNode.ID) {
|
||||||
|
body.AdvertiseExitNode = true
|
||||||
|
}
|
||||||
|
const metrics: MetricName[] = []
|
||||||
|
return optimisticMutate<NodeData>(
|
||||||
|
"/data",
|
||||||
|
apiFetch<void>("/routes", "POST", body),
|
||||||
|
(old) => {
|
||||||
|
// Only update metrics whose values have changed.
|
||||||
|
if (old.AdvertisingExitNode !== Boolean(body.AdvertiseExitNode)) {
|
||||||
|
metrics.push(
|
||||||
|
body.AdvertiseExitNode
|
||||||
|
? "web_client_advertise_exitnode_enable"
|
||||||
|
: "web_client_advertise_exitnode_disable"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (Boolean(old.UsingExitNode) !== Boolean(body.UseExitNode)) {
|
||||||
|
metrics.push(
|
||||||
|
body.UseExitNode
|
||||||
|
? "web_client_use_exitnode_enable"
|
||||||
|
: "web_client_use_exitnode_disable"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
UsingExitNode: Boolean(body.UseExitNode) ? t.data : undefined,
|
||||||
|
AdvertisingExitNode: Boolean(body.AdvertiseExitNode),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(() => metrics.forEach((m) => incrementMetric(m)))
|
||||||
|
.catch(handlePostError("Failed to update exit node"))
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
assertNever(t)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handlePostError, mutate, optimisticMutate]
|
||||||
|
)
|
||||||
|
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
// apiFetch wraps the standard JS fetch function with csrf header
|
/**
|
||||||
// management and param additions specific to the web client.
|
* apiFetch wraps the standard JS fetch function with csrf header
|
||||||
//
|
* management and param additions specific to the web client.
|
||||||
// apiFetch adds the `api` prefix to the request URL,
|
*
|
||||||
// so endpoint should be provided without the `api` prefix
|
* apiFetch adds the `api` prefix to the request URL,
|
||||||
// (i.e. provide `/data` rather than `api/data`).
|
* so endpoint should be provided without the `api` prefix
|
||||||
|
* (i.e. provide `/data` rather than `api/data`).
|
||||||
|
*/
|
||||||
export function apiFetch<T>(
|
export function apiFetch<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
method: "GET" | "POST" | "PATCH",
|
method: "GET" | "POST" | "PATCH",
|
||||||
@ -73,6 +310,10 @@ export function apiFetch<T>(
|
|||||||
return r.json()
|
return r.json()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.then((r) => {
|
||||||
|
r?.UnraidToken && setUnraidCsrfToken(r.UnraidToken)
|
||||||
|
return r
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCsrfToken(r: Response) {
|
function updateCsrfToken(r: Response) {
|
||||||
@ -86,12 +327,14 @@ export function setSynoToken(token?: string) {
|
|||||||
synoToken = token
|
synoToken = token
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setUnraidCsrfToken(token?: string) {
|
function setUnraidCsrfToken(token?: string) {
|
||||||
unraidCsrfToken = token
|
unraidCsrfToken = token
|
||||||
}
|
}
|
||||||
|
|
||||||
// incrementMetric hits the client metrics local API endpoint to
|
/**
|
||||||
// increment the given counter metric by one.
|
* incrementMetric hits the client metrics local API endpoint to
|
||||||
|
* increment the given counter metric by one.
|
||||||
|
*/
|
||||||
export function incrementMetric(metricName: MetricName) {
|
export function incrementMetric(metricName: MetricName) {
|
||||||
const postData: MetricsPOSTData[] = [
|
const postData: MetricsPOSTData[] = [
|
||||||
{
|
{
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
import React, { useEffect } from "react"
|
import React from "react"
|
||||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||||
import LoginToggle from "src/components/login-toggle"
|
import LoginToggle from "src/components/login-toggle"
|
||||||
import DeviceDetailsView from "src/components/views/device-details-view"
|
import DeviceDetailsView from "src/components/views/device-details-view"
|
||||||
@ -11,12 +11,9 @@ import SSHView from "src/components/views/ssh-view"
|
|||||||
import SubnetRouterView from "src/components/views/subnet-router-view"
|
import SubnetRouterView from "src/components/views/subnet-router-view"
|
||||||
import { UpdatingView } from "src/components/views/updating-view"
|
import { UpdatingView } from "src/components/views/updating-view"
|
||||||
import useAuth, { AuthResponse } from "src/hooks/auth"
|
import useAuth, { AuthResponse } from "src/hooks/auth"
|
||||||
import useNodeData, {
|
import { Feature, featureDescription, NodeData } from "src/types"
|
||||||
Feature,
|
|
||||||
featureDescription,
|
|
||||||
NodeData,
|
|
||||||
} from "src/hooks/node-data"
|
|
||||||
import LoadingDots from "src/ui/loading-dots"
|
import LoadingDots from "src/ui/loading-dots"
|
||||||
|
import useSWR from "swr"
|
||||||
import { Link, Route, Router, Switch, useLocation } from "wouter"
|
import { Link, Route, Router, Switch, useLocation } from "wouter"
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@ -40,53 +37,38 @@ function WebClient({
|
|||||||
auth: AuthResponse
|
auth: AuthResponse
|
||||||
newSession: () => Promise<void>
|
newSession: () => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const { data, refreshData, nodeUpdaters } = useNodeData()
|
const { data: node } = useSWR<NodeData>("/data")
|
||||||
useEffect(() => {
|
|
||||||
refreshData()
|
|
||||||
}, [auth, refreshData])
|
|
||||||
|
|
||||||
return !data ? (
|
return !node ? (
|
||||||
<LoadingView />
|
<LoadingView />
|
||||||
) : data.Status === "NeedsLogin" ||
|
) : node.Status === "NeedsLogin" ||
|
||||||
data.Status === "NoState" ||
|
node.Status === "NoState" ||
|
||||||
data.Status === "Stopped" ? (
|
node.Status === "Stopped" ? (
|
||||||
// Client not on a tailnet, render login.
|
// Client not on a tailnet, render login.
|
||||||
<LoginView data={data} refreshData={refreshData} />
|
<LoginView data={node} />
|
||||||
) : (
|
) : (
|
||||||
// Otherwise render the new web client.
|
// Otherwise render the new web client.
|
||||||
<>
|
<>
|
||||||
<Router base={data.URLPrefix}>
|
<Router base={node.URLPrefix}>
|
||||||
<Header node={data} auth={auth} newSession={newSession} />
|
<Header node={node} auth={auth} newSession={newSession} />
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/">
|
<Route path="/">
|
||||||
<HomeView
|
<HomeView readonly={!auth.canManageNode} node={node} />
|
||||||
readonly={!auth.canManageNode}
|
|
||||||
node={data}
|
|
||||||
nodeUpdaters={nodeUpdaters}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/details">
|
<Route path="/details">
|
||||||
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
|
<DeviceDetailsView readonly={!auth.canManageNode} node={node} />
|
||||||
</Route>
|
</Route>
|
||||||
<FeatureRoute path="/subnets" feature="advertise-routes" node={data}>
|
<FeatureRoute path="/subnets" feature="advertise-routes" node={node}>
|
||||||
<SubnetRouterView
|
<SubnetRouterView readonly={!auth.canManageNode} node={node} />
|
||||||
readonly={!auth.canManageNode}
|
|
||||||
node={data}
|
|
||||||
nodeUpdaters={nodeUpdaters}
|
|
||||||
/>
|
|
||||||
</FeatureRoute>
|
</FeatureRoute>
|
||||||
<FeatureRoute path="/ssh" feature="ssh" node={data}>
|
<FeatureRoute path="/ssh" feature="ssh" node={node}>
|
||||||
<SSHView
|
<SSHView readonly={!auth.canManageNode} node={node} />
|
||||||
readonly={!auth.canManageNode}
|
|
||||||
node={data}
|
|
||||||
nodeUpdaters={nodeUpdaters}
|
|
||||||
/>
|
|
||||||
</FeatureRoute>
|
</FeatureRoute>
|
||||||
<Route path="/serve">{/* TODO */}Share local content</Route>
|
<Route path="/serve">{/* TODO */}Share local content</Route>
|
||||||
<FeatureRoute path="/update" feature="auto-update" node={data}>
|
<FeatureRoute path="/update" feature="auto-update" node={node}>
|
||||||
<UpdatingView
|
<UpdatingView
|
||||||
versionInfo={data.ClientVersion}
|
versionInfo={node.ClientVersion}
|
||||||
currentVersion={data.IPNVersion}
|
currentVersion={node.IPNVersion}
|
||||||
/>
|
/>
|
||||||
</FeatureRoute>
|
</FeatureRoute>
|
||||||
<Route>
|
<Route>
|
||||||
@ -111,7 +93,7 @@ function FeatureRoute({
|
|||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
path: string
|
path: string
|
||||||
node: NodeData // TODO: once we have swr, just call useNodeData within FeatureView
|
node: NodeData
|
||||||
feature: Feature
|
feature: Feature
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { NodeData } from "src/hooks/node-data"
|
import { NodeData } from "src/types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AdminContainer renders its contents only if the node's control
|
* AdminContainer renders its contents only if the node's control
|
||||||
|
@ -2,32 +2,32 @@
|
|||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react"
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { useAPI } from "src/api"
|
||||||
import { ReactComponent as Check } from "src/assets/icons/check.svg"
|
import { ReactComponent as Check } from "src/assets/icons/check.svg"
|
||||||
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
||||||
import useExitNodes, {
|
import useExitNodes, {
|
||||||
ExitNode,
|
|
||||||
noExitNode,
|
noExitNode,
|
||||||
runAsExitNode,
|
runAsExitNode,
|
||||||
trimDNSSuffix,
|
trimDNSSuffix,
|
||||||
} from "src/hooks/exit-nodes"
|
} from "src/hooks/exit-nodes"
|
||||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
import { ExitNode, NodeData } from "src/types"
|
||||||
import Popover from "src/ui/popover"
|
import Popover from "src/ui/popover"
|
||||||
import SearchInput from "src/ui/search-input"
|
import SearchInput from "src/ui/search-input"
|
||||||
|
|
||||||
export default function ExitNodeSelector({
|
export default function ExitNodeSelector({
|
||||||
className,
|
className,
|
||||||
node,
|
node,
|
||||||
nodeUpdaters,
|
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
node: NodeData
|
node: NodeData
|
||||||
nodeUpdaters: NodeUpdaters
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const api = useAPI()
|
||||||
const [open, setOpen] = useState<boolean>(false)
|
const [open, setOpen] = useState<boolean>(false)
|
||||||
const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node))
|
const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node))
|
||||||
|
useEffect(() => setSelected(toSelectedExitNode(node)), [node])
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(n: ExitNode) => {
|
(n: ExitNode) => {
|
||||||
@ -35,11 +35,9 @@ export default function ExitNodeSelector({
|
|||||||
if (n.ID === selected.ID) {
|
if (n.ID === selected.ID) {
|
||||||
return // no update
|
return // no update
|
||||||
}
|
}
|
||||||
const old = selected
|
api({ action: "update-exit-node", data: n })
|
||||||
setSelected(n) // optimistic UI update
|
|
||||||
nodeUpdaters.postExitNode(n).catch(() => setSelected(old))
|
|
||||||
},
|
},
|
||||||
[nodeUpdaters, selected]
|
[api, selected]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [
|
const [
|
||||||
|
@ -7,7 +7,7 @@ import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg
|
|||||||
import { ReactComponent as Eye } from "src/assets/icons/eye.svg"
|
import { ReactComponent as Eye } from "src/assets/icons/eye.svg"
|
||||||
import { ReactComponent as User } from "src/assets/icons/user.svg"
|
import { ReactComponent as User } from "src/assets/icons/user.svg"
|
||||||
import { AuthResponse, AuthType } from "src/hooks/auth"
|
import { AuthResponse, AuthType } from "src/hooks/auth"
|
||||||
import { NodeData } from "src/hooks/node-data"
|
import { NodeData } from "src/types"
|
||||||
import Button from "src/ui/button"
|
import Button from "src/ui/button"
|
||||||
import Popover from "src/ui/popover"
|
import Popover from "src/ui/popover"
|
||||||
import ProfilePic from "src/ui/profile-pic"
|
import ProfilePic from "src/ui/profile-pic"
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { VersionInfo } from "src/hooks/self-update"
|
import { VersionInfo } from "src/types"
|
||||||
import Button from "src/ui/button"
|
import Button from "src/ui/button"
|
||||||
import { useLocation } from "wouter"
|
import { useLocation } from "wouter"
|
||||||
|
|
||||||
|
@ -3,12 +3,12 @@
|
|||||||
|
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { apiFetch, incrementMetric } from "src/api"
|
import { useAPI } from "src/api"
|
||||||
import ACLTag from "src/components/acl-tag"
|
import ACLTag from "src/components/acl-tag"
|
||||||
import * as Control from "src/components/control-components"
|
import * as Control from "src/components/control-components"
|
||||||
import NiceIP from "src/components/nice-ip"
|
import NiceIP from "src/components/nice-ip"
|
||||||
import { UpdateAvailableNotification } from "src/components/update-available"
|
import { UpdateAvailableNotification } from "src/components/update-available"
|
||||||
import { NodeData } from "src/hooks/node-data"
|
import { NodeData } from "src/types"
|
||||||
import Button from "src/ui/button"
|
import Button from "src/ui/button"
|
||||||
import QuickCopy from "src/ui/quick-copy"
|
import QuickCopy from "src/ui/quick-copy"
|
||||||
import { useLocation } from "wouter"
|
import { useLocation } from "wouter"
|
||||||
@ -20,6 +20,7 @@ export default function DeviceDetailsView({
|
|||||||
readonly: boolean
|
readonly: boolean
|
||||||
node: NodeData
|
node: NodeData
|
||||||
}) {
|
}) {
|
||||||
|
const api = useAPI()
|
||||||
const [, setLocation] = useLocation()
|
const [, setLocation] = useLocation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -40,13 +41,9 @@ export default function DeviceDetailsView({
|
|||||||
{!readonly && (
|
{!readonly && (
|
||||||
<Button
|
<Button
|
||||||
sizeVariant="small"
|
sizeVariant="small"
|
||||||
onClick={() => {
|
onClick={() =>
|
||||||
// increment metrics before logout as we don't gracefully handle disconnect currently
|
api({ action: "logout" }).then(() => setLocation("/"))
|
||||||
incrementMetric("web_client_node_disconnect")
|
}
|
||||||
apiFetch("/local/v0/logout", "POST")
|
|
||||||
.then(() => setLocation("/"))
|
|
||||||
.catch((err) => alert("Logout failed: " + err.message))
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Disconnect…
|
Disconnect…
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -8,18 +8,16 @@ import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
|
|||||||
import { ReactComponent as Machine } from "src/assets/icons/machine.svg"
|
import { ReactComponent as Machine } from "src/assets/icons/machine.svg"
|
||||||
import AddressCard from "src/components/address-copy-card"
|
import AddressCard from "src/components/address-copy-card"
|
||||||
import ExitNodeSelector from "src/components/exit-node-selector"
|
import ExitNodeSelector from "src/components/exit-node-selector"
|
||||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
import { NodeData } from "src/types"
|
||||||
import { pluralize } from "src/utils/util"
|
import { pluralize } from "src/utils/util"
|
||||||
import { Link, useLocation } from "wouter"
|
import { Link, useLocation } from "wouter"
|
||||||
|
|
||||||
export default function HomeView({
|
export default function HomeView({
|
||||||
readonly,
|
readonly,
|
||||||
node,
|
node,
|
||||||
nodeUpdaters,
|
|
||||||
}: {
|
}: {
|
||||||
readonly: boolean
|
readonly: boolean
|
||||||
node: NodeData
|
node: NodeData
|
||||||
nodeUpdaters: NodeUpdaters
|
|
||||||
}) {
|
}) {
|
||||||
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
|
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@ -62,12 +60,7 @@ export default function HomeView({
|
|||||||
</div>
|
</div>
|
||||||
{(node.Features["advertise-exit-node"] ||
|
{(node.Features["advertise-exit-node"] ||
|
||||||
node.Features["use-exit-node"]) && (
|
node.Features["use-exit-node"]) && (
|
||||||
<ExitNodeSelector
|
<ExitNodeSelector className="mb-5" node={node} disabled={readonly} />
|
||||||
className="mb-5"
|
|
||||||
node={node}
|
|
||||||
nodeUpdaters={nodeUpdaters}
|
|
||||||
disabled={readonly}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
className="link font-medium"
|
className="link font-medium"
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
import React, { useCallback, useState } from "react"
|
import React, { useState } from "react"
|
||||||
import { apiFetch, incrementMetric } from "src/api"
|
import { useAPI } from "src/api"
|
||||||
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
import { ReactComponent as TailscaleIcon } from "src/assets/icons/tailscale-icon.svg"
|
||||||
import { NodeData } from "src/hooks/node-data"
|
import { NodeData } from "src/types"
|
||||||
import Button from "src/ui/button"
|
import Button from "src/ui/button"
|
||||||
import Collapsible from "src/ui/collapsible"
|
import Collapsible from "src/ui/collapsible"
|
||||||
import Input from "src/ui/input"
|
import Input from "src/ui/input"
|
||||||
@ -13,26 +13,11 @@ import Input from "src/ui/input"
|
|||||||
* LoginView is rendered when the client is not authenticated
|
* LoginView is rendered when the client is not authenticated
|
||||||
* to a tailnet.
|
* to a tailnet.
|
||||||
*/
|
*/
|
||||||
export default function LoginView({
|
export default function LoginView({ data }: { data: NodeData }) {
|
||||||
data,
|
const api = useAPI()
|
||||||
refreshData,
|
|
||||||
}: {
|
|
||||||
data: NodeData
|
|
||||||
refreshData: () => void
|
|
||||||
}) {
|
|
||||||
const [controlURL, setControlURL] = useState<string>("")
|
const [controlURL, setControlURL] = useState<string>("")
|
||||||
const [authKey, setAuthKey] = useState<string>("")
|
const [authKey, setAuthKey] = useState<string>("")
|
||||||
|
|
||||||
const login = useCallback(
|
|
||||||
(opt: TailscaleUpOptions) => {
|
|
||||||
tailscaleUp(opt).then(() => {
|
|
||||||
incrementMetric("web_client_node_connect")
|
|
||||||
refreshData()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[refreshData]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||||
<TailscaleIcon className="my-2 mb-8" />
|
<TailscaleIcon className="my-2 mb-8" />
|
||||||
@ -45,7 +30,7 @@ export default function LoginView({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => login({})}
|
onClick={() => api({ action: "up", data: {} })}
|
||||||
className="w-full mb-4"
|
className="w-full mb-4"
|
||||||
intent="primary"
|
intent="primary"
|
||||||
>
|
>
|
||||||
@ -70,7 +55,9 @@ export default function LoginView({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => login({ Reauthenticate: true })}
|
onClick={() =>
|
||||||
|
api({ action: "up", data: { Reauthenticate: true } })
|
||||||
|
}
|
||||||
className="w-full mb-4"
|
className="w-full mb-4"
|
||||||
intent="primary"
|
intent="primary"
|
||||||
>
|
>
|
||||||
@ -97,10 +84,13 @@ export default function LoginView({
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
login({
|
api({
|
||||||
Reauthenticate: true,
|
action: "up",
|
||||||
ControlURL: controlURL,
|
data: {
|
||||||
AuthKey: authKey,
|
Reauthenticate: true,
|
||||||
|
ControlURL: controlURL,
|
||||||
|
AuthKey: authKey,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="w-full mb-4"
|
className="w-full mb-4"
|
||||||
@ -141,19 +131,3 @@ export default function LoginView({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TailscaleUpOptions = {
|
|
||||||
Reauthenticate?: boolean // force reauthentication
|
|
||||||
ControlURL?: string
|
|
||||||
AuthKey?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function tailscaleUp(options: TailscaleUpOptions) {
|
|
||||||
return apiFetch<{ url?: string }>("/up", "POST", options)
|
|
||||||
.then((d) => {
|
|
||||||
d.url && window.open(d.url, "_blank")
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to login:", e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
@ -2,20 +2,21 @@
|
|||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
import { useAPI } from "src/api"
|
||||||
import * as Control from "src/components/control-components"
|
import * as Control from "src/components/control-components"
|
||||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
import { NodeData } from "src/types"
|
||||||
import Card from "src/ui/card"
|
import Card from "src/ui/card"
|
||||||
import Toggle from "src/ui/toggle"
|
import Toggle from "src/ui/toggle"
|
||||||
|
|
||||||
export default function SSHView({
|
export default function SSHView({
|
||||||
readonly,
|
readonly,
|
||||||
node,
|
node,
|
||||||
nodeUpdaters,
|
|
||||||
}: {
|
}: {
|
||||||
readonly: boolean
|
readonly: boolean
|
||||||
node: NodeData
|
node: NodeData
|
||||||
nodeUpdaters: NodeUpdaters
|
|
||||||
}) {
|
}) {
|
||||||
|
const api = useAPI()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="mb-1">Tailscale SSH server</h1>
|
<h1 className="mb-1">Tailscale SSH server</h1>
|
||||||
@ -36,9 +37,12 @@ export default function SSHView({
|
|||||||
<Toggle
|
<Toggle
|
||||||
checked={node.RunningSSHServer}
|
checked={node.RunningSSHServer}
|
||||||
onChange={() =>
|
onChange={() =>
|
||||||
nodeUpdaters.patchPrefs({
|
api({
|
||||||
RunSSHSet: true,
|
action: "update-prefs",
|
||||||
RunSSH: !node.RunningSSHServer,
|
data: {
|
||||||
|
RunSSHSet: true,
|
||||||
|
RunSSH: !node.RunningSSHServer,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
|
@ -2,11 +2,12 @@
|
|||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
import React, { useCallback, useMemo, useState } from "react"
|
import React, { useCallback, useMemo, useState } from "react"
|
||||||
|
import { useAPI } from "src/api"
|
||||||
import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg"
|
import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg"
|
||||||
import { ReactComponent as Clock } from "src/assets/icons/clock.svg"
|
import { ReactComponent as Clock } from "src/assets/icons/clock.svg"
|
||||||
import { ReactComponent as Plus } from "src/assets/icons/plus.svg"
|
import { ReactComponent as Plus } from "src/assets/icons/plus.svg"
|
||||||
import * as Control from "src/components/control-components"
|
import * as Control from "src/components/control-components"
|
||||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
import { NodeData } from "src/types"
|
||||||
import Button from "src/ui/button"
|
import Button from "src/ui/button"
|
||||||
import Card from "src/ui/card"
|
import Card from "src/ui/card"
|
||||||
import EmptyState from "src/ui/empty-state"
|
import EmptyState from "src/ui/empty-state"
|
||||||
@ -15,12 +16,11 @@ import Input from "src/ui/input"
|
|||||||
export default function SubnetRouterView({
|
export default function SubnetRouterView({
|
||||||
readonly,
|
readonly,
|
||||||
node,
|
node,
|
||||||
nodeUpdaters,
|
|
||||||
}: {
|
}: {
|
||||||
readonly: boolean
|
readonly: boolean
|
||||||
node: NodeData
|
node: NodeData
|
||||||
nodeUpdaters: NodeUpdaters
|
|
||||||
}) {
|
}) {
|
||||||
|
const api = useAPI()
|
||||||
const [advertisedRoutes, hasRoutes, hasUnapprovedRoutes] = useMemo(() => {
|
const [advertisedRoutes, hasRoutes, hasUnapprovedRoutes] = useMemo(() => {
|
||||||
const routes = node.AdvertisedRoutes || []
|
const routes = node.AdvertisedRoutes || []
|
||||||
return [routes, routes.length > 0, routes.find((r) => !r.Approved)]
|
return [routes, routes.length > 0, routes.find((r) => !r.Approved)]
|
||||||
@ -70,12 +70,15 @@ export default function SubnetRouterView({
|
|||||||
<Button
|
<Button
|
||||||
intent="primary"
|
intent="primary"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
nodeUpdaters
|
api({
|
||||||
.postSubnetRoutes([
|
action: "update-routes",
|
||||||
...advertisedRoutes.map((r) => r.Route),
|
data: [
|
||||||
...inputText.split(","),
|
...advertisedRoutes,
|
||||||
])
|
...inputText
|
||||||
.then(resetInput)
|
.split(",")
|
||||||
|
.map((r) => ({ Route: r, Approved: false })),
|
||||||
|
],
|
||||||
|
}).then(resetInput)
|
||||||
}
|
}
|
||||||
disabled={!inputText}
|
disabled={!inputText}
|
||||||
>
|
>
|
||||||
@ -124,11 +127,12 @@ export default function SubnetRouterView({
|
|||||||
<Button
|
<Button
|
||||||
sizeVariant="small"
|
sizeVariant="small"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
nodeUpdaters.postSubnetRoutes(
|
api({
|
||||||
advertisedRoutes
|
action: "update-routes",
|
||||||
.map((it) => it.Route)
|
data: advertisedRoutes.filter(
|
||||||
.filter((it) => it !== r.Route)
|
(it) => it.Route !== r.Route
|
||||||
)
|
),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Stop advertising…
|
Stop advertising…
|
||||||
|
@ -5,11 +5,8 @@ import React from "react"
|
|||||||
import { ReactComponent as CheckCircleIcon } from "src/assets/icons/check-circle.svg"
|
import { ReactComponent as CheckCircleIcon } from "src/assets/icons/check-circle.svg"
|
||||||
import { ReactComponent as XCircleIcon } from "src/assets/icons/x-circle.svg"
|
import { ReactComponent as XCircleIcon } from "src/assets/icons/x-circle.svg"
|
||||||
import { ChangelogText } from "src/components/update-available"
|
import { ChangelogText } from "src/components/update-available"
|
||||||
import {
|
import { UpdateState, useInstallUpdate } from "src/hooks/self-update"
|
||||||
UpdateState,
|
import { VersionInfo } from "src/types"
|
||||||
useInstallUpdate,
|
|
||||||
VersionInfo,
|
|
||||||
} from "src/hooks/self-update"
|
|
||||||
import Button from "src/ui/button"
|
import Button from "src/ui/button"
|
||||||
import Spinner from "src/ui/spinner"
|
import Spinner from "src/ui/spinner"
|
||||||
import { useLocation } from "wouter"
|
import { useLocation } from "wouter"
|
||||||
|
@ -1,44 +1,18 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useMemo } from "react"
|
||||||
import { apiFetch } from "src/api"
|
import {
|
||||||
import { NodeData } from "src/hooks/node-data"
|
CityCode,
|
||||||
|
CountryCode,
|
||||||
export type ExitNode = {
|
ExitNode,
|
||||||
ID: string
|
ExitNodeLocation,
|
||||||
Name: string
|
NodeData,
|
||||||
Location?: ExitNodeLocation
|
} from "src/types"
|
||||||
Online?: boolean
|
import useSWR from "swr"
|
||||||
}
|
|
||||||
|
|
||||||
type ExitNodeLocation = {
|
|
||||||
Country: string
|
|
||||||
CountryCode: CountryCode
|
|
||||||
City: string
|
|
||||||
CityCode: CityCode
|
|
||||||
Priority: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type CountryCode = string
|
|
||||||
type CityCode = string
|
|
||||||
|
|
||||||
export type ExitNodeGroup = {
|
|
||||||
id: string
|
|
||||||
name?: string
|
|
||||||
nodes: ExitNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function useExitNodes(node: NodeData, filter?: string) {
|
export default function useExitNodes(node: NodeData, filter?: string) {
|
||||||
const [data, setData] = useState<ExitNode[]>([])
|
const { data } = useSWR<ExitNode[]>("/exit-nodes")
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
apiFetch<ExitNode[]>("/exit-nodes", "GET")
|
|
||||||
.then((r) => setData(r))
|
|
||||||
.catch((err) => {
|
|
||||||
alert("Failed operation: " + err.message)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const { tailnetNodesSorted, locationNodesMap } = useMemo(() => {
|
const { tailnetNodesSorted, locationNodesMap } = useMemo(() => {
|
||||||
// First going through exit nodes and splitting them into two groups:
|
// First going through exit nodes and splitting them into two groups:
|
||||||
|
@ -1,243 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
||||||
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
|
|
||||||
Status: NodeState
|
|
||||||
DeviceName: string
|
|
||||||
OS: string
|
|
||||||
IPv4: string
|
|
||||||
IPv6: string
|
|
||||||
ID: string
|
|
||||||
KeyExpiry: string
|
|
||||||
KeyExpired: boolean
|
|
||||||
UsingExitNode?: ExitNode
|
|
||||||
AdvertisingExitNode: boolean
|
|
||||||
AdvertisedRoutes?: SubnetRoute[]
|
|
||||||
TUNMode: boolean
|
|
||||||
IsSynology: boolean
|
|
||||||
DSMVersion: number
|
|
||||||
IsUnraid: boolean
|
|
||||||
UnraidToken: string
|
|
||||||
IPNVersion: string
|
|
||||||
ClientVersion?: VersionInfo
|
|
||||||
URLPrefix: string
|
|
||||||
DomainName: string
|
|
||||||
TailnetName: string
|
|
||||||
IsTagged: boolean
|
|
||||||
Tags: string[]
|
|
||||||
RunningSSHServer: boolean
|
|
||||||
ControlAdminURL: string
|
|
||||||
LicensesURL: string
|
|
||||||
Features: { [key in Feature]: boolean } // value is true if given feature is available on this client
|
|
||||||
ACLAllowsAnyIncomingTraffic: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type NodeState =
|
|
||||||
| "NoState"
|
|
||||||
| "NeedsLogin"
|
|
||||||
| "NeedsMachineAuth"
|
|
||||||
| "Stopped"
|
|
||||||
| "Starting"
|
|
||||||
| "Running"
|
|
||||||
|
|
||||||
export type UserProfile = {
|
|
||||||
LoginName: string
|
|
||||||
DisplayName: string
|
|
||||||
ProfilePicURL: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SubnetRoute = {
|
|
||||||
Route: string
|
|
||||||
Approved: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Feature =
|
|
||||||
| "advertise-exit-node"
|
|
||||||
| "advertise-routes"
|
|
||||||
| "use-exit-node"
|
|
||||||
| "ssh"
|
|
||||||
| "auto-update"
|
|
||||||
|
|
||||||
export const featureDescription = (f: Feature) => {
|
|
||||||
switch (f) {
|
|
||||||
case "advertise-exit-node":
|
|
||||||
return "Advertising as an exit node"
|
|
||||||
case "advertise-routes":
|
|
||||||
return "Advertising subnet routes"
|
|
||||||
case "use-exit-node":
|
|
||||||
return "Using an exit node"
|
|
||||||
case "ssh":
|
|
||||||
return "Running a Tailscale SSH server"
|
|
||||||
case "auto-update":
|
|
||||||
return "Auto updating client versions"
|
|
||||||
default:
|
|
||||||
assertNever(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NodeUpdaters provides a set of mutation functions for a node.
|
|
||||||
*
|
|
||||||
* These functions handle both making the requested change, as well as
|
|
||||||
* refreshing the app's node data state upon completion to reflect any
|
|
||||||
* relevant changes in the UI.
|
|
||||||
*/
|
|
||||||
export type NodeUpdaters = {
|
|
||||||
/**
|
|
||||||
* patchPrefs updates node preferences.
|
|
||||||
* Only provided preferences will be updated.
|
|
||||||
* Similar to running the tailscale set command in the CLI.
|
|
||||||
*/
|
|
||||||
patchPrefs: (d: PrefsPATCHData) => Promise<void>
|
|
||||||
/**
|
|
||||||
* postExitNode updates the node's status as either using or
|
|
||||||
* running as an exit node.
|
|
||||||
*/
|
|
||||||
postExitNode: (d: ExitNode) => Promise<void>
|
|
||||||
/**
|
|
||||||
* postSubnetRoutes updates the node's advertised subnet routes.
|
|
||||||
*/
|
|
||||||
postSubnetRoutes: (d: string[]) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type PrefsPATCHData = {
|
|
||||||
RunSSHSet?: boolean
|
|
||||||
RunSSH?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type RoutesPOSTData = {
|
|
||||||
UseExitNode?: string
|
|
||||||
AdvertiseExitNode?: boolean
|
|
||||||
AdvertiseRoutes?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// useNodeData returns basic data about the current node.
|
|
||||||
export default function useNodeData() {
|
|
||||||
const { data, mutate } = useSWR<NodeData>("/data")
|
|
||||||
const [isPosting, setIsPosting] = useState<boolean>(false)
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => setUnraidCsrfToken(data?.IsUnraid ? data.UnraidToken : undefined),
|
|
||||||
[data]
|
|
||||||
)
|
|
||||||
|
|
||||||
const prefsPATCH = useCallback(
|
|
||||||
(d: PrefsPATCHData) => {
|
|
||||||
setIsPosting(true)
|
|
||||||
if (data) {
|
|
||||||
const optimisticUpdates = data
|
|
||||||
if (d.RunSSHSet) {
|
|
||||||
optimisticUpdates.RunningSSHServer = Boolean(d.RunSSH)
|
|
||||||
}
|
|
||||||
// Reflect the pref change immediatley on the frontend,
|
|
||||||
// then make the prefs PATCH. If the request fails,
|
|
||||||
// data will be updated to it's previous value in
|
|
||||||
// onComplete below.
|
|
||||||
mutate(optimisticUpdates, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onComplete = () => {
|
|
||||||
setIsPosting(false)
|
|
||||||
mutate() // refresh data after PATCH finishes
|
|
||||||
}
|
|
||||||
const updateMetrics = () => {
|
|
||||||
// only update metrics if values have changed
|
|
||||||
if (data?.RunningSSHServer !== d.RunSSH) {
|
|
||||||
incrementMetric(
|
|
||||||
d.RunSSH ? "web_client_ssh_enable" : "web_client_ssh_disable"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiFetch("/local/v0/prefs", "PATCH", d)
|
|
||||||
.then(() => {
|
|
||||||
updateMetrics()
|
|
||||||
onComplete()
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
onComplete()
|
|
||||||
alert("Failed to update prefs")
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[data, mutate]
|
|
||||||
)
|
|
||||||
|
|
||||||
const routesPOST = useCallback(
|
|
||||||
(d: RoutesPOSTData) => {
|
|
||||||
setIsPosting(true)
|
|
||||||
const onComplete = () => {
|
|
||||||
setIsPosting(false)
|
|
||||||
mutate() // refresh data after POST finishes
|
|
||||||
}
|
|
||||||
const updateMetrics = () => {
|
|
||||||
// only update metrics if values have changed
|
|
||||||
if (data?.AdvertisingExitNode !== d.AdvertiseExitNode) {
|
|
||||||
incrementMetric(
|
|
||||||
d.AdvertiseExitNode
|
|
||||||
? "web_client_advertise_exitnode_enable"
|
|
||||||
: "web_client_advertise_exitnode_disable"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// useExitNode is the ID of the exit node to use
|
|
||||||
if (data?.UsingExitNode?.ID !== d.UseExitNode) {
|
|
||||||
incrementMetric(
|
|
||||||
d.UseExitNode
|
|
||||||
? "web_client_use_exitnode_enable"
|
|
||||||
: "web_client_use_exitnode_disable"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiFetch("/routes", "POST", d)
|
|
||||||
.then(() => {
|
|
||||||
updateMetrics()
|
|
||||||
onComplete()
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
onComplete()
|
|
||||||
alert("Failed to update routes")
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[mutate, data?.AdvertisingExitNode, data?.UsingExitNode?.ID]
|
|
||||||
)
|
|
||||||
|
|
||||||
const nodeUpdaters: NodeUpdaters = useMemo(
|
|
||||||
() => ({
|
|
||||||
patchPrefs: prefsPATCH,
|
|
||||||
postExitNode: (node) =>
|
|
||||||
routesPOST({
|
|
||||||
AdvertiseExitNode: node.ID === runAsExitNode.ID,
|
|
||||||
UseExitNode:
|
|
||||||
node.ID === noExitNode.ID || node.ID === runAsExitNode.ID
|
|
||||||
? undefined
|
|
||||||
: node.ID,
|
|
||||||
AdvertiseRoutes: data?.AdvertisedRoutes?.map((r) => r.Route), // unchanged
|
|
||||||
}),
|
|
||||||
postSubnetRoutes: (routes) =>
|
|
||||||
routesPOST({
|
|
||||||
AdvertiseRoutes: routes,
|
|
||||||
AdvertiseExitNode: data?.AdvertisingExitNode, // unchanged
|
|
||||||
UseExitNode: data?.UsingExitNode?.ID, // unchanged
|
|
||||||
}).then(() => incrementMetric("web_client_advertise_routes_change")),
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
data?.AdvertisingExitNode,
|
|
||||||
data?.AdvertisedRoutes,
|
|
||||||
data?.UsingExitNode?.ID,
|
|
||||||
prefsPATCH,
|
|
||||||
routesPOST,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
return { data, refreshData: mutate, nodeUpdaters, isPosting }
|
|
||||||
}
|
|
@ -3,13 +3,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { apiFetch } from "src/api"
|
import { apiFetch } from "src/api"
|
||||||
|
import { VersionInfo } from "src/types"
|
||||||
// this type is deserialized from tailcfg.ClientVersion,
|
|
||||||
// so it should not include fields not included in that type.
|
|
||||||
export type VersionInfo = {
|
|
||||||
RunningLatest: boolean
|
|
||||||
LatestVersion?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// see ipnstate.UpdateProgress
|
// see ipnstate.UpdateProgress
|
||||||
export type UpdateProgress = {
|
export type UpdateProgress = {
|
||||||
|
112
client/web/src/types.ts
Normal file
112
client/web/src/types.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
import { assertNever } from "src/utils/util"
|
||||||
|
|
||||||
|
export type NodeData = {
|
||||||
|
Profile: UserProfile
|
||||||
|
Status: NodeState
|
||||||
|
DeviceName: string
|
||||||
|
OS: string
|
||||||
|
IPv4: string
|
||||||
|
IPv6: string
|
||||||
|
ID: string
|
||||||
|
KeyExpiry: string
|
||||||
|
KeyExpired: boolean
|
||||||
|
UsingExitNode?: ExitNode
|
||||||
|
AdvertisingExitNode: boolean
|
||||||
|
AdvertisedRoutes?: SubnetRoute[]
|
||||||
|
TUNMode: boolean
|
||||||
|
IsSynology: boolean
|
||||||
|
DSMVersion: number
|
||||||
|
IsUnraid: boolean
|
||||||
|
UnraidToken: string
|
||||||
|
IPNVersion: string
|
||||||
|
ClientVersion?: VersionInfo
|
||||||
|
URLPrefix: string
|
||||||
|
DomainName: string
|
||||||
|
TailnetName: string
|
||||||
|
IsTagged: boolean
|
||||||
|
Tags: string[]
|
||||||
|
RunningSSHServer: boolean
|
||||||
|
ControlAdminURL: string
|
||||||
|
LicensesURL: string
|
||||||
|
Features: { [key in Feature]: boolean } // value is true if given feature is available on this client
|
||||||
|
ACLAllowsAnyIncomingTraffic: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeState =
|
||||||
|
| "NoState"
|
||||||
|
| "NeedsLogin"
|
||||||
|
| "NeedsMachineAuth"
|
||||||
|
| "Stopped"
|
||||||
|
| "Starting"
|
||||||
|
| "Running"
|
||||||
|
|
||||||
|
export type UserProfile = {
|
||||||
|
LoginName: string
|
||||||
|
DisplayName: string
|
||||||
|
ProfilePicURL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubnetRoute = {
|
||||||
|
Route: string
|
||||||
|
Approved: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExitNode = {
|
||||||
|
ID: string
|
||||||
|
Name: string
|
||||||
|
Location?: ExitNodeLocation
|
||||||
|
Online?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExitNodeLocation = {
|
||||||
|
Country: string
|
||||||
|
CountryCode: CountryCode
|
||||||
|
City: string
|
||||||
|
CityCode: CityCode
|
||||||
|
Priority: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CountryCode = string
|
||||||
|
export type CityCode = string
|
||||||
|
|
||||||
|
export type ExitNodeGroup = {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
nodes: ExitNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Feature =
|
||||||
|
| "advertise-exit-node"
|
||||||
|
| "advertise-routes"
|
||||||
|
| "use-exit-node"
|
||||||
|
| "ssh"
|
||||||
|
| "auto-update"
|
||||||
|
|
||||||
|
export const featureDescription = (f: Feature) => {
|
||||||
|
switch (f) {
|
||||||
|
case "advertise-exit-node":
|
||||||
|
return "Advertising as an exit node"
|
||||||
|
case "advertise-routes":
|
||||||
|
return "Advertising subnet routes"
|
||||||
|
case "use-exit-node":
|
||||||
|
return "Using an exit node"
|
||||||
|
case "ssh":
|
||||||
|
return "Running a Tailscale SSH server"
|
||||||
|
case "auto-update":
|
||||||
|
return "Auto updating client versions"
|
||||||
|
default:
|
||||||
|
assertNever(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VersionInfo type is deserialized from tailcfg.ClientVersion,
|
||||||
|
* so it should not include fields not included in that type.
|
||||||
|
*/
|
||||||
|
export type VersionInfo = {
|
||||||
|
RunningLatest: boolean
|
||||||
|
LatestVersion?: string
|
||||||
|
}
|
@ -745,9 +745,11 @@ func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type postRoutesRequest struct {
|
type postRoutesRequest struct {
|
||||||
|
SetExitNode bool // when set, UseExitNode and AdvertiseExitNode values are applied
|
||||||
|
SetRoutes bool // when set, AdvertiseRoutes value is applied
|
||||||
UseExitNode tailcfg.StableNodeID
|
UseExitNode tailcfg.StableNodeID
|
||||||
AdvertiseRoutes []string
|
|
||||||
AdvertiseExitNode bool
|
AdvertiseExitNode bool
|
||||||
|
AdvertiseRoutes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -758,6 +760,27 @@ func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
prefs, err := s.lc.GetPrefs(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var currNonExitRoutes []string
|
||||||
|
var currAdvertisingExitNode bool
|
||||||
|
for _, r := range prefs.AdvertiseRoutes {
|
||||||
|
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||||
|
currAdvertisingExitNode = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
currNonExitRoutes = append(currNonExitRoutes, r.String())
|
||||||
|
}
|
||||||
|
// Set non-edited fields to their current values.
|
||||||
|
if data.SetExitNode {
|
||||||
|
data.AdvertiseRoutes = currNonExitRoutes
|
||||||
|
} else if data.SetRoutes {
|
||||||
|
data.AdvertiseExitNode = currAdvertisingExitNode
|
||||||
|
data.UseExitNode = prefs.ExitNodeID
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate routes.
|
// Calculate routes.
|
||||||
routesStr := strings.Join(data.AdvertiseRoutes, ",")
|
routesStr := strings.Join(data.AdvertiseRoutes, ",")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user