diff --git a/features/utxo-list-progressive-loading.md b/features/utxo-list-progressive-loading.md new file mode 100644 index 0000000..7bbd3f5 --- /dev/null +++ b/features/utxo-list-progressive-loading.md @@ -0,0 +1,60 @@ +# Chargement progressif de la liste UTXO depuis le fichier + +**Auteur** : Équipe 4NK +**Date** : 2026-01-26 +**Version** : 1.0 + +## Objectif + +Raccourcir le chargement de la page [Liste des UTXO](https://dashboard.certificator.4nkweb.com/utxo-list) en utilisant directement le fichier `utxo_list.txt` au lieu de `/api/utxo/list`, et afficher une barre de progression pendant le chargement. + +## Problème initial + +- La page UTXO-list est très longue à charger. +- Elle appelait `/api/utxo/list`, qui utilise `getUtxoList()` (RPC + fichier) et peut être lent ou bloquer le serveur. +- Le fichier `utxo_list.txt` peut être en retard par rapport au nœud, mais il est servi rapidement. +- Aucun retour visuel pendant le chargement. + +## Impacts + +- **Page UTXO-list** : Charge désormais depuis `/api/utxo/list.txt` (fichier uniquement). +- **Serveur** : `/api/utxo/list.txt` envoie `Content-Length` et `Last-Modified` pour la progression et l’affichage de la date de mise à jour. +- **Utilisateurs** : Barre de progression pendant le chargement ; indication que la source est le fichier (potentiellement pas à jour). + +## Modifications + +### Serveur (`signet-dashboard/src/server.js`) + +- Route `/api/utxo/list.txt` : ajout de `Content-Length` et `Last-Modified` (via `statSync`). + +### Page UTXO-list (`signet-dashboard/public/utxo-list.html`) + +- **Source des données** : `loadUtxoList()` lit `/api/utxo/list.txt` au lieu de `/api/utxo/list`. +- **Streaming** : utilisation de `response.body.getReader()` + `TextDecoder` pour traiter le flux par morceaux. +- **Parsing** : `parseUtxoLine()` parse le format fichier (`category;txid;vout;amount;confirmations;isAnchorChange;blockTime`) et produit des objets UTXO compatibles avec `renderTable` / `renderFeesTable`. +- **Barre de progression** : + - Section dédiée avec barre (pourcentage) et texte « X ligne(s) parsée(s) ». + - Progression = `bytes reçus / Content-Length` ; si pas de `Content-Length`, 0 % puis 100 % à la fin. +- **Source fichier** : mention « Source : fichier utxo_list.txt (peut ne pas être à jour) » et, si disponible, « Dernière modif. : … » (depuis `Last-Modified`). +- **Statut** : pour les UTXO chargés depuis le fichier (`fromFile: true`), la colonne Statut affiche « — » (pas d’info dépensé/verrouillé disponible). + +### Compatibilité + +- Format 7 champs (nouveau) et 6 champs (ancien avec `address`) gérés par `parseUtxoLine`. +- `actualiser` recharge depuis le fichier (même flux que le chargement initial). + +## Modalités de déploiement + +1. Redémarrer le dashboard : `sudo systemctl restart signet-dashboard.service`. +2. Vider le cache navigateur si nécessaire puis recharger `/utxo-list`. + +## Modalités d’analyse + +- Ouvrir les DevTools (Network) : vérifier que la requête va vers `/api/utxo/list.txt` et que les headers `Content-Length` et `Last-Modified` sont présents. +- Vérifier que la barre de progression s’affiche puis disparaît une fois le chargement terminé. +- Vérifier que les quatre tableaux (Bloc Rewards, Ancrages, Changes, Frais) et la pagination s’affichent correctement. + +## Pages affectées + +- `signet-dashboard/src/server.js` : route `/api/utxo/list.txt` +- `signet-dashboard/public/utxo-list.html` : `loadUtxoList`, `parseUtxoLine`, rendu progression, statut `fromFile` diff --git a/signet-dashboard/public/utxo-list.html b/signet-dashboard/public/utxo-list.html index 148ee1d..1180c6d 100644 --- a/signet-dashboard/public/utxo-list.html +++ b/signet-dashboard/public/utxo-list.html @@ -123,6 +123,42 @@ font-size: 1.2em; color: #666; } + .progress-section { + padding: 30px; + text-align: center; + background: #f8f9fa; + border-radius: 8px; + margin: 20px 0; + } + .progress-section p { + margin: 10px 0; + color: #333; + } + .progress-bar-container { + width: 100%; + max-width: 400px; + height: 24px; + background: #e9ecef; + border-radius: 12px; + overflow: hidden; + margin: 15px auto; + } + .progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, #28a745, #20c997); + transition: width 0.15s ease; + } + .progress-stats { + font-size: 0.9em; + color: #666; + margin-top: 10px; + } + .source-note { + font-size: 0.9em; + color: #666; + margin-top: 10px; + font-style: italic; + } .error { background: #f8d7da; color: #721c24; @@ -263,7 +299,7 @@

