Skip to content

Commit 744bb51

Browse files
fix(ui): improve ux of backend disconnect modal (#2378)
* handle backend disconnect * add new lines * fix lint * use a simple component instead of react context * new lines * make health check timeout less aggressive * remove consecutiveattempts logic for simplicity * clean up * synchornize timer with retry button * remove merge markers * fix lint * remove retry button
1 parent 005e576 commit 744bb51

File tree

2 files changed

+100
-75
lines changed

2 files changed

+100
-75
lines changed

web/src/components/common/ConnectionMonitor.tsx

Lines changed: 88 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,34 @@
11
import React, { useEffect, useState, useCallback } from 'react';
22

3+
const RETRY_INTERVAL = 10000; // 10 seconds
4+
5+
// Reusable spinner component
6+
const Spinner: React.FC = () => (
7+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
8+
);
9+
310
// Connection modal component
4-
const ConnectionModal: React.FC<{ onRetry: () => void; isRetrying: boolean }> = ({ onRetry, isRetrying }) => {
5-
const [retryCount, setRetryCount] = useState(0);
11+
const ConnectionModal: React.FC<{
12+
nextRetryTime?: number;
13+
}> = ({ nextRetryTime }) => {
14+
const [secondsUntilRetry, setSecondsUntilRetry] = useState(0);
615

716
useEffect(() => {
8-
const interval = setInterval(() => {
9-
setRetryCount(count => count + 1);
10-
}, 1000);
17+
if (!nextRetryTime) return;
18+
19+
const updateCountdown = () => {
20+
const now = Date.now();
21+
const remaining = Math.max(0, Math.floor((nextRetryTime - now) / 1000));
22+
setSecondsUntilRetry(remaining);
23+
};
24+
25+
// Update immediately
26+
updateCountdown();
27+
28+
// Update every second
29+
const interval = setInterval(updateCountdown, 1000);
1130
return () => clearInterval(interval);
12-
}, []);
31+
}, [nextRetryTime]);
1332

1433
return (
1534
<div className="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center">
@@ -31,90 +50,99 @@ const ConnectionModal: React.FC<{ onRetry: () => void; isRetrying: boolean }> =
3150
installer is running and accessible.
3251
</p>
3352

34-
<div className="flex items-center justify-between">
53+
<div className="flex items-center justify-center">
3554
<div className="flex items-center text-sm font-semibold text-gray-600">
36-
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
37-
Trying again in {Math.max(1, 10 - (retryCount % 10))} second{Math.max(1, 10 - (retryCount % 10)) !== 1 ? 's' : ''}
55+
{secondsUntilRetry > 0 ? (
56+
<>
57+
<Spinner />
58+
Retrying in {secondsUntilRetry} second{secondsUntilRetry !== 1 ? 's' : ''}
59+
</>
60+
) : (
61+
<>
62+
<Spinner />
63+
Retrying now...
64+
</>
65+
)}
3866
</div>
39-
<button
40-
onClick={onRetry}
41-
disabled={isRetrying}
42-
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
43-
>
44-
{isRetrying ? 'Retrying...' : 'Try Now'}
45-
</button>
4667
</div>
4768
</div>
4869
</div>
4970
);
5071
};
5172

