Skip to content

Commit adc2a0b

Browse files
authored
fix: Ensure errors from proxy are shown to the user (#601)
1. Ensures errors from the ch proxy are properly returned 2. Adds a connection check hook to periodically check connections and display a toast if connections are bad 3. Fixes the ConnectionForm to ensure that toast notifications are shown when test connections fail or when there's an error saving the form.
1 parent 9a84384 commit adc2a0b

File tree

7 files changed

+380
-28
lines changed

7 files changed

+380
-28
lines changed

.changeset/nervous-timers-dream.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/api": patch
3+
---
4+
5+
fix: Ensure errors from proxy are shown to the user

packages/api/src/routers/api/clickhouseProxy.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,33 @@ router.post(
2828
'X-ClickHouse-Key': password || '',
2929
},
3030
signal: AbortSignal.timeout(2000),
31-
}).then(res => res.json());
32-
return res.json({ success: result === 1 });
33-
} catch (e) {
34-
return res.json({ success: false });
31+
});
32+
// For status codes 204-399
33+
if (!result.ok) {
34+
const errorText = await result.text();
35+
return res.status(result.status).json({
36+
success: false,
37+
error: errorText || 'Error connecting to ClickHouse server',
38+
});
39+
}
40+
const data = await result.json();
41+
return res.json({ success: data === 1 });
42+
} catch (e: any) {
43+
// fetch returns a 400+ error and throws
44+
console.error(e);
45+
const errorMessage =
46+
e.cause?.code === 'ENOTFOUND'
47+
? `Unable to resolve host: ${e.cause.hostname}`
48+
: e.cause?.message ||
49+
e.message ||
50+
'Error connecting to ClickHouse server';
51+
52+
return res.status(500).json({
53+
success: false,
54+
error:
55+
errorMessage +
56+
', please check the host and credentials and try again.',
57+
});
3558
}
3659
},
3760
);
@@ -105,15 +128,28 @@ router.get(
105128
}
106129
},
107130
error: (err, _req, _res) => {
108-
console.error(err);
131+
console.error('Proxy error:', err);
132+
res.writeHead(500, {
133+
'Content-Type': 'application/json',
134+
});
135+
res.end(
136+
JSON.stringify({
137+
success: false,
138+
error: err.message || 'Failed to connect to ClickHouse server',
139+
}),
140+
);
109141
},
110142
},
111143
// ...(config.IS_DEV && {
112144
// logger: console,
113145
// }),
114146
})(req, res, next);
115147
} catch (e) {
116-
next(e);
148+
console.error('Router error:', e);
149+
res.status(500).json({
150+
success: false,
151+
error: e instanceof Error ? e.message : 'Internal server error',
152+
});
117153
}
118154
},
119155
);

packages/app/src/ThemeWrapper.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
} from '@mantine/core';
99
import { Notifications } from '@mantine/notifications';
1010

11+
import { useConnectionHealth } from '@/hooks/useConnectionHealth';
12+
1113
const makeTheme = ({
1214
fontFamily = '"IBM Plex Sans", monospace',
1315
}: {
@@ -149,6 +151,9 @@ export const ThemeWrapper = ({
149151
}) => {
150152
const theme = React.useMemo(() => makeTheme({ fontFamily }), [fontFamily]);
151153

154+
// Add connection health monitoring
155+
useConnectionHealth();
156+
152157
return (
153158
<MantineProvider forceColorScheme="dark" theme={theme}>
154159
<Notifications zIndex={999999} />

packages/app/src/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -761,7 +761,7 @@ const api = {
761761
username,
762762
password,
763763
},
764-
}).json() as Promise<{ success: boolean }>,
764+
}).json() as Promise<{ success: boolean; error?: string }>,
765765
});
766766
},
767767
};

packages/app/src/components/ConnectionForm.tsx

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useCallback, useState } from 'react';
1+
import { useCallback, useEffect, useState } from 'react';
22
import { useForm } from 'react-hook-form';
33
import { testLocalConnection } from '@hyperdx/common-utils/dist/clickhouse';
44
import { Box, Button, Flex, Group, Stack, Text } from '@mantine/core';
5+
import { notifications } from '@mantine/notifications';
56