Liste des UTXO

Total d'UTXO : -

-

Capacité d'ancrage restante : - ancrages (- UTXOs confirmés)

+

Capacité d'ancrage restante : - ancrages

Montant total : -

Dernière mise à jour : -

@@ -282,6 +318,15 @@
Chargement des UTXO...
+
@@ -291,6 +336,10 @@ // État de tri et pagination pour chaque catégorie const tableState = {}; + /** Données UTXO chargées (blocRewards, anchors, changes, fees) */ + let currentUtxosData = {}; + /** true si les données viennent du fichier (pas de Statut connu) */ + let utxosFromFile = false; // Charger la liste au chargement de la page document.addEventListener('DOMContentLoaded', () => { @@ -316,9 +365,10 @@ if (sortColumn === 'amount') { aVal = a.amount; bVal = b.amount; - } else if (sortColumn === 'confirmations') { - aVal = a.confirmations || 0; - bVal = b.confirmations || 0; + } else if (sortColumn === 'confirmed') { + // Trier par confirmé/non confirmé (booléen) + aVal = (a.confirmations || 0) > 0 ? 1 : 0; + bVal = (b.confirmations || 0) > 0 ? 1 : 0; } else { return 0; } @@ -401,21 +451,21 @@ Transaction ID Vout - Adresse `; if (isAnchors) { tableHTML += ` Numéro de bloc Montant (✅)${getSortArrow('amount', state.sortColumn, state.sortDirection)} - Confirmations${getSortArrow('confirmations', state.sortColumn, state.sortDirection)} + Date + Confirmé${getSortArrow('confirmed', state.sortColumn, state.sortDirection)} Statut `; } else if (isBlocRewards) { tableHTML += ` Montant (🛡)${getSortArrow('amount', state.sortColumn, state.sortDirection)} Date - Confirmations${getSortArrow('confirmations', state.sortColumn, state.sortDirection)} + Confirmé${getSortArrow('confirmed', state.sortColumn, state.sortDirection)} Statut `; } else { @@ -424,7 +474,8 @@ tableHTML += ` Montant (🛡)${getSortArrow('amount', state.sortColumn, state.sortDirection)} Montant (✅)${getSortArrow('amount', state.sortColumn, state.sortDirection)} - Confirmations${getSortArrow('confirmations', state.sortColumn, state.sortDirection)} + Date + Confirmé${getSortArrow('confirmed', state.sortColumn, state.sortDirection)} Type Statut `; @@ -432,7 +483,8 @@ tableHTML += ` Montant (🛡)${getSortArrow('amount', state.sortColumn, state.sortDirection)} Montant (✅)${getSortArrow('amount', state.sortColumn, state.sortDirection)} - Confirmations${getSortArrow('confirmations', state.sortColumn, state.sortDirection)} + Date + Confirmé${getSortArrow('confirmed', state.sortColumn, state.sortDirection)} Statut `; } @@ -453,11 +505,17 @@ tableHTML += ''; tableHTML += `${utxo.txid}`; tableHTML += `${utxo.vout}`; - tableHTML += `${utxo.address || '-'}`; if (isAnchors) { tableHTML += `${utxo.blockHeight !== null && utxo.blockHeight !== undefined ? utxo.blockHeight.toLocaleString('fr-FR') : '-'}`; tableHTML += `${amountSats.toLocaleString('fr-FR')} ✅`; + // Date pour les ancrages + if (utxo.blockTime) { + const date = new Date(utxo.blockTime * 1000); + tableHTML += `${date.toLocaleString('fr-FR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}`; + } else { + tableHTML += '-'; + } } else if (isBlocRewards) { tableHTML += `${amountBTC.toLocaleString('fr-FR')} 🛡`; if (utxo.blockTime) { @@ -469,9 +527,18 @@ } else { tableHTML += `${amountBTC.toLocaleString('fr-FR')} 🛡`; tableHTML += `${amountSats.toLocaleString('fr-FR')} ✅`; + // Date pour les changes + if (utxo.blockTime) { + const date = new Date(utxo.blockTime * 1000); + tableHTML += `${date.toLocaleString('fr-FR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}`; + } else { + tableHTML += '-'; + } } - tableHTML += `${utxo.confirmations.toLocaleString('fr-FR')}`; + // Colonne Confirmé : coche si confirmé, sinon vide + const isConfirmed = (utxo.confirmations || 0) > 0; + tableHTML += `${isConfirmed ? '✓' : ''}`; // Colonne Type (uniquement pour les changes) if (categoryName === 'changes') { @@ -479,10 +546,13 @@ tableHTML += `${changeType}`; } - // Colonne Statut + // Colonne Statut (— si source fichier, pas de RPC) let statusText = ''; let statusClass = ''; - if (utxo.isSpentOnchain) { + if (utxo.fromFile) { + statusText = '—'; + statusClass = ''; + } else if (utxo.isSpentOnchain) { statusText = 'Dépensé onchain'; statusClass = 'status-spent'; } else if (utxo.isLockedInMutex) { @@ -533,10 +603,10 @@ const data = await response.json(); const count = data.count || 0; - const totalSats = data.totalSats || 0; if (count > 0) { - button.textContent = `Consolider la capacité d'ancrage résiduelle (${count.toLocaleString('fr-FR')} UTXOs, ${totalSats.toLocaleString('fr-FR')} ✅)`; + const anchorCount = Math.max(0, count - 1); + button.textContent = `Consolider la capacité d'ancrage résiduelle - ${anchorCount.toLocaleString('fr-FR')} ancrages)`; button.disabled = false; } else { button.textContent = 'Aucun UTXO à consolider'; @@ -552,46 +622,112 @@ async function loadUtxoList() { const contentDiv = document.getElementById('content'); const refreshButton = document.querySelector('.refresh-button'); + const progressSection = document.getElementById('progress-section'); + const progressBarFill = document.getElementById('progress-bar-fill'); + const progressPercent = document.getElementById('progress-percent'); + const progressStats = document.getElementById('progress-stats'); refreshButton.disabled = true; - contentDiv.innerHTML = '
Chargement des UTXO...
'; + contentDiv.innerHTML = ''; + utxosFromFile = true; + progressSection.style.display = 'block'; + progressBarFill.style.width = '0%'; + progressPercent.textContent = '0 %'; + progressStats.textContent = '0 ligne(s) parsée(s)'; - // Charger les infos des petits UTXOs en parallèle loadSmallUtxosInfo(); try { - const response = await fetch(`${API_BASE_URL}/api/utxo/list`); + const response = await fetch(`${API_BASE_URL}/api/utxo/list.txt`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } - const data = await response.json(); - const counts = data.counts || {}; - const blocRewards = data.blocRewards || []; - const anchors = data.anchors || []; - const changes = data.changes || []; - const fees = data.fees || []; + const contentLength = response.headers.get('Content-Length'); + const totalBytes = contentLength ? parseInt(contentLength, 10) : 0; + const lastModified = response.headers.get('Last-Modified'); + if (lastModified && document.getElementById('source-note')) { + document.getElementById('source-note').textContent = + 'Source : fichier utxo_list.txt (peut ne pas être à jour). Dernière modif. : ' + new Date(lastModified).toLocaleString('fr-FR'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let received = 0; + let buffer = ''; + let lineCount = 0; + const blocRewards = []; + const anchors = []; + const changes = []; + const fees = []; + const minAnchorAmount = 2000 / 100000000; + + for (;;) { + const { done, value } = await reader.read(); + if (value && value.length) { + received += value.length; + buffer += decoder.decode(value, { stream: !done }); + } + + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const utxo = parseUtxoLine(trimmed); + if (!utxo) continue; + + lineCount++; + if (utxo.category === 'bloc_rewards') blocRewards.push(utxo); + else if (utxo.category === 'anchor') anchors.push(utxo); + else if (utxo.category === 'change') changes.push(utxo); + else if (utxo.category === 'fee') fees.push(utxo); + } + + const pct = totalBytes > 0 ? Math.min(100, Math.round((received / totalBytes) * 100)) : (done ? 100 : 0); + progressBarFill.style.width = pct + '%'; + progressPercent.textContent = pct + ' %'; + progressStats.textContent = lineCount.toLocaleString('fr-FR') + ' ligne(s) parsée(s)'; + + if (done) break; + } + + if (buffer.trim()) { + const utxo = parseUtxoLine(buffer.trim()); + if (utxo) { + lineCount++; + if (utxo.category === 'bloc_rewards') blocRewards.push(utxo); + else if (utxo.category === 'anchor') anchors.push(utxo); + else if (utxo.category === 'change') changes.push(utxo); + else if (utxo.category === 'fee') fees.push(utxo); + } + } + + progressBarFill.style.width = '100%'; + progressPercent.textContent = '100 %'; + progressStats.textContent = lineCount.toLocaleString('fr-FR') + ' ligne(s) parsée(s)'; + + const total = blocRewards.length + anchors.length + changes.length + fees.length; + const availableForAnchor = anchors.filter(u => + u.amount >= minAnchorAmount && (u.confirmations || 0) > 0 + ).length; + const totalAmount = blocRewards.reduce((s, u) => s + u.amount, 0) + + anchors.reduce((s, u) => s + u.amount, 0) + + changes.reduce((s, u) => s + u.amount, 0); - // Sauvegarder les données pour la pagination currentUtxosData = { blocRewards, anchors, changes, fees }; - document.getElementById('utxo-count').textContent = counts.total.toLocaleString('fr-FR'); - document.getElementById('available-for-anchor').textContent = (counts.availableForAnchor || 0).toLocaleString('fr-FR'); - document.getElementById('confirmed-available-for-anchor').textContent = (counts.confirmedAvailableForAnchor || 0).toLocaleString('fr-FR'); - - // Calculer le montant total - const totalAmount = blocRewards.reduce((sum, utxo) => sum + utxo.amount, 0) + - anchors.reduce((sum, utxo) => sum + utxo.amount, 0) + - changes.reduce((sum, utxo) => sum + utxo.amount, 0); + document.getElementById('utxo-count').textContent = total.toLocaleString('fr-FR'); + document.getElementById('available-for-anchor').textContent = availableForAnchor.toLocaleString('fr-FR'); document.getElementById('total-amount').textContent = formatBTC(totalAmount); - updateLastUpdateTime(); - // Afficher la barre de navigation + progressSection.style.display = 'none'; document.getElementById('navigation').style.display = 'flex'; - // Afficher les 4 listes let html = ''; html += renderTable(blocRewards, 'bloc-rewards', '💰 Bloc Rewards (Récompenses de minage)'); html += renderTable(anchors, 'ancrages', '🔗 Ancrages (Transactions d\'ancrage)'); @@ -601,14 +737,47 @@ contentDiv.innerHTML = html; } catch (error) { console.error('Error loading UTXO list:', error); + progressSection.style.display = 'none'; contentDiv.innerHTML = `
Erreur lors du chargement de la liste des UTXO : ${error.message}
`; } finally { refreshButton.disabled = false; - // Recharger les infos des petits UTXOs après le chargement de la liste loadSmallUtxosInfo(); } } + function parseUtxoLine(line) { + const parts = line.split(';'); + if (parts.length < 6) return null; + + let category, txid, vout, amount, confirmations, isAnchorChange, blockTimeRaw; + if (parts.length >= 7 && !isNaN(parseFloat(parts[3]))) { + [category, txid, vout, amount, confirmations, isAnchorChange, blockTimeRaw] = parts; + } else { + [category, txid, vout, , amount, confirmations] = parts; + isAnchorChange = parts.length > 6 ? parts[6] === 'true' : false; + blockTimeRaw = parts.length > 7 ? parts[7] : null; + } + + const c = (category || '').trim(); + if (c !== 'bloc_rewards' && c !== 'anchor' && c !== 'change' && c !== 'fee') return null; + + const blockTime = (blockTimeRaw && blockTimeRaw.trim()) ? (parseInt(blockTimeRaw, 10) || null) : null; + + return { + txid: (txid || '').trim(), + vout: parseInt(vout, 10) || 0, + amount: parseFloat(amount) || 0, + confirmations: parseInt(confirmations, 10) || 0, + blockHeight: null, + blockTime, + isAnchorChange: isAnchorChange === 'true' || isAnchorChange === true, + category: c, + isSpentOnchain: false, + isLockedInMutex: false, + fromFile: true, + }; + } + function formatBTC(btc) { if (btc === 0) return '0 🛡'; // Arrondir sans décimales @@ -664,11 +833,10 @@ Transaction ID Frais (BTC) Frais (✅) - Change Address Change Amount Bloc Date - Confirmations + Confirmé @@ -683,7 +851,6 @@ tableHTML += `${fee.txid}`; tableHTML += `${fee.fee.toFixed(8)}`; tableHTML += `${feeSats.toLocaleString('fr-FR')} ✅`; - tableHTML += `${fee.changeAddress || '-'}`; tableHTML += `${fee.changeAmount ? formatBTC(fee.changeAmount) : '-'}`; tableHTML += `${fee.blockHeight !== null && fee.blockHeight !== undefined ? fee.blockHeight.toLocaleString('fr-FR') : '-'}`; if (fee.blockTime) { @@ -692,7 +859,9 @@ } else { tableHTML += '-'; } - tableHTML += `${fee.confirmations.toLocaleString('fr-FR')}`; + // Colonne Confirmé : coche si confirmé, sinon vide + const isConfirmed = (fee.confirmations || 0) > 0; + tableHTML += `${isConfirmed ? '✓' : ''}`; tableHTML += ''; }); diff --git a/signet-dashboard/src/server.js b/signet-dashboard/src/server.js index 842ad59..81ae1ab 100644 --- a/signet-dashboard/src/server.js +++ b/signet-dashboard/src/server.js @@ -393,15 +393,20 @@ app.post('/api/utxo/consolidate', async (req, res) => { } }); -// Route pour servir le fichier texte des UTXO +// Route pour servir le fichier texte des UTXO (utilisé pour chargement progressif) app.get('/api/utxo/list.txt', async (req, res) => { try { - const { readFileSync, existsSync } = await import('fs'); + const { readFileSync, existsSync, statSync } = await import('fs'); const utxoListPath = join(__dirname, '../../utxo_list.txt'); if (existsSync(utxoListPath)) { const content = readFileSync(utxoListPath, 'utf8'); + const stat = statSync(utxoListPath); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.setHeader('Content-Length', Buffer.byteLength(content, 'utf8')); + if (stat.mtime) { + res.setHeader('Last-Modified', stat.mtime.toUTCString()); + } res.send(content); } else { res.status(404).send('UTXO list file not found');