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:

committed by
Sonia Appasamy

parent
650c67a0a1
commit
a95b3cbfa8
131
client/web/src/components/address-copy-card.tsx
Normal file
131
client/web/src/components/address-copy-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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 (
|
||||
<div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}>
|
||||
|
65
client/web/src/components/nice-ip.tsx
Normal file
65
client/web/src/components/nice-ip.tsx
Normal 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]
|
||||
}
|
@ -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({
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Machine name</td>
|
||||
<td>{node.DeviceName}</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.DeviceName}
|
||||
primaryActionSubject="machine name"
|
||||
>
|
||||
{node.DeviceName}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OS</td>
|
||||
@ -77,7 +86,14 @@ export default function DeviceDetailsView({
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ID</td>
|
||||
<td>{node.ID}</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.ID}
|
||||
primaryActionSubject="ID"
|
||||
>
|
||||
{node.ID}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tailscale version</td>
|
||||
@ -101,20 +117,46 @@ export default function DeviceDetailsView({
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Tailscale IPv4</td>
|
||||
<td>{node.IP}</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.IPv4}
|
||||
primaryActionSubject="IPv4 address"
|
||||
>
|
||||
{node.IPv4}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tailscale IPv6</td>
|
||||
<td>{node.IPv6}</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.IPv6}
|
||||
primaryActionSubject="IPv6 address"
|
||||
>
|
||||
<NiceIP ip={node.IPv6} />
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Short domain</td>
|
||||
<td>{node.DeviceName}</td>
|
||||
<td>
|
||||
<QuickCopy
|
||||
primaryActionValue={node.DeviceName}
|
||||
primaryActionSubject="short domain"
|
||||
>
|
||||
{node.DeviceName}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Full domain</td>
|
||||
<td>
|
||||
{node.DeviceName}.{node.TailnetName}
|
||||
<QuickCopy
|
||||
primaryActionValue={`${node.DeviceName}.${node.TailnetName}`}
|
||||
primaryActionSubject="full domain"
|
||||
>
|
||||
{node.DeviceName}.{node.TailnetName}
|
||||
</QuickCopy>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@ -125,7 +167,7 @@ export default function DeviceDetailsView({
|
||||
node={node}
|
||||
>
|
||||
Want even more details? Visit{" "}
|
||||
<Control.AdminLink node={node} path={`/machines/${node.IP}`}>
|
||||
<Control.AdminLink node={node} path={`/machines/${node.IPv4}`}>
|
||||
this device’s page
|
||||
</Control.AdminLink>{" "}
|
||||
in the admin console.
|
||||
|
@ -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({
|
||||
</p>
|
||||
</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>
|
||||
{(node.Features["advertise-exit-node"] ||
|
||||
node.Features["use-exit-node"]) && (
|
||||
|
@ -142,7 +142,7 @@ export default function SubnetRouterView({
|
||||
node={node}
|
||||
>
|
||||
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 machine’s route settings
|
||||
</Control.AdminLink>
|
||||
.
|
||||
|
Reference in New Issue
Block a user