diff --git a/README.md b/README.md index bbfbaf3..121a824 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ To start the local UI server, navigate to `ui` and run: yarn start ``` -This will spin up a local server on [localhost:3000](http://localhost:3000). Once running, instruct Docker Desktop to use that server as your extension UI with the command: +This will spin up a local server on [localhost:3011](http://localhost:3011). Once running, instruct Docker Desktop to use that server as your extension UI with the command: ``` -docker extension dev ui-source http://localhost:3000 +docker extension dev ui-source http://localhost:3011 ``` diff --git a/ui/package.json b/ui/package.json index 44ee58b..c80ef0c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "start": "react-scripts start", + "start": "PORT=3011 react-scripts start", "build": "react-scripts build", "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", "format": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'" diff --git a/ui/src/components/dropdown-menu.tsx b/ui/src/components/dropdown-menu.tsx index 786e599..6911fbd 100644 --- a/ui/src/components/dropdown-menu.tsx +++ b/ui/src/components/dropdown-menu.tsx @@ -55,15 +55,18 @@ DropdownMenu.defaultProps = { DropdownMenu.Group = DropdownMenuGroup DropdownMenu.Item = DropdownMenuItem DropdownMenu.Link = DropdownMenuLink +DropdownMenu.Label = DropdownLabel +DropdownMenu.ItemIndicator = DropdownItemIndicator DropdownMenu.RadioGroup = MenuPrimitive.RadioGroup -DropdownMenu.RadioItem = MenuPrimitive.RadioItem +DropdownMenu.RadioItem = DropdownRadioItem /** * DropdownMenu.Separator should be used to divide items into sections within a * DropdownMenu. */ DropdownMenu.Separator = DropdownSeparator -const menuItemClasses = "block px-4 py-2" +const menuItemPadding = "pl-5 pr-4 py-2" +const menuItemClasses = cx("block", menuItemPadding) const menuItemInteractiveClasses = "cursor-pointer focus:outline-none hover:enabled:bg-gray-100 focus:bg-gray-100 dark:hover:enabled:bg-[#5E6971] dark:focus:bg-[#5E6971]" @@ -176,3 +179,50 @@ function DropdownSeparator(props: DropdownSeparatorProps) { /> ) } + +function DropdownLabel(props: MenuPrimitive.MenuLabelProps) { + const { className, ...rest } = props + return ( + + ) +} + +function DropdownRadioItem(props: MenuPrimitive.MenuRadioItemProps) { + const { className, disabled, ...rest } = props + + return ( + + ) +} + +function DropdownItemIndicator(props: MenuPrimitive.MenuItemIndicatorProps) { + const { className, ...rest } = props + return ( + + ) +} diff --git a/ui/src/components/icon.tsx b/ui/src/components/icon.tsx index fde7f5c..360452e 100644 --- a/ui/src/components/icon.tsx +++ b/ui/src/components/icon.tsx @@ -106,6 +106,20 @@ const icons: Record> = { ), + dot: (props: IconProps) => ( + + + + ), } export default function Icon(props: { diff --git a/ui/src/tailscale.ts b/ui/src/tailscale.ts index b557244..893a4b2 100644 --- a/ui/src/tailscale.ts +++ b/ui/src/tailscale.ts @@ -1,6 +1,6 @@ import create from "zustand" import shallowCompare from "zustand/shallow" -import { isMacOS, isWindows, openBrowser } from "src/utils" +import { isMacOS, isWindows, openBrowser, timeout } from "src/utils" // BackendState // Keep in sync with https://github.com/tailscale/tailscale/blob/main/ipn/backend.go @@ -34,6 +34,8 @@ export type State = { hostStatus: HostStatus tailscaleIPs: string[] + loginServer?: string + /** * loginInfo is an object with details that allow a user to log in. */ @@ -47,6 +49,7 @@ export type State = { connect: () => Promise disconnect: () => Promise switchAccount: () => Promise + setLoginServer: (loginServer: string | undefined) => Promise logout: () => Promise /** @@ -78,6 +81,8 @@ const useTailscale = create((set, get) => ({ hostname: "", hostStatus: { status: "unknown", loginName: "" }, tailscaleIPs: [], + loginServer: + window.localStorage.getItem("tailscale/loginServer") || undefined, loginInfo: undefined, loginUser: undefined, @@ -115,18 +120,28 @@ const useTailscale = create((set, get) => ({ } throw new Error("No login URL") }, + setLoginServer: async (loginServer: string | undefined) => { + if (loginServer) { + window.localStorage.setItem("tailscale/loginServer", loginServer) + } else { + window.localStorage.removeItem("tailscale/loginServer") + } + set({ loginServer }) + }, fetchLoginInfo: async () => { if (get().loginInfo) { // If we already have loginInfo, don't overwrite it. return } try { - let hostname = get().hostname + let { hostname, loginServer } = get() if (hostname === "") { await get().fetchHostname() - hostname = get().hostname + const data = get() + hostname = data.hostname + loginServer = data.loginServer } - const info = await getLoginInfo(hostname) + const info = await getLoginInfo(hostname, loginServer) const loginInfo = typeof info.AuthURL === "string" && typeof info.QR === "string" ? { @@ -271,9 +286,20 @@ async function getTailscaleStatus(): Promise { return JSON.parse(status.stdout) } -async function runTailscaleCommand(command: string): Promise { - const resp = await window.ddClient.backend.execInVMExtension( - `/app/tailscale ${command}`, +async function runTailscaleCommand( + command: string, + options: { backgroundOutput?: boolean; timeout?: number } = {}, +): Promise { + const cmd = [ + options?.backgroundOutput ? "/app/background-output.sh" : "", + `/app/tailscale`, + command, + ] + .filter(Boolean) + .join(" ") + const resp = await timeout( + window.ddClient.backend.execInVMExtension(cmd), + options?.timeout || 10000, ) return resp } @@ -288,12 +314,21 @@ type TailscaleUpResponse = { /** * getLoginInfo fetches the current login state from Tailscale. */ -async function getLoginInfo(hostname: string): Promise { +async function getLoginInfo( + hostname: string, + loginServer?: string, +): Promise { // We use `--force-reauth` because we want to provide users the option to // change their account. If we call `up` without `--force-reauth`, it just // tells us that it's already running. - const command = `/app/background-output.sh /app/tailscale up --hostname=${hostname}-docker-desktop --accept-dns=false --json --reset --force-reauth` - const resp = await window.ddClient.backend.execInVMExtension(command) + const cmd = [ + `up --accept-dns=false --json --reset`, + loginServer ? `--login-server=${loginServer}` : "", + `--hostname=${hostname}-docker-desktop`, + ] + .filter(Boolean) + .join(" ") + const resp = await runTailscaleCommand(cmd, { backgroundOutput: true }) let info = JSON.parse(resp.stdout) if (typeof info.AuthURL === "string") { // Add referral partner info to the URL diff --git a/ui/src/utils.ts b/ui/src/utils.ts index 99f29db..24b8f78 100644 --- a/ui/src/utils.ts +++ b/ui/src/utils.ts @@ -1,3 +1,20 @@ +/** + * timeout allows setting a max deadline for a promise to be executed. + */ +export function timeout(task: Promise, timeout: number) { + let timer: number + return Promise.race([ + task, + new Promise( + (_, reject) => + (timer = window.setTimeout( + () => reject(new Error("Exceeded timeout")), + timeout, + )), + ), + ]).finally(() => window.clearTimeout(timer)) as Promise +} + /** * openBrowser opens a URL in the host system's browser */ diff --git a/ui/src/views/container-view.tsx b/ui/src/views/container-view.tsx index 1068ac4..439b165 100644 --- a/ui/src/views/container-view.tsx +++ b/ui/src/views/container-view.tsx @@ -11,7 +11,7 @@ import useTailscale, { openTailscaleOnHost, } from "src/tailscale" import copyToClipboard from "src/lib/clipboard" -import { openBrowser } from "src/utils" +import { isWindows, openBrowser } from "src/utils" import Icon from "src/components/icon" import useTimedToggle from "src/hooks/timed-toggle" @@ -23,7 +23,6 @@ const selector = (state: State) => ({ connect: state.connect, disconnect: state.disconnect, switchAccount: state.switchAccount, - logout: state.logout, }) /** @@ -31,13 +30,11 @@ const selector = (state: State) => ({ * the list of containers and Tailscale URLs they can use to access them. */ export default function ContainerView() { - const { backendState, loginUser, connect, disconnect, logout } = useTailscale( + const { backendState, loginUser, connect, disconnect } = useTailscale( selector, shallow, ) const [connecting, setConnecting] = useState(false) - const [confirmLogoutAction, setConfirmLogoutAction] = - useState("none") const handleConnectClick = useCallback(async () => { setConnecting(true) @@ -45,31 +42,8 @@ export default function ContainerView() { setConnecting(false) }, [connect]) - const handleConfirmLogout = useCallback(() => { - if (confirmLogoutAction === "logout") { - logout() - } - setConfirmLogoutAction("none") - }, [confirmLogoutAction, logout]) - return (
- - open ? undefined : setConfirmLogoutAction("none") - } - onConfirm={handleConfirmLogout} - title="Log out?" - action="Log out" - destructive - > -

