client/web: add copyable components throughout UI

Updates the IP address on home view to open a copyable list of node
addresses on click. And makes various values on the details view
copyable text items, mirroring the machine admin panel table.

As part of these changes, pulls the AddressCard, NiceIP and QuickCopy
components from the admin panel, with the AddressCard slightly modified
to avoid needing to also pull in the CommandLine component.

A new toaster interface is also added, allowing us to display success
and failure toasts throughout the UI. The toaster code is slightly
modified from it's admin form to avoid the need for some excess
libraries.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
Sonia Appasamy 2023-12-05 10:09:33 -05:00 committed by Sonia Appasamy
parent 650c67a0a1
commit a95b3cbfa8
19 changed files with 850 additions and 26 deletions

View File

@ -13,7 +13,8 @@
"classnames": "^2.3.1", "classnames": "^2.3.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"wouter": "^2.11.0" "wouter": "^2.11.0",
"zustand": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.20", "@types/react": "^18.0.20",

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 9H11C9.89543 9 9 9.89543 9 11V20C9 21.1046 9.89543 22 11 22H20C21.1046 22 22 21.1046 22 20V11C22 9.89543 21.1046 9 20 9Z" stroke="#292828" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 15H4C3.46957 15 2.96086 14.7893 2.58579 14.4142C2.21071 14.0391 2 13.5304 2 13V4C2 3.46957 2.21071 2.96086 2.58579 2.58579C2.96086 2.21071 3.46957 2 4 2H13C13.5304 2 14.0391 2.21071 14.4142 2.58579C14.7893 2.96086 15 3.46957 15 4V5" stroke="#292828" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 649 B

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 6L18 18" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 277 B

View File

@ -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 = (
<ul className="flex flex-col divide-y rounded-md overflow-hidden">
{shortDomain && <AddressRow label="short domain" value={shortDomain} />}
{fullDomain && <AddressRow label="full domain" value={fullDomain} />}
{v4Address && (
<AddressRow
key={v4Address}
label="IPv4 address"
ip={true}
value={v4Address}
/>
)}
{v6Address && (
<AddressRow
key={v6Address}
label="IPv6 address"
ip={true}
value={v6Address}
/>
)}
</ul>
)
return (
<Primitive.Root>
<Primitive.Trigger asChild>
<Button
variant="minimal"
className="-ml-1 px-1 py-0 hover:!bg-transparent font-normal"
suffixIcon={
<ChevronDown className="w-5 h-5" stroke="#232222" /* gray-800 */ />
}
aria-label="See all addresses for this device."
>
<NiceIP className={triggerClassName} ip={v4Address ?? v6Address} />
</Button>
</Primitive.Trigger>
<Primitive.Content
className="shadow-popover origin-radix-popover state-open:animate-scale-in state-closed:animate-scale-out bg-white rounded-md z-50 max-w-sm"
sideOffset={10}
side="top"
>
{children}
</Primitive.Content>
</Primitive.Root>
)
}
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 (
<li className="py flex items-center gap-2">
<button
className={cx(
"relative flex group items-center transition-colors",
"focus:outline-none focus-visible:ring",
"disabled:text-text-muted enabled:hover:text-gray-500",
"w-60 text-sm flex-1"
)}
onClick={onCopyClick}
aria-label={`Copy ${value} to your clip board.`}
>
<div className="overflow-hidden pl-3 pr-10 py-2 tabular-nums">
{ip ? (
<NiceIP ip={value} />
) : (
<div className="truncate m-w-full">{value}</div>
)}
</div>
<span
className={cx(
"absolute right-0 pl-6 pr-3 bg-gradient-to-r from-transparent",
"text-gray-900 group-hover:text-gray-600"
)}
>
<Copy className="w-4 h-4" />
</span>
</button>
</li>
)
}

View File

