Ajaxify the wallet transaction list to avoid timeout (Fix #4987) (#5100)

* Ajaxify the wallet transaction list to avoid timeout (Fix #4987)

* Add cancellation to request to wallet transactions

* Fix tests

* Improve empty state

* Cleanups

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
This commit is contained in:
Nicolas Dorier
2023-06-22 16:09:53 +09:00
committed by GitHub
parent 13203c3e2b
commit 2eff45e65c
8 changed files with 126 additions and 130 deletions

View File

@@ -122,6 +122,13 @@ retry:
driver.ExecuteJavaScript($"document.getElementById('{element}').{funcName}()"); driver.ExecuteJavaScript($"document.getElementById('{element}').{funcName}()");
} }
public static void WaitWalletTransactionsLoaded(this IWebDriver driver)
{
var wait = new WebDriverWait(driver, SeleniumTester.ImplicitWait);
wait.UntilJsIsReady();
wait.Until(d => d.WaitForElement(By.CssSelector("#WalletTransactions[data-loaded='true']")));
}
public static IWebElement WaitForElement(this IWebDriver driver, By selector) public static IWebElement WaitForElement(this IWebDriver driver, By selector)
{ {
var wait = new WebDriverWait(driver, SeleniumTester.ImplicitWait); var wait = new WebDriverWait(driver, SeleniumTester.ImplicitWait);

View File

@@ -1441,7 +1441,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("CancelWizard")).Click(); s.Driver.FindElement(By.Id("CancelWizard")).Click();
// Check the label is applied to the tx // Check the label is applied to the tx
s.Driver.WaitWalletTransactionsLoaded();
Assert.Equal("label2", s.Driver.FindElement(By.XPath("//*[@id=\"WalletTransactionsList\"]//*[contains(@class, 'transaction-label')]")).Text); Assert.Equal("label2", s.Driver.FindElement(By.XPath("//*[@id=\"WalletTransactionsList\"]//*[contains(@class, 'transaction-label')]")).Text);
//change the wallet and ensure old address is not there and generating a new one does not result in the prev one //change the wallet and ensure old address is not there and generating a new one does not result in the prev one
@@ -1492,7 +1492,9 @@ namespace BTCPayServer.Tests
// Check the tx sent earlier arrived // Check the tx sent earlier arrived
s.Driver.FindElement(By.Id($"StoreNav-Wallet{cryptoCode}")).Click(); s.Driver.FindElement(By.Id($"StoreNav-Wallet{cryptoCode}")).Click();
Assert.Contains(tx.ToString(), s.Driver.PageSource); s.Driver.WaitWalletTransactionsLoaded();
s.Driver.FindElement(By.PartialLinkText(tx.ToString()));
var walletTransactionUri = new Uri(s.Driver.Url); var walletTransactionUri = new Uri(s.Driver.Url);
// Send to bob // Send to bob
@@ -1618,9 +1620,8 @@ namespace BTCPayServer.Tests
// Transactions list is empty // Transactions list is empty
s.Driver.FindElement(By.Id($"StoreNav-Wallet{cryptoCode}")).Click(); s.Driver.FindElement(By.Id($"StoreNav-Wallet{cryptoCode}")).Click();
Assert.Contains("There are no transactions yet.", s.Driver.PageSource); s.Driver.WaitWalletTransactionsLoaded();
s.Driver.AssertElementNotFound(By.Id("ExportDropdownToggle")); Assert.Contains("There are no transactions yet", s.Driver.FindElement(By.Id("WalletTransactions")).Text);
s.Driver.AssertElementNotFound(By.Id("ActionsDropdownToggle"));
} }
[Fact] [Fact]

View File