67
import api from '@/api';
78
import { InputControlled } from '@/components/InputControlled';
@@ -15,15 +16,20 @@ import {
1516

1617
import ConfirmDeleteMenu from './ConfirmDeleteMenu';
1718

19+
enum TestConnectionState {
20+
Loading = 'loading',
21+
Valid = 'valid',
22+
Invalid = 'invalid',
23+
}
24+
1825
function useTestConnection({
1926
getValues,
2027
}: {
2128
getValues: (name: string) => string;
2229
}) {
2330
const testConnection = api.useTestConnection();
24-
const [testConnectionState, setTestConnectionState] = useState<
25-
null | 'loading' | 'valid' | 'invalid'
26-
>(null);
31+
const [testConnectionState, setTestConnectionState] =
32+
useState<TestConnectionState | null>(null);
2733

2834
const handleTestConnection = useCallback(async () => {
2935
const host = getValues('host');
@@ -34,15 +40,29 @@ function useTestConnection({
3440
return;
3541
}
3642

37-
setTestConnectionState('loading');
43+
setTestConnectionState(TestConnectionState.Loading);
3844

3945
if (IS_LOCAL_MODE) {
4046
try {
4147
const result = await testLocalConnection({ host, username, password });
42-
setTestConnectionState(result ? 'valid' : 'invalid');
48+
if (result) {
49+
setTestConnectionState(TestConnectionState.Valid);
50+
} else {
51+
setTestConnectionState(TestConnectionState.Invalid);
52+
notifications.show({
53+
color: 'red',
54+
message: 'Connection test failed',
55+
autoClose: 5000,
56+
});
57+
}
4358
} catch (e) {
4459
console.error(e);
45-
setTestConnectionState('invalid');
60+
setTestConnectionState(TestConnectionState.Invalid);
61+
notifications.show({
62+
color: 'red',
63+
message: e.message,
64+
autoClose: 5000,
65+
});
4666
}
4767
} else {
4868
try {
@@ -51,10 +71,24 @@ function useTestConnection({
5171
username,
5272
password,
5373
});
54-
setTestConnectionState(result.success ? 'valid' : 'invalid');
55-
} catch (e) {
56-
console.error(e);
57-
setTestConnectionState('invalid');
74+
if (result.success) {
75+
setTestConnectionState(TestConnectionState.Valid);
76+
} else {
77+
setTestConnectionState(TestConnectionState.Invalid);
78+
notifications.show({
79+
color: 'red',
80+
message: result.error || 'Connection test failed',
81+
autoClose: 5000,
82+
});
83+
}
84+
} catch (error: any) {
85+
const body = await error.response?.json();
86+
setTestConnectionState(TestConnectionState.Invalid);
87+
notifications.show({
88+
color: 'red',
89+
message: body?.error ?? 'Failed to test connection',
90+
autoClose: 5000,
91+
});
5892
}
5993
}
6094

@@ -105,17 +139,41 @@ export function ConnectionForm({
105139
{ connection: data },
106140
{
107141
onSuccess: () => {
142+
notifications.show({
143+
color: 'green',
144+
message: 'Connection created successfully',
145+
});
108146
onSave?.();
109147
},
148+
onError: () => {
149+
notifications.show({
150+
color: 'red',
151+
message:
152+
'Error creating connection, please check the host and credentials and try again.',
153+
autoClose: 5000,
154+
});
155+
},
110156
},
111157
);
112158
} else {
113159
updateConnection.mutate(
114160
{ connection: data, id: connection.id },
115161
{
116162
onSuccess: () => {
163+
notifications.show({
164+
color: 'green',
165+
message: 'Connection updated successfully',
166+
});
117167
onSave?.();
118168
},
169+
onError: () => {
170+
notifications.show({
171+
color: 'red',
172+
message:
173+
'Error updating connection, please check the host and credentials and try again.',
174+
autoClose: 5000,
175+
});
176+
},
119177
},
120178
);
121179
}
@@ -207,12 +265,6 @@ export function ConnectionForm({
207265
</Flex>
208266
)}
209267
</Box>
210-
{createConnection.isError && (
211-
<Text c="red.7" size="sm">
212-
Error creating connection, please check the host and credentials and
213-
try again.
214-
</Text>
215-
)}
216268
<Group justify="space-between">
217269
<Group gap="xs" justify="flex-start">
218270
<Button
@@ -229,12 +281,16 @@ export function ConnectionForm({
229281
variant="subtle"
230282
type="button"
231283
onClick={handleTestConnection}
232-
loading={testConnectionState === 'loading'}
233-
color={testConnectionState === 'invalid' ? 'yellow' : 'teal'}
284+
loading={testConnectionState === TestConnectionState.Loading}
285+
color={
286+
testConnectionState === TestConnectionState.Invalid
287+
? 'yellow'
288+
: 'teal'
289+
}
234290
>
235-
{testConnectionState === 'valid' ? (
291+
{testConnectionState === TestConnectionState.Valid ? (
236292
<>Connection successful</>
237-
) : testConnectionState === 'invalid' ? (
293+
) : testConnectionState === TestConnectionState.Invalid ? (
238294
<>Unable to connect</>
239295
) : (
240296
'Test Connection'
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { notifications } from '@mantine/notifications';
2+
import { act, renderHook } from '@testing-library/react';
3+
4+
import api from '@/api';
5+
import { useConnections } from '@/connection';
6+
7+
import { useConnectionHealth } from '../useConnectionHealth';
8+
9+
// Mock dependencies
10+
jest.mock('@mantine/notifications');
11+
jest.mock('@/api');
12+
jest.mock('@/connection');
13+
14+
describe('useConnectionHealth', () => {
15+
const mockConnections = [
16+
{
17+
id: '1',
18+
name: 'Test Connection 1',
19+
host: 'localhost',
20+
username: 'user1',
21+
password: 'pass1',
22+
},
23+
];
24+
25+
beforeEach(() => {
26+
jest.useFakeTimers();
27+
jest.clearAllMocks();
28+
29+
(useConnections as jest.Mock).mockReturnValue({
30+
data: mockConnections,
31+
});
32+
});
33+
34+
afterEach(() => {
35+
jest.useRealTimers();
36+
});
37+
38+
describe('connection monitoring', () => {
39+
it('should start monitoring after initial delay', async () => {
40+
const testConnectionMock = jest.fn().mockResolvedValue({ success: true });
41+
(api.useTestConnection as jest.Mock).mockReturnValue({
42+
mutateAsync: testConnectionMock,
43+
});
44+
45+
renderHook(() => useConnectionHealth());
46+
47+
await act(async () => {
48+
jest.advanceTimersByTime(5000);
49+
});
50+
51+
expect(testConnectionMock).toHaveBeenCalledWith({
52+
host: mockConnections[0].host,
53+
username: mockConnections[0].username,
54+
password: mockConnections[0].password,
55+
});
56+
});
57+
58+
it('should show notification when connection fails', async () => {
59+
const testConnectionMock = jest.fn().mockResolvedValue({
60+
success: false,
61+
error: 'Connection failed',
62+
});
63+
(api.useTestConnection as jest.Mock).mockReturnValue({
64+
mutateAsync: testConnectionMock,
65+
});
66+
67+
renderHook(() => useConnectionHealth());
68+
69+
await act(async () => {
70+
jest.advanceTimersByTime(5000);
71+
});
72+
73+
expect(notifications.show).toHaveBeenCalledWith({
74+
id: 'connection-error-1',
75+
color: 'red',
76+
message:
77+
'Connection "Test Connection 1" is not responding: Connection failed',
78+
autoClose: false,
79+
});
80+
});
81+
82+
it('should respect retry delay for failed connections', async () => {
83+
const testConnectionMock = jest.fn().mockResolvedValue({
84+
success: false,
85+
});
86+
(api.useTestConnection as jest.Mock).mockReturnValue({
87+
mutateAsync: testConnectionMock,
88+
});
89+
90+
renderHook(() => useConnectionHealth());
91+
92+
await act(async () => {
93+
jest.advanceTimersByTime(5000);
94+
});
95+
96+
await act(async () => {
97+
jest.advanceTimersByTime(10000);
98+
});
99+
100+
expect(testConnectionMock).toHaveBeenCalledTimes(1);
101+
});
102+
});
103+
104+
describe('cleanup', () => {
105+
it('should clean up intervals on unmount', () => {
106+
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
107+
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
108+
109+
const { unmount } = renderHook(() => useConnectionHealth());
110+
111+
act(() => {
112+
unmount();
113+
});
114+
115+
expect(clearTimeoutSpy).toHaveBeenCalled();
116+
expect(clearIntervalSpy).toHaveBeenCalled();
117+
});
118+
});
119+
});

0 commit comments

Comments
 (0)