mirror of
https://github.com/tailscale/tailscale.git
synced 2025-04-16 03:31:39 +00:00
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:
parent
de2af54ffc
commit
5e095ddc20
@ -68,7 +68,7 @@ function HomeView({
|
|||||||
data: NodeData
|
data: NodeData
|
||||||
newSession: () => Promise<void>
|
newSession: () => Promise<void>
|
||||||
refreshData: () => Promise<void>
|
refreshData: () => Promise<void>
|
||||||
updateNode: (update: NodeUpdate) => void
|
updateNode: (update: NodeUpdate) => Promise<void> | undefined
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -80,7 +80,7 @@ function HomeView({
|
|||||||
/>
|
/>
|
||||||
) : data.DebugMode === "full" && auth?.ok ? (
|
) : data.DebugMode === "full" && auth?.ok ? (
|
||||||
// Render new client interface in management mode.
|
// Render new client interface in management mode.
|
||||||
<ManagementClientView {...data} />
|
<ManagementClientView node={data} updateNode={updateNode} />
|
||||||
) : data.DebugMode === "login" || data.DebugMode === "full" ? (
|
) : data.DebugMode === "login" || data.DebugMode === "full" ? (
|
||||||
// Render new client interface in readonly mode.
|
// Render new client interface in readonly mode.
|
||||||
<ReadonlyClientView data={data} auth={auth} newSession={newSession} />
|
<ReadonlyClientView data={data} auth={auth} newSession={newSession} />
|
||||||
|
171
client/web/src/components/exit-node-selector.tsx
Normal file
171
client/web/src/components/exit-node-selector.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -1,12 +1,18 @@
|
|||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import React from "react"
|
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 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 { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg"
|
||||||
import { Link } from "wouter"
|
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 (
|
return (
|
||||||
<div className="mb-12 w-full">
|
<div className="mb-12 w-full">
|
||||||
<h2 className="mb-3">This device</h2>
|
<h2 className="mb-3">This device</h2>
|
||||||
@ -15,16 +21,20 @@ export default function ManagementClientView(props: NodeData) {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<ConnectedDeviceIcon />
|
<ConnectedDeviceIcon />
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<h1>{props.DeviceName}</h1>
|
<h1>{node.DeviceName}</h1>
|
||||||
{/* TODO(sonia): display actual status */}
|
{/* TODO(sonia): display actual status */}
|
||||||
<p className="text-neutral-500 text-sm">Connected</p>
|
<p className="text-neutral-500 text-sm">Connected</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-neutral-800 text-lg leading-[25.20px]">
|
<p className="text-neutral-800 text-lg leading-[25.20px]">
|
||||||
{props.IP}
|
{node.IP}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ExitNodeSelector className="mb-5" />
|
<ExitNodeSelector
|
||||||
|
className="mb-5"
|
||||||
|
node={node}
|
||||||
|
updateNode={updateNode}
|
||||||
|
/>
|
||||||
<Link
|
<Link
|
||||||
className="text-indigo-500 font-medium leading-snug"
|
className="text-indigo-500 font-medium leading-snug"
|
||||||
to="/details"
|
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({
|
function SettingsCard({
|
||||||
title,
|
title,
|
||||||
link,
|
link,
|
||||||
|
@ -75,7 +75,7 @@ export default function useNodeData() {
|
|||||||
: data.AdvertiseExitNode,
|
: data.AdvertiseExitNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
apiFetch("/data", "POST", update, { up: "true" })
|
return apiFetch("/data", "POST", update, { up: "true" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setIsPosting(false)
|
setIsPosting(false)
|
||||||
@ -89,7 +89,10 @@ export default function useNodeData() {
|
|||||||
}
|
}
|
||||||
refreshData()
|
refreshData()
|
||||||
})
|
})
|
||||||
.catch((err) => alert("Failed operation: " + err.message))
|
.catch((err) => {
|
||||||
|
alert("Failed operation: " + err.message)
|
||||||
|
throw err
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[data]
|
[data]
|
||||||
)
|
)
|
||||||
|
3
client/web/src/icons/check.svg
Normal file
3
client/web/src/icons/check.svg
Normal 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 |
@ -1,3 +1,3 @@
|
|||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<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>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 220 B After Width: | Height: | Size: 203 B |
4
client/web/src/icons/search.svg
Normal file
4
client/web/src/icons/search.svg
Normal 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 |
@ -31,13 +31,6 @@
|
|||||||
.card td:last-child {
|
.card td:last-child {
|
||||||
@apply text-neutral-800 text-sm leading-tight;
|
@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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
x
Reference in New Issue
Block a user