diff --git a/frontend/src/components/TelemetryDashboard.jsx b/frontend/src/components/TelemetryDashboard.jsx
index d06ae380..a7009565 100644
--- a/frontend/src/components/TelemetryDashboard.jsx
+++ b/frontend/src/components/TelemetryDashboard.jsx
@@ -29,19 +29,56 @@ import {
/**
* TelemetryDashboard -- displays application performance metrics and error logs.
+ *
+ * Error Handling:
+ * - Displays visible error state when analytics data fails to load
+ * - Keeps refresh and export actions available during errors
+ * - Allows retry after failed loads
+ *
* Note: Shared sub-components (MetricCard, AlertPanel, etc.) must be defined
* at the top level to avoid "symbol has already been declared" errors during
* Vite/Rollup transformation when build optimizations are enabled.
*/
+
+const DEFAULT_ERROR_MESSAGE = 'Failed to load telemetry data';
+
+/**
+ * Extract a user-friendly error message from an exception.
+ * @param {Error} err - The error object
+ * @returns {string} User-friendly error message
+ */
+function extractErrorMessage(err) {
+ if (!err) return DEFAULT_ERROR_MESSAGE;
+
+ if (err.message) {
+ return err.message;
+ }
+
+ if (typeof err === 'string') {
+ return err;
+ }
+
+ return DEFAULT_ERROR_MESSAGE;
+}
+
+/**
+ * TelemetryDashboard component displays application performance metrics.
+ *
+ * @param {Object} props - Component props
+ * @param {Function} props.addToast - Function to display toast notifications
+ * @returns {JSX.Element} The telemetry dashboard UI
+ */
export default function TelemetryDashboard({ addToast }) {
const { demoEnabled } = useDemoMode();
const [summary, setSummary] = useState(null);
const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
const [exporting, setExporting] = useState(false);
const [syncing, setSyncing] = useState(false);
const loadData = useCallback(() => {
setLoading(true);
+ setError(null);
if (demoEnabled) {
setTimeout(() => {
@@ -81,7 +118,12 @@ export default function TelemetryDashboard({ addToast }) {
try {
const data = analytics.getSummary();
setSummary(data);
+ setError(null);
} catch (err) {
+ // Extract error message for display, fallback to generic message
+ const errorMessage = extractErrorMessage(err);
+ setError(errorMessage);
+ // Keep console logging for debugging purposes
console.error('Failed to load telemetry:', err);
} finally {
setLoading(false);
@@ -170,6 +212,55 @@ export default function TelemetryDashboard({ addToast }) {
);
}
+ // Render error state with retry and export options
+ if (error) {
+ return (
+
+
+
+
+
+
+ Failed to Load Telemetry Data
+
+
+ {error}
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
const vitalsSummary = computeVitalsSummary(summary?.webVitals || {});
const tipFunnel = computeTipFunnel(summary || {});
const batchFunnel = computeBatchFunnel(summary || {});
diff --git a/frontend/src/test/TelemetryDashboard.error.test.jsx b/frontend/src/test/TelemetryDashboard.error.test.jsx
new file mode 100644
index 00000000..4b415268
--- /dev/null
+++ b/frontend/src/test/TelemetryDashboard.error.test.jsx
@@ -0,0 +1,477 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+vi.mock('../lib/analytics', () => ({
+ analytics: {
+ getSummary: vi.fn(),
+ reset: vi.fn(),
+ },
+}));
+
+import TelemetryDashboard from '../components/TelemetryDashboard';
+import { analytics } from '../lib/analytics';
+
+vi.mock('../context/DemoContext', () => ({
+ useDemoMode: vi.fn(() => ({
+ demoEnabled: false,
+ setDemoBalance: vi.fn(),
+ })),
+}));
+
+vi.mock('../lib/telemetry-env', () => ({
+ getEnvironmentLabel: vi.fn(() => 'PRODUCTION'),
+ getEnvironmentColor: vi.fn(() => 'green'),
+}));
+
+vi.mock('../lib/telemetry-vitals', () => ({
+ computeVitalsSummary: vi.fn(() => ({
+ vitals: [],
+ overallScore: null,
+ coreVitalsPassing: false,
+ })),
+}));
+
+vi.mock('../lib/telemetry-funnel', () => ({
+ computeTipFunnel: vi.fn(() => ({
+ stages: [],
+ overallConversion: 0,
+ cancelled: 0,
+ failed: 0,
+ })),
+ computeBatchFunnel: vi.fn(() => ({
+ stages: [],
+ overallConversion: 0,
+ cancelled: 0,
+ failed: 0,
+ })),
+ computeWalletDropOff: vi.fn(() => ({
+ connections: 0,
+ disconnections: 0,
+ retentionRate: 0,
+ dropOffRate: 0,
+ })),
+ identifyDropOffPoints: vi.fn(() => []),
+}));
+
+vi.mock('../lib/telemetry-export', () => ({
+ downloadExport: vi.fn(() => 'export.json'),
+ exportToCsv: vi.fn(() => 'export.csv'),
+ copyToClipboard: vi.fn(),
+}));
+
+vi.mock('../lib/telemetry-sink', () => ({
+ isSinkEnabled: vi.fn(() => false),
+ getSinkConfig: vi.fn(() => ({ endpoint: '' })),
+ sendSnapshot: vi.fn(),
+}));
+
+vi.mock('../lib/telemetry-storage', () => ({
+ getStorageUsage: vi.fn(() => ({ telemetryBytes: 0 })),
+}));
+
+describe('TelemetryDashboard error handling', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // Basic error display tests
+ it('displays error state when analytics.getSummary throws', async () => {
+ const errorMessage = 'Analytics service unavailable';
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error(errorMessage);
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument();
+ expect(screen.getByText(errorMessage)).toBeInTheDocument();
+ });
+ });
+
+ it('shows retry button in error state', async () => {
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error('Service error');
+ });
+
+ render();
+
+ await waitFor(() => {
+ const retryButton = screen.getByRole('button', { name: /retry/i });
+ expect(retryButton).toBeInTheDocument();
+ });
+ });
+
+ // Button availability tests
+ it('keeps export buttons available during error state', async () => {
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error('Service error');
+ });
+
+ render();
+
+ await waitFor(() => {
+ const exportJsonButton = screen.getByLabelText('Export telemetry data as JSON');
+ const exportCsvButton = screen.getByLabelText('Export telemetry data as CSV');
+ expect(exportJsonButton).toBeInTheDocument();
+ expect(exportCsvButton).toBeInTheDocument();
+ });
+ });
+
+ // Retry functionality tests
+ it('allows retry after error', async () => {
+ const user = userEvent.setup();
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error('Service error');
+ });
+
+ const { rerender } = render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument();
+ });
+
+ analytics.getSummary.mockReturnValue({
+ sessions: 10,
+ totalPageViews: 50,
+ tipsConfirmed: 5,
+ tipCompletionRate: '50',
+ tipDropOffRate: '50',
+ batchTipsConfirmed: 1,
+ batchCompletionRate: '100',
+ totalErrors: 0,
+ sortedPages: [],
+ sortedErrors: [],
+ webVitals: {},
+ walletConnections: 5,
+ walletDisconnections: 0,
+ firstSeen: Date.now(),
+ lastSeen: Date.now(),
+ });
+
+ const retryButton = screen.getByRole('button', { name: /retry/i });
+ await user.click(retryButton);
+
+ await waitFor(() => {
+ expect(screen.queryByText('Failed to Load Telemetry Data')).not.toBeInTheDocument();
+ });
+ });
+
+ it('clears error when data loads successfully', async () => {
+ const user = userEvent.setup();
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error('Service error');
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument();
+ });
+
+ analytics.getSummary.mockReturnValue({
+ sessions: 10,
+ totalPageViews: 50,
+ tipsConfirmed: 5,
+ tipCompletionRate: '50',
+ tipDropOffRate: '50',
+ batchTipsConfirmed: 1,
+ batchCompletionRate: '100',
+ totalErrors: 0,
+ sortedPages: [],
+ sortedErrors: [],
+ webVitals: {},
+ walletConnections: 5,
+ walletDisconnections: 0,
+ firstSeen: Date.now(),
+ lastSeen: Date.now(),
+ });
+
+ const retryButton = screen.getByRole('button', { name: /retry/i });
+ await user.click(retryButton);
+
+ await waitFor(() => {
+ expect(screen.queryByText('Failed to Load Telemetry Data')).not.toBeInTheDocument();
+ });
+ });
+
+ // Error message handling tests
+ it('displays generic error message for unknown errors', async () => {
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error();
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument();
+ });
+ });
+
+ it('shows error with specific message from exception', async () => {
+ const specificError = 'Database connection timeout';
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error(specificError);
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(specificError)).toBeInTheDocument();
+ });
+ });
+
+ it('maintains error state across re-renders', async () => {
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error('Persistent error');
+ });
+
+ const { rerender } = render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument();
+ });
+
+ rerender();
+
+ expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument();
+ });
+
+ it('exports JSON even when data load fails', async () => {
+ const user = userEvent.setup();
+
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error('Service error');
+ });
+
+ const addToast = vi.fn();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument();
+ });
+
+ const exportJsonButton = screen.getByLabelText('Export telemetry data as JSON');
+ await user.click(exportJsonButton);
+
+ await waitFor(() => {
+ expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Exported'), 'success');
+ });
+ });
+
+ it('exports CSV even when data load fails', async () => {
+ const user = userEvent.setup();
+
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error('Service error');
+ });
+
+ const addToast = vi.fn();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument();
+ });
+
+ const exportCsvButton = screen.getByLabelText('Export telemetry data as CSV');
+ await user.click(exportCsvButton);
+
+ await waitFor(() => {
+ expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Exported'), 'success');
+ });
+ });
+
+ it('displays error message in monospace font', async () => {
+ const errorMessage = 'Connection refused: localhost:5432';
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error(errorMessage);
+ });
+
+ render();
+
+ await waitFor(() => {
+ const errorElement = screen.getByText(errorMessage);
+ expect(errorElement).toBeInTheDocument();
+ expect(errorElement.className).toContain('font-mono');
+ });
+ });
+
+ it('shows alert icon in error state', async () => {
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error('Service error');
+ });
+
+ const { container } = render();
+
+ await waitFor(() => {
+ const alertIcon = container.querySelector('svg[class*="text-red-500"]');
+ expect(alertIcon).toBeInTheDocument();
+ });
+ });
+
+ it('clears loading state when error occurs', async () => {
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error('Service error');
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
+ expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument();
+ });
+ });
+
+ it('error container has alert role for accessibility', async () => {
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error('Service error');
+ });
+
+ render();
+
+ await waitFor(() => {
+ const alertContainer = screen.getByRole('alert');
+ expect(alertContainer).toBeInTheDocument();
+ expect(alertContainer).toHaveAttribute('aria-live', 'assertive');
+ });
+ });
+
+ it('buttons have descriptive aria labels in error state', async () => {
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error('Service error');
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText('Retry loading telemetry data')).toBeInTheDocument();
+ expect(screen.getByLabelText('Export telemetry data as JSON')).toBeInTheDocument();
+ expect(screen.getByLabelText('Export telemetry data as CSV')).toBeInTheDocument();
+ });
+ });
+
+ it('export buttons are enabled in error state', async () => {
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error('Service error');
+ });
+
+ render();
+
+ await waitFor(() => {
+ const exportJsonButton = screen.getByLabelText('Export telemetry data as JSON');
+ const exportCsvButton = screen.getByLabelText('Export telemetry data as CSV');
+ expect(exportJsonButton).not.toBeDisabled();
+ expect(exportCsvButton).not.toBeDisabled();
+ });
+ });
+
+ it('handles string errors gracefully', async () => {
+ analytics.getSummary.mockImplementation(() => {
+ throw 'String error message';
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument();
+ expect(screen.getByText('String error message')).toBeInTheDocument();
+ });
+ });
+
+ it('does not show error state when data loads successfully', async () => {
+ analytics.getSummary.mockReturnValue({
+ sessions: 10,
+ totalPageViews: 50,
+ tipsConfirmed: 5,
+ tipCompletionRate: '50',
+ tipDropOffRate: '50',
+ batchTipsConfirmed: 1,
+ batchCompletionRate: '100',
+ totalErrors: 0,
+ sortedPages: [],
+ sortedErrors: [],
+ webVitals: {},
+ walletConnections: 5,
+ walletDisconnections: 0,
+ firstSeen: Date.now(),
+ lastSeen: Date.now(),
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByText('Failed to Load Telemetry Data')).not.toBeInTheDocument();
+ expect(screen.getByText('Telemetry Dashboard')).toBeInTheDocument();
+ });
+ });
+
+ it('handles null error with fallback message', async () => {
+ analytics.getSummary.mockImplementation(() => {
+ throw null;
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument();
+ });
+ });
+
+ it('retry button triggers data reload', async () => {
+ const user = userEvent.setup();
+ let callCount = 0;
+
+ analytics.getSummary.mockImplementation(() => {
+ callCount++;
+ if (callCount === 1) {
+ throw new Error('First attempt failed');
+ }
+ return {
+ sessions: 10,
+ totalPageViews: 50,
+ tipsConfirmed: 5,
+ tipCompletionRate: '50',
+ tipDropOffRate: '50',
+ batchTipsConfirmed: 1,
+ batchCompletionRate: '100',
+ totalErrors: 0,
+ sortedPages: [],
+ sortedErrors: [],
+ webVitals: {},
+ walletConnections: 5,
+ walletDisconnections: 0,
+ firstSeen: Date.now(),
+ lastSeen: Date.now(),
+ };
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to Load Telemetry Data')).toBeInTheDocument();
+ });
+
+ const retryButton = screen.getByLabelText('Retry loading telemetry data');
+ await user.click(retryButton);
+
+ await waitFor(() => {
+ expect(callCount).toBe(2);
+ expect(screen.queryByText('Failed to Load Telemetry Data')).not.toBeInTheDocument();
+ });
+ });
+
+ it('error message container has proper styling', async () => {
+ analytics.getSummary.mockImplementation(() => {
+ throw new Error('Service error');
+ });
+
+ const { container } = render();
+
+ await waitFor(() => {
+ const errorMessage = screen.getByText('Service error');
+ expect(errorMessage.className).toContain('bg-gray-50');
+ expect(errorMessage.className).toContain('border');
+ });
+ });
+});