Skip to content

Commit efc6147

Browse files
authored
Merge pull request #1055 from Samuel1505/memo
Pull Request: Frontend Bug Fixes — Memory Leaks, CSV Export, WebSocket Reconnect, Search Debounce
2 parents 7813a5b + 8c68a5b commit efc6147

8 files changed

Lines changed: 3330 additions & 98 deletions

File tree

Dechat/dex_with_fiat_frontend/src/components/AuditTable.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export default function AuditTable({}: AuditTableProps) {
4040
endDate: '',
4141
});
4242
const [retryQueueCount, setRetryQueueCount] = useState(0);
43+
const [isExporting, setIsExporting] = useState(false);
4344

4445
const pageSize = 20;
4546
const fetchAbortRef = useRef<AbortController | null>(null);
@@ -149,6 +150,76 @@ export default function AuditTable({}: AuditTableProps) {
149150
wasOnlineRef.current = isOnline;
150151
}, [isOnline, wasOffline, addToast, resetWasOffline, fetchAuditEntries]);
151152

153+
const handleExportCSV = useCallback(async () => {
154+
setIsExporting(true);
155+
try {
156+
const params = new URLSearchParams();
157+
if (filters.actionType) params.append('actionType', filters.actionType);
158+
if (filters.status) params.append('status', filters.status);
159+
if (filters.adminAddress) params.append('adminAddress', filters.adminAddress);
160+
if (filters.txHash) params.append('txHash', filters.txHash);
161+
if (filters.startDate) params.append('startDate', filters.startDate);
162+
if (filters.endDate) params.append('endDate', filters.endDate);
163+
params.append('limit', '10000');
164+
params.append('offset', '0');
165+
params.append('sortKey', sortKey);
166+
params.append('sortOrder', sortOrder);
167+
168+
const response = await fetch(`/api/admin-audit?${params.toString()}`);
169+
if (!response.ok) throw new Error(`Export failed: ${response.statusText}`);
170+
const data = await response.json() as { entries: AuditEntry[]; total: number };
171+
172+
const CHUNK_SIZE = 500;
173+
const csvRows: string[] = [
174+
['Timestamp', 'Admin Address', 'Action Type', 'Description', 'TX Hash', 'Status'].join(','),
175+
];
176+
177+
const processChunk = (start: number): Promise<void> =>
178+
new Promise((resolve) => {
179+
setTimeout(() => {
180+
const end = Math.min(start + CHUNK_SIZE, data.entries.length);
181+
for (let i = start; i < end; i++) {
182+
const e = data.entries[i];
183+
csvRows.push([
184+
`"${formatTimestamp(new Date(e.timestamp))}"`,
185+
`"${e.adminAddress}"`,
186+
`"${getActionTypeDisplay(e.actionType)}"`,
187+
`"${(e.actionDescription ?? '').replace(/"/g, '""')}"`,
188+
`"${e.txHash ?? ''}"`,
189+
`"${e.status}"`,
190+
].join(','));
191+
}
192+
resolve();
193+
}, 0);
194+
});
195+
196+
for (let i = 0; i < data.entries.length; i += CHUNK_SIZE) {
197+
await processChunk(i);
198+
}
199+
200+
const dateLabel =
201+
filters.startDate && filters.endDate
202+
? `${filters.startDate}_to_${filters.endDate}`
203+
: filters.startDate
204+
? `from_${filters.startDate}`
205+
: filters.endDate
206+
? `to_${filters.endDate}`
207+
: new Date().toISOString().slice(0, 10);
208+
209+
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' });
210+
const url = URL.createObjectURL(blob);
211+
const link = document.createElement('a');
212+
link.href = url;
213+
link.download = `audit-log_${dateLabel}.csv`;
214+
link.click();
215+
URL.revokeObjectURL(url);
216+
} catch (err) {
217+
console.error('CSV export error:', err);
218+
} finally {
219+
setIsExporting(false);
220+
}
221+
}, [filters, sortKey, sortOrder]);
222+
152223
const handleFilterChange = (key: keyof FilterState, value: string) => {
153224
setFilters((prev) => ({ ...prev, [key]: value }));
154225
setCurrentPage(0); // Reset to first page when filtering
@@ -389,6 +460,14 @@ export default function AuditTable({}: AuditTableProps) {
389460
>
390461
{loading ? 'Loading...' : 'Refresh'}
391462
</button>
463+
<button
464+
onClick={handleExportCSV}
465+
disabled={isExporting || loading}
466+
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 font-medium"
467+
aria-label="Export filtered audit entries as CSV"
468+
>
469+
{isExporting ? 'Exporting…' : 'Export CSV'}
470+
</button>
392471
</div>
393472
</div>
394473

Dechat/dex_with_fiat_frontend/src/components/PriceTicker.tsx

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ export default function PriceTicker({
1818
const [prices, setPrices] = useState<TickerData>({});
1919
const [error, setError] = useState(false);
2020
const [isLoading, setIsLoading] = useState(true);
21+
const [isStale, setIsStale] = useState(false);
2122
const refreshTimeoutRef = useRef<ReturnType<typeof setInterval> | null>(null);
23+
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
24+
const retryCountRef = useRef(0);
25+
const hasPreviousDataRef = useRef(false);
26+
const fetchPricesRef = useRef<(() => Promise<void>) | null>(null);
2227
const isMounted = useRef(true);
2328
const kbHelpId = useId();
2429

@@ -56,42 +61,65 @@ export default function PriceTicker({
5661
return `${change >= 0 ? '+' : ''}${formatted}%`;
5762
};
5863

59-
// Fetch prices
64+
// Fetch prices with exponential backoff retry on failure
6065
const fetchPrices = useCallback(async () => {
66+
if (retryTimeoutRef.current) {
67+
clearTimeout(retryTimeoutRef.current);
68+
retryTimeoutRef.current = null;
69+
}
70+
6171
try {
6272
if (isMounted.current) setError(false);
6373
const newPrices = await fetchTickerData(symbols, currency);
64-
6574
if (!isMounted.current) return;
6675

67-
if (Object.keys(newPrices).length === 0) {
68-
setError(true);
69-
} else {
76+
if (Object.keys(newPrices).length > 0) {
7077
setPrices(newPrices);
78+
hasPreviousDataRef.current = true;
79+
setIsStale(false);
80+
retryCountRef.current = 0;
81+
} else {
82+
setError(true);
83+
if (hasPreviousDataRef.current) setIsStale(true);
84+
const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30000);
85+
retryCountRef.current++;
86+
retryTimeoutRef.current = setTimeout(() => {
87+
if (isMounted.current && fetchPricesRef.current) void fetchPricesRef.current();
88+
}, delay);
7189
}
7290
} catch (err) {
7391
console.error('Failed to fetch ticker data:', err);
74-
if (isMounted.current) setError(true);
92+
if (!isMounted.current) return;
93+
setError(true);
94+
if (hasPreviousDataRef.current) setIsStale(true);
95+
const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30000);
96+
retryCountRef.current++;
97+
retryTimeoutRef.current = setTimeout(() => {
98+
if (isMounted.current && fetchPricesRef.current) void fetchPricesRef.current();
99+
}, delay);
75100
} finally {
76101
if (isMounted.current) setIsLoading(false);
77102
}
78103
}, [symbols, currency]);
79104

105+
// Keep ref in sync so retry timeouts always call the latest version
106+
useEffect(() => {
107+
fetchPricesRef.current = fetchPrices;
108+
}, [fetchPrices]);
109+
80110
// Initial fetch and setup refresh interval
81111
useEffect(() => {
82-
fetchPrices();
112+
void fetchPrices();
83113

84-
// Setup auto-refresh
85114
refreshTimeoutRef.current = setInterval(() => {
86115
if (isMounted.current) {
87116
void fetchPrices();
88117
}
89118
}, refreshInterval);
90119

91120
return () => {
92-
if (refreshTimeoutRef.current) {
93-
clearInterval(refreshTimeoutRef.current);
94-
}
121+
if (refreshTimeoutRef.current) clearInterval(refreshTimeoutRef.current);
122+
if (retryTimeoutRef.current) clearTimeout(retryTimeoutRef.current);
95123
};
96124
}, [fetchPrices, refreshInterval]);
97125

@@ -167,9 +195,9 @@ export default function PriceTicker({
167195
</h3>
168196
<div
169197
className={`w-2 h-2 rounded-full transition-colors duration-300 ${
170-
error ? 'bg-red-500' : 'bg-green-500'
198+
isStale ? 'bg-yellow-400' : error ? 'bg-red-500' : 'bg-green-500'
171199
}`}
172-
aria-hidden
200+
aria-label={isStale ? 'Stale data — reconnecting' : error ? 'Data unavailable' : 'Live'}
173201
/>
174202
</div>
175203

@@ -284,7 +312,12 @@ export default function PriceTicker({
284312
</div>
285313
)}
286314

287-
{error && Object.keys(prices).length > 0 && (
315+
{isStale && (
316+
<p className="text-yellow-500 dark:text-yellow-400 text-[10px] mt-2 text-center" aria-live="polite">
317+
Prices may be stale — retrying&hellip;
318+
</p>
319+
)}
320+
{error && !isStale && Object.keys(prices).length > 0 && (
288321
<p className="theme-text-secondary text-[10px] mt-2 text-center opacity-60">
289322
Last updated • Focus this panel and press R to refresh
290323
</p>

Dechat/dex_with_fiat_frontend/src/hooks/useBridgeStats.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useState } from 'react';
1+
import { useCallback, useEffect, useRef, useState } from 'react';
22
import {
33
clearCache,
44
getContractBalance,
@@ -22,8 +22,17 @@ export default function useBridgeStats(): BridgeStats {
2222
const [totalDeposited, setTotalDeposited] = useState<bigint | null>(null);
2323
const [loading, setLoading] = useState(false);
2424
const [error, setError] = useState<string | null>(null);
25+
const isMountedRef = useRef(true);
26+
27+
useEffect(() => {
28+
isMountedRef.current = true;
29+
return () => {
30+
isMountedRef.current = false;
31+
};
32+
}, []);
2533

2634
const refetchStats = useCallback(async () => {
35+
if (!isMountedRef.current) return;
2736
setLoading(true);
2837
setError(null);
2938
try {
@@ -32,13 +41,15 @@ export default function useBridgeStats(): BridgeStats {
3241
getBridgeLimit(),
3342
getTotalDeposited(),
3443
]);
44+
if (!isMountedRef.current) return;
3545
setBalance(b);
3646
setLimit(l);
3747
setTotalDeposited(t);
3848
} catch (err) {
49+
if (!isMountedRef.current) return;
3950
setError(err instanceof Error ? err.message : String(err));
4051
} finally {
41-
setLoading(false);
52+
if (isMountedRef.current) setLoading(false);
4253
}
4354
}, []);
4455

Dechat/dex_with_fiat_frontend/src/hooks/useChatHistory.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export const useChatHistory = () => {
1212
sessions: [],
1313
});
1414
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
15+
const [searchQuery, setSearchQuery] = useState('');
16+
const [searchResults, setSearchResults] = useState<ChatSession[]>([]);
1517

1618
// Load history from localStorage on mount
1719
useEffect(() => {
@@ -30,6 +32,20 @@ export const useChatHistory = () => {
3032
}
3133
}, [historyState]);
3234

35+
// Debounced search — avoids triggering a lookup on every keystroke
36+
useEffect(() => {
37+
if (!searchQuery.trim()) {
38+
setSearchResults([]);
39+
return;
40+
}
41+
const timeoutId = setTimeout(() => {
42+
setSearchResults(
43+
ChatHistoryManager.searchSessions(historyState.sessions, searchQuery),
44+
);
45+
}, 300);
46+
return () => clearTimeout(timeoutId);
47+
}, [searchQuery, historyState.sessions]);
48+
3349
const createNewSession = useCallback(
3450
(initialMessages: ChatMessage[] = []): string => {
3551
const newSession = ChatHistoryManager.createNewSession(
@@ -256,5 +272,10 @@ export const useChatHistory = () => {
256272

257273
// Utils
258274
hasHistory: historyState.sessions.length > 0,
275+
276+
// Debounced search
277+
searchQuery,
278+
setSearchQuery,
279+
searchResults,
259280
};
260281
};

0 commit comments

Comments
 (0)