@ -105,13 +105,13 @@ function LoginPopoverContent({
return // already checking return // already checking
} }
setIsRunningCheck(true) setIsRunningCheck(true)
fetch(`http://${node.IP}:5252/ok`, { mode: "no-cors" }) fetch(`http://${node.IPv4}:5252/ok`, { mode: "no-cors" })
.then(() => { .then(() => {
setIsRunningCheck(false) setIsRunningCheck(false)
setCanConnectOverTS(true) setCanConnectOverTS(true)
}) })
.catch(() => setIsRunningCheck(false)) .catch(() => setIsRunningCheck(false))
}, [auth.viewerIdentity, isRunningCheck, node.IP]) }, [auth.viewerIdentity, isRunningCheck, node.IPv4])
/** /**
* Checking connection for first time on page load. * Checking connection for first time on page load.
@ -130,7 +130,7 @@ function LoginPopoverContent({
} else { } else {
// Must be connected over Tailscale to log in. // Must be connected over Tailscale to log in.
// Send user to Tailscale IP and start check mode // 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 (window.self !== window.top) {
// if we're inside an iframe, open management client in new window // if we're inside an iframe, open management client in new window
window.open(manageURL, "_blank") window.open(manageURL, "_blank")
@ -138,7 +138,7 @@ function LoginPopoverContent({
window.location.href = manageURL window.location.href = manageURL
} }
} }
}, [node.IP, auth.viewerIdentity, newSession]) }, [node.IPv4, auth.viewerIdentity, newSession])
return ( return (
<div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}> <div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}>

View File

@ -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 <span className={className}>{ip}</span>
}
const [trimmable, untrimmable] = splitIPv6(ip)
return (
<span
className={cx("inline-flex justify-start min-w-0 max-w-full", className)}
>
{trimmable.length > 0 && (
<span className="truncate w-fit flex-shrink">{trimmable}</span>
)}
<span className="flex-grow-0 flex-shrink-0">{untrimmable}</span>
</span>
)
}
/**
* 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]
}

View File

@ -6,9 +6,11 @@ import React from "react"
import { apiFetch } from "src/api" import { apiFetch } from "src/api"
import ACLTag from "src/components/acl-tag" import ACLTag from "src/components/acl-tag"
import * as Control from "src/components/control-components" import * as Control from "src/components/control-components"
import NiceIP from "src/components/nice-ip"
import { UpdateAvailableNotification } from "src/components/update-available" import { UpdateAvailableNotification } from "src/components/update-available"
import { NodeData } from "src/hooks/node-data" import { NodeData } from "src/hooks/node-data"
import Button from "src/ui/button" import Button from "src/ui/button"
import QuickCopy from "src/ui/quick-copy"
import { useLocation } from "wouter" import { useLocation } from "wouter"
export default function DeviceDetailsView({ export default function DeviceDetailsView({
@ -69,7 +71,14 @@ export default function DeviceDetailsView({
</tr> </tr>
<tr> <tr>
<td>Machine name</td> <td>Machine name</td>
<td>{node.DeviceName}</td> <td>
<QuickCopy
primaryActionValue={node.DeviceName}
primaryActionSubject="machine name"
>
{node.DeviceName}
</QuickCopy>
</td>
</tr> </tr>
<tr> <tr>
<td>OS</td> <td>OS</td>
@ -77,7 +86,14 @@ export default function DeviceDetailsView({
</tr> </tr>
<tr> <tr>
<td>ID</td> <td>ID</td>
<td>{node.ID}</td> <td>
<QuickCopy
primaryActionValue={node.ID}
primaryActionSubject="ID"
>
{node.ID}
</QuickCopy>
</td>
</tr> </tr>
<tr> <tr>
<td>Tailscale version</td> <td>Tailscale version</td>
@ -101,20 +117,46 @@ export default function DeviceDetailsView({
<tbody> <tbody>
<tr> <tr>
<td>Tailscale IPv4</td> <td>Tailscale IPv4</td>
<td>{node.IP}</td> <td>
<QuickCopy
primaryActionValue={node.IPv4}
primaryActionSubject="IPv4 address"
>
{node.IPv4}
</QuickCopy>
</td>
</tr> </tr>
<tr> <tr>
<td>Tailscale IPv6</td> <td>Tailscale IPv6</td>
<td>{node.IPv6}</td> <td>
<QuickCopy
primaryActionValue={node.IPv6}
primaryActionSubject="IPv6 address"
>
<NiceIP ip={node.IPv6} />
</QuickCopy>
</td>
</tr> </tr>
<tr> <tr>
<td>Short domain</td> <td>Short domain</td>
<td>{node.DeviceName}</td> <td>
<QuickCopy
primaryActionValue={node.DeviceName}
primaryActionSubject="short domain"
>
{node.DeviceName}
</QuickCopy>
</td>
</tr> </tr>
<tr> <tr>
<td>Full domain</td> <td>Full domain</td>
<td> <td>
{node.DeviceName}.{node.TailnetName} <QuickCopy
primaryActionValue={`${node.DeviceName}.${node.TailnetName}`}
primaryActionSubject="full domain"
>
{node.DeviceName}.{node.TailnetName}
</QuickCopy>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -125,7 +167,7 @@ export default function DeviceDetailsView({
node={node} node={node}
> >
Want even more details? Visit{" "} Want even more details? Visit{" "}
<Control.AdminLink node={node} path={`/machines/${node.IP}`}> <Control.AdminLink node={node} path={`/machines/${node.IPv4}`}>
this devices page this devices page
</Control.AdminLink>{" "} </Control.AdminLink>{" "}
in the admin console. in the admin console.

View File

@ -5,9 +5,10 @@ import cx from "classnames"
import React, { useMemo } from "react" import React, { useMemo } from "react"
import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg" import { ReactComponent as ArrowRight } from "src/assets/icons/arrow-right.svg"
import { ReactComponent as Machine } from "src/assets/icons/machine.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 ExitNodeSelector from "src/components/exit-node-selector"
import { NodeData, NodeUpdaters } from "src/hooks/node-data" import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import { pluralize } from "src/util" import { pluralize } from "src/utils/util"
import { Link, useLocation } from "wouter" import { Link, useLocation } from "wouter"
export default function HomeView({ export default function HomeView({
@ -49,7 +50,13 @@ export default function HomeView({
</p> </p>
</div> </div>
</div> </div>
<p className="text-gray-800 text-lg leading-[25.20px]">{node.IP}</p> <AddressCard
triggerClassName="text-gray-800 text-lg leading-[25.20px]"
v4Address={node.IPv4}
v6Address={node.IPv6}
shortDomain={node.DeviceName}
fullDomain={`${node.DeviceName}.${node.TailnetName}`}
/>
</div> </div>
{(node.Features["advertise-exit-node"] || {(node.Features["advertise-exit-node"] ||
node.Features["use-exit-node"]) && ( node.Features["use-exit-node"]) && (

View File

@ -142,7 +142,7 @@ export default function SubnetRouterView({
node={node} node={node}
> >
To approve routes, in the admin console go to{" "} To approve routes, in the admin console go to{" "}
<Control.AdminLink node={node} path={`/machines/${node.IP}`}> <Control.AdminLink node={node} path={`/machines/${node.IPv4}`}>
the machines route settings the machines route settings
</Control.AdminLink> </Control.AdminLink>
. .

View File

@ -2,18 +2,17 @@
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useMemo, useState } from "react" 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 { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes"
import { VersionInfo } from "src/hooks/self-update" import { VersionInfo } from "src/hooks/self-update"
import { assertNever } from "src/util" import { assertNever } from "src/utils/util"
import { incrementMetric, MetricName } from "src/api"
export type NodeData = { export type NodeData = {
Profile: UserProfile Profile: UserProfile
Status: NodeState Status: NodeState
DeviceName: string DeviceName: string
OS: string OS: string
IP: string IPv4: string
IPv6: string IPv6: string
ID: string ID: string
KeyExpiry: string KeyExpiry: string
@ -177,7 +176,11 @@ export default function useNodeData() {
const updateMetrics = () => { const updateMetrics = () => {
// only update metrics if values have changed // only update metrics if values have changed
if (data?.AdvertisingExitNode !== d.AdvertiseExitNode) { 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"
)
} }
} }

View File

@ -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

View File

@ -188,7 +188,7 @@
@apply text-gray-500 text-sm leading-tight truncate; @apply text-gray-500 text-sm leading-tight truncate;
} }
.card td:last-child { .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 { .description {

View File

@ -11,6 +11,7 @@
import React from "react" import React from "react"
import { createRoot } from "react-dom/client" import { createRoot } from "react-dom/client"
import App from "src/components/app" import App from "src/components/app"
import ToastProvider from "src/ui/toaster"
declare var window: any declare var window: any
// This is used to determine if the react client is built. // This is used to determine if the react client is built.
@ -25,6 +26,8 @@ const root = createRoot(rootEl)
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <ToastProvider>
<App />
</ToastProvider>
</React.StrictMode> </React.StrictMode>
) )

View File

@ -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 <subject> 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 <subject> 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<HTMLDivElement>(null)
const buttonRef = useRef<HTMLDivElement>(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 (
<div
className="flex relative min-w-0"
ref={containerRef}
// Since the affordance is a child of this element, we assign both event
// handlers here.
onMouseLeave={() => setShowButton(false)}
>
<div
onMouseEnter={() => setShowButton(true)}
className={cx("truncate", className)}
>
{children}
</div>
{!hideAffordance && (
<button
onMouseEnter={() => setShowButton(true)}
onClick={handlePrimaryAction}
className={cx("cursor-pointer text-blue-500", { "ml-2": children })}
>
Copy
</button>
)}
{showButton && (
<div
className="absolute -mt-1 -ml-2 -top-px -left-px
shadow-md cursor-pointer rounded-md active:shadow-sm
transition-shadow duration-100 ease-in-out z-50"
style={{ visibility: "hidden" }}
ref={buttonRef}
>
<div className="flex border rounded-md button-outline bg-white">
<div
className={cx("flex min-w-0 py-1 px-2 hover:bg-gray-0", {
"rounded-md": !secondaryActionValue,
"rounded-l-md": secondaryActionValue,
})}
onClick={handlePrimaryAction}
>
<span
className={cx(className, "inline-block select-none truncate")}
>
{children}
</span>
<button
className={cx("cursor-pointer text-blue-500", {
"ml-2": children,
})}
>
Copy
</button>
</div>
{secondaryActionValue && (
<div
className="text-blue-500 py-1 px-2 border-l hover:bg-gray-100 rounded-r-md"
onClick={handleSecondaryAction}
>
{secondaryActionName}
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@ -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<State>((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}
<ToastContainer />
</>
)
}
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<ToastWithKey[]>(toasts)
useEffect(() => setPrevToasts(toasts), [toasts])
const [refMap] = useState(() => new Map<string, HTMLDivElement>())
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(
<div className="fixed bottom-6 right-6 z-[99]">
{toastsWithStyles.map(({ toast, style }) => (
<ToastBlock
key={toast.key}
ref={(ref) => ref && refMap.set(toast.key, ref)}
toast={toast}
onDismiss={dismiss}
style={style}
/>
))}
</div>,
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 (
<div
className={cx(
"transition ease-in-out animate-scale-in",
"bottom-0 right-0 z-[99] w-[85vw] origin-bottom",
"sm:min-w-[400px] sm:max-w-[500px]",
"absolute shadow-sm rounded-md text-md flex items-center justify-between",
{
"text-white bg-gray-700": variant === undefined,
"text-white bg-orange-400": variant === "danger",
}
)}
aria-live="polite"
ref={ref}
onBlur={onBlur}
onFocus={onFocus}
onMouseEnter={onFocus}
onMouseLeave={onBlur}
tabIndex={0}
style={style}
>
<span className="pl-4 py-3 pr-2">{message}</span>
<button
className="cursor-pointer opacity-75 hover:opacity-50 transition-opacity py-3 px-3"
onClick={dismiss}
>
<X className="w-[1em] h-[1em] stroke-current" />
</button>
</div>
)
})
function remToPixels(rem: number) {
return (
rem * Number.parseFloat(getComputedStyle(document.documentElement).fontSize)
)
}

View File

@ -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<string | void>) {
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()
}

View File

@ -9,6 +9,11 @@ export function assertNever(a: never): never {
return a 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 * pluralize is a very simple function that returns either
* the singular or plural form of a string based on the given * 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) { export function pluralize(signular: string, plural: string, qty: number) {
return qty === 1 ? signular : plural 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<T = unknown>(val: unknown): val is Promise<T> {
if (!val) {
return false
}
return typeof val === "object" && "then" in val
}

View File

@ -536,7 +536,7 @@ type nodeData struct {
DeviceName string DeviceName string
TailnetName string // TLS cert name TailnetName string // TLS cert name
DomainName string DomainName string
IP string // IPv4 IPv4 string
IPv6 string IPv6 string
OS string OS string
IPNVersion string IPNVersion string
@ -630,11 +630,11 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
} }
for _, ip := range st.TailscaleIPs { for _, ip := range st.TailscaleIPs {
if ip.Is4() { if ip.Is4() {
data.IP = ip.String() data.IPv4 = ip.String()
} else if ip.Is6() { } else if ip.Is6() {
data.IPv6 = ip.String() data.IPv6 = ip.String()
} }
if data.IP != "" && data.IPv6 != "" { if data.IPv4 != "" && data.IPv6 != "" {
break break
} }
} }

View File

@ -4953,7 +4953,7 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0" detect-node-es "^1.1.0"
tslib "^2.0.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" version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" 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== integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
@ -5148,3 +5148,10 @@ yocto-queue@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== 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"