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 ` +
+
+
+
+
+
+ Time Indicator Colors:
++ + Green: More than 30 minutes left +
++ + Blue: Between 5 and 30 minutes left +
++ + Grey: Less than 5 minutes left or expired +
+Transaction Status
+ITX:
+${swap.tx_state_a || 'N/A'}
+PTX:
+${swap.tx_state_b || 'N/A'}
+Home
- -Your swaps that are currently in progress.
-Monitor your currently active swap transactions.
|
-
- Bid ID
-
- |
-
-
- Offer ID
-
- |
-
-
- Bid Status
-
- |
-
-
- ITX Status
-
- |
-
-
- PTX Status
-
- |
-
|---|---|---|---|---|
| - {{ s[0]|truncate(50,true,'...',0) }} - | -- {{ s[1]|truncate(50,true,'...',0) }} - | -{{ s[2] }} | -{{ s[3] }} | -{{ s[4] }} | -
|
+
+
-
+ |
+
+
+ Time
+
+ |
+
+
+
+ You Send
+
+ |
+
+
+ Swap
+
+ |
+
+
+ You Receive
+
+ |
+
+
+ Status
+
+ |
+
+
+ Actions
+
+ |
+
|---|
Active Swaps: 0
+ {% if debug_ui_mode == true %} + + {% endif %} +