diff options
| author | Sonia Appasamy <sonia@tailscale.com> | 2023-11-07 11:16:23 -0500 |
|---|---|---|
| committer | Sonia Appasamy <appasamysm@gmail.com> | 2023-11-07 11:30:22 -0500 |
| commit | 5e095ddc2000ac07d2aedee9038bc9c503cb94e0 (patch) | |
| tree | 6bd3d4e361afe09fe1a582cb8c204d92393c6f90 | |
| parent | de2af54ffcf22e995c9e09f32a7ef14c6906a5ac (diff) | |
| download | tailscale-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.tsx | 4 | ||||
| -rw-r--r-- | client/web/src/components/exit-node-selector.tsx | 171 | ||||
| -rw-r--r-- | client/web/src/components/views/management-client-view.tsx | 38 | ||||
| -rw-r--r-- | client/web/src/hooks/node-data.ts | 7 | ||||
| -rw-r--r-- | client/web/src/icons/check.svg | 3 | ||||
| -rw-r--r-- | client/web/src/icons/chevron-down.svg | 2 | ||||
| -rw-r--r-- | client/web/src/icons/search.svg | 4 | ||||
| -rw-r--r-- | client/web/src/index.css | 7 |
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; - } } /** |
