mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 04:55:31 +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
|
||||
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} />
|
||||
|
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 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,
|
||||
|
@ -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]
|
||||
)
|
||||
|
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">
|
||||
<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 |
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 {
|
||||
@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…
Reference in New Issue
Block a user