@@ -719,7 +719,7 @@ namespace BTCPayServer.Tests
btcDerivationScheme.GetDerivation(new KeyPath("0/90")).ScriptPubKey, Money.Coins(1.0m)); btcDerivationScheme.GetDerivation(new KeyPath("0/90")).ScriptPubKey, Money.Coins(1.0m));
tester.ExplorerNode.Generate(1); tester.ExplorerNode.Generate(1);
var transactions = Assert.IsType<ListTransactionsViewModel>(Assert var transactions = Assert.IsType<ListTransactionsViewModel>(Assert
.IsType<ViewResult>(walletController.WalletTransactions(walletId).Result).Model); .IsType<ViewResult>(walletController.WalletTransactions(walletId, loadTransactions: true).Result).Model);
Assert.Empty(transactions.Transactions); Assert.Empty(transactions.Transactions);
Assert.IsType<RedirectToActionResult>(walletController.WalletRescan(walletId, rescan).Result); Assert.IsType<RedirectToActionResult>(walletController.WalletRescan(walletId, rescan).Result);
@@ -748,7 +748,7 @@ namespace BTCPayServer.Tests
Assert.NotNull(rescan.TimeOfScan); Assert.NotNull(rescan.TimeOfScan);
Assert.Equal(1, rescan.LastSuccess.Found); Assert.Equal(1, rescan.LastSuccess.Found);
transactions = Assert.IsType<ListTransactionsViewModel>(Assert transactions = Assert.IsType<ListTransactionsViewModel>(Assert
.IsType<ViewResult>(walletController.WalletTransactions(walletId).Result).Model); .IsType<ViewResult>(walletController.WalletTransactions(walletId, loadTransactions: true).Result).Model);
var tx = Assert.Single(transactions.Transactions); var tx = Assert.Single(transactions.Transactions);
Assert.Equal(tx.Id, txId.ToString()); Assert.Equal(tx.Id, txId.ToString());
@@ -763,7 +763,7 @@ namespace BTCPayServer.Tests
await walletController.ModifyTransaction(walletId, tx.Id, addcomment: "hello")); await walletController.ModifyTransaction(walletId, tx.Id, addcomment: "hello"));
transactions = Assert.IsType<ListTransactionsViewModel>(Assert transactions = Assert.IsType<ListTransactionsViewModel>(Assert
.IsType<ViewResult>(walletController.WalletTransactions(walletId).Result).Model); .IsType<ViewResult>(walletController.WalletTransactions(walletId, loadTransactions: true).Result).Model);
tx = Assert.Single(transactions.Transactions); tx = Assert.Single(transactions.Transactions);
Assert.Equal("hello", tx.Comment); Assert.Equal("hello", tx.Comment);
@@ -775,7 +775,7 @@ namespace BTCPayServer.Tests
await walletController.ModifyTransaction(walletId, tx.Id, removelabel: "test2")); await walletController.ModifyTransaction(walletId, tx.Id, removelabel: "test2"));
transactions = Assert.IsType<ListTransactionsViewModel>(Assert transactions = Assert.IsType<ListTransactionsViewModel>(Assert
.IsType<ViewResult>(walletController.WalletTransactions(walletId).Result).Model); .IsType<ViewResult>(walletController.WalletTransactions(walletId, loadTransactions: true).Result).Model);
tx = Assert.Single(transactions.Transactions); tx = Assert.Single(transactions.Transactions);
Assert.Equal("hello", tx.Comment); Assert.Equal("hello", tx.Comment);

View File