52-
const ConnectionMonitor: React.FC = () => {
73+
// Custom hook for connection monitoring logic
74+
const useConnectionMonitor = () => {
5375
const [isConnected, setIsConnected] = useState(true);
54-
const [isChecking, setIsChecking] = useState(false);
76+
const [nextRetryTime, setNextRetryTime] = useState<number | undefined>();
77+
const [checkInterval, setCheckInterval] = useState<NodeJS.Timeout | null>(null);
5578

5679
const checkConnection = useCallback(async () => {
57-
setIsChecking(true);
58-
5980
try {
60-
// Try up to 3 times before marking as disconnected
61-
let attempts = 0;
62-
const maxAttempts = 3;
81+
const timeoutPromise = new Promise((_, reject) =>
82+
setTimeout(() => reject(new Error('Timeout')), 5000)
83+
);
6384

64-
while (attempts < maxAttempts) {
65-
try {
66-
// Create a timeout promise
67-
const timeoutPromise = new Promise((_, reject) =>
68-
setTimeout(() => reject(new Error('Timeout')), 5000)
69-
);
70-
71-
const fetchPromise = fetch('/api/health', {
72-
method: 'GET',
73-
headers: { 'Content-Type': 'application/json' },
74-
});
75-
76-
const response = await Promise.race([fetchPromise, timeoutPromise]) as Response;
77-
78-
if (response.ok) {
79-
setIsConnected(true);
80-
return;
81-
} else {
82-
throw new Error(`HTTP ${response.status}`);
83-
}
84-
} catch {
85-
attempts++;
86-
if (attempts < maxAttempts) {
87-
await new Promise(resolve => setTimeout(resolve, 1000));
88-
}
89-
}
90-
}
85+
const fetchPromise = fetch('/api/health', {
86+
method: 'GET',
87+
headers: { 'Content-Type': 'application/json' },
88+
});
9189

92-
// All attempts failed - show modal immediately
93-
setIsConnected(false);
90+
const response = await Promise.race([fetchPromise, timeoutPromise]) as Response;
9491

92+
if (response.ok) {
93+
setIsConnected(true);
94+
setNextRetryTime(undefined);
95+
} else {
96+
throw new Error(`HTTP ${response.status}`);
97+
}
9598
} catch {
99+
// Connection failed - set up countdown for next retry
96100
setIsConnected(false);
97-
} finally {
98-
setIsChecking(false);
101+
const retryTime = Date.now() + RETRY_INTERVAL;
102+
setNextRetryTime(retryTime);
99103
}
100104
}, []);
101105

102106
useEffect(() => {
103107
// Initial check
104108
checkConnection();
105109

106-
// Set up periodic health checks every 5 seconds
107-
const interval = setInterval(checkConnection, 5000);
110+
// Set up regular interval checks
111+
const interval = setInterval(checkConnection, RETRY_INTERVAL);
112+
setCheckInterval(interval);
108113

109-
return () => clearInterval(interval);
110-
}, [checkConnection]);
114+
// Cleanup on unmount
115+
return () => {
116+
if (interval) {
117+
clearInterval(interval);
118+
}
119+
};
120+
// eslint-disable-next-line react-hooks/exhaustive-deps
121+
}, []); // Empty dependency array to prevent infinite loops
122+
123+
// Cleanup interval when it changes
124+
useEffect(() => {
125+
return () => {
126+
if (checkInterval) {
127+
clearInterval(checkInterval);
128+
}
129+
};
130+
}, [checkInterval]);
131+
132+
return {
133+
isConnected,
134+
nextRetryTime,
135+
};
136+
};
137+
138+
const ConnectionMonitor: React.FC = () => {
139+
const { isConnected, nextRetryTime } = useConnectionMonitor();
111140

112141
return (
113142
<>
114143
{!isConnected && (
115144
<ConnectionModal
116-
onRetry={checkConnection}
117-
isRetrying={isChecking}
145+
nextRetryTime={nextRetryTime}
118146
/>
119147
)}
120148
</>

web/src/components/common/tests/ConnectionMonitor.test.tsx

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
2+
import { render, screen, waitFor } from '@testing-library/react';
33
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
44
import { http, HttpResponse } from 'msw';
55
import { setupServer } from 'msw/node';
@@ -46,14 +46,15 @@ describe('ConnectionMonitor', () => {
4646
}, { timeout: 4000 });
4747
}, 6000);
4848

49-
it('should handle manual retry', async () => {
50-
let manualRetryClicked = false;
49+
it('should handle automatic retry', async () => {
50+
let retryCount = 0;
5151

5252
server.use(
5353
http.get('*/api/health', () => {
54+
retryCount++;
5455

55-
// Keep failing until manual retry is clicked, then succeed
56-
if (!manualRetryClicked) {
56+
// Fail first time, succeed on second automatic retry
57+
if (retryCount === 1) {
5758
return HttpResponse.error();
5859
}
5960

@@ -71,20 +72,16 @@ describe('ConnectionMonitor', () => {
7172
expect(screen.getByText('Cannot connect')).toBeInTheDocument();
7273
}, { timeout: 6000 });
7374

74-
// Wait for the retry button to be available
75+
// Should show countdown
7576
await waitFor(() => {
76-
expect(screen.getByText('Try Now')).toBeInTheDocument();
77+
expect(screen.getByText(/Retrying in \d+ second/)).toBeInTheDocument();
7778
}, { timeout: 1000 });
7879

79-
// Mark that manual retry was clicked, then click it
80-
manualRetryClicked = true;
81-
fireEvent.click(screen.getByText('Try Now'));
82-
83-
// Modal should disappear when connection is restored
80+
// Modal should disappear when automatic retry succeeds
8481
await waitFor(() => {
8582
expect(screen.queryByText('Cannot connect')).not.toBeInTheDocument();
86-
}, { timeout: 6000 });
87-
}, 12000);
83+
}, { timeout: 12000 });
84+
}, 15000);
8885

8986
it('should show retry countdown timer', async () => {
9087
server.use(
@@ -99,6 +96,6 @@ describe('ConnectionMonitor', () => {
9996
expect(screen.getByText('Cannot connect')).toBeInTheDocument();
10097
}, { timeout: 4000 });
10198

102-
expect(screen.getByText(/Trying again in \d+ second/)).toBeInTheDocument();
99+
expect(screen.getByText(/Retrying in \d+ second/)).toBeInTheDocument();
103100
}, 6000);
104101
});

0 commit comments

Comments
 (0)