client/web: add subnet routes view
Add UI view for mutating the node's advertised subnet routes. Updates #10261 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:

committed by
Sonia Appasamy

parent
7aa981ba49
commit
ecd1ccb917
@ -8,6 +8,7 @@ import DeviceDetailsView from "src/components/views/device-details-view"
|
||||
import HomeView from "src/components/views/home-view"
|
||||
import LoginView from "src/components/views/login-view"
|
||||
import SSHView from "src/components/views/ssh-view"
|
||||
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"
|
||||
@ -34,7 +35,7 @@ function WebClient({
|
||||
auth: AuthResponse
|
||||
newSession: () => Promise<void>
|
||||
}) {
|
||||
const { data, refreshData, updateNode, updatePrefs } = useNodeData()
|
||||
const { data, refreshData, nodeUpdaters } = useNodeData()
|
||||
useEffect(() => {
|
||||
refreshData()
|
||||
}, [auth, refreshData])
|
||||
@ -56,19 +57,24 @@ function WebClient({
|
||||
<HomeView
|
||||
readonly={!auth.canManageNode}
|
||||
node={data}
|
||||
updateNode={updateNode}
|
||||
updatePrefs={updatePrefs}
|
||||
nodeUpdaters={nodeUpdaters}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/details">
|
||||
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
|
||||
</Route>
|
||||
<Route path="/subnets">{/* TODO */}Subnet router</Route>
|
||||
<Route path="/subnets">
|
||||
<SubnetRouterView
|
||||
readonly={!auth.canManageNode}
|
||||
node={data}
|
||||
nodeUpdaters={nodeUpdaters}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/ssh">
|
||||
<SSHView
|
||||
readonly={!auth.canManageNode}
|
||||
runningSSH={data.RunningSSHServer}
|
||||
updatePrefs={updatePrefs}
|
||||
nodeUpdaters={nodeUpdaters}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/serve">{/* TODO */}Share local content</Route>
|
||||
|
@ -11,21 +11,19 @@ import useExitNodes, {
|
||||
runAsExitNode,
|
||||
trimDNSSuffix,
|
||||
} from "src/hooks/exit-nodes"
|
||||
import { NodeData, NodeUpdate, PrefsUpdate } from "src/hooks/node-data"
|
||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||
import Popover from "src/ui/popover"
|
||||
import SearchInput from "src/ui/search-input"
|
||||
|
||||
export default function ExitNodeSelector({
|
||||
className,
|
||||
node,
|
||||
updateNode,
|
||||
updatePrefs,
|
||||
nodeUpdaters,
|
||||
disabled,
|
||||
}: {
|
||||
className?: string
|
||||
node: NodeData
|
||||
updateNode: (update: NodeUpdate) => Promise<void> | undefined
|
||||
updatePrefs: (p: PrefsUpdate) => Promise<void>
|
||||
nodeUpdaters: NodeUpdaters
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
@ -37,48 +35,11 @@ export default function ExitNodeSelector({
|
||||
if (n.ID === selected.ID) {
|
||||
return // no update
|
||||
}
|
||||
|
||||
const old = selected
|
||||
setSelected(n) // optimistic UI update
|
||||
const reset = () => setSelected(old)
|
||||
|
||||
switch (n.ID) {
|
||||
case noExitNode.ID: {
|
||||
if (old === runAsExitNode) {
|
||||
// stop advertising as exit node
|
||||
updateNode({ AdvertiseExitNode: false })?.catch(reset)
|
||||
} else {
|
||||
// stop using exit node
|
||||
updatePrefs({ ExitNodeIDSet: true, ExitNodeID: "" }).catch(reset)
|
||||
}
|
||||
break
|
||||
}
|
||||
case runAsExitNode.ID: {
|
||||
const update = () =>
|
||||
updateNode({ AdvertiseExitNode: true })?.catch(reset)
|
||||
if (old !== noExitNode) {
|
||||
// stop using exit node first
|
||||
updatePrefs({ ExitNodeIDSet: true, ExitNodeID: "" })
|
||||
.catch(reset)
|
||||
.then(update)
|
||||
} else {
|
||||
update()
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
const update = () =>
|
||||
updatePrefs({ ExitNodeIDSet: true, ExitNodeID: n.ID }).catch(reset)
|
||||
if (old === runAsExitNode) {
|
||||
// stop advertising as exit node first
|
||||
updateNode({ AdvertiseExitNode: false })?.catch(reset).then(update)
|
||||
} else {
|
||||
update()
|
||||
}
|
||||
}
|
||||
}
|
||||
nodeUpdaters.postExitNode(n).catch(() => setSelected(old))
|
||||
},
|
||||
[selected, updateNode, updatePrefs]
|
||||
[nodeUpdaters, selected]
|
||||
)
|
||||
|
||||
const [
|
||||
@ -186,12 +147,12 @@ export default function ExitNodeSelector({
|
||||
}
|
||||
|
||||
function toSelectedExitNode(data: NodeData): ExitNode {
|
||||
if (data.AdvertiseExitNode) {
|
||||
if (data.AdvertisingExitNode) {
|
||||
return runAsExitNode
|
||||
}
|
||||
if (data.ExitNodeStatus) {
|
||||
if (data.UsingExitNode) {
|
||||
// TODO(sonia): also use online status
|
||||
const node = { ...data.ExitNodeStatus }
|
||||
const node = { ...data.UsingExitNode }
|
||||
if (node.Location) {
|
||||
// For mullvad nodes, use location as name.
|
||||
node.Name = `${node.Location.Country}: ${node.Location.City}`
|
||||
|
@ -6,19 +6,17 @@ import React from "react"
|
||||
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
|
||||
import { ReactComponent as ConnectedDeviceIcon } from "src/assets/icons/connected-device.svg"
|
||||
import ExitNodeSelector from "src/components/exit-node-selector"
|
||||
import { NodeData, NodeUpdate, PrefsUpdate } from "src/hooks/node-data"
|
||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||
import { Link } from "wouter"
|
||||
|
||||
export default function HomeView({
|
||||
readonly,
|
||||
node,
|
||||
updateNode,
|
||||
updatePrefs,
|
||||
nodeUpdaters,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
updateNode: (update: NodeUpdate) => Promise<void> | undefined
|
||||
updatePrefs: (p: PrefsUpdate) => Promise<void>
|
||||
nodeUpdaters: NodeUpdaters
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-12 w-full">
|
||||
@ -40,8 +38,7 @@ export default function HomeView({
|
||||
<ExitNodeSelector
|
||||
className="mb-5"
|
||||
node={node}
|
||||
updateNode={updateNode}
|
||||
updatePrefs={updatePrefs}
|
||||
nodeUpdaters={nodeUpdaters}
|
||||
disabled={readonly}
|
||||
/>
|
||||
<Link
|
||||
@ -52,13 +49,12 @@ export default function HomeView({
|
||||
</Link>
|
||||
</div>
|
||||
<h2 className="mb-3">Settings</h2>
|
||||
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
|
||||
{/* <SettingsCard
|
||||
<SettingsCard
|
||||
link="/subnets"
|
||||
className="mb-3"
|
||||
title="Subnet router"
|
||||
body="Add devices to your tailnet without installing Tailscale on them."
|
||||
/> */}
|
||||
/>
|
||||
<SettingsCard
|
||||
link="/ssh"
|
||||
className="mb-3"
|
||||
@ -73,6 +69,7 @@ export default function HomeView({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
|
||||
{/* <SettingsCard
|
||||
link="/serve"
|
||||
title="Share local content"
|
||||
|
@ -2,17 +2,17 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React from "react"
|
||||
import { PrefsUpdate } from "src/hooks/node-data"
|
||||
import { NodeUpdaters } from "src/hooks/node-data"
|
||||
import Toggle from "src/ui/toggle"
|
||||
|
||||
export default function SSHView({
|
||||
readonly,
|
||||
runningSSH,
|
||||
updatePrefs,
|
||||
nodeUpdaters,
|
||||
}: {
|
||||
readonly: boolean
|
||||
runningSSH: boolean
|
||||
updatePrefs: (p: PrefsUpdate) => Promise<void>
|
||||
nodeUpdaters: NodeUpdaters
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@ -32,7 +32,9 @@ export default function SSHView({
|
||||
<div className="-mx-5 px-4 py-3 bg-white rounded-lg border border-gray-200 flex gap-2.5 mb-3">
|
||||
<Toggle
|
||||
checked={runningSSH}
|
||||
onChange={() => updatePrefs({ RunSSHSet: true, RunSSH: !runningSSH })}
|
||||
onChange={() =>
|
||||
nodeUpdaters.patchPrefs({ RunSSHSet: true, RunSSH: !runningSSH })
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
<div className="text-black text-sm font-medium leading-tight">
|
||||
|
146
client/web/src/components/views/subnet-router-view.tsx
Normal file
146
client/web/src/components/views/subnet-router-view.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { ReactComponent as CheckCircle } from "src/assets/icons/check-circle.svg"
|
||||
import { ReactComponent as Clock } from "src/assets/icons/clock.svg"
|
||||
import { ReactComponent as Plus } from "src/assets/icons/plus.svg"
|
||||
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
|
||||
import Button from "src/ui/button"
|
||||
import Input from "src/ui/input"
|
||||
|
||||
export default function SubnetRouterView({
|
||||
readonly,
|
||||
node,
|
||||
nodeUpdaters,
|
||||
}: {
|
||||
readonly: boolean
|
||||
node: NodeData
|
||||
nodeUpdaters: NodeUpdaters
|
||||
}) {
|
||||
const advertisedRoutes = useMemo(
|
||||
() => node.AdvertisedRoutes || [],
|
||||
[node.AdvertisedRoutes]
|
||||
)
|
||||
const [inputOpen, setInputOpen] = useState<boolean>(
|
||||
advertisedRoutes.length === 0 && !readonly
|
||||
)
|
||||
const [inputText, setInputText] = useState<string>("")
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-1">Subnet router</h1>
|
||||
<p className="description mb-5">
|
||||
Add devices to your tailnet without installing Tailscale.{" "}
|
||||
<a
|
||||
href="https://tailscale.com/kb/1019/subnets/"
|
||||
className="text-indigo-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
{inputOpen ? (
|
||||
<div className="-mx-5 card shadow">
|
||||
<p className="font-medium leading-snug mb-3">Advertise new routes</p>
|
||||
<Input
|
||||
type="text"
|
||||
className="text-sm"
|
||||
placeholder="192.168.0.0/24"
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
/>
|
||||
<p className="my-2 h-6 text-neutral-500 text-sm leading-tight">
|
||||
Add multiple routes by providing a comma-separated list.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() =>
|
||||
nodeUpdaters
|
||||
.postSubnetRoutes([
|
||||
...advertisedRoutes.map((r) => r.Route),
|
||||
...inputText.split(","),
|
||||
])
|
||||
.then(() => {
|
||||
setInputText("")
|
||||
setInputOpen(false)
|
||||
})
|
||||
}
|
||||
disabled={readonly || !inputText}
|
||||
>
|
||||
Advertise routes
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setInputOpen(true)} disabled={readonly}>
|
||||
<Plus />
|
||||
Advertise new route
|
||||
</Button>
|
||||
)}
|
||||
<div className="-mx-5 mt-10">
|
||||
{advertisedRoutes.length > 0 ? (
|
||||
<>
|
||||
<div className="px-5 py-3 bg-white rounded-lg border border-gray-200">
|
||||
{advertisedRoutes.map((r) => (
|
||||
<div
|
||||
className="flex justify-between items-center pb-2.5 mb-2.5 border-b border-b-gray-200 last:pb-0 last:mb-0 last:border-b-0"
|
||||
key={r.Route}
|
||||
>
|
||||
<div className="text-neutral-800 leading-snug">{r.Route}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{r.Approved ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : (
|
||||
<Clock className="w-4 h-4" />
|
||||
)}
|
||||
{r.Approved ? (
|
||||
<div className="text-emerald-800 text-sm leading-tight">
|
||||
Approved
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 text-sm leading-tight">
|
||||
Pending approval
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
intent="secondary"
|
||||
className="text-sm font-medium"
|
||||
onClick={() =>
|
||||
nodeUpdaters.postSubnetRoutes(
|
||||
advertisedRoutes
|
||||
.map((it) => it.Route)
|
||||
.filter((it) => it !== r.Route)
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
>
|
||||
Stop advertising…
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 w-full text-center text-neutral-500 text-sm leading-tight">
|
||||
To approve routes, in the admin console go to{" "}
|
||||
<a
|
||||
href={`https://login.tailscale.com/admin/machines/${node.IP}`}
|
||||
className="text-indigo-700"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
the machine’s route settings
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-5 py-4 bg-stone-50 rounded-lg border border-gray-200 text-center text-neutral-500">
|
||||
Not advertising any routes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user