From 18a7105f20d00a502f896fa6b8c9720c162d5041 Mon Sep 17 00:00:00 2001 From: Gerlof van Ek Date: Tue, 25 Feb 2025 20:20:55 +0100 Subject: [PATCH] New Swaps in Progress page + various fixes + CSV export on bids page. (#267) * New Swaps in Progress page + various fixes. * LINT * Fix small memory leak in bids page. * Fix coin filter logic. * Add CSV export on bids page + various fixes. * Update basicswap/static/js/bids_sentreceived.js Co-authored-by: nahuhh <50635951+nahuhh@users.noreply.github.com> * Update basicswap/static/js/bids_sentreceived.js Co-authored-by: nahuhh <50635951+nahuhh@users.noreply.github.com> * Update basicswap/static/js/bids_sentreceived.js Co-authored-by: nahuhh <50635951+nahuhh@users.noreply.github.com> * Update basicswap/static/js/bids_sentreceived.js Co-authored-by: nahuhh <50635951+nahuhh@users.noreply.github.com> * Various fixes. --------- Co-authored-by: nahuhh <50635951+nahuhh@users.noreply.github.com> --- basicswap/js_server.py | 62 + basicswap/static/js/active.js | 872 ++++++++++++++ basicswap/static/js/bids_available.js | 2 +- basicswap/static/js/bids_export.js | 141 +++ basicswap/static/js/bids_sentreceived.js | 1331 +++++++++++++++------- basicswap/templates/active.html | 208 ++-- basicswap/templates/bids.html | 61 +- 7 files changed, 2163 insertions(+), 514 deletions(-) create mode 100644 basicswap/static/js/active.js create mode 100644 basicswap/static/js/bids_export.js diff --git a/basicswap/js_server.py b/basicswap/js_server.py index b95c662..5e799fd 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -980,6 +980,67 @@ def js_readurl(self, url_split, post_string, is_json) -> bytes: raise ValueError("Requires URL.") +def js_active(self, url_split, post_string, is_json) -> bytes: + swap_client = self.server.swap_client + swap_client.checkSystemStatus() + filters = { + "sort_by": "created_at", + "sort_dir": "desc" + } + EXCLUDED_STATES = [ + 'Completed', + 'Expired', + 'Timed-out', + 'Abandoned', + 'Failed, refunded', + 'Failed, swiped', + 'Failed', + 'Error', + 'received' + ] + all_bids = [] + + try: + received_bids = swap_client.listBids(filters=filters) + sent_bids = swap_client.listBids(sent=True, filters=filters) + for bid in received_bids + sent_bids: + try: + bid_state = strBidState(bid[5]) + tx_state_a = strTxState(bid[7]) + tx_state_b = strTxState(bid[8]) + if bid_state in EXCLUDED_STATES: + continue + offer = swap_client.getOffer(bid[3]) + if not offer: + continue + swap_data = { + "bid_id": bid[2].hex(), + "offer_id": bid[3].hex(), + "created_at": bid[0], + "bid_state": bid_state, + "tx_state_a": tx_state_a if tx_state_a else 'None', + "tx_state_b": tx_state_b if tx_state_b else 'None', + "coin_from": swap_client.ci(bid[9]).coin_name(), + "coin_to": swap_client.ci(offer.coin_to).coin_name(), + "amount_from": swap_client.ci(bid[9]).format_amount(bid[4]), + "amount_to": swap_client.ci(offer.coin_to).format_amount( + (bid[4] * bid[10]) // swap_client.ci(bid[9]).COIN() + ), + "addr_from": bid[11], + "status": { + "main": bid_state, + "initial_tx": tx_state_a if tx_state_a else 'None', + "payment_tx": tx_state_b if tx_state_b else 'None' + } + } + all_bids.append(swap_data) + except Exception: + continue + except Exception: + return bytes(json.dumps([]), "UTF-8") + return bytes(json.dumps(all_bids), "UTF-8") + + pages = { "coins": js_coins, "wallets": js_wallets, @@ -1005,6 +1066,7 @@ pages = { "lock": js_lock, "help": js_help, "readurl": js_readurl, + "active": js_active, } diff --git a/basicswap/static/js/active.js b/basicswap/static/js/active.js new file mode 100644 index 0000000..d6d1767 --- /dev/null +++ b/basicswap/static/js/active.js @@ -0,0 +1,872 @@ +// Constants and State +const PAGE_SIZE = 50; +const COIN_NAME_TO_SYMBOL = { + 'Bitcoin': 'BTC', + 'Litecoin': 'LTC', + 'Monero': 'XMR', + 'Particl': 'PART', + 'Particl Blind': 'PART', + 'Particl Anon': 'PART', + 'PIVX': 'PIVX', + 'Firo': 'FIRO', + 'Dash': 'DASH', + 'Decred': 'DCR', + 'Wownero': 'WOW', + 'Bitcoin Cash': 'BCH', + 'Dogecoin': 'DOGE' +}; + +// Global state +const state = { + identities: new Map(), + currentPage: 1, + wsConnected: false, + swapsData: [], + isLoading: false, + isRefreshing: false, + refreshPromise: null +}; + +// DOM +const elements = { + swapsBody: document.getElementById('active-swaps-body'), + prevPageButton: document.getElementById('prevPage'), + nextPageButton: document.getElementById('nextPage'), + currentPageSpan: document.getElementById('currentPage'), + paginationControls: document.getElementById('pagination-controls'), + activeSwapsCount: document.getElementById('activeSwapsCount'), + refreshSwapsButton: document.getElementById('refreshSwaps'), + statusDot: document.getElementById('status-dot'), + statusText: document.getElementById('status-text') +}; + +// Identity Manager +const IdentityManager = { + cache: new Map(), + pendingRequests: new Map(), + retryDelay: 2000, + maxRetries: 3, + cacheTimeout: 5 * 60 * 1000, // 5 minutes + + async getIdentityData(address) { + if (!address) { + return { address: '' }; + } + + const cachedData = this.getCachedIdentity(address); + if (cachedData) { + return { ...cachedData, address }; + } + + if (this.pendingRequests.has(address)) { + const pendingData = await this.pendingRequests.get(address); + return { ...pendingData, address }; + } + + const request = this.fetchWithRetry(address); + this.pendingRequests.set(address, request); + + try { + const data = await request; + this.cache.set(address, { + data, + timestamp: Date.now() + }); + return { ...data, address }; + } catch (error) { + console.warn(`Error fetching identity for ${address}:`, error); + return { address }; + } finally { + this.pendingRequests.delete(address); + } + }, + + getCachedIdentity(address) { + const cached = this.cache.get(address); + if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) { + return cached.data; + } + if (cached) { + this.cache.delete(address); + } + return null; + }, + + async fetchWithRetry(address, attempt = 1) { + try { + const response = await fetch(`/json/identities/${address}`, { + signal: AbortSignal.timeout(5000) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return { + ...data, + address, + num_sent_bids_successful: safeParseInt(data.num_sent_bids_successful), + num_recv_bids_successful: safeParseInt(data.num_recv_bids_successful), + num_sent_bids_failed: safeParseInt(data.num_sent_bids_failed), + num_recv_bids_failed: safeParseInt(data.num_recv_bids_failed), + num_sent_bids_rejected: safeParseInt(data.num_sent_bids_rejected), + num_recv_bids_rejected: safeParseInt(data.num_recv_bids_rejected), + label: data.label || '', + note: data.note || '', + automation_override: safeParseInt(data.automation_override) + }; + } catch (error) { + if (attempt >= this.maxRetries) { + console.warn(`Failed to fetch identity for ${address} after ${attempt} attempts`); + return { + address, + num_sent_bids_successful: 0, + num_recv_bids_successful: 0, + num_sent_bids_failed: 0, + num_recv_bids_failed: 0, + num_sent_bids_rejected: 0, + num_recv_bids_rejected: 0, + label: '', + note: '', + automation_override: 0 + }; + } + + await new Promise(resolve => setTimeout(resolve, this.retryDelay * attempt)); + return this.fetchWithRetry(address, attempt + 1); + } + } +}; + +const safeParseInt = (value) => { + const parsed = parseInt(value); + return isNaN(parsed) ? 0 : parsed; +}; + +const getStatusClass = (status, tx_a, tx_b) => { + switch (status) { + case 'Completed': + return 'bg-green-300 text-black dark:bg-green-600 dark:text-white'; + case 'Expired': + case 'Timed-out': + return 'bg-gray-200 text-black dark:bg-gray-400 dark:text-white'; + case 'Error': + case 'Failed': + return 'bg-red-300 text-black dark:bg-red-600 dark:text-white'; + case 'Failed, swiped': + case 'Failed, refunded': + return 'bg-gray-200 text-black dark:bg-gray-400 dark:text-red-500'; + case 'InProgress': + case 'Script coin locked': + case 'Scriptless coin locked': + case 'Script coin lock released': + case 'SendingInitialTx': + case 'SendingPaymentTx': + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + case 'Received': + case 'Exchanged script lock tx sigs msg': + case 'Exchanged script lock spend tx msg': + case 'Script tx redeemed': + case 'Scriptless tx redeemed': + case 'Scriptless tx recovered': + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + case 'Accepted': + case 'Request accepted': + return 'bg-green-300 text-black dark:bg-green-600 dark:text-white'; + case 'Delaying': + case 'Auto accept delay': + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + case 'Abandoned': + case 'Rejected': + return 'bg-red-300 text-black dark:bg-red-600 dark:text-white'; + default: + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + } +}; + +const getTxStatusClass = (status) => { + if (!status || status === 'None') return 'text-gray-400'; + + if (status.includes('Complete') || status.includes('Confirmed')) { + return 'text-green-500'; + } + if (status.includes('Error') || status.includes('Failed')) { + return 'text-red-500'; + } + if (status.includes('Progress') || status.includes('Sending')) { + return 'text-yellow-500'; + } + return 'text-blue-500'; +}; + +// Util +const formatTimeAgo = (timestamp) => { + const now = Math.floor(Date.now() / 1000); + const diff = now - timestamp; + + if (diff < 60) return `${diff} seconds ago`; + if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`; + return `${Math.floor(diff / 86400)} days ago`; +}; + + +const formatTime = (timestamp) => { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +}; + +const formatAddress = (address, displayLength = 15) => { + if (!address) return ''; + if (address.length <= displayLength) return address; + return `${address.slice(0, displayLength)}...`; +}; + +const getStatusColor = (status) => { + const statusColors = { + 'Received': 'text-blue-500', + 'Accepted': 'text-green-500', + 'InProgress': 'text-yellow-500', + 'Complete': 'text-green-600', + 'Failed': 'text-red-500', + 'Expired': 'text-gray-500' + }; + return statusColors[status] || 'text-gray-500'; +}; + +const getTimeStrokeColor = (expireTime) => { + const now = Math.floor(Date.now() / 1000); + const timeLeft = expireTime - now; + + if (timeLeft <= 300) return '#9CA3AF'; // 5 minutes or less + if (timeLeft <= 1800) return '#3B82F6'; // 30 minutes or less + return '#10B981'; // More than 30 minutes +}; + +// WebSocket Manager +const WebSocketManager = { + ws: null, + processingQueue: false, + reconnectTimeout: null, + maxReconnectAttempts: 5, + reconnectAttempts: 0, + reconnectDelay: 5000, + + initialize() { + this.connect(); + this.startHealthCheck(); + }, + + connect() { + if (this.ws?.readyState === WebSocket.OPEN) return; + + try { + const wsPort = window.ws_port || '11700'; + this.ws = new WebSocket(`ws://${window.location.hostname}:${wsPort}`); + this.setupEventHandlers(); + } catch (error) { + console.error('WebSocket connection error:', error); + this.handleReconnect(); + } + }, + + setupEventHandlers() { + this.ws.onopen = () => { + state.wsConnected = true; + this.reconnectAttempts = 0; + updateConnectionStatus('connected'); + console.log('🟢 WebSocket connection established for Swaps in Progress'); + updateSwapsTable({ resetPage: true, refreshData: true }); + }; + + this.ws.onmessage = () => { + if (!this.processingQueue) { + this.processingQueue = true; + setTimeout(async () => { + try { + if (!state.isRefreshing) { + await updateSwapsTable({ resetPage: false, refreshData: true }); + } + } finally { + this.processingQueue = false; + } + }, 200); + } + }; + + this.ws.onclose = () => { + state.wsConnected = false; + updateConnectionStatus('disconnected'); + this.handleReconnect(); + }; + + this.ws.onerror = () => { + updateConnectionStatus('error'); + }; + }, + + startHealthCheck() { + setInterval(() => { + if (this.ws?.readyState !== WebSocket.OPEN) { + this.handleReconnect(); + } + }, 30000); + }, + + handleReconnect() { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + } + + this.reconnectAttempts++; + if (this.reconnectAttempts <= this.maxReconnectAttempts) { + const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1); + this.reconnectTimeout = setTimeout(() => this.connect(), delay); + } else { + updateConnectionStatus('error'); + setTimeout(() => { + this.reconnectAttempts = 0; + this.connect(); + }, 60000); + } + } +}; + +// UI +const updateConnectionStatus = (status) => { + const { statusDot, statusText } = elements; + if (!statusDot || !statusText) return; + + const statusConfig = { + connected: { + dotClass: 'w-2.5 h-2.5 rounded-full bg-green-500 mr-2', + textClass: 'text-sm text-green-500', + message: 'Connected' + }, + disconnected: { + dotClass: 'w-2.5 h-2.5 rounded-full bg-red-500 mr-2', + textClass: 'text-sm text-red-500', + message: 'Disconnected - Reconnecting...' + }, + error: { + dotClass: 'w-2.5 h-2.5 rounded-full bg-yellow-500 mr-2', + textClass: 'text-sm text-yellow-500', + message: 'Connection Error' + }, + default: { + dotClass: 'w-2.5 h-2.5 rounded-full bg-gray-500 mr-2', + textClass: 'text-sm text-gray-500', + message: 'Connecting...' + } + }; + + const config = statusConfig[status] || statusConfig.default; + statusDot.className = config.dotClass; + statusText.className = config.textClass; + statusText.textContent = config.message; +}; + +const updateLoadingState = (isLoading) => { + state.isLoading = isLoading; + if (elements.refreshSwapsButton) { + elements.refreshSwapsButton.disabled = isLoading; + elements.refreshSwapsButton.classList.toggle('opacity-75', isLoading); + elements.refreshSwapsButton.classList.toggle('cursor-wait', isLoading); + + const refreshIcon = elements.refreshSwapsButton.querySelector('svg'); + const refreshText = elements.refreshSwapsButton.querySelector('#refreshText'); + + if (refreshIcon) { + refreshIcon.style.transition = 'transform 0.3s ease'; + refreshIcon.classList.toggle('animate-spin', isLoading); + } + + if (refreshText) { + refreshText.textContent = isLoading ? 'Refreshing...' : 'Refresh'; + } + } +}; + +const processIdentityStats = (identity) => { + if (!identity) return null; + + const stats = { + sentSuccessful: safeParseInt(identity.num_sent_bids_successful), + recvSuccessful: safeParseInt(identity.num_recv_bids_successful), + sentFailed: safeParseInt(identity.num_sent_bids_failed), + recvFailed: safeParseInt(identity.num_recv_bids_failed), + sentRejected: safeParseInt(identity.num_sent_bids_rejected), + recvRejected: safeParseInt(identity.num_recv_bids_rejected) + }; + + stats.totalSuccessful = stats.sentSuccessful + stats.recvSuccessful; + stats.totalFailed = stats.sentFailed + stats.recvFailed; + stats.totalRejected = stats.sentRejected + stats.recvRejected; + stats.totalBids = stats.totalSuccessful + stats.totalFailed + stats.totalRejected; + + stats.successRate = stats.totalBids > 0 + ? ((stats.totalSuccessful / stats.totalBids) * 100).toFixed(1) + : '0.0'; + + return stats; +}; + +const createIdentityTooltip = (identity) => { + if (!identity) return ''; + + const stats = processIdentityStats(identity); + if (!stats) return ''; + + const getSuccessRateColor = (rate) => { + const numRate = parseFloat(rate); + if (numRate >= 80) return 'text-green-600'; + if (numRate >= 60) return 'text-yellow-600'; + return 'text-red-600'; + }; + + return ` +
+ ${identity.label ? ` +
+
Label:
+
${identity.label}
+
+ ` : ''} + +
+
Address:
+
+ ${identity.address || ''} +
+
+ + ${identity.note ? ` +
+
Note:
+
${identity.note}
+
+ ` : ''} + +
+
Swap History:
+
+
+
+ ${stats.successRate}% +
+
Success Rate
+
+
+
${stats.totalBids}
+
Total Trades
+
+
+
+
+
+ ${stats.totalSuccessful} +
+
Successful
+
+
+
+ ${stats.totalRejected} +
+
Rejected
+
+
+
+ ${stats.totalFailed} +
+
Failed
+
+
+
+
+ `; +}; + +const createSwapTableRow = async (swap) => { + if (!swap || !swap.bid_id) { + console.warn('Invalid swap data:', swap); + return ''; + } + + const identity = await IdentityManager.getIdentityData(swap.addr_from); + const uniqueId = `${swap.bid_id}_${swap.created_at}`; + const fromSymbol = COIN_NAME_TO_SYMBOL[swap.coin_from] || swap.coin_from; + const toSymbol = COIN_NAME_TO_SYMBOL[swap.coin_to] || swap.coin_to; + const timeColor = getTimeStrokeColor(swap.expire_at); + const fromAmount = parseFloat(swap.amount_from) || 0; + const toAmount = parseFloat(swap.amount_to) || 0; + + return ` + + +
+ + + + +
+
+ + + + + + +
+ +
+ + + + +
+ + + +
+ + + +
+
+
+
${fromAmount.toFixed(8)}
+
${fromSymbol}
+
+
+
+ + + + +
+
+ + ${swap.coin_from} + + + + + + ${swap.coin_to} + +
+
+ + + + +
+
+
${toAmount.toFixed(8)}
+
${toSymbol}
+
+
+ + + + +
+ + ${swap.bid_state} + +
+ + + + + + Details + + + + + + + + + + + + + + + `; +}; + +async function updateSwapsTable(options = {}) { + const { resetPage = false, refreshData = true } = options; + + if (state.refreshPromise) { + await state.refreshPromise; + return; + } + + try { + updateLoadingState(true); + + if (refreshData) { + state.refreshPromise = (async () => { + try { + const response = await fetch('/json/active', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sort_by: "created_at", + sort_dir: "desc" + }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + state.swapsData = Array.isArray(data) ? data : []; + } catch (error) { + console.error('Error fetching swap data:', error); + state.swapsData = []; + } finally { + state.refreshPromise = null; + } + })(); + + await state.refreshPromise; + } + + if (elements.activeSwapsCount) { + elements.activeSwapsCount.textContent = state.swapsData.length; + } + + const totalPages = Math.ceil(state.swapsData.length / PAGE_SIZE); + + if (resetPage && state.swapsData.length > 0) { + state.currentPage = 1; + } + + state.currentPage = Math.min(Math.max(1, state.currentPage), Math.max(1, totalPages)); + + const startIndex = (state.currentPage - 1) * PAGE_SIZE; + const endIndex = startIndex + PAGE_SIZE; + const currentPageSwaps = state.swapsData.slice(startIndex, endIndex); + + if (elements.swapsBody) { + if (currentPageSwaps.length > 0) { + const rowPromises = currentPageSwaps.map(swap => createSwapTableRow(swap)); + const rows = await Promise.all(rowPromises); + elements.swapsBody.innerHTML = rows.join(''); + + // Initialize tooltips + if (window.TooltipManager) { + window.TooltipManager.cleanup(); + const tooltipTriggers = document.querySelectorAll('[data-tooltip-target]'); + tooltipTriggers.forEach(trigger => { + const targetId = trigger.getAttribute('data-tooltip-target'); + const tooltipContent = document.getElementById(targetId); + if (tooltipContent) { + window.TooltipManager.create(trigger, tooltipContent.innerHTML, { + placement: trigger.getAttribute('data-tooltip-placement') || 'top' + }); + } + }); + } + } else { + elements.swapsBody.innerHTML = ` + + + No active swaps found + + `; + } + } + + if (elements.paginationControls) { + elements.paginationControls.style.display = totalPages > 1 ? 'flex' : 'none'; + } + + if (elements.currentPageSpan) { + elements.currentPageSpan.textContent = state.currentPage; + } + + if (elements.prevPageButton) { + elements.prevPageButton.style.display = state.currentPage > 1 ? 'inline-flex' : 'none'; + } + + if (elements.nextPageButton) { + elements.nextPageButton.style.display = state.currentPage < totalPages ? 'inline-flex' : 'none'; + } + + } catch (error) { + console.error('Error updating swaps table:', error); + if (elements.swapsBody) { + elements.swapsBody.innerHTML = ` + + + Error loading active swaps. Please try again later. + + `; + } + } finally { + updateLoadingState(false); + } +} + +// Event +const setupEventListeners = () => { + if (elements.refreshSwapsButton) { + elements.refreshSwapsButton.addEventListener('click', async (e) => { + e.preventDefault(); + if (state.isRefreshing) return; + + updateLoadingState(true); + try { + await updateSwapsTable({ resetPage: true, refreshData: true }); + } finally { + updateLoadingState(false); + } + }); + } + + if (elements.prevPageButton) { + elements.prevPageButton.addEventListener('click', async (e) => { + e.preventDefault(); + if (state.isLoading) return; + if (state.currentPage > 1) { + state.currentPage--; + await updateSwapsTable({ resetPage: false, refreshData: false }); + } + }); + } + + if (elements.nextPageButton) { + elements.nextPageButton.addEventListener('click', async (e) => { + e.preventDefault(); + if (state.isLoading) return; + const totalPages = Math.ceil(state.swapsData.length / PAGE_SIZE); + if (state.currentPage < totalPages) { + state.currentPage++; + await updateSwapsTable({ resetPage: false, refreshData: false }); + } + }); + } +}; + +// Init +document.addEventListener('DOMContentLoaded', () => { + WebSocketManager.initialize(); + setupEventListeners(); +}); diff --git a/basicswap/static/js/bids_available.js b/basicswap/static/js/bids_available.js index b3b7e48..a183fd2 100644 --- a/basicswap/static/js/bids_available.js +++ b/basicswap/static/js/bids_available.js @@ -374,7 +374,7 @@ const WebSocketManager = { state.wsConnected = true; this.reconnectAttempts = 0; updateConnectionStatus('connected'); - console.log('🟢 WebSocket connection established'); + console.log('🟢 WebSocket connection established for Bid Requests'); updateBidsTable({ resetPage: true, refreshData: true }); }; diff --git a/basicswap/static/js/bids_export.js b/basicswap/static/js/bids_export.js new file mode 100644 index 0000000..823a1e4 --- /dev/null +++ b/basicswap/static/js/bids_export.js @@ -0,0 +1,141 @@ +const BidExporter = { + toCSV(bids, type) { + if (!bids || !bids.length) { + return 'No data to export'; + } + + const isSent = type === 'sent'; + + const headers = [ + 'Date/Time', + 'Bid ID', + 'Offer ID', + 'From Address', + isSent ? 'You Send Amount' : 'You Receive Amount', + isSent ? 'You Send Coin' : 'You Receive Coin', + isSent ? 'You Receive Amount' : 'You Send Amount', + isSent ? 'You Receive Coin' : 'You Send Coin', + 'Status', + 'Created At', + 'Expires At' + ]; + + let csvContent = headers.join(',') + '\n'; + + bids.forEach(bid => { + const row = [ + `"${formatTime(bid.created_at)}"`, + `"${bid.bid_id}"`, + `"${bid.offer_id}"`, + `"${bid.addr_from}"`, + isSent ? bid.amount_from : bid.amount_to, + `"${isSent ? bid.coin_from : bid.coin_to}"`, + isSent ? bid.amount_to : bid.amount_from, + `"${isSent ? bid.coin_to : bid.coin_from}"`, + `"${bid.bid_state}"`, + bid.created_at, + bid.expire_at + ]; + + csvContent += row.join(',') + '\n'; + }); + + return csvContent; + }, + + download(content, filename) { + try { + const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' }); + + if (window.navigator && window.navigator.msSaveOrOpenBlob) { + window.navigator.msSaveOrOpenBlob(blob, filename); + return; + } + + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = url; + link.download = filename; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + setTimeout(() => { + URL.revokeObjectURL(url); + }, 100); + } catch (error) { + console.error('Error downloading CSV:', error); + + const csvData = 'data:text/csv;charset=utf-8,' + encodeURIComponent(content); + const link = document.createElement('a'); + link.setAttribute('href', csvData); + link.setAttribute('download', filename); + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + }, + + exportCurrentView() { + const type = state.currentTab; + const data = state.data[type]; + + if (!data || !data.length) { + alert('No data to export'); + return; + } + + const csvContent = this.toCSV(data, type); + + const now = new Date(); + const dateStr = now.toISOString().split('T')[0]; + const filename = `bsx_${type}_bids_${dateStr}.csv`; + + this.download(csvContent, filename); + } +}; + +document.addEventListener('DOMContentLoaded', function() { + setTimeout(function() { + if (typeof state !== 'undefined' && typeof EventManager !== 'undefined') { + const exportSentButton = document.getElementById('exportSentBids'); + if (exportSentButton) { + EventManager.add(exportSentButton, 'click', (e) => { + e.preventDefault(); + state.currentTab = 'sent'; + BidExporter.exportCurrentView(); + }); + } + + const exportReceivedButton = document.getElementById('exportReceivedBids'); + if (exportReceivedButton) { + EventManager.add(exportReceivedButton, 'click', (e) => { + e.preventDefault(); + state.currentTab = 'received'; + BidExporter.exportCurrentView(); + }); + } + } + }, 500); +}); + +const originalCleanup = window.cleanup || function(){}; +window.cleanup = function() { + originalCleanup(); + + const exportSentButton = document.getElementById('exportSentBids'); + const exportReceivedButton = document.getElementById('exportReceivedBids'); + + if (exportSentButton && typeof EventManager !== 'undefined') { + EventManager.remove(exportSentButton, 'click'); + } + + if (exportReceivedButton && typeof EventManager !== 'undefined') { + EventManager.remove(exportReceivedButton, 'click'); + } +}; diff --git a/basicswap/static/js/bids_sentreceived.js b/basicswap/static/js/bids_sentreceived.js index 61e1522..c3321a9 100644 --- a/basicswap/static/js/bids_sentreceived.js +++ b/basicswap/static/js/bids_sentreceived.js @@ -93,6 +93,140 @@ const elements = { refreshReceivedBids: document.getElementById('refreshReceivedBids') }; +const EventManager = { + listeners: new Map(), + + add(element, type, handler, options = false) { + if (!element) return null; + + if (!this.listeners.has(element)) { + this.listeners.set(element, new Map()); + } + + const elementListeners = this.listeners.get(element); + if (!elementListeners.has(type)) { + elementListeners.set(type, new Set()); + } + + const handlerInfo = { handler, options }; + elementListeners.get(type).add(handlerInfo); + element.addEventListener(type, handler, options); + + return handlerInfo; + }, + + remove(element, type, handler, options = false) { + if (!element) return; + + const elementListeners = this.listeners.get(element); + if (!elementListeners) return; + + const typeListeners = elementListeners.get(type); + if (!typeListeners) return; + + typeListeners.forEach(info => { + if (info.handler === handler) { + element.removeEventListener(type, handler, options); + typeListeners.delete(info); + } + }); + + if (typeListeners.size === 0) { + elementListeners.delete(type); + } + if (elementListeners.size === 0) { + this.listeners.delete(element); + } + }, + + removeAll(element) { + if (!element) return; + + const elementListeners = this.listeners.get(element); + if (!elementListeners) return; + + elementListeners.forEach((typeListeners, type) => { + typeListeners.forEach(info => { + try { + element.removeEventListener(type, info.handler, info.options); + } catch (e) { + console.warn('Error removing event listener:', e); + } + }); + }); + + this.listeners.delete(element); + }, + + clearAll() { + this.listeners.forEach((elementListeners, element) => { + this.removeAll(element); + }); + this.listeners.clear(); + } +}; + +function cleanup() { + console.log('Starting cleanup process'); + EventManager.clearAll(); + + const exportSentButton = document.getElementById('exportSentBids'); + const exportReceivedButton = document.getElementById('exportReceivedBids'); + + if (exportSentButton) { + exportSentButton.remove(); + } + + if (exportReceivedButton) { + exportReceivedButton.remove(); + } + + if (window.TooltipManager) { + const originalCleanup = window.TooltipManager.cleanup; + window.TooltipManager.cleanup = function() { + originalCleanup.call(window.TooltipManager); + + setTimeout(() => { + forceTooltipDOMCleanup(); + + const detachedTooltips = document.querySelectorAll('[id^="tooltip-"]'); + detachedTooltips.forEach(tooltip => { + const tooltipId = tooltip.id; + const trigger = document.querySelector(`[data-tooltip-target="${tooltipId}"]`); + if (!trigger || !document.body.contains(trigger)) { + tooltip.remove(); + } + }); + }, 10); + }; +} + + WebSocketManager.cleanup(); + if (searchTimeout) { + clearTimeout(searchTimeout); + searchTimeout = null; + } + state.data = { + sent: [], + received: [] + }; + IdentityManager.clearCache(); + Object.keys(elements).forEach(key => { + elements[key] = null; + }); + + console.log('Cleanup completed'); +} + +document.addEventListener('beforeunload', cleanup); +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + WebSocketManager.pause(); + } else { + WebSocketManager.resume(); + } +}); + // WebSocket Management const WebSocketManager = { ws: null, @@ -101,7 +235,10 @@ const WebSocketManager = { maxReconnectAttempts: 5, reconnectAttempts: 0, reconnectDelay: 5000, - + healthCheckInterval: null, + isPaused: false, + lastMessageTime: Date.now(), + initialize() { this.connect(); this.startHealthCheck(); @@ -112,7 +249,11 @@ const WebSocketManager = { }, connect() { - if (this.isConnected()) return; + if (this.isConnected() || this.isPaused) return; + + if (this.ws) { + this.cleanupConnection(); + } try { const wsPort = window.ws_port || '11700'; @@ -125,15 +266,21 @@ const WebSocketManager = { }, setupEventHandlers() { + if (!this.ws) return; + this.ws.onopen = () => { state.wsConnected = true; this.reconnectAttempts = 0; + this.lastMessageTime = Date.now(); updateConnectionStatus('connected'); - console.log('🟢 WebSocket connection established'); + console.log('🟢 WebSocket connection established for Sent Bids / Received Bids'); updateBidsTable(); }; this.ws.onmessage = () => { + this.lastMessageTime = Date.now(); + if (this.isPaused) return; + if (!this.processingQueue) { this.processingQueue = true; setTimeout(async () => { @@ -151,7 +298,9 @@ const WebSocketManager = { this.ws.onclose = () => { state.wsConnected = false; updateConnectionStatus('disconnected'); - this.handleReconnect(); + if (!this.isPaused) { + this.handleReconnect(); + } }; this.ws.onerror = () => { @@ -160,29 +309,100 @@ const WebSocketManager = { }, startHealthCheck() { - setInterval(() => { + this.stopHealthCheck(); + + this.healthCheckInterval = setInterval(() => { + if (this.isPaused) return; + + const timeSinceLastMessage = Date.now() - this.lastMessageTime; + if (timeSinceLastMessage > 120000) { + console.log('WebSocket connection appears stale. Reconnecting...'); + this.cleanupConnection(); + this.connect(); + return; + } + if (!this.isConnected()) { this.handleReconnect(); } }, 30000); }, + stopHealthCheck() { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; + } + }, + handleReconnect() { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; } + if (this.isPaused) return; + this.reconnectAttempts++; if (this.reconnectAttempts <= this.maxReconnectAttempts) { const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1); + //console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); this.reconnectTimeout = setTimeout(() => this.connect(), delay); } else { updateConnectionStatus('error'); + //console.log('Maximum reconnection attempts reached. Will try again in 60 seconds.'); setTimeout(() => { this.reconnectAttempts = 0; this.connect(); }, 60000); } + }, + + cleanupConnection() { + if (this.ws) { + this.ws.onopen = null; + this.ws.onmessage = null; + this.ws.onclose = null; + this.ws.onerror = null; + if (this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.close(1000, 'Cleanup'); + } catch (e) { + console.warn('Error closing WebSocket:', e); + } + } + this.ws = null; + } + }, + + pause() { + this.isPaused = true; + //console.log('WebSocket operations paused'); + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + }, + + resume() { + if (!this.isPaused) return; + this.isPaused = false; + //console.log('WebSocket operations resumed'); + this.lastMessageTime = Date.now(); + if (!this.isConnected()) { + this.reconnectAttempts = 0; + this.connect(); + } + }, + + cleanup() { + this.isPaused = true; + this.stopHealthCheck(); + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + this.cleanupConnection(); } }; @@ -226,13 +446,37 @@ const getStatusClass = (status) => { case 'Completed': return 'bg-green-300 text-black dark:bg-green-600 dark:text-white'; case 'Expired': + case 'Timed-out': return 'bg-gray-200 text-black dark:bg-gray-400 dark:text-white'; case 'Error': - return 'bg-red-300 text-black dark:bg-red-600 dark:text-white'; case 'Failed': return 'bg-red-300 text-black dark:bg-red-600 dark:text-white'; + case 'Failed, swiped': case 'Failed, refunded': return 'bg-gray-200 text-black dark:bg-gray-400 dark:text-red-500'; + case 'InProgress': + case 'Script coin locked': + case 'Scriptless coin locked': + case 'Script coin lock released': + case 'SendingInitialTx': + case 'SendingPaymentTx': + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + case 'Received': + case 'Exchanged script lock tx sigs msg': + case 'Exchanged script lock spend tx msg': + case 'Script tx redeemed': + case 'Scriptless tx redeemed': + case 'Scriptless tx recovered': + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + case 'Accepted': + case 'Request accepted': + return 'bg-green-300 text-black dark:bg-green-600 dark:text-white'; + case 'Delaying': + case 'Auto accept delay': + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + case 'Abandoned': + case 'Rejected': + return 'bg-red-300 text-black dark:bg-red-600 dark:text-white'; default: return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; } @@ -289,7 +533,6 @@ function hasActiveFilters() { function filterAndSortData(bids) { if (!Array.isArray(bids)) { - console.log('Invalid bids data:', bids); return []; } @@ -313,7 +556,7 @@ function filterAndSortData(bids) { const coinName = selectedOption?.textContent.trim(); if (coinName) { - const coinToMatch = state.currentTab === 'sent' ? bid.coin_from : bid.coin_to; + const coinToMatch = state.currentTab === 'sent' ? bid.coin_to : bid.coin_from; if (!coinMatches(coinToMatch, coinName)) { return false; } @@ -326,7 +569,7 @@ function filterAndSortData(bids) { const coinName = selectedOption?.textContent.trim(); if (coinName) { - const coinToMatch = state.currentTab === 'sent' ? bid.coin_to : bid.coin_from; + const coinToMatch = state.currentTab === 'sent' ? bid.coin_from : bid.coin_to; if (!coinMatches(coinToMatch, coinName)) { return false; } @@ -457,6 +700,7 @@ const IdentityManager = { retryDelay: 2000, maxRetries: 3, cacheTimeout: 5 * 60 * 1000, + maxCacheSize: 500, async getIdentityData(address) { if (!address) return { address: '' }; @@ -465,8 +709,12 @@ const IdentityManager = { if (cachedData) return { ...cachedData, address }; if (this.pendingRequests.has(address)) { - const pendingData = await this.pendingRequests.get(address); - return { ...pendingData, address }; + try { + const pendingData = await this.pendingRequests.get(address); + return { ...pendingData, address }; + } catch (error) { + this.pendingRequests.delete(address); + } } const request = this.fetchWithRetry(address); @@ -474,10 +722,14 @@ const IdentityManager = { try { const data = await request; + + this.trimCacheIfNeeded(); + this.cache.set(address, { data, timestamp: Date.now() }); + return { ...data, address }; } catch (error) { console.warn(`Error fetching identity for ${address}:`, error); @@ -490,6 +742,7 @@ const IdentityManager = { getCachedIdentity(address) { const cached = this.cache.get(address); if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) { + cached.timestamp = Date.now(); return cached.data; } if (cached) { @@ -498,9 +751,36 @@ const IdentityManager = { return null; }, + trimCacheIfNeeded() { + if (this.cache.size > this.maxCacheSize) { + + const entries = Array.from(this.cache.entries()); + const sortedByAge = entries.sort((a, b) => a[1].timestamp - b[1].timestamp); + + const toRemove = Math.ceil(this.maxCacheSize * 0.2); + for (let i = 0; i < toRemove && i < sortedByAge.length; i++) { + this.cache.delete(sortedByAge[i][0]); + } + console.log(`Trimmed identity cache: removed ${toRemove} oldest entries`); + } + }, + + clearCache() { + this.cache.clear(); + this.pendingRequests.clear(); + }, + async fetchWithRetry(address, attempt = 1) { try { - const response = await fetch(`/json/identities/${address}`); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(`/json/identities/${address}`, { + signal: controller.signal + }); + + clearTimeout(timeoutId); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return await response.json(); } catch (error) { @@ -615,9 +895,132 @@ const createIdentityTooltipContent = (identity) => { }; // Table +let tooltipIdsToCleanup = new Set(); + +const cleanupTooltips = () => { + if (window.TooltipManager) { + Array.from(tooltipIdsToCleanup).forEach(id => { + const element = document.getElementById(id); + if (element) { + element.remove(); + } + }); + tooltipIdsToCleanup.clear(); + } + forceTooltipDOMCleanup(); +}; + +const forceTooltipDOMCleanup = () => { + let foundCount = 0; + let removedCount = 0; + const allTooltipElements = document.querySelectorAll('[role="tooltip"], [id^="tooltip-"], .tippy-box, [data-tippy-root]'); + foundCount += allTooltipElements.length; + + allTooltipElements.forEach(element => { + + const isDetached = !document.body.contains(element) || + element.classList.contains('hidden') || + element.style.display === 'none'; + + if (element.id && element.id.startsWith('tooltip-')) { + const triggerId = element.id; + const triggerElement = document.querySelector(`[data-tooltip-target="${triggerId}"]`); + + if (!triggerElement || + !document.body.contains(triggerElement) || + triggerElement.classList.contains('hidden')) { + element.remove(); + removedCount++; + return; + } + } + + if (isDetached) { + try { + element.remove(); + removedCount++; + } catch (e) { + console.warn('Error removing detached tooltip:', e); + } + } + }); + + const tippyRoots = document.querySelectorAll('[data-tippy-root]'); + foundCount += tippyRoots.length; + + tippyRoots.forEach(element => { + const isOrphan = !element.children.length || + element.children[0].classList.contains('hidden') || + !document.body.contains(element); + + if (isOrphan) { + try { + element.remove(); + removedCount++; + } catch (e) { + console.warn('Error removing tippy root:', e); + } + } + }); + + const tippyBoxes = document.querySelectorAll('.tippy-box'); + foundCount += tippyBoxes.length; + tippyBoxes.forEach(element => { + if (!element.parentElement || !document.body.contains(element.parentElement)) { + try { + element.remove(); + removedCount++; + } catch (e) { + console.warn('Error removing tippy box:', e); + } + } + }); + + // Handle legacy tooltip elements + document.querySelectorAll('.tooltip').forEach(element => { + const isTrulyDetached = !element.parentElement || + !document.body.contains(element.parentElement) || + element.classList.contains('hidden'); + + if (isTrulyDetached) { + try { + element.remove(); + removedCount++; + } catch (e) { + console.warn('Error removing legacy tooltip:', e); + } + } + }); + + if (window.TooltipManager && window.TooltipManager.activeTooltips) { + window.TooltipManager.activeTooltips.forEach((instance, id) => { + const tooltipElement = document.getElementById(id.split('tooltip-trigger-')[1]); + const triggerElement = document.querySelector(`[data-tooltip-trigger-id="${id}"]`); + + if (!tooltipElement || !triggerElement || + !document.body.contains(tooltipElement) || + !document.body.contains(triggerElement)) { + if (instance?.[0]) { + try { + instance[0].destroy(); + } catch (e) { + console.warn('Error destroying tooltip instance:', e); + } + } + window.TooltipManager.activeTooltips.delete(id); + } + }); + } + if (removedCount > 0) { + // console.log(`Tooltip cleanup: found ${foundCount}, removed ${removedCount} detached tooltips`); + } +}; + const createTableRow = async (bid) => { const identity = await IdentityManager.getIdentityData(bid.addr_from); const uniqueId = `${bid.bid_id}_${Date.now()}`; + tooltipIdsToCleanup.add(`tooltip-identity-${uniqueId}`); + tooltipIdsToCleanup.add(`tooltip-status-${uniqueId}`); const timeColor = getTimeStrokeColor(bid.expire_at); return ` @@ -641,8 +1044,8 @@ const createTableRow = async (bid) => {
- - -
- - ${bid.bid_state} - -
+ + +
+ + ${bid.bid_state} + +
- + - + +