@@ -58,7 +58,7 @@ public class StoreRecentTransactions : ViewComponent
{ {
var network = derivationSettings.Network; var network = derivationSettings.Network;
var wallet = _walletProvider.GetWallet(network); var wallet = _walletProvider.GetWallet(network);
var allTransactions = await wallet.FetchTransactionHistory(derivationSettings.AccountDerivation, 0, 5, TimeSpan.FromDays(31.0)); var allTransactions = await wallet.FetchTransactionHistory(derivationSettings.AccountDerivation, 0, 5, TimeSpan.FromDays(31.0), cancellationToken: this.HttpContext.RequestAborted);
var walletTransactionsInfo = await _walletRepository.GetWalletTransactionsInfo(vm.WalletId, allTransactions.Select(t => t.TransactionId.ToString()).ToArray()); var walletTransactionsInfo = await _walletRepository.GetWalletTransactionsInfo(vm.WalletId, allTransactions.Select(t => t.TransactionId.ToString()).ToArray());
transactions = allTransactions transactions = allTransactions

View File

@@ -182,7 +182,8 @@ namespace BTCPayServer.Controllers.Greenfield
[FromQuery] TransactionStatus[]? statusFilter = null, [FromQuery] TransactionStatus[]? statusFilter = null,
[FromQuery] string? labelFilter = null, [FromQuery] string? labelFilter = null,
[FromQuery] int skip = 0, [FromQuery] int skip = 0,
[FromQuery] int limit = int.MaxValue [FromQuery] int limit = int.MaxValue,
CancellationToken cancellationToken = default
) )
{ {
if (IsInvalidWalletRequest(cryptoCode, out var network, if (IsInvalidWalletRequest(cryptoCode, out var network,
@@ -197,7 +198,7 @@ namespace BTCPayServer.Controllers.Greenfield
if (statusFilter?.Any() is true || !string.IsNullOrWhiteSpace(labelFilter)) if (statusFilter?.Any() is true || !string.IsNullOrWhiteSpace(labelFilter))
preFiltering = false; preFiltering = false;
var txs = await wallet.FetchTransactionHistory(derivationScheme.AccountDerivation, preFiltering ? skip : 0, var txs = await wallet.FetchTransactionHistory(derivationScheme.AccountDerivation, preFiltering ? skip : 0,
preFiltering ? limit : int.MaxValue); preFiltering ? limit : int.MaxValue, cancellationToken: cancellationToken);
if (!preFiltering) if (!preFiltering)
{ {
var filteredList = new List<TransactionHistoryLine>(txs.Count); var filteredList = new List<TransactionHistoryLine>(txs.Count);

View File

@@ -212,7 +212,9 @@ namespace BTCPayServer.Controllers
WalletId walletId, WalletId walletId,
string? labelFilter = null, string? labelFilter = null,
int skip = 0, int skip = 0,
int count = 50 int count = 50,
bool loadTransactions = false,
CancellationToken cancellationToken = default
) )
{ {
var paymentMethod = GetDerivationSchemeSettings(walletId); var paymentMethod = GetDerivationSchemeSettings(walletId);
@@ -223,25 +225,25 @@ namespace BTCPayServer.Controllers
// We can't filter at the database level if we need to apply label filter // We can't filter at the database level if we need to apply label filter
var preFiltering = string.IsNullOrEmpty(labelFilter); var preFiltering = string.IsNullOrEmpty(labelFilter);
var transactions = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, preFiltering ? skip : null, preFiltering ? count : null);
var walletTransactionsInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, transactions.Select(t => t.TransactionId.ToString()).ToArray());
var model = new ListTransactionsViewModel { Skip = skip, Count = count }; var model = new ListTransactionsViewModel { Skip = skip, Count = count };
model.Labels.AddRange( model.Labels.AddRange(
(await WalletRepository.GetWalletLabels(walletId)) (await WalletRepository.GetWalletLabels(walletId))
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color)))); .Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color))));
IList<TransactionHistoryLine>? transactions = null;
Dictionary<string, WalletTransactionInfo>? walletTransactionsInfo = null;
if (loadTransactions)
{
transactions = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, preFiltering ? skip : null, preFiltering ? count : null, cancellationToken: cancellationToken);
walletTransactionsInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, transactions.Select(t => t.TransactionId.ToString()).ToArray());
}
if (labelFilter != null) if (labelFilter != null)
{ {
model.PaginationQuery = new Dictionary<string, object> { { "labelFilter", labelFilter } }; model.PaginationQuery = new Dictionary<string, object> { { "labelFilter", labelFilter } };
} }
if (transactions == null) if (transactions == null || walletTransactionsInfo is null)
{ {
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message =
"There was an error retrieving the transactions list. Is NBXplorer configured correctly?"
});
model.Transactions = new List<ListTransactionsViewModel.TransactionViewModel>(); model.Transactions = new List<ListTransactionsViewModel.TransactionViewModel>();
} }
else else
@@ -1311,7 +1313,7 @@ namespace BTCPayServer.Controllers
[HttpGet("{walletId}/export")] [HttpGet("{walletId}/export")]
public async Task<IActionResult> Export( public async Task<IActionResult> Export(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
string format, string? labelFilter = null) string format, string? labelFilter = null, CancellationToken cancellationToken = default)
{ {
var paymentMethod = GetDerivationSchemeSettings(walletId); var paymentMethod = GetDerivationSchemeSettings(walletId);
if (paymentMethod == null) if (paymentMethod == null)
@@ -1319,7 +1321,7 @@ namespace BTCPayServer.Controllers
var wallet = _walletProvider.GetWallet(paymentMethod.Network); var wallet = _walletProvider.GetWallet(paymentMethod.Network);
var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId, (string[]?)null); var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId, (string[]?)null);
var input = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation); var input = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, cancellationToken: cancellationToken);
var walletTransactionsInfo = await walletTransactionsInfoAsync; var walletTransactionsInfo = await walletTransactionsInfoAsync;
var export = new TransactionsExport(wallet, walletTransactionsInfo); var export = new TransactionsExport(wallet, walletTransactionsInfo);
var res = export.Process(input, format); var res = export.Process(input, format);

View File

