mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2024-11-19 01:43:50 +01:00
Dashboard: Add lightning balance graph
This commit is contained in:
parent
e6afc487df
commit
f4307aa549
@ -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;
|
||||
}
|
||||
<div id="StoreLightningBalance-@Model.Store.Id" class="widget store-lightning-balance">
|
||||
<div id="StoreLightningBalance-@Model.StoreId" class="widget store-lightning-balance">
|
||||
<div class="d-flex gap-3 align-items-center justify-content-between mb-2">
|
||||
<h6>Lightning Balance</h6>
|
||||
@if (Model.CryptoCode != Model.DefaultCurrency && Model.Balance != null)
|
||||
@ -128,12 +130,32 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.Balance.OffchainBalance != null && Model.Balance.OnchainBalance != null)
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 @(Model.Series != null ? "my-3" : "mt-3")">
|
||||
@if (Model.Balance.OffchainBalance != null && Model.Balance.OnchainBalance != null)
|
||||
{
|
||||
<button class="d-inline-flex align-items-center btn btn-link text-primary fw-semibold p-0 ms-n1" type="button" data-bs-toggle="collapse" data-bs-target=".balance-details" aria-expanded="false" aria-controls="balanceDetailsOffchain balanceDetailsOnchain">
|
||||
<vc:icon symbol="caret-down"/>
|
||||
<span class="ms-1">Details</span>
|
||||
</button>
|
||||
}
|
||||
@if (Model.Series != null)
|
||||
{
|
||||
<div class="btn-group only-for-js mt-1" role="group" aria-label="Period">
|
||||
<input type="radio" class="btn-check" name="StoreLightningBalancePeriod-@Model.StoreId" id="StoreLightningBalancePeriodWeek-@Model.StoreId" value="@WalletHistogramType.Week" @(Model.Type == WalletHistogramType.Week ? "checked" : "")>
|
||||
<label class="btn btn-link" for="StoreLightningBalancePeriodWeek-@Model.StoreId">1W</label>
|
||||
<input type="radio" class="btn-check" name="StoreLightningBalancePeriod-@Model.StoreId" id="StoreLightningBalancePeriodMonth-@Model.StoreId" value="@WalletHistogramType.Month" @(Model.Type == WalletHistogramType.Month ? "checked" : "")>
|
||||
<label class="btn btn-link" for="StoreLightningBalancePeriodMonth-@Model.StoreId">1M</label>
|
||||
<input type="radio" class="btn-check" name="StoreLightningBalancePeriod-@Model.StoreId" id="StoreLightningBalancePeriodYear-@Model.StoreId" value="@WalletHistogramType.Year" @(Model.Type == WalletHistogramType.Year ? "checked" : "")>
|
||||
<label class="btn btn-link" for="StoreLightningBalancePeriodYear-@Model.StoreId">1Y</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.Series != null)
|
||||
{
|
||||
<button class="d-inline-flex align-items-center btn btn-link text-primary fw-semibold p-0 mt-3 ms-n1" type="button" data-bs-toggle="collapse" data-bs-target=".balance-details" aria-expanded="false" aria-controls="balanceDetailsOffchain balanceDetailsOnchain">
|
||||
<vc:icon symbol="caret-down"/>
|
||||
<span class="ms-1">Details</span>
|
||||
</button>
|
||||
<div class="ct-chart"></div>
|
||||
<template>
|
||||
@Safe.Json(Model)
|
||||
</template>
|
||||
}
|
||||
}
|
||||
else
|
||||
@ -143,47 +165,18 @@
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<script src="~/Components/StoreLightningBalance/Default.cshtml.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const url = @Safe.Json(Url.Action("LightningBalance", "UIStores", new { storeId = Model.Store.Id, cryptoCode = Model.CryptoCode }));
|
||||
const storeId = @Safe.Json(Model.Store.Id);
|
||||
const url = @Safe.Json(Model.DataUrl);
|
||||
const storeId = @Safe.Json(Model.StoreId);
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
document.getElementById(`StoreLightningBalance-${storeId}`).outerHTML = await response.text();
|
||||
const data = document.querySelector(`#StoreLightningBalance-${storeId} template`);
|
||||
if (data) window.storeLightningBalance.dataLoaded(JSON.parse(data.innerHTML));
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const storeId = @Safe.Json(Model.Store.Id);
|
||||
const cryptoCode = @Safe.Json(Model.CryptoCode);
|
||||
const defaultCurrency = @Safe.Json(Model.DefaultCurrency);
|
||||
const divisibility = @Safe.Json(Model.CurrencyData.Divisibility);
|
||||
const id = `StoreLightningBalance-${storeId}`;
|
||||
|
||||
const render = rate => {
|
||||
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 = rate
|
||||
? DashboardUtils.displayDefaultCurrency(value, rate, currency, divisibility)
|
||||
: value
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
delegate('change', `#${id} .currency-toggle input`, async e => {
|
||||
const { target } = e;
|
||||
if (target.value === defaultCurrency) {
|
||||
const rate = await DashboardUtils.fetchRate(`${cryptoCode}_${defaultCurrency}`);
|
||||
if (rate) render(rate);
|
||||
} else {
|
||||
render(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
@ -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<ExternalServicesOptions> _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> lightningNetworkOptions,
|
||||
IOptions<ExternalServicesOptions> 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<IViewComponentResult> 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);
|
||||
}
|
||||
|
||||
|
@ -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<DateTimeOffset> Labels { get; set; }
|
||||
public IList<decimal> Series { get; set; }
|
||||
public string DataUrl { get; set; }
|
||||
}
|
||||
|
@ -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<IActionResult> 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<T>(paymentMethodId, _handlers);
|
||||
}
|
||||
|
||||
private async Task<ILightningClient?> GetLightningClient(StoreData store, string cryptoCode)
|
||||
{
|
||||
var network = _networkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
||||
var id = PaymentTypes.LN.GetPaymentMethodId(cryptoCode);
|
||||
var existing = store.GetPaymentMethodConfig<LightningPaymentMethodConfig>(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;
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user