Skip to content

Add control server picker #11

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <extension-id> http://localhost:3000
docker extension dev ui-source <extension-id> http://localhost:3011
```
2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}'"
Expand Down
54 changes: 52 additions & 2 deletions ui/src/components/dropdown-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]"

Expand Down Expand Up @@ -176,3 +179,50 @@ function DropdownSeparator(props: DropdownSeparatorProps) {
/>
)
}

function DropdownLabel(props: MenuPrimitive.MenuLabelProps) {
const { className, ...rest } = props
return (
<MenuPrimitive.Label
className={cx(
className,
menuItemClasses,
"text-xs text-gray-500 dark:text-gray-400",
)}
{...rest}
/>
)
}

function DropdownRadioItem(props: MenuPrimitive.MenuRadioItemProps) {
const { className, disabled, ...rest } = props

return (
<MenuPrimitive.RadioItem
className={cx(
"relative",
className,
menuItemClasses,
menuItemInteractiveClasses,
{
"text-gray-400 bg-white cursor-default": disabled,
},
)}
disabled={disabled}
{...rest}
/>
)
}

function DropdownItemIndicator(props: MenuPrimitive.MenuItemIndicatorProps) {
const { className, ...rest } = props
return (
<MenuPrimitive.ItemIndicator
className={cx(
className,
"absolute right-4 top-1/2 -translate-y-1/2 z-20",
)}
{...rest}
/>
)
}
14 changes: 14 additions & 0 deletions ui/src/components/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ const icons: Record<string, React.FC<IconProps>> = {
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
</svg>
),
dot: (props: IconProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="1"></circle>
</svg>
),
}

export default function Icon(props: {
Expand Down
55 changes: 45 additions & 10 deletions ui/src/tailscale.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
*/
Expand All @@ -47,6 +49,7 @@ export type State = {
connect: () => Promise<void>
disconnect: () => Promise<void>
switchAccount: () => Promise<void>
setLoginServer: (loginServer: string | undefined) => Promise<void>
logout: () => Promise<void>

/**
Expand Down Expand Up @@ -78,6 +81,8 @@ const useTailscale = create<State>((set, get) => ({
hostname: "",
hostStatus: { status: "unknown", loginName: "" },
tailscaleIPs: [],
loginServer:
window.localStorage.getItem("tailscale/loginServer") || undefined,
loginInfo: undefined,
loginUser: undefined,

Expand Down Expand Up @@ -115,18 +120,28 @@ const useTailscale = create<State>((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"
? {
Expand Down Expand Up @@ -271,9 +286,20 @@ async function getTailscaleStatus(): Promise<StatusResponse> {
return JSON.parse(status.stdout)
}

async function runTailscaleCommand(command: string): Promise<CommandOutput> {
const resp = await window.ddClient.backend.execInVMExtension(
`/app/tailscale ${command}`,
async function runTailscaleCommand(
command: string,
options: { backgroundOutput?: boolean; timeout?: number } = {},
): Promise<CommandOutput> {
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
}
Expand All @@ -288,12 +314,21 @@ type TailscaleUpResponse = {
/**
* getLoginInfo fetches the current login state from Tailscale.
*/
async function getLoginInfo(hostname: string): Promise<TailscaleUpResponse> {
async function getLoginInfo(
hostname: string,
loginServer?: string,
): Promise<TailscaleUpResponse> {
// 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
Expand Down
17 changes: 17 additions & 0 deletions ui/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
/**
* timeout allows setting a max deadline for a promise to be executed.
*/
export function timeout<T>(task: Promise<T>, 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<T>
}

/**
* openBrowser opens a URL in the host system's browser
*/
Expand Down
Loading