@@ -215,7 +215,7 @@ namespace BTCPayServer.Services.Wallets
return await completionSource.Task; return await completionSource.Task;
} }
List<TransactionInformation> dummy = new List<TransactionInformation>(); List<TransactionInformation> dummy = new List<TransactionInformation>();
public async Task<IList<TransactionHistoryLine>> FetchTransactionHistory(DerivationStrategyBase derivationStrategyBase, int? skip = null, int? count = null, TimeSpan? interval = null) public async Task<IList<TransactionHistoryLine>> FetchTransactionHistory(DerivationStrategyBase derivationStrategyBase, int? skip = null, int? count = null, TimeSpan? interval = null, CancellationToken cancellationToken = default)
{ {
// This is two paths: // This is two paths:
// * Sometimes we can ask the DB to do the filtering of rows: If that's the case, we should try to filter at the DB level directly as it is the most efficient. // * Sometimes we can ask the DB to do the filtering of rows: If that's the case, we should try to filter at the DB level directly as it is the most efficient.
@@ -243,18 +243,21 @@ namespace BTCPayServer.Services.Wallets
else else
{ {
await using var ctx = await NbxplorerConnectionFactory.OpenConnection(); await using var ctx = await NbxplorerConnectionFactory.OpenConnection();
var rows = await ctx.QueryAsync<(string tx_id, DateTimeOffset seen_at, string blk_id, long? blk_height, long balance_change, string asset_id, long confs)>( var cmd = new CommandDefinition(
"SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change, r.asset_id, COALESCE((SELECT height FROM get_tip('BTC')) - t.blk_height + 1, 0) AS confs " + commandText: "SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change, r.asset_id, COALESCE((SELECT height FROM get_tip('BTC')) - t.blk_height + 1, 0) AS confs " +
"FROM get_wallets_recent(@wallet_id, @code, @interval, @count, @skip) r " + "FROM get_wallets_recent(@wallet_id, @code, @interval, @count, @skip) r " +
"JOIN txs t USING (code, tx_id) " + "JOIN txs t USING (code, tx_id) " +
"ORDER BY r.seen_at DESC", new "ORDER BY r.seen_at DESC",
parameters: new
{ {
wallet_id = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(Network.CryptoCode, derivationStrategyBase.ToString()), wallet_id = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(Network.CryptoCode, derivationStrategyBase.ToString()),
code = Network.CryptoCode, code = Network.CryptoCode,
count = count, count = count,
skip = skip, skip = skip,
interval = interval is TimeSpan t ? t : TimeSpan.FromDays(365 * 1000) interval = interval is TimeSpan t ? t : TimeSpan.FromDays(365 * 1000)
}); },
cancellationToken: cancellationToken);
var rows = await ctx.QueryAsync<(string tx_id, DateTimeOffset seen_at, string blk_id, long? blk_height, long balance_change, string asset_id, long confs)>(cmd);
rows.TryGetNonEnumeratedCount(out int c); rows.TryGetNonEnumeratedCount(out int c);
var lines = new List<TransactionHistoryLine>(c); var lines = new List<TransactionHistoryLine>(c);
foreach (var row in rows) foreach (var row in rows)

View File

@@ -49,10 +49,10 @@
<script src="~/modal/btcpay.js" asp-append-version="true" async></script> <script src="~/modal/btcpay.js" asp-append-version="true" async></script>
@* Custom Range Modal *@ @* Custom Range Modal *@
<script> <script>
let observer = null;
let $loadMore = document.getElementById('LoadMore');
const $actions = document.getElementById('ListActions'); const $actions = document.getElementById('ListActions');
const $transactions = document.getElementById('WalletTransactions');
const $list = document.getElementById('WalletTransactionsList'); const $list = document.getElementById('WalletTransactionsList');
const $dropdowns = document.getElementById('Dropdowns');
const $indicator = document.getElementById('LoadingIndicator'); const $indicator = document.getElementById('LoadingIndicator');
delegate('click', '#selectAllCheckbox', e => { delegate('click', '#selectAllCheckbox', e => {
@@ -65,19 +65,15 @@
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}); });
delegate('click', '#LoadMore', async () => {
$loadMore.setAttribute('disabled', 'disabled');
await loadMoreTransactions();
});
if ($actions && $actions.offsetTop - window.innerHeight > 0) { if ($actions && $actions.offsetTop - window.innerHeight > 0) {
document.getElementById('GoToTop').classList.remove('d-none'); document.getElementById('GoToTop').classList.remove('d-none');
} }
const count = @Safe.Json(Model.Count); const count = @Safe.Json(Model.Count);
const skipInitial = @Safe.Json(Model.Skip); const skipInitial = @Safe.Json(Model.Skip);
const loadMoreUrl = @Safe.Json(Url.Action("WalletTransactions", new { walletId, labelFilter, skip = Model.Skip, count = Model.Count })); const loadMoreUrl = @Safe.Json(Url.Action("WalletTransactions", new { walletId, labelFilter, skip = Model.Skip, count = Model.Count, loadTransactions = true }));
let skip = @Safe.Json(Model.Skip); // The next time we load transactions, skip will become 0
let skip = @Safe.Json(Model.Skip) - count;
async function loadMoreTransactions() { async function loadMoreTransactions() {
$indicator.classList.remove('d-none'); $indicator.classList.remove('d-none');
@@ -93,16 +89,30 @@
if (response.ok) { if (response.ok) {
const html = await response.text(); const html = await response.text();
const responseEmpty = html.trim() === '';
$list.insertAdjacentHTML('beforeend', html); $list.insertAdjacentHTML('beforeend', html);
skip = skipNext; skip = skipNext;
if ($loadMore) { if (responseEmpty) {
// remove load more button // in case the response html was empty, remove the observer and stop loading
$loadMore.remove(); observer.unobserve($actions);
$loadMore = null; }
if (!$transactions.dataset.loaded) {
$transactions.dataset.loaded = 'true';
// replace table and dropdowns if initial response was empty
if (responseEmpty) {
$dropdowns.remove();
$transactions.innerHTML = '<div class="text-secondary" data-loaded="true">There are no transactions yet.</div>';
}
}
}
// switch to infinite scroll mode $indicator.classList.add('d-none');
observer = new IntersectionObserver(async entries => { formatDateTimes(document.getElementById('switchTimeFormat').dataset.mode);
initLabelManagers();
}
const observer = new IntersectionObserver(async entries => {
const { isIntersecting } = entries[0]; const { isIntersecting } = entries[0];
if (isIntersecting) { if (isIntersecting) {
await loadMoreTransactions(); await loadMoreTransactions();
@@ -111,20 +121,6 @@
// the actions div marks the end of the list table // the actions div marks the end of the list table
observer.observe($actions); observer.observe($actions);
}
if (html.trim() === '') {
// in case the response html was empty, remove the observer and stop loading
observer.unobserve($actions);
}
} else if ($loadMore) {
$loadMore.removeAttribute('disabled');
}
$indicator.classList.add('d-none');
formatDateTimes(document.getElementById('switchTimeFormat').dataset.mode);
initLabelManagers();
}
</script> </script>
} }
@@ -147,8 +143,6 @@
</div> </div>
} }
@if (Model.Transactions.Any())
{
<div class="d-inline-flex align-items-center gap-3" id="Dropdowns"> <div class="d-inline-flex align-items-center gap-3" id="Dropdowns">
<div class="dropdown ms-auto" id="Actions"> <div class="dropdown ms-auto" id="Actions">
<button class="btn btn-secondary dropdown-toggle" type="button" id="ActionsDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <button class="btn btn-secondary dropdown-toggle" type="button" id="ActionsDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@@ -173,8 +167,7 @@
</div> </div>
<div style="clear:both"></div> <div style="clear:both"></div>
<div class="row"> <div id="WalletTransactions" class="table-responsive-md">
<div class="col table-responsive-md" id="walletTable">
<table class="table table-hover"> <table class="table table-hover">
<thead class="thead-inverse"> <thead class="thead-inverse">
<tr> <tr>
@@ -198,7 +191,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<noscript> <noscript>
<vc:pager view-model="Model"/> <vc:pager view-model="Model"/>
</noscript> </noscript>
@@ -209,18 +201,8 @@
</div> </div>
</div> </div>
<div class="d-flex flex-wrap align-items-center justify-content-center gap-3 mb-5 only-for-js" id="ListActions"> <div class="d-flex flex-wrap align-items-center justify-content-center gap-3 mb-5 only-for-js" id="ListActions">
<button type="button" class="btn btn-secondary d-flex align-items-center" id="LoadMore">
Load more
</button>
<button type="button" class="btn btn-secondary d-none" id="GoToTop">Go to top</button> <button type="button" class="btn btn-secondary d-none" id="GoToTop">Go to top</button>
</div> </div>
}
else
{
<p class="text-secondary mt-3">
There are no transactions @(string.IsNullOrEmpty(labelFilter) ? "yet" : $"labeled with \"{labelFilter}\"").
</p>
}
<p class="mt-4 mb-0"> <p class="mt-4 mb-0">
If BTCPay Server shows you an invalid balance, <a asp-action="WalletRescan" asp-route-walletId="@Context.GetRouteValue("walletId")">rescan your wallet</a>. If BTCPay Server shows you an invalid balance, <a asp-action="WalletRescan" asp-route-walletId="@Context.GetRouteValue("walletId")">rescan your wallet</a>.