- Logging out of Tailscale will disconnect all exposed ports. Any - members of your Tailscale network using these Tailscale URLs will no - longer be able to access your containers. -

-
Tailscale
@@ -97,49 +71,7 @@ export default function ContainerView() { Disconnect )} - - - - - } - > - -

- {loginUser?.displayName} -

-

{loginUser?.loginName}

-
- - - Tailscale docs - - {loginUser?.isAdmin && ( - - Admin console - - )} - - Download Tailscale - - - setConfirmLogoutAction("logout")} - > - Log out - -
+
{backendState === "Stopped" ? ( @@ -171,6 +103,155 @@ export default function ContainerView() { ) } +const menuSelector = (state: State) => ({ + loginUser: state.loginUser, + loginServer: state.loginServer, + logout: state.logout, + setLoginServer: state.setLoginServer, +}) + +function HeaderMenu() { + const { loginUser, loginServer, logout, setLoginServer } = useTailscale( + menuSelector, + shallow, + ) + + const [menuOpen, setMenuOpen] = useState(false) + const [debugMenuOpen, setDebugMenuOpen] = useState(false) + const [confirmLogoutAction, setConfirmLogoutAction] = + useState("none") + + const handleConfirmLogout = useCallback(() => { + if (confirmLogoutAction === "logout") { + logout() + } + setConfirmLogoutAction("none") + }, [confirmLogoutAction, logout]) + + const handleLoginServerChange = useCallback( + (value: string) => { + console.log("Changing login server to", value) + if (value === "default") { + setLoginServer(undefined) + } else { + setLoginServer(value) + } + }, + [setLoginServer], + ) + + useEffect(() => { + // Show a hidden debug menu when the option key is pressed on macOS and + // Linux, and when the shift key is pressed on Windows. + + const isDebugKeyDown = (e: KeyboardEvent) => + isWindows() ? e.shiftKey : e.altKey + + function handleKeyDown(e: KeyboardEvent) { + if (isDebugKeyDown(e)) { + setDebugMenuOpen(true) + } + } + function handleKeyUp(e: KeyboardEvent) { + if (!isDebugKeyDown(e)) { + setDebugMenuOpen(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + window.addEventListener("keyup", handleKeyUp) + return () => { + window.removeEventListener("keydown", handleKeyDown) + window.removeEventListener("keyup", handleKeyUp) + } + }, [menuOpen]) + + return ( + <> + + open ? undefined : setConfirmLogoutAction("none") + } + onConfirm={handleConfirmLogout} + title="Log out?" + action="Log out" + destructive + > +

+ Logging out of Tailscale will disconnect all exposed ports. Any + members of your Tailscale network using these Tailscale URLs will no + longer be able to access your containers. +

+
+ + + + + } + > + +

{loginUser?.displayName}

+

{loginUser?.loginName}

+
+ + + Tailscale docs + + {loginUser?.isAdmin && ( + + Admin console + + )} + + Download Tailscale + + {debugMenuOpen && ( + <> + + Control Server + + + + + + Default + + + + + + localhost:31544 + + + + )} + + setConfirmLogoutAction("logout")}> + Log out + +
+ + ) +} + const containerSelector = (state: State) => ({ containers: state.containers, tailscaleIPs: state.tailscaleIPs,