client/web: add initial framework for exit node selector

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
Sonia Appasamy 2023-11-07 11:16:23 -05:00 committed by Sonia Appasamy
parent de2af54ffc
commit 5e095ddc20
8 changed files with 202 additions and 34 deletions

View File

@ -68,7 +68,7 @@ function HomeView({
data: NodeData
newSession: () => Promise<void>
refreshData: () => Promise<void>
updateNode: (update: NodeUpdate) => void
updateNode: (update: NodeUpdate) => Promise<void> | undefined
}) {
return (
<>
@ -80,7 +80,7 @@ function HomeView({
/>
) : data.DebugMode === "full" && auth?.ok ? (
// Render new client interface in management mode.
<ManagementClientView {...data} />
<ManagementClientView node={data} updateNode={updateNode} />
) : data.DebugMode === "login" || data.DebugMode === "full" ? (
// Render new client interface in readonly mode.
<ReadonlyClientView data={data} auth={auth} newSession={newSession} />

View File

@ -0,0 +1,171 @@
import cx from "classnames"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { NodeData, NodeUpdate } from "src/hooks/node-data"
import { ReactComponent as Check } from "src/icons/check.svg"
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
import { ReactComponent as Search } from "src/icons/search.svg"
const noExitNode = "None"
const runAsExitNode = "Run as exit node…"
export default function ExitNodeSelector({
className,
node,
updateNode,
}: {
className?: string
node: NodeData
updateNode: (update: NodeUpdate) => Promise<void> | undefined
}) {
const [open, setOpen] = useState<boolean>(false)
const [selected, setSelected] = useState(
node.AdvertiseExitNode ? runAsExitNode : noExitNode
)
useEffect(() => {
setSelected(node.AdvertiseExitNode ? runAsExitNode : noExitNode)
}, [node])
const handleSelect = useCallback(
(item: string) => {
setOpen(false)
if (item === selected) {
return // no update
}
const old = selected
setSelected(item)
var update: NodeUpdate = {}
switch (item) {
case noExitNode:
// turn off exit node
update = { AdvertiseExitNode: false }
break
case runAsExitNode:
// turn on exit node
update = { AdvertiseExitNode: true }
break
}
updateNode(update)?.catch(() => setSelected(old))
},
[setOpen, selected, setSelected]
)
// TODO: close on click outside
// TODO(sonia): allow choosing to use another exit node
const [
none, // not using exit nodes
advertising, // advertising as exit node
using, // using another exit node
] = useMemo(
() => [
selected === noExitNode,
selected === runAsExitNode,
selected !== noExitNode && selected !== runAsExitNode,
],
[selected]
)
return (
<>
<div
className={cx(
"p-1.5 rounded-md border flex items-stretch gap-1.5",
{
"border-gray-200": none,
"bg-amber-600 border-amber-600": advertising,
"bg-indigo-500 border-indigo-500": using,
},
className
)}
>
<button
className={cx("flex-1 px-2 py-1.5 rounded-[1px] cursor-pointer", {
"bg-white hover:bg-stone-100": none,
"bg-amber-600 hover:bg-orange-400": advertising,
"bg-indigo-500 hover:bg-indigo-400": using,
})}
onClick={() => setOpen(!open)}
>
<p
className={cx(
"text-neutral-500 text-xs text-left font-medium uppercase tracking-wide mb-1",
{ "bg-opacity-70 text-white": advertising || using }
)}
>
Exit node
</p>
<div className="flex items-center">
<p
className={cx("text-neutral-800", {
"text-white": advertising || using,
})}
>
{selected === runAsExitNode ? "Running as exit node" : "None"}
</p>
<ChevronDown
className={cx("ml-1", {
"stroke-neutral-800": none,
"stroke-white": advertising || using,
})}
/>
</div>
</button>
{(advertising || using) && (
<button
className={cx("px-3 py-2 rounded-sm text-white cursor-pointer", {
"bg-orange-400": advertising,
"bg-indigo-400": using,
})}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleSelect(noExitNode)
}}
>
Disable
</button>
)}
</div>
{open && (
<div className="absolute ml-1.5 -mt-3 w-full max-w-md py-1 bg-white rounded-lg shadow">
<div className="w-full px-4 py-2 flex items-center gap-2.5">
<Search />
<input
className="flex-1 leading-snug"
placeholder="Search exit nodes…"
/>
</div>
<DropdownSection
items={[noExitNode, runAsExitNode]}
selected={selected}
onSelect={handleSelect}
/>
</div>
)}
</>
)
}
function DropdownSection({
items,
selected,
onSelect,
}: {
items: string[]
selected?: string
onSelect: (item: string) => void
}) {
return (
<div className="w-full mt-1 pt-1 border-t border-gray-200">
{items.map((v) => (
<button
key={v}
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
onClick={() => onSelect(v)}
>
<div className="leading-snug">{v}</div>
{selected == v && <Check />}
</button>
))}
</div>
)
}

View File

@ -1,12 +1,18 @@
import cx from "classnames"
import React from "react"
import { NodeData } from "src/hooks/node-data"
import ExitNodeSelector from "src/components/exit-node-selector"
import { NodeData, NodeUpdate } from "src/hooks/node-data"
import { ReactComponent as ArrowRight } from "src/icons/arrow-right.svg"
import { ReactComponent as ChevronDown } from "src/icons/chevron-down.svg"
import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
import { Link } from "wouter"
export default function ManagementClientView(props: NodeData) {
export default function ManagementClientView({
node,
updateNode,
}: {
node: NodeData
updateNode: (update: NodeUpdate) => Promise<void> | undefined
}) {
return (
<div className="mb-12 w-full">
<h2 className="mb-3">This device</h2>
@ -15,16 +21,20 @@ export default function ManagementClientView(props: NodeData) {
<div className="flex items-center">
<ConnectedDeviceIcon />
<div className="ml-3">
<h1>{props.DeviceName}</h1>
<h1>{node.DeviceName}</h1>
{/* TODO(sonia): display actual status */}
<p className="text-neutral-500 text-sm">Connected</p>
</div>
</div>
<p className="text-neutral-800 text-lg leading-[25.20px]">
{props.IP}
{node.IP}
</p>
</div>
<ExitNodeSelector className="mb-5" />
<ExitNodeSelector
className="mb-5"
node={node}
updateNode={updateNode}
/>
<Link
className="text-indigo-500 font-medium leading-snug"
to="/details"
@ -54,22 +64,6 @@ export default function ManagementClientView(props: NodeData) {
)
}
function ExitNodeSelector({ className }: { className?: string }) {
return (
<div className={cx("p-1.5 rounded-md border border-gray-200", className)}>
<div className="hover-button">
<p className="text-neutral-500 text-xs font-medium uppercase tracking-wide mb-1">
Exit node
</p>
<div className="flex items-center">
<p className="text-neutral-800">None</p>
<ChevronDown className="ml-[9px]" />
</div>
</div>
</div>
)
}
function SettingsCard({
title,
link,

View File

@ -75,7 +75,7 @@ export default function useNodeData() {
: data.AdvertiseExitNode,
}
apiFetch("/data", "POST", update, { up: "true" })
return apiFetch("/data", "POST", update, { up: "true" })
.then((r) => r.json())
.then((r) => {
setIsPosting(false)
@ -89,7 +89,10 @@ export default function useNodeData() {
}
refreshData()
})
.catch((err) => alert("Failed operation: " + err.message))
.catch((err) => {
alert("Failed operation: " + err.message)
throw err
})
},
[data]
)

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.6673 5L7.50065 14.1667L3.33398 10" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@ -1,3 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 7.5L10 12.5L15 7.5" stroke="#706E6D" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 7.5L10 12.5L15 7.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 220 B

After

Width:  |  Height:  |  Size: 203 B

View File

@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.16667 15.8333C12.8486 15.8333 15.8333 12.8486 15.8333 9.16667C15.8333 5.48477 12.8486 2.5 9.16667 2.5C5.48477 2.5 2.5 5.48477 2.5 9.16667C2.5 12.8486 5.48477 15.8333 9.16667 15.8333Z" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.5 17.5L13.875 13.875" stroke="#706E6D" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 500 B

View File

@ -31,13 +31,6 @@
.card td:last-child {
@apply text-neutral-800 text-sm leading-tight;
}
.hover-button {
@apply px-2 py-1.5 bg-white rounded-[1px] cursor-pointer;
}
.hover-button:hover {
@apply bg-stone-100;
}
}
/**