Skip to content

Commit f0fd762

Browse files
alex-fedotyevclaude
andcommitted
fix(app): copy buttons silently fail over plain HTTP
Closes #2135. `navigator.clipboard` is only defined when the page is served over HTTPS or `localhost`. When HyperDX is reached over plain HTTP (Tailscale tunnel, corporate VPN, non-localhost host), every "copy" button throws `TypeError: Cannot read properties of undefined (reading 'writeText')` and nothing reaches the user's clipboard. Add `packages/app/src/utils/clipboard.ts` with two helpers: - `copyTextToClipboard(text)` tries the modern API first, then falls back to a hidden-textarea + `document.execCommand('copy')` trick that works in non-secure contexts. - `copyTextWithToast(text, successMessage)` wraps the copy with a Mantine notification (green on success, red on failure with a message that explains the HTTPS / localhost requirement). Replace all six raw `navigator.clipboard.writeText` call sites with the new util: - `DBRowTableRowButtons.tsx` row JSON + shareable URL buttons - `DBRowTableFieldWithPopover.tsx` field-value popover - `DBRowJsonViewer.tsx` "Copy row as JSON", "Copy Object", and "Copy Value" actions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent bc96b1d commit f0fd762

7 files changed

Lines changed: 644 additions & 61 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": patch
3+
---
4+
5+
Fix copy buttons silently failing when HyperDX is served over plain HTTP. Add a `document.execCommand('copy')` fallback for non-secure contexts and a clear toast when both paths fail.

packages/app/src/components/DBRowJsonViewer.tsx

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929

3030
import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson';
3131
import { mergePath } from '@/utils';
32+
import { copyTextWithToast } from '@/utils/clipboard';
3233

