Skip to content

Commit 58b56c3

Browse files
feat(ui): show modal when frontend loses connection to backend (#2370)
1 parent b824d77 commit 58b56c3

File tree

3 files changed

+230
-0
lines changed

3 files changed

+230
-0
lines changed

web/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ConfigProvider } from "./contexts/ConfigContext";
33
import { WizardModeProvider } from "./contexts/WizardModeContext";
44
import { BrandingProvider } from "./contexts/BrandingContext";
55
import { AuthProvider } from "./contexts/AuthContext";
6+
import ConnectionMonitor from "./components/common/ConnectionMonitor";
67
import InstallWizard from "./components/wizard/InstallWizard";
78
import { QueryClientProvider } from "@tanstack/react-query";
89
import { getQueryClient } from "./query-client";
@@ -33,6 +34,7 @@ function App() {
3334
</BrandingProvider>
3435
</ConfigProvider>
3536
</AuthProvider>
37+
<ConnectionMonitor />
3638
</QueryClientProvider>
3739
);
3840
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import React, { useEffect, useState, useCallback } from 'react';
2+
3+
// Connection modal component
4+
const ConnectionModal: React.FC<{ onRetry: () => void; isRetrying: boolean }> = ({ onRetry, isRetrying }) => {
5+
const [retryCount, setRetryCount] = useState(0);
6+
7+
useEffect(() => {
8+
const interval = setInterval(() => {
9+
setRetryCount(count => count + 1);
10+
}, 1000);
11+
return () => clearInterval(interval);
12+
}, []);
13+
14+
return (
15+
<div className="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center">
16+
<div className="bg-white rounded-lg p-6 max-w-md mx-4 shadow-xl">
17+
<div className="flex items-center justify-center mb-4">
18+
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mr-4">
19+
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
20+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
21+
</svg>
22+
</div>
23+
</div>
24+
25+
<h2 className="text-xl font-bold text-gray-900 text-center mb-2">
26+
Cannot connect
27+
</h2>
28+
29+
<p className="text-gray-700 text-center mb-6">
30+
We're unable to reach the server right now. Please check that the
31+
installer is running and accessible.
32+
</p>
33+
34+
<div className="flex items-center justify-between">
35+
<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' : ''}
38+
</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>
46+
</div>
47+
</div>
48+
</div>
49+
);
50+
};
51+
52+
const ConnectionMonitor: React.FC = () => {
53+
const [isConnected, setIsConnected] = useState(true);
54+
const [isChecking, setIsChecking] = useState(false);
55+
56+
const checkConnection = useCallback(async () => {
57+
setIsChecking(true);
58+
59+
try {
60+
// Try up to 3 times before marking as disconnected
61+
let attempts = 0;
62+
const maxAttempts = 3;
63+
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+
}
91+
92+
// All attempts failed - show modal immediately
93+
setIsConnected(false);
94+
95+
} catch {
96+
setIsConnected(false);
97+
} finally {
98+
setIsChecking(false);
99+
}
100+
}, []);
101+
102+
useEffect(() => {
103+
// Initial check
104+
checkConnection();
105+
106+
// Set up periodic health checks every 5 seconds
107+
const interval = setInterval(checkConnection, 5000);
108+
109+
return () => clearInterval(interval);
110+
}, [checkConnection]);
111+
112+
return (
113+
<>
114+
{!isConnected && (
115+
<ConnectionModal
116+
onRetry={checkConnection}
117+
isRetrying={isChecking}
118+
/>
119+
)}
120+
</>
121+
);
122+
};
123+
124+
export default ConnectionMonitor;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import React from 'react';
2+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4+
import { http, HttpResponse } from 'msw';
5+
import { setupServer } from 'msw/node';
6+
import ConnectionMonitor from '../ConnectionMonitor';
7+
8+
const server = setupServer(
9+
http.get('*/api/health', () => {
10+
return new HttpResponse(JSON.stringify({ status: 'ok' }), {
11+
status: 200,
12+
headers: { 'Content-Type': 'application/json' },
13+
});
14+
})
15+
);
16+
17+
describe('ConnectionMonitor', () => {
18+
beforeEach(() => {
19+
server.listen({ onUnhandledRequest: 'warn' });
20+
});
21+
22+
afterEach(() => {
23+
server.resetHandlers();
24+
vi.clearAllMocks();
25+
});
26+
27+
it('should not show modal when API is available', async () => {
28+
render(<ConnectionMonitor />);
29+
30+
// Modal should not appear when connected
31+
await new Promise(resolve => setTimeout(resolve, 100));
32+
expect(screen.queryByText('Cannot connect')).not.toBeInTheDocument();
33+
});
34+
35+
it('should show modal when health check fails', async () => {
36+
server.use(
37+
http.get('*/api/health', () => {
38+
return HttpResponse.error();
39+
})
40+
);
41+
42+
render(<ConnectionMonitor />);
43+
44+
await waitFor(() => {
45+
expect(screen.getByText('Cannot connect')).toBeInTheDocument();
46+
}, { timeout: 4000 });
47+
}, 6000);
48+
49+
it('should handle manual retry', async () => {
50+
let manualRetryClicked = false;
51+
52+
server.use(
53+
http.get('*/api/health', () => {
54+
55+
// Keep failing until manual retry is clicked, then succeed
56+
if (!manualRetryClicked) {
57+
return HttpResponse.error();
58+
}
59+
60+
return new HttpResponse(JSON.stringify({ status: 'ok' }), {
61+
status: 200,
62+
headers: { 'Content-Type': 'application/json' },
63+
});
64+
})
65+
);
66+
67+
render(<ConnectionMonitor />);
68+
69+
// Wait for modal to appear after first health check fails
70+
await waitFor(() => {
71+
expect(screen.getByText('Cannot connect')).toBeInTheDocument();
72+
}, { timeout: 6000 });
73+
74+
// Wait for the retry button to be available
75+
await waitFor(() => {
76+
expect(screen.getByText('Try Now')).toBeInTheDocument();
77+
}, { timeout: 1000 });
78+
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
84+
await waitFor(() => {
85+
expect(screen.queryByText('Cannot connect')).not.toBeInTheDocument();
86+
}, { timeout: 6000 });
87+
}, 12000);
88+
89+
it('should show retry countdown timer', async () => {
90+
server.use(
91+
http.get('*/api/health', () => {
92+
return HttpResponse.error();
93+
})
94+
);
95+
96+
render(<ConnectionMonitor />);
97+
98+
await waitFor(() => {
99+
expect(screen.getByText('Cannot connect')).toBeInTheDocument();
100+
}, { timeout: 4000 });
101+
102+
expect(screen.getByText(/Trying again in \d+ second/)).toBeInTheDocument();
103+
}, 6000);
104+
});

0 commit comments

Comments
 (0)