summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorSonia Appasamy <sonia@tailscale.com>2023-11-07 11:16:23 -0500
committerSonia Appasamy <appasamysm@gmail.com>2023-11-07 11:30:22 -0500
commit5e095ddc2000ac07d2aedee9038bc9c503cb94e0 (patch)
tree6bd3d4e361afe09fe1a582cb8c204d92393c6f90
parentde2af54ffcf22e995c9e09f32a7ef14c6906a5ac (diff)
downloadtailscale-5e095ddc2000ac07d2aedee9038bc9c503cb94e0.tar.xz
tailscale-5e095ddc2000ac07d2aedee9038bc9c503cb94e0.zip
client/web: add initial framework for exit node selector
Updates tailscale/corp#14335 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
-rw-r--r--client/web/src/components/app.tsx4
-rw-r--r--client/web/src/components/exit-node-selector.tsx171
-rw-r--r--client/web/src/components/views/management-client-view.tsx38
-rw-r--r--client/web/src/hooks/node-data.ts7
-rw-r--r--client/web/src/icons/check.svg3
-rw-r--r--client/web/src/icons/chevron-down.svg2
-rw-r--r--client/web/src/icons/search.svg4
-rw-r--r--client/web/src/index.css7
8 files changed, 202 insertions, 34 deletions
diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx
index 5665ac19f..b8ec34a6c 100644
--- a/client/web/src/components/app.tsx
+++ b/client/web/src/components/app.tsx
@@ -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} />
diff --git a/client/web/src/components/exit-node-selector.tsx b/client/web/src/components/exit-node-selector.tsx
new file mode 100644
index 000000000..b4f39b680
--- /dev/null
+++ b/client/web/src/components/exit-node-selector.tsx
@@ -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>
+ )
+}
diff --git a/client/web/src/components/views/management-client-view.tsx b/client/web/src/components/views/management-client-view.tsx
index 9fcb822de..6bb76d223 100644
--- a/client/web/src/components/views/management-client-view.tsx
+++ b/client/web/src/components/views/management-client-view.tsx
@@ -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,
diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts
index 316c69b64..613f72d39 100644
--- a/client/web/src/hooks/node-data.ts
+++ b/client/web/src/hooks/node-data.ts
@@ -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]
)
diff --git a/client/web/src/icons/check.svg b/client/web/src/icons/check.svg
new file mode 100644
index 000000000..efa11685d
--- /dev/null
+++ b/client/web/src/icons/check.svg
@@ -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>
diff --git a/client/web/src/icons/chevron-down.svg b/client/web/src/icons/chevron-down.svg
index 7d32b1de8..afc98f255 100644
--- a/client/web/src/icons/chevron-down.svg
+++ b/client/web/src/icons/chevron-down.svg
@@ -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>
diff --git a/client/web/src/icons/search.svg b/client/web/src/icons/search.svg
new file mode 100644
index 000000000..782cd90ee
--- /dev/null
+++ b/client/web/src/icons/search.svg
@@ -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>
diff --git a/client/web/src/index.css b/client/web/src/index.css
index 4ebbf9ff3..0cd0aa15c 100644
--- a/client/web/src/index.css
+++ b/client/web/src/index.css
@@ -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;
- }
}
/**