From f4307aa549dbf797ec4701edcd212daed2af8aed Mon Sep 17 00:00:00 2001 From: Dennis Reimann Date: Mon, 5 Aug 2024 20:43:15 +0200 Subject: [PATCH] Dashboard: Add lightning balance graph --- .../StoreLightningBalance/Default.cshtml | 75 +++++++-------- .../StoreLightningBalance/Default.cshtml.js | 95 +++++++++++++++++++ .../StoreLightningBalance.cs | 43 +++++---- .../StoreLightningBalanceViewModel.cs | 10 +- .../UIStoresController.LightningLike.cs | 56 +++++++++++ .../Controllers/UIStoresController.cs | 8 +- 6 files changed, 228 insertions(+), 59 deletions(-) create mode 100644 BTCPayServer/Components/StoreLightningBalance/Default.cshtml.js diff --git a/BTCPayServer/Components/StoreLightningBalance/Default.cshtml b/BTCPayServer/Components/StoreLightningBalance/Default.cshtml index 8ab1c3d52..07a05c9dc 100644 --- a/BTCPayServer/Components/StoreLightningBalance/Default.cshtml +++ b/BTCPayServer/Components/StoreLightningBalance/Default.cshtml @@ -1,9 +1,11 @@ +@using BTCPayServer.Abstractions.TagHelpers +@using BTCPayServer.Services.Wallets @model BTCPayServer.Components.StoreLightningBalance.StoreLightningBalanceViewModel -@if(!Model.InitialRendering && Model.Balance == null) +@if (!Model.InitialRendering && Model.Balance == null) { return; } -
+
Lightning Balance
@if (Model.CryptoCode != Model.DefaultCurrency && Model.Balance != null) @@ -128,12 +130,32 @@
}
- @if (Model.Balance.OffchainBalance != null && Model.Balance.OnchainBalance != null) +
+ @if (Model.Balance.OffchainBalance != null && Model.Balance.OnchainBalance != null) + { + + } + @if (Model.Series != null) + { +
+ + + + + + +
+ } +
+ @if (Model.Series != null) { - +
+ } } else @@ -143,47 +165,18 @@ Loading...
+ } - diff --git a/BTCPayServer/Components/StoreLightningBalance/Default.cshtml.js b/BTCPayServer/Components/StoreLightningBalance/Default.cshtml.js new file mode 100644 index 000000000..82a99eda7 --- /dev/null +++ b/BTCPayServer/Components/StoreLightningBalance/Default.cshtml.js @@ -0,0 +1,95 @@ +if (!window.storeLightningBalance) { + window.storeLightningBalance = { + dataLoaded (model) { + const { storeId, cryptoCode, defaultCurrency, currencyData: { divisibility } } = model; + const id = `StoreLightningBalance-${storeId}`; + const valueTransform = value => rate + ? DashboardUtils.displayDefaultCurrency(value, rate, defaultCurrency, divisibility).toString() + : value + const labelCount = 6 + const chartOpts = { + fullWidth: true, + showArea: true, + axisY: { + labelInterpolationFnc: valueTransform + } + }; + const baseUrl = model.dataUrl; + let data = model; + let rate = null; + + const render = data => { + let { series, labels } = data; + const currency = rate ? defaultCurrency : cryptoCode; + document.querySelectorAll(`#${id} .currency`).forEach(c => c.innerText = currency) + document.querySelectorAll(`#${id} [data-balance]`).forEach(c => { + const value = Number.parseFloat(c.dataset.balance); + c.innerText = valueTransform(value) + }); + if (!series) return; + + const min = Math.min(...series); + const max = Math.max(...series); + const low = Math.max(min - ((max - min) / 5), 0); + const tooltip = Chartist.plugins.tooltip2({ + template: '{{value}}', + offset: { + x: 0, + y: -16 + }, + valueTransformFunction: valueTransform + }) + const renderOpts = Object.assign({}, chartOpts, { low, plugins: [tooltip] }); + const pointCount = series.length; + const labelEvery = pointCount / labelCount; + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat + const dateFormatter = new Intl.DateTimeFormat('default', { month: 'short', day: 'numeric' }) + const chart = new Chartist.Line(`#${id} .ct-chart`, { + labels: labels.map((date, i) => i % labelEvery == 0 + ? dateFormatter.format(new Date(date)) + : null), + series: [series] + }, renderOpts); + + // prevent y-axis labels from getting cut off + window.setTimeout(() => { + const yLabels = [...document.querySelectorAll('.ct-label.ct-vertical.ct-start')]; + if (yLabels) { + const width = Math.max(...(yLabels.map(l => l.innerText.length * 7.5))); + const opts = Object.assign({}, renderOpts, { + axisY: Object.assign({}, renderOpts.axisY, { offset: width }) + }); + chart.update(null, opts); + } + }, 0) + }; + console.log(baseUrl) + + const update = async type => { + const url = `${baseUrl}/${type}`; + const response = await fetch(url); + if (response.ok) { + data = await response.json(); + render(data); + } + }; + + render(data); + + delegate('change', `#${id} [name="StoreLightningBalancePeriod-${storeId}"]`, async e => { + const type = e.target.value; + await update(type); + }) + delegate('change', `#${id} .currency-toggle input`, async e => { + const { target } = e; + if (target.value === defaultCurrency) { + rate = await DashboardUtils.fetchRate(`${cryptoCode}_${defaultCurrency}`); + if (rate) render(data); + } else { + rate = null; + render(data); + } + }); + } + }; +} diff --git a/BTCPayServer/Components/StoreLightningBalance/StoreLightningBalance.cs b/BTCPayServer/Components/StoreLightningBalance/StoreLightningBalance.cs index e0a878fff..25fd01f91 100644 --- a/BTCPayServer/Components/StoreLightningBalance/StoreLightningBalance.cs +++ b/BTCPayServer/Components/StoreLightningBalance/StoreLightningBalance.cs @@ -1,21 +1,18 @@ using System; -using System.Linq; +using System.Threading; using System.Threading.Tasks; using BTCPayApp.CommonServer; -using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Client; using BTCPayServer.Configuration; using BTCPayServer.Data; using BTCPayServer.Lightning; -using BTCPayServer.Models; -using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Payments; using BTCPayServer.Payments.Lightning; -using BTCPayServer.Security; using BTCPayServer.Services; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; +using BTCPayServer.Services.Wallets; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -24,6 +21,8 @@ namespace BTCPayServer.Components.StoreLightningBalance; public class StoreLightningBalance : ViewComponent { + private const WalletHistogramType DefaultType = WalletHistogramType.Week; + private readonly StoreRepository _storeRepo; private readonly CurrencyNameTable _currencies; private readonly BTCPayServerOptions _btcpayServerOptions; @@ -33,6 +32,7 @@ public class StoreLightningBalance : ViewComponent private readonly IOptions _externalServiceOptions; private readonly IAuthorizationService _authorizationService; private readonly PaymentMethodHandlerDictionary _handlers; + private readonly LightningHistogramService _lnHistogramService; public StoreLightningBalance( StoreRepository storeRepo, @@ -43,7 +43,8 @@ public class StoreLightningBalance : ViewComponent IOptions lightningNetworkOptions, IOptions externalServiceOptions, IAuthorizationService authorizationService, - PaymentMethodHandlerDictionary handlers) + PaymentMethodHandlerDictionary handlers, + LightningHistogramService lnHistogramService) { _storeRepo = storeRepo; _currencies = currencies; @@ -54,6 +55,7 @@ public class StoreLightningBalance : ViewComponent _handlers = handlers; _lightningClientFactory = lightningClientFactory; _lightningNetworkOptions = lightningNetworkOptions; + _lnHistogramService = lnHistogramService; } public async Task InvokeAsync(StoreLightningBalanceViewModel vm) @@ -65,20 +67,19 @@ public class StoreLightningBalance : ViewComponent vm.DefaultCurrency = vm.Store.GetStoreBlob().DefaultCurrency; vm.CurrencyData = _currencies.GetCurrencyData(vm.DefaultCurrency, true); + vm.DataUrl = Url.Action("LightningBalanceDashboard", "UIStores", + new { storeId = vm.Store.Id, cryptoCode = vm.CryptoCode }); + vm.StoreId = vm.Store.Id; + if (vm.InitialRendering) + return View(vm); try { var lightningClient = await GetLightningClient(vm.Store, vm.CryptoCode); - if (lightningClient == null) - { - vm.InitialRendering = false; - return View(vm); - } - if (vm.InitialRendering) - return View(vm); - - var balance = await lightningClient.GetBalance(); + // balance + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var balance = await lightningClient.GetBalance(cts.Token); vm.Balance = balance; vm.TotalOnchain = balance.OnchainBalance != null ? (balance.OnchainBalance.Confirmed ?? 0L) + (balance.OnchainBalance.Reserved ?? 0L) + @@ -88,8 +89,16 @@ public class StoreLightningBalance : ViewComponent ? (balance.OffchainBalance.Opening ?? 0) + (balance.OffchainBalance.Local ?? 0) + (balance.OffchainBalance.Closing ?? 0) : null; + + // histogram + var data = await _lnHistogramService.GetHistogram(lightningClient, DefaultType, cts.Token); + if (data != null) + { + vm.Type = data.Type; + vm.Series = data.Series; + vm.Labels = data.Labels; + } } - catch (Exception ex) when (ex is NotImplementedException or NotSupportedException) { // not all implementations support balance fetching @@ -100,6 +109,8 @@ public class StoreLightningBalance : ViewComponent // general error vm.ProblemDescription = "Could not fetch Lightning balance."; } + // unset store to prevent circular reference in JSON + vm.Store = null; return View(vm); } diff --git a/BTCPayServer/Components/StoreLightningBalance/StoreLightningBalanceViewModel.cs b/BTCPayServer/Components/StoreLightningBalance/StoreLightningBalanceViewModel.cs index 32bd1f449..c08b08a9f 100644 --- a/BTCPayServer/Components/StoreLightningBalance/StoreLightningBalanceViewModel.cs +++ b/BTCPayServer/Components/StoreLightningBalance/StoreLightningBalanceViewModel.cs @@ -1,12 +1,16 @@ +using System; +using System.Collections.Generic; using BTCPayServer.Data; using BTCPayServer.Lightning; using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Wallets; using NBitcoin; namespace BTCPayServer.Components.StoreLightningBalance; public class StoreLightningBalanceViewModel { + public string StoreId { get; set; } public string CryptoCode { get; set; } public string DefaultCurrency { get; set; } public CurrencyData CurrencyData { get; set; } @@ -15,5 +19,9 @@ public class StoreLightningBalanceViewModel public LightMoney TotalOffchain { get; set; } public LightningNodeBalance Balance { get; set; } public string ProblemDescription { get; set; } - public bool InitialRendering { get; set; } + public bool InitialRendering { get; set; } = true; + public WalletHistogramType Type { get; set; } + public IList Labels { get; set; } + public IList Series { get; set; } + public string DataUrl { get; set; } } diff --git a/BTCPayServer/Controllers/UIStoresController.LightningLike.cs b/BTCPayServer/Controllers/UIStoresController.LightningLike.cs index a59d6986a..1d8ceb005 100644 --- a/BTCPayServer/Controllers/UIStoresController.LightningLike.cs +++ b/BTCPayServer/Controllers/UIStoresController.LightningLike.cs @@ -7,12 +7,15 @@ using BTCPayApp.CommonServer; using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Client; +using BTCPayServer.Components.StoreLightningBalance; using BTCPayServer.Configuration; using BTCPayServer.Data; +using BTCPayServer.Lightning; using BTCPayServer.Models; using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Payments; using BTCPayServer.Payments.Lightning; +using BTCPayServer.Services.Wallets; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; @@ -83,6 +86,37 @@ public partial class UIStoresController return View(vm); } + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [HttpGet("{storeId}/lightning/{cryptoCode}/dashboard/balance")] + public IActionResult LightningBalanceDashboard(string storeId, string cryptoCode) + { + var store = HttpContext.GetStoreData(); + if (store == null) + return NotFound(); + + return ViewComponent("StoreLightningBalance", new StoreLightningBalanceViewModel + { + Store = store, + CryptoCode = cryptoCode, + InitialRendering = false + }); + } + + [HttpGet("{storeId}/lightning/{cryptoCode}/dashboard/balance/{type}")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task LightningBalanceDashboard(string storeId, string cryptoCode, WalletHistogramType type) + { + var store = HttpContext.GetStoreData(); + if (store == null) + return NotFound(); + var lightningClient = await GetLightningClient(store, cryptoCode); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var data = await _lnHistogramService.GetHistogram(lightningClient, type, cts.Token); + if (data == null) return NotFound(); + + return Json(data); + } + [HttpGet("{storeId}/lightning/{cryptoCode}/setup")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public IActionResult SetupLightningNode(string storeId, string cryptoCode) @@ -321,4 +355,26 @@ public partial class UIStoresController { return store.GetPaymentMethodConfig(paymentMethodId, _handlers); } + + private async Task GetLightningClient(StoreData store, string cryptoCode) + { + var network = _networkProvider.GetNetwork(cryptoCode); + var id = PaymentTypes.LN.GetPaymentMethodId(cryptoCode); + var existing = store.GetPaymentMethodConfig(id, _handlers); + if (existing == null) + return null; + + if (existing.GetExternalLightningUrl() is { } connectionString) + { + return _lightningClientFactory.Create(connectionString, network); + } + if (existing.IsInternalNode && _lightningNetworkOptions.InternalLightningByCryptoCode.TryGetValue(cryptoCode, out var internalLightningNode)) + { + var result = await _authorizationService.AuthorizeAsync(HttpContext.User, null, + new PolicyRequirement(Policies.CanUseInternalLightningNode)); + return result.Succeeded ? internalLightningNode : null; + } + + return null; + } } diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs index 66bc417f4..5965a3691 100644 --- a/BTCPayServer/Controllers/UIStoresController.cs +++ b/BTCPayServer/Controllers/UIStoresController.cs @@ -61,7 +61,9 @@ public partial class UIStoresController : Controller WalletFileParsers onChainWalletParsers, UriResolver uriResolver, SettingsRepository settingsRepository, - EventAggregator eventAggregator) + EventAggregator eventAggregator, + LightningHistogramService lnHistogramService, + LightningClientFactoryService lightningClientFactory) { _rateFactory = rateFactory; _storeRepo = storeRepo; @@ -90,6 +92,8 @@ public partial class UIStoresController : Controller _dataProtector = dataProtector.CreateProtector("ConfigProtector"); _webhookNotificationManager = webhookNotificationManager; _lightningNetworkOptions = lightningNetworkOptions.Value; + _lnHistogramService = lnHistogramService; + _lightningClientFactory = lightningClientFactory; } private readonly BTCPayServerOptions _btcpayServerOptions; @@ -119,6 +123,8 @@ public partial class UIStoresController : Controller private readonly WebhookSender _webhookNotificationManager; private readonly LightningNetworkOptions _lightningNetworkOptions; private readonly IDataProtector _dataProtector; + private readonly LightningHistogramService _lnHistogramService; + private readonly LightningClientFactoryService _lightningClientFactory; public string? GeneratedPairingCode { get; set; }