3334
type JSONExtractFn =
3435
| 'JSONExtractString'
@@ -220,15 +221,12 @@ function HyperJsonMenu({ rowData }: { rowData: any }) {
220221
{rowData != null && (
221222
<UnstyledButton
222223
onClick={() => {
223-
window.navigator.clipboard.writeText(
224+
copyTextWithToast(
224225
typeof rowData === 'string'
225226
? rowData
226227
: JSON.stringify(rowData, null, 2),
228+
'Value copied to clipboard',
227229
);
228-
notifications.show({
229-
color: 'green',
230-
message: `Value copied to clipboard`,
231-
});
232230
}}
233231
variant="copy"
234232
title={'Copy row as JSON'}
@@ -559,13 +557,10 @@ export function DBRowJsonViewer({
559557
copiedObj = keyPath.length === 0 ? rowData : get(rowData, keyPath);
560558
}
561559

562-
window.navigator.clipboard.writeText(
560+
copyTextWithToast(
563561
JSON.stringify(copiedObj, null, 2),
562+
'Copied object to clipboard',
564563
);
565-
notifications.show({
566-
color: 'green',
567-
message: `Copied object to clipboard`,
568-
});
569564
};
570565

571566
if (typeof value === 'object') {
@@ -584,15 +579,12 @@ export function DBRowJsonViewer({
584579
</Group>
585580
),
586581
onClick: () => {
587-
window.navigator.clipboard.writeText(
582+
copyTextWithToast(
588583
typeof value === 'string'
589584
? value
590585
: JSON.stringify(value, null, 2),
586+
'Value copied to clipboard',
591587
);
592-
notifications.show({
593-
color: 'green',
594-
message: `Value copied to clipboard`,
595-
});
596588
},
597589
});
598590
}

packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { Popover } from '@mantine/core';
44
import { useDisclosure } from '@mantine/hooks';
55
import { IconCopy, IconFilter, IconFilterX } from '@tabler/icons-react';
66

7+
import { copyTextWithToast } from '@/utils/clipboard';
8+
79
import { RowSidePanelContext } from '../DBRowSidePanel';
810

911
import { DBRowTableIconButton } from './DBRowTableIconButton';
@@ -83,15 +85,12 @@ const DBRowTableFieldWithPopover = ({
8385
};
8486

8587
const copyFieldValue = async () => {
86-
try {
87-
const value =
88-
typeof cellValue === 'string' ? cellValue : String(cellValue ?? '');
89-
await navigator.clipboard.writeText(value);
88+
const value =
89+
typeof cellValue === 'string' ? cellValue : String(cellValue ?? '');
90+
const ok = await copyTextWithToast(value, 'Copied field value');
91+
if (ok) {
9092
setIsCopied(true);
9193
setTimeout(() => setIsCopied(false), 2000);
92-
} catch (error) {
93-
console.error('Failed to copy to clipboard:', error);
94-
// Optionally show an error toast notification to the user
9594
}
9695
};
9796

packages/app/src/components/DBTable/DBRowTableRowButtons.tsx

Lines changed: 36 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
22
import { IconCopy, IconLink, IconTextWrap } from '@tabler/icons-react';
33

44
import { INTERNAL_ROW_FIELDS, RowWhereResult } from '@/hooks/useRowWhere';
5+
import { copyTextWithToast } from '@/utils/clipboard';
56

67
import { DBRowTableIconButton } from './DBRowTableIconButton';
78

@@ -26,57 +27,53 @@ const DBRowTableRowButtons: React.FC<DBRowTableRowButtonsProps> = ({
2627
const [isUrlCopied, setIsUrlCopied] = useState(false);
2728

2829
const copyRowData = async () => {
29-
try {
30-
// Filter out internal metadata fields that start with __ or are generated IDs
30+
// Filter out internal metadata fields that start with __ or are generated IDs
31+
const { [INTERNAL_ROW_FIELDS.ID]: _id, ...cleanRow } = row;
3132

32-
const { [INTERNAL_ROW_FIELDS.ID]: _id, ...cleanRow } = row;
33-
34-
// Parse JSON string fields to make them proper JSON objects
35-
const parsedRow = Object.entries(cleanRow).reduce(
36-
(acc, [key, value]) => {
37-
if (
38-
typeof value === 'string' &&
39-
(value.startsWith('{') || value.startsWith('['))
40-
) {
41-
try {
42-
acc[key] = JSON.parse(value);
43-
} catch {
44-
// If parsing fails, keep the original string
45-
acc[key] = value;
46-
}
47-
} else {
33+
// Parse JSON string fields to make them proper JSON objects
34+
const parsedRow = Object.entries(cleanRow).reduce(
35+
(acc, [key, value]) => {
36+
if (
37+
typeof value === 'string' &&
38+
(value.startsWith('{') || value.startsWith('['))
39+
) {
40+
try {
41+
acc[key] = JSON.parse(value);
42+
} catch {
43+
// If parsing fails, keep the original string
4844
acc[key] = value;
4945
}
50-
return acc;
51-
},
52-
{} as Record<string, any>,
53-
);
46+
} else {
47+
acc[key] = value;
48+
}
49+
return acc;
50+
},
51+
{} as Record<string, any>,
52+
);
5453

55-
const rowData = JSON.stringify(parsedRow, null, 2);
56-
await navigator.clipboard.writeText(rowData);
54+
const rowData = JSON.stringify(parsedRow, null, 2);
55+
const ok = await copyTextWithToast(rowData, 'Copied row as JSON');
56+
if (ok) {
5757
setIsCopied(true);
5858
setTimeout(() => setIsCopied(false), 2000);
59-
} catch (error) {
60-
console.error('Failed to copy row data to clipboard:', error);
61-
// Optionally show an error toast notification to the user
6259
}
6360
};
6461

6562
const copyRowUrl = async () => {
66-
try {
67-
const rowWhereResult = getRowWhere(row);
68-
const currentUrl = new URL(window.location.href);
69-
// Add the row identifier as query parameters
70-
currentUrl.searchParams.set('rowWhere', rowWhereResult.where);
71-
if (sourceId) {
72-
currentUrl.searchParams.set('rowSource', sourceId);
73-
}
74-
await navigator.clipboard.writeText(currentUrl.toString());
63+
const rowWhereResult = getRowWhere(row);
64+
const currentUrl = new URL(window.location.href);
65+
// Add the row identifier as query parameters
66+
currentUrl.searchParams.set('rowWhere', rowWhereResult.where);
67+
if (sourceId) {
68+
currentUrl.searchParams.set('rowSource', sourceId);
69+
}
70+
const ok = await copyTextWithToast(
71+
currentUrl.toString(),
72+
'Copied shareable link',
73+
);
74+
if (ok) {
7575
setIsUrlCopied(true);
7676
setTimeout(() => setIsUrlCopied(false), 2000);
77-
} catch (error) {
78-
console.error('Failed to copy URL to clipboard:', error);
79-
// Optionally show an error toast notification to the user
8077
}
8178
};
8279

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { copyTextToClipboard, copyTextWithToast } from '../clipboard';
2+
3+
const mantineShow = jest.fn();
4+
jest.mock('@mantine/notifications', () => ({
5+
notifications: {
6+
show: (...args: any[]) => mantineShow(...args),
7+
},
8+
}));
9+
10+
describe('copyTextToClipboard', () => {
11+
beforeEach(() => {
12+
mantineShow.mockClear();
13+
// Reset clipboard between tests
14+
Object.defineProperty(navigator, 'clipboard', {
15+
value: undefined,
16+
writable: true,
17+
configurable: true,
18+
});
19+
// Default execCommand stub returns true
20+
document.execCommand = jest.fn().mockReturnValue(true);
21+
});
22+
23+
it('uses navigator.clipboard when available', async () => {
24+
const writeText = jest.fn().mockResolvedValue(undefined);
25+
Object.defineProperty(navigator, 'clipboard', {
26+
value: { writeText },
27+
writable: true,
28+
configurable: true,
29+
});
30+
const execSpy = document.execCommand as jest.Mock;
31+
32+
const result = await copyTextToClipboard('hello');
33+
34+
expect(result).toBe(true);
35+
expect(writeText).toHaveBeenCalledWith('hello');
36+
expect(execSpy).not.toHaveBeenCalled();
37+
});
38+
39+
it('falls back to execCommand when navigator.clipboard.writeText rejects', async () => {
40+
const writeText = jest.fn().mockRejectedValue(new Error('denied'));
41+
Object.defineProperty(navigator, 'clipboard', {
42+
value: { writeText },
43+
writable: true,
44+
configurable: true,
45+
});
46+
const execSpy = document.execCommand as jest.Mock;
47+
48+
const result = await copyTextToClipboard('hello');
49+
50+
expect(result).toBe(true);
51+
expect(writeText).toHaveBeenCalledWith('hello');
52+
expect(execSpy).toHaveBeenCalledWith('copy');
53+
});
54+
55+
it('falls back to execCommand when navigator.clipboard is undefined', async () => {
56+
// clipboard is undefined per beforeEach
57+
const execSpy = document.execCommand as jest.Mock;
58+
59+
const result = await copyTextToClipboard('hello');
60+
61+
expect(result).toBe(true);
62+
expect(execSpy).toHaveBeenCalledWith('copy');
63+
});
64+
65+
it('returns false when both paths fail', async () => {
66+
const writeText = jest.fn().mockRejectedValue(new Error('denied'));
67+
Object.defineProperty(navigator, 'clipboard', {
68+
value: { writeText },
69+
writable: true,
70+
configurable: true,
71+
});
72+
document.execCommand = jest.fn().mockReturnValue(false);
73+
74+
const result = await copyTextToClipboard('hello');
75+
76+
expect(result).toBe(false);
77+
});
78+
79+
it('removes the textarea from the DOM after the fallback runs', async () => {
80+
const result = await copyTextToClipboard('hello');
81+
82+
expect(result).toBe(true);
83+
expect(document.querySelectorAll('textarea').length).toBe(0);
84+
});
85+
86+
it('removes the textarea even when execCommand throws', async () => {
87+
document.execCommand = jest.fn().mockImplementation(() => {
88+
throw new Error('boom');
89+
});
90+
91+
const result = await copyTextToClipboard('hello');
92+
93+
expect(result).toBe(false);
94+
expect(document.querySelectorAll('textarea').length).toBe(0);
95+
});
96+
97+
it('restores the existing selection after the fallback runs', async () => {
98+
// Seed an existing range on a paragraph in the DOM
99+
const p = document.createElement('p');
100+
p.textContent = 'preserved selection target';
101+
document.body.appendChild(p);
102+
103+
const range = document.createRange();
104+
range.selectNodeContents(p);
105+
106+
const selection = document.getSelection();
107+
if (!selection) {
108+
throw new Error('jsdom should provide getSelection');
109+
}
110+
selection.removeAllRanges();
111+
selection.addRange(range);
112+
113+
const result = await copyTextToClipboard('hello');
114+
115+
expect(result).toBe(true);
116+
expect(selection.rangeCount).toBe(1);
117+
expect(selection.getRangeAt(0).toString()).toBe(
118+
'preserved selection target',
119+
);
120+
121+
document.body.removeChild(p);
122+
});
123+
});
124+
125+
describe('copyTextWithToast', () => {
126+
beforeEach(() => {
127+
mantineShow.mockClear();
128+
Object.defineProperty(navigator, 'clipboard', {
129+
value: undefined,
130+
writable: true,
131+
configurable: true,
132+
});
133+
document.execCommand = jest.fn().mockReturnValue(true);
134+
});
135+
136+
it('shows a green success toast on success', async () => {
137+
const writeText = jest.fn().mockResolvedValue(undefined);
138+
Object.defineProperty(navigator, 'clipboard', {
139+
value: { writeText },
140+
writable: true,
141+
configurable: true,
142+
});
143+
144+
const result = await copyTextWithToast('hello');
145+
146+
expect(result).toBe(true);
147+
expect(mantineShow).toHaveBeenCalledWith({
148+
color: 'green',
149+
message: 'Copied to clipboard',
150+
});
151+
});
152+
153+
it('uses a custom success message when provided', async () => {
154+
const writeText = jest.fn().mockResolvedValue(undefined);
155+
Object.defineProperty(navigator, 'clipboard', {
156+
value: { writeText },
157+
writable: true,
158+
configurable: true,
159+
});
160+
161+
await copyTextWithToast('hello', 'Value copied to clipboard');
162+
163+
expect(mantineShow).toHaveBeenCalledWith({
164+
color: 'green',
165+
message: 'Value copied to clipboard',
166+
});
167+
});
168+
169+
it('shows a red failure toast when both paths fail', async () => {
170+
document.execCommand = jest.fn().mockReturnValue(false);
171+
172+
const result = await copyTextWithToast('hello');
173+
174+
expect(result).toBe(false);
175+
expect(mantineShow).toHaveBeenCalledWith({
176+
color: 'red',
177+
message:
178+
"Couldn't copy. HyperDX needs HTTPS or localhost to use the browser clipboard API.",
179+
});
180+
});
181+
});

0 commit comments

Comments
 (0)