diff --git a/client/web/package.json b/client/web/package.json index a545756df..e04c97f6c 100644 --- a/client/web/package.json +++ b/client/web/package.json @@ -13,7 +13,8 @@ "classnames": "^2.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "wouter": "^2.11.0" + "wouter": "^2.11.0", + "zustand": "^4.4.7" }, "devDependencies": { "@types/react": "^18.0.20", diff --git a/client/web/src/assets/icons/copy.svg b/client/web/src/assets/icons/copy.svg new file mode 100644 index 000000000..01b732081 --- /dev/null +++ b/client/web/src/assets/icons/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/web/src/assets/icons/x.svg b/client/web/src/assets/icons/x.svg new file mode 100644 index 000000000..91984b30c --- /dev/null +++ b/client/web/src/assets/icons/x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/web/src/components/address-copy-card.tsx b/client/web/src/components/address-copy-card.tsx new file mode 100644 index 000000000..3ed582997 --- /dev/null +++ b/client/web/src/components/address-copy-card.tsx @@ -0,0 +1,131 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import * as Primitive from "@radix-ui/react-popover" +import cx from "classnames" +import React, { useCallback } from "react" +import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg" +import { ReactComponent as Copy } from "src/assets/icons/copy.svg" +import NiceIP from "src/components/nice-ip" +import useToaster from "src/hooks/toaster" +import Button from "src/ui/button" +import { copyText } from "src/utils/clipboard" + +/** + * AddressCard renders a clickable IP address text that opens a + * dialog with a copyable list of all addresses (IPv4, IPv6, DNS) + * for the machine. + */ +export default function AddressCard({ + v4Address, + v6Address, + shortDomain, + fullDomain, + triggerClassName, +}: { + v4Address: string + v6Address: string + shortDomain?: string + fullDomain?: string + triggerClassName?: string +}) { + const children = ( + + ) + + return ( + + + + + + {children} + + + ) +} + +function AddressRow({ + label, + value, + ip, +}: { + label: string + value: string + ip?: boolean +}) { + const toaster = useToaster() + const onCopyClick = useCallback(() => { + copyText(value) + .then(() => toaster.show({ message: `Copied ${label} to clipboard` })) + .catch(() => + toaster.show({ + message: `Failed to copy ${label} to clipboard`, + variant: "danger", + }) + ) + }, [label, toaster, value]) + + return ( +
  • + +
  • + ) +} diff --git a/client/web/src/components/login-toggle.tsx b/client/web/src/components/login-toggle.tsx index a34d915b2..53b52bc98 100644 --- a/client/web/src/components/login-toggle.tsx +++ b/client/web/src/components/login-toggle.tsx @@ -105,13 +105,13 @@ function LoginPopoverContent({ return // already checking } setIsRunningCheck(true) - fetch(`http://${node.IP}:5252/ok`, { mode: "no-cors" }) + fetch(`http://${node.IPv4}:5252/ok`, { mode: "no-cors" }) .then(() => { setIsRunningCheck(false) setCanConnectOverTS(true) }) .catch(() => setIsRunningCheck(false)) - }, [auth.viewerIdentity, isRunningCheck, node.IP]) + }, [auth.viewerIdentity, isRunningCheck, node.IPv4]) /** * Checking connection for first time on page load. @@ -130,7 +130,7 @@ function LoginPopoverContent({ } else { // Must be connected over Tailscale to log in. // Send user to Tailscale IP and start check mode - const manageURL = `http://${node.IP}:5252/?check=now` + const manageURL = `http://${node.IPv4}:5252/?check=now` if (window.self !== window.top) { // if we're inside an iframe, open management client in new window window.open(manageURL, "_blank") @@ -138,7 +138,7 @@ function LoginPopoverContent({ window.location.href = manageURL } } - }, [node.IP, auth.viewerIdentity, newSession]) + }, [node.IPv4, auth.viewerIdentity, newSession]) return (
    diff --git a/client/web/src/components/nice-ip.tsx b/client/web/src/components/nice-ip.tsx new file mode 100644 index 000000000..f00d763f9 --- /dev/null +++ b/client/web/src/components/nice-ip.tsx @@ -0,0 +1,65 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import cx from "classnames" +import React from "react" +import { isTailscaleIPv6 } from "src/utils/util" + +type Props = { + ip: string + className?: string +} + +/** + * NiceIP displays IP addresses with nice truncation. + */ +export default function NiceIP(props: Props) { + const { ip, className } = props + + if (!isTailscaleIPv6(ip)) { + return {ip} + } + + const [trimmable, untrimmable] = splitIPv6(ip) + + return ( + + {trimmable.length > 0 && ( + {trimmable} + )} + {untrimmable} + + ) +} + +/** + * Split an IPv6 address into two pieces, to help with truncating the middle. + * Only exported for testing purposes. Do not use. + */ +export function splitIPv6(ip: string): [string, string] { + // We want to split the IPv6 address into segments, but not remove the delimiter. + // So we inject an invalid IPv6 character ("|") as a delimiter into the string, + // then split on that. + const parts = ip.replace(/(:{1,2})/g, "|$1").split("|") + + // Then we find the number of end parts that fits within the character limit, + // and join them back together. + const characterLimit = 12 + let characterCount = 0 + let idxFromEnd = 1 + for (let i = parts.length - 1; i >= 0; i--) { + const part = parts[i] + if (characterCount + part.length > characterLimit) { + break + } + characterCount += part.length + idxFromEnd++ + } + + const start = parts.slice(0, -idxFromEnd).join("") + const end = parts.slice(-idxFromEnd).join("") + + return [start, end] +} diff --git a/client/web/src/components/views/device-details-view.tsx b/client/web/src/components/views/device-details-view.tsx index efa912166..70b29c40c 100644 --- a/client/web/src/components/views/device-details-view.tsx +++ b/client/web/src/components/views/device-details-view.tsx @@ -6,9 +6,11 @@ import React from "react" import { apiFetch } from "src/api" import ACLTag from "src/components/acl-tag" import * as Control from "src/components/control-components" +import NiceIP from "src/components/nice-ip" import { UpdateAvailableNotification } from "src/components/update-available" import { NodeData } from "src/hooks/node-data" import Button from "src/ui/button" +import QuickCopy from "src/ui/quick-copy" import { useLocation } from "wouter" export default function DeviceDetailsView({ @@ -69,7 +71,14 @@ export default function DeviceDetailsView({ Machine name - {node.DeviceName} + + + {node.DeviceName} + + OS @@ -77,7 +86,14 @@ export default function DeviceDetailsView({ ID - {node.ID} + + + {node.ID} + + Tailscale version @@ -101,20 +117,46 @@ export default function DeviceDetailsView({ Tailscale IPv4 - {node.IP} + + + {node.IPv4} + + Tailscale IPv6 - {node.IPv6} + + + + + Short domain - {node.DeviceName} + + + {node.DeviceName} + + Full domain - {node.DeviceName}.{node.TailnetName} + + {node.DeviceName}.{node.TailnetName} + @@ -125,7 +167,7 @@ export default function DeviceDetailsView({ node={node} > Want even more details? Visit{" "} - + this device’s page {" "} in the admin console. diff --git a/client/web/src/components/views/home-view.tsx b/client/web/src/components/views/home-view.tsx index 6f71b07f7..9a069727b 100644 --- a/client/web/src/components/views/home-view.tsx +++ b/client/web/src/components/views/home-view.tsx @@ -5,9 +5,10 @@ import cx from "classnames" import React, { useMemo } from "react" import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg" import { ReactComponent as Machine } from "src/assets/icons/machine.svg" +import AddressCard from "src/components/address-copy-card" import ExitNodeSelector from "src/components/exit-node-selector" import { NodeData, NodeUpdaters } from "src/hooks/node-data" -import { pluralize } from "src/util" +import { pluralize } from "src/utils/util" import { Link, useLocation } from "wouter" export default function HomeView({ @@ -49,7 +50,13 @@ export default function HomeView({

    -

    {node.IP}

    + {(node.Features["advertise-exit-node"] || node.Features["use-exit-node"]) && ( diff --git a/client/web/src/components/views/subnet-router-view.tsx b/client/web/src/components/views/subnet-router-view.tsx index e8febd9e4..fa20bc679 100644 --- a/client/web/src/components/views/subnet-router-view.tsx +++ b/client/web/src/components/views/subnet-router-view.tsx @@ -142,7 +142,7 @@ export default function SubnetRouterView({ node={node} > To approve routes, in the admin console go to{" "} - + the machine’s route settings . diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index a5825cac2..5cca13023 100644 --- a/client/web/src/hooks/node-data.ts +++ b/client/web/src/hooks/node-data.ts @@ -2,18 +2,17 @@ // SPDX-License-Identifier: BSD-3-Clause import { useCallback, useEffect, useMemo, useState } from "react" -import { apiFetch, setUnraidCsrfToken } from "src/api" +import { apiFetch, incrementMetric, setUnraidCsrfToken } from "src/api" import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes" import { VersionInfo } from "src/hooks/self-update" -import { assertNever } from "src/util" -import { incrementMetric, MetricName } from "src/api" +import { assertNever } from "src/utils/util" export type NodeData = { Profile: UserProfile Status: NodeState DeviceName: string OS: string - IP: string + IPv4: string IPv6: string ID: string KeyExpiry: string @@ -177,7 +176,11 @@ export default function useNodeData() { const updateMetrics = () => { // only update metrics if values have changed if (data?.AdvertisingExitNode !== d.AdvertiseExitNode) { - incrementMetric(d.AdvertiseExitNode ? "web_client_advertise_exitnode_enable" : "web_client_advertise_exitnode_disable") + incrementMetric( + d.AdvertiseExitNode + ? "web_client_advertise_exitnode_enable" + : "web_client_advertise_exitnode_disable" + ) } } diff --git a/client/web/src/hooks/toaster.ts b/client/web/src/hooks/toaster.ts new file mode 100644 index 000000000..41fb4f42d --- /dev/null +++ b/client/web/src/hooks/toaster.ts @@ -0,0 +1,17 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import { useRawToasterForHook } from "src/ui/toaster" + +/** + * useToaster provides a mechanism to display toasts. It returns an object with + * methods to show, dismiss, or clear all toasts: + * + * const toastKey = toaster.show({ message: "Hello world" }) + * toaster.dismiss(toastKey) + * toaster.clear() + * + */ +const useToaster = useRawToasterForHook + +export default useToaster diff --git a/client/web/src/index.css b/client/web/src/index.css index b6da3af80..9740d043c 100644 --- a/client/web/src/index.css +++ b/client/web/src/index.css @@ -188,7 +188,7 @@ @apply text-gray-500 text-sm leading-tight truncate; } .card td:last-child { - @apply col-span-2 text-gray-800 text-sm leading-tight truncate; + @apply col-span-2 text-gray-800 text-sm leading-tight; } .description { diff --git a/client/web/src/index.tsx b/client/web/src/index.tsx index ef5a65a44..cbab009b7 100644 --- a/client/web/src/index.tsx +++ b/client/web/src/index.tsx @@ -11,6 +11,7 @@ import React from "react" import { createRoot } from "react-dom/client" import App from "src/components/app" +import ToastProvider from "src/ui/toaster" declare var window: any // This is used to determine if the react client is built. @@ -25,6 +26,8 @@ const root = createRoot(rootEl) root.render( - + + + ) diff --git a/client/web/src/ui/quick-copy.tsx b/client/web/src/ui/quick-copy.tsx new file mode 100644 index 000000000..bc8f916c8 --- /dev/null +++ b/client/web/src/ui/quick-copy.tsx @@ -0,0 +1,160 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import cx from "classnames" +import React, { useEffect, useRef, useState } from "react" +import useToaster from "src/hooks/toaster" +import { copyText } from "src/utils/clipboard" + +type Props = { + className?: string + hideAffordance?: boolean + /** + * primaryActionSubject is the subject of the toast confirmation message + * "Copied to clipboard" + */ + primaryActionSubject: string + primaryActionValue: string + secondaryActionName?: string + secondaryActionValue?: string + /** + * secondaryActionSubject is the subject of the toast confirmation message + * prompted by the secondary action "Copied to clipboard" + */ + secondaryActionSubject?: string + children?: React.ReactNode + + /** + * onSecondaryAction is used to trigger events when the secondary copy + * function is used. It is not used when the secondary action is hidden. + */ + onSecondaryAction?: () => void +} + +/** + * QuickCopy is a UI component that allows for copying textual content in one click. + */ +export default function QuickCopy(props: Props) { + const { + className, + hideAffordance, + primaryActionSubject, + primaryActionValue, + secondaryActionValue, + secondaryActionName, + secondaryActionSubject, + onSecondaryAction, + children, + } = props + const toaster = useToaster() + const containerRef = useRef(null) + const buttonRef = useRef(null) + const [showButton, setShowButton] = useState(false) + + useEffect(() => { + if (!showButton) { + return + } + if (!containerRef.current || !buttonRef.current) { + return + } + // We don't need to watch any `resize` event because it's pretty unlikely + // the browser will resize while their cursor is over one of these items. + const rect = containerRef.current.getBoundingClientRect() + const maximumPossibleWidth = window.innerWidth - rect.left + 4 + + // We add the border-width (1px * 2 sides) and the padding (0.5rem * 2 sides) + // and add 1px for rounding up the calculation in order to get the final + // maxWidth value. This should be kept in sync with the CSS classes below. + buttonRef.current.style.maxWidth = `${maximumPossibleWidth}px` + buttonRef.current.style.visibility = "visible" + }, [showButton]) + + const handlePrimaryAction = () => { + copyText(primaryActionValue) + toaster.show({ + message: `Copied ${primaryActionSubject} to the clipboard`, + }) + } + + const handleSecondaryAction = () => { + if (!secondaryActionValue) { + return + } + copyText(secondaryActionValue) + toaster.show({ + message: `Copied ${ + secondaryActionSubject || secondaryActionName + } to the clipboard`, + }) + onSecondaryAction?.() + } + + return ( +
    setShowButton(false)} + > +
    setShowButton(true)} + className={cx("truncate", className)} + > + {children} +
    + {!hideAffordance && ( + + )} + + {showButton && ( +
    +
    +
    + + {children} + + +
    + + {secondaryActionValue && ( +
    + {secondaryActionName} +
    + )} +
    +
    + )} +
    + ) +} diff --git a/client/web/src/ui/toaster.tsx b/client/web/src/ui/toaster.tsx new file mode 100644 index 000000000..e6e877c83 --- /dev/null +++ b/client/web/src/ui/toaster.tsx @@ -0,0 +1,280 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import cx from "classnames" +import React, { + forwardRef, + useCallback, + useEffect, + useMemo, + useState, +} from "react" +import { createPortal } from "react-dom" +import { ReactComponent as X } from "src/assets/icons/x.svg" +import { noop } from "src/utils/util" +import { create } from "zustand" +import { shallow } from "zustand/shallow" + +// Set up root element on the document body for toasts to render into. +const root = document.createElement("div") +root.id = "toast-root" +root.classList.add("relative", "z-20") +document.body.append(root) + +const toastSpacing = remToPixels(1) + +export type Toaster = { + clear: () => void + dismiss: (key: string) => void + show: (props: Toast) => string +} + +type Toast = { + key?: string // key is a unique string value that ensures only one toast with a given key is shown at a time. + className?: string + variant?: "danger" // styling for the toast, undefined is neutral, danger is for failed requests + message: React.ReactNode + timeout?: number + added?: number // timestamp of when the toast was added +} + +type ToastWithKey = Toast & { key: string } + +type State = { + toasts: ToastWithKey[] + maxToasts: number + clear: () => void + dismiss: (key: string) => void + show: (props: Toast) => string +} + +const useToasterState = create((set, get) => ({ + toasts: [], + maxToasts: 5, + clear: () => { + set({ toasts: [] }) + }, + dismiss: (key: string) => { + set((prev) => ({ + toasts: prev.toasts.filter((t) => t.key !== key), + })) + }, + show: (props: Toast) => { + const { toasts: prevToasts, maxToasts } = get() + + const propsWithKey = { + key: Date.now().toString(), + ...props, + } + const prevIdx = prevToasts.findIndex((t) => t.key === propsWithKey.key) + + // If the toast already exists, update it. Otherwise, append it. + const nextToasts = + prevIdx !== -1 + ? [ + ...prevToasts.slice(0, prevIdx), + propsWithKey, + ...prevToasts.slice(prevIdx + 1), + ] + : [...prevToasts, propsWithKey] + + set({ + // Get the last `maxToasts` toasts of the set. + toasts: nextToasts.slice(-maxToasts), + }) + return propsWithKey.key + }, +})) + +const clearSelector = (state: State) => state.clear + +const toasterSelector = (state: State) => ({ + show: state.show, + dismiss: state.dismiss, + clear: state.clear, +}) + +/** + * useRawToasterForHook is meant to supply the hook function for hooks/toaster. + * Use hooks/toaster instead. + */ +export const useRawToasterForHook = () => + useToasterState(toasterSelector, shallow) + +type ToastProviderProps = { + children: React.ReactNode + canEscapeKeyClear?: boolean +} + +/** + * ToastProvider is the top-level toaster component. It stores the toast state. + */ +export default function ToastProvider(props: ToastProviderProps) { + const { children, canEscapeKeyClear = true } = props + const clear = useToasterState(clearSelector) + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (!canEscapeKeyClear) { + return + } + if (e.key === "Esc" || e.key === "Escape") { + clear() + } + } + window.addEventListener("keydown", handleKeyDown) + return () => { + window.removeEventListener("keydown", handleKeyDown) + } + }, [canEscapeKeyClear, clear]) + + return ( + <> + {children} + + + ) +} + +const toastContainerSelector = (state: State) => ({ + toasts: state.toasts, + dismiss: state.dismiss, +}) + +/** + * ToastContainer manages the positioning and animation for all currently + * displayed toasts. It should only be used by ToastProvider. + */ +function ToastContainer() { + const { toasts, dismiss } = useToasterState(toastContainerSelector, shallow) + + const [prevToasts, setPrevToasts] = useState(toasts) + useEffect(() => setPrevToasts(toasts), [toasts]) + + const [refMap] = useState(() => new Map()) + const getOffsetForToast = useCallback( + (key: string) => { + let offset = 0 + + let arr = toasts + let index = arr.findIndex((t) => t.key === key) + if (index === -1) { + arr = prevToasts + index = arr.findIndex((t) => t.key === key) + } + + if (index === -1) { + return offset + } + + for (let i = arr.length; i > index; i--) { + if (!arr[i]) { + continue + } + const ref = refMap.get(arr[i].key) + if (!ref) { + continue + } + offset -= ref.offsetHeight + offset -= toastSpacing + } + return offset + }, + [refMap, prevToasts, toasts] + ) + + const toastsWithStyles = useMemo( + () => + toasts.map((toast) => ({ + toast: toast, + style: { + transform: `translateY(${getOffsetForToast(toast.key)}px) scale(1.0)`, + }, + })), + [getOffsetForToast, toasts] + ) + + if (!root) { + throw new Error("Could not find toast root") // should never happen + } + + return createPortal( +
    + {toastsWithStyles.map(({ toast, style }) => ( + ref && refMap.set(toast.key, ref)} + toast={toast} + onDismiss={dismiss} + style={style} + /> + ))} +
    , + root + ) +} + +/** + * ToastBlock is the display of an individual toast, and also manages timeout + * settings for a particular toast. + */ +const ToastBlock = forwardRef< + HTMLDivElement, + { + toast: ToastWithKey + onDismiss?: (key: string) => void + style?: React.CSSProperties + } +>(({ toast, onDismiss = noop, style }, ref) => { + const { message, key, timeout = 5000, variant } = toast + + const [focused, setFocused] = useState(false) + const dismiss = useCallback(() => onDismiss(key), [onDismiss, key]) + const onFocus = useCallback(() => setFocused(true), []) + const onBlur = useCallback(() => setFocused(false), []) + + useEffect(() => { + if (timeout <= 0 || focused) { + return + } + const timerId = setTimeout(() => dismiss(), timeout) + return () => clearTimeout(timerId) + }, [dismiss, timeout, focused]) + + return ( +
    + {message} + +
    + ) +}) + +function remToPixels(rem: number) { + return ( + rem * Number.parseFloat(getComputedStyle(document.documentElement).fontSize) + ) +} diff --git a/client/web/src/utils/clipboard.ts b/client/web/src/utils/clipboard.ts new file mode 100644 index 000000000..f003bc240 --- /dev/null +++ b/client/web/src/utils/clipboard.ts @@ -0,0 +1,77 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +import { isPromise } from "src/utils/util" + +/** + * copyText copies text to the clipboard, handling cross-browser compatibility + * issues with different clipboard APIs. + * + * To support copying after running a network request (eg. generating an invite), + * pass a promise that resolves to the text to copy. + * + * @example + * copyText("Hello, world!") + * copyText(generateInvite().then(res => res.data.inviteCode)) + */ +export function copyText(text: string | Promise) { + if (!navigator.clipboard) { + if (isPromise(text)) { + return text.then((val) => fallbackCopy(validateString(val))) + } + return fallbackCopy(text) + } + if (isPromise(text)) { + if (typeof ClipboardItem === "undefined") { + return text.then((val) => + navigator.clipboard.writeText(validateString(val)) + ) + } + return navigator.clipboard.write([ + new ClipboardItem({ + "text/plain": text.then( + (val) => new Blob([validateString(val)], { type: "text/plain" }) + ), + }), + ]) + } + return navigator.clipboard.writeText(text) +} + +function validateString(val: unknown): string { + if (typeof val !== "string" || val.length === 0) { + throw new TypeError("Expected string, got " + typeof val) + } + if (val.length === 0) { + throw new TypeError("Expected non-empty string") + } + return val +} + +function fallbackCopy(text: string) { + const el = document.createElement("textarea") + el.value = text + el.setAttribute("readonly", "") + el.className = "absolute opacity-0 pointer-events-none" + document.body.append(el) + + // Check if text is currently selected + let selection = document.getSelection() + const selected = + selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : false + + el.select() + document.execCommand("copy") + el.remove() + + // Restore selection + if (selected) { + selection = document.getSelection() + if (selection) { + selection.removeAllRanges() + selection.addRange(selected) + } + } + + return Promise.resolve() +} diff --git a/client/web/src/util.ts b/client/web/src/utils/util.ts similarity index 53% rename from client/web/src/util.ts rename to client/web/src/utils/util.ts index fa5cbe3b8..e755f07ec 100644 --- a/client/web/src/util.ts +++ b/client/web/src/utils/util.ts @@ -9,6 +9,11 @@ export function assertNever(a: never): never { return a } +/** + * noop is an empty function for use as a default value. + */ +export function noop() {} + /** * pluralize is a very simple function that returns either * the singular or plural form of a string based on the given @@ -19,3 +24,21 @@ export function assertNever(a: never): never { export function pluralize(signular: string, plural: string, qty: number) { return qty === 1 ? signular : plural } + +/** + * isTailscaleIPv6 returns true when the ip matches + * Tailnet's IPv6 format. + */ +export function isTailscaleIPv6(ip: string): boolean { + return ip.startsWith("fd7a:115c:a1e0") +} + +/** + * isPromise returns whether the current value is a promise. + */ +export function isPromise(val: unknown): val is Promise { + if (!val) { + return false + } + return typeof val === "object" && "then" in val +} diff --git a/client/web/web.go b/client/web/web.go index e17ab7fc2..812bfe9b1 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -536,7 +536,7 @@ type nodeData struct { DeviceName string TailnetName string // TLS cert name DomainName string - IP string // IPv4 + IPv4 string IPv6 string OS string IPNVersion string @@ -630,11 +630,11 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) { } for _, ip := range st.TailscaleIPs { if ip.Is4() { - data.IP = ip.String() + data.IPv4 = ip.String() } else if ip.Is6() { data.IPv6 = ip.String() } - if data.IP != "" && data.IPv6 != "" { + if data.IPv4 != "" && data.IPv6 != "" { break } } diff --git a/client/web/yarn.lock b/client/web/yarn.lock index efcabd8ab..30ee00096 100644 --- a/client/web/yarn.lock +++ b/client/web/yarn.lock @@ -4953,7 +4953,7 @@ use-sidecar@^1.1.2: detect-node-es "^1.1.0" tslib "^2.0.0" -use-sync-external-store@^1.0.0: +use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== @@ -5148,3 +5148,10 @@ yocto-queue@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== + +zustand@^4.4.7: + version "4.4.7" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.4.7.tgz#355406be6b11ab335f59a66d2cf9815e8f24038c" + integrity sha512-QFJWJMdlETcI69paJwhSMJz7PPWjVP8Sjhclxmxmxv/RYI7ZOvR5BHX+ktH0we9gTWQMxcne8q1OY8xxz604gw== + dependencies: + use-sync-external-store "1.2.0"