* Add dashboard and chart basics

* More widgets

* Make widgets responsive

* Layout dashboard

* Prepare ExplorerClient

* Switch to Chartist

* Dynamic data for store numbers and recent transactions tiles

* Dynamic data for recent invoices tile

* Improvements

* Plug NBXPlorer DB

* Properly filter by code

* Reorder cheat mode button

* AJAX update for graph data

* Fix create invoice button

* Retry connection on transient issues

* App Top Items stats

* Design updates

* App Sales stats

* Add points for weekly histogram, set last point to current balance

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
d11n 2022-04-12 09:55:10 +02:00 committed by GitHub
parent d58803a058
commit 7ec978fcdb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 2037 additions and 115 deletions

View File

@ -453,6 +453,7 @@ namespace BTCPayServer.Tests
s.GoToStore();
Assert.Contains(storeName, s.Driver.PageSource);
Assert.DoesNotContain("id=\"Dashboard\"", s.Driver.PageSource);
// verify steps for wallet setup are displayed correctly
s.GoToStore(StoreNavPages.Dashboard);
@ -466,10 +467,11 @@ namespace BTCPayServer.Tests
s.Driver.AssertNoError();
s.GoToStore(StoreNavPages.Dashboard);
Assert.True(s.Driver.FindElement(By.Id("SetupGuide-WalletDone")).Displayed);
Assert.DoesNotContain("id=\"SetupGuide\"", s.Driver.PageSource);
Assert.True(s.Driver.FindElement(By.Id("Dashboard")).Displayed);
// setup offchain wallet
s.Driver.FindElement(By.Id("SetupGuide-Lightning")).Click();
s.Driver.FindElement(By.Id("StoreNav-LightningBTC")).Click();
s.AddLightningNode();
s.Driver.AssertNoError();
var successAlert = s.FindAlertMessage();
@ -477,9 +479,6 @@ namespace BTCPayServer.Tests
s.ClickOnAllSectionLinks();
s.GoToStore(StoreNavPages.Dashboard);
Assert.True(s.Driver.FindElement(By.Id("SetupGuide-LightningDone")).Displayed);
s.GoToInvoices();
Assert.Contains("There are no invoices matching your criteria.", s.Driver.PageSource);
var invoiceId = s.CreateInvoice();

View File

@ -53,6 +53,7 @@
<PackageReference Include="BundlerMinifier.Core" Version="3.2.435" />
<PackageReference Include="BundlerMinifier.TagHelpers" Version="3.2.435" />
<PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Fido2" Version="2.0.1" />
<PackageReference Include="Fido2.AspNet" Version="2.0.1" />
<PackageReference Include="HtmlSanitizer" Version="5.0.372" />

View File

@ -0,0 +1,34 @@
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Components.AppSales;
public class AppSales : ViewComponent
{
private readonly AppService _appService;
private readonly StoreRepository _storeRepo;
public AppSales(AppService appService, StoreRepository storeRepo)
{
_appService = appService;
_storeRepo = storeRepo;
}
public async Task<IViewComponentResult> InvokeAsync(AppData app)
{
var stats = await _appService.GetSalesStats(app);
var vm = new AppSalesViewModel
{
App = app,
SalesCount = stats.SalesCount,
Series = stats.Series
};
return View(vm);
}
}

View File

@ -0,0 +1,13 @@
using System.Collections;
using System.Collections.Generic;
using BTCPayServer.Data;
using BTCPayServer.Services.Apps;
namespace BTCPayServer.Components.AppSales;
public class AppSalesViewModel
{
public AppData App { get; set; }
public int SalesCount { get; set; }
public IEnumerable<SalesStatsItem> Series { get; set; }
}

View File

@ -0,0 +1,30 @@
@model BTCPayServer.Components.AppSales.AppSalesViewModel
@{
var action = $"Update{Model.App.AppType}";
}
<div id="AppSales-@Model.App.Id" class="widget app-sales">
<header class="mb-3">
<h3>@Model.App.Name Sales</h3>
<a asp-controller="UIApps" asp-action="@action" asp-route-appId="@Model.App.Id">Manage</a>
</header>
<p>@Model.SalesCount Total Sales</p>
<div class="ct-chart ct-major-octave"></div>
<script>
(function () {
const id = 'AppSales-@Model.App.Id';
const labels = @Safe.Json(Model.Series.Select(i => i.Label));
const series = @Safe.Json(Model.Series.Select(i => i.SalesCount));
const min = Math.min(...series);
const max = Math.max(...series);
const low = min === max ? 0 : Math.max(min - ((max - min) / 5), 0);
new Chartist.Bar(`#${id} .ct-chart`, {
labels,
series: [series]
}, {
low,
});
})();
</script>
</div>

View File

@ -0,0 +1,32 @@
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Components.AppTopItems;
public class AppTopItems : ViewComponent
{
private readonly AppService _appService;
private readonly StoreRepository _storeRepo;
public AppTopItems(AppService appService, StoreRepository storeRepo)
{
_appService = appService;
_storeRepo = storeRepo;
}
public async Task<IViewComponentResult> InvokeAsync(AppData app)
{
var entries = await _appService.GetPerkStats(app);
var vm = new AppTopItemsViewModel
{
App = app,
Entries = entries
};
return View(vm);
}
}

View File

@ -0,0 +1,11 @@
using System.Collections.Generic;
using BTCPayServer.Data;
using BTCPayServer.Services.Apps;
namespace BTCPayServer.Components.AppTopItems;
public class AppTopItemsViewModel
{
public AppData App { get; set; }
public IEnumerable<ItemStats> Entries { get; set; }
}

View File

@ -0,0 +1,33 @@
@model BTCPayServer.Components.AppTopItems.AppTopItemsViewModel
@{
var action = $"Update{Model.App.AppType}";
}
<div class="widget app-top-items">
<header class="mb-3">
<h3>@Model.App.Name Top Items</h3>
<a asp-controller="UIApps" asp-action="@action" asp-route-appId="@Model.App.Id">View All</a>
</header>
@if (Model.Entries.Any())
{
<div class="app-items">
@foreach (var entry in Model.Entries)
{
<div class="app-item">
<span class="app-item-name">@entry.Title</span>
<span class="app-item-value">
@entry.SalesCount sale@(entry.SalesCount == 1 ? "" : "s"),
@entry.TotalFormatted total
</span>
</div>
}
</div>
}
else
{
<p class="text-secondary mt-3">
There are no sales yet.
</p>
}
</div>

View File

@ -0,0 +1,27 @@
@model BTCPayServer.Components.StoreNumbers.StoreNumbersViewModel
<div class="widget store-numbers">
<div class="store-number">
<header>
<h6>Payouts Pending</h6>
<a asp-controller="UIStorePullPayments" asp-action="Payouts" asp-route-storeId="@Model.Store.Id">Manage</a>
</header>
<div class="h3">@Model.PayoutsPending</div>
</div>
<div class="store-number">
<header>
<h6>TXs in the last @Model.TransactionDays days</h6>
@if (Model.Transactions > 0)
{
<a asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@Model.WalletId">View All</a>
}
</header>
<div class="h3">@Model.Transactions</div>
</div>
<div class="store-number">
<header>
<h6>Refunds Issued</h6>
</header>
<div class="h3">@Model.RefundsIssued</div>
</div>
</div>

View File

@ -0,0 +1,77 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Components.StoreRecentTransactions;
using BTCPayServer.Data;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Components.StoreNumbers;
public class StoreNumbers : ViewComponent
{
private const string CryptoCode = "BTC";
private const int TransactionDays = 7;
private readonly StoreRepository _storeRepo;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly BTCPayWalletProvider _walletProvider;
private readonly BTCPayNetworkProvider _networkProvider;
public StoreNumbers(
StoreRepository storeRepo,
ApplicationDbContextFactory dbContextFactory,
BTCPayNetworkProvider networkProvider,
BTCPayWalletProvider walletProvider)
{
_storeRepo = storeRepo;
_walletProvider = walletProvider;
_networkProvider = networkProvider;
_dbContextFactory = dbContextFactory;
}
public async Task<IViewComponentResult> InvokeAsync(StoreData store)
{
await using var ctx = _dbContextFactory.CreateContext();
var payoutsCount = await ctx.Payouts
.Where(p => p.PullPaymentData.StoreId == store.Id && !p.PullPaymentData.Archived && p.State == PayoutState.AwaitingApproval)
.CountAsync();
var refundsCount = await ctx.Invoices
.Where(i => i.StoreData.Id == store.Id && !i.Archived && i.CurrentRefundId != null)
.CountAsync();
var walletId = new WalletId(store.Id, CryptoCode);
var derivation = store.GetDerivationSchemeSettings(_networkProvider, walletId.CryptoCode);
var transactionsCount = 0;
if (derivation != null)
{
var network = derivation.Network;
var wallet = _walletProvider.GetWallet(network);
var allTransactions = await wallet.FetchTransactions(derivation.AccountDerivation);
var afterDate = DateTimeOffset.UtcNow - TimeSpan.FromDays(TransactionDays);
transactionsCount = allTransactions.UnconfirmedTransactions.Transactions
.Concat(allTransactions.ConfirmedTransactions.Transactions)
.Count(t => t.Timestamp > afterDate);
}
var vm = new StoreNumbersViewModel
{
Store = store,
WalletId = walletId,
PayoutsPending = payoutsCount,
Transactions = transactionsCount,
TransactionDays = TransactionDays,
RefundsIssued = refundsCount
};
return View(vm);
}
}

View File

@ -0,0 +1,14 @@
using System.Collections;
using BTCPayServer.Data;
namespace BTCPayServer.Components.StoreNumbers;
public class StoreNumbersViewModel
{
public StoreData Store { get; set; }
public WalletId WalletId { get; set; }
public int PayoutsPending { get; set; }
public int Transactions { get; set; }
public int RefundsIssued { get; set; }
public int TransactionDays { get; set; }
}

View File

@ -0,0 +1,57 @@
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Client.Models
@using BTCPayServer.Services.Invoices
@model BTCPayServer.Components.StoreRecentInvoices.StoreRecentInvoicesViewModel
<div class="widget store-recent-transactions">
<header>
<h3>Recent Invoices</h3>
@if (Model.Invoices.Any())
{
<a asp-controller="UIInvoice" asp-action="ListInvoices" asp-route-storeId="@Model.Store.Id">View All</a>
}
</header>
@if (Model.Invoices.Any())
{
<table class="table table-hover">
<thead>
<tr>
<th class="w-125px">Date</th>
<th class="text-nowrap">Invoice Id</th>
<th>Status</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
@foreach (var invoice in Model.Invoices)
{
<tr>
<td>@invoice.Date.ToTimeAgo()</td>
<td>
<a asp-controller="UIInvoice" asp-action="Invoice" asp-route-invoiceId="@invoice.InvoiceId" class="text-break">@invoice.InvoiceId</a>
</td>
<td>
<span class="badge badge-@invoice.Status.Status.ToModernStatus().ToString().ToLower()">
@invoice.Status.Status.ToModernStatus().ToString()
@if (invoice.Status.ExceptionStatus != InvoiceExceptionStatus.None)
{
@($"({invoice.Status.ExceptionStatus.ToString()})")
}
</span>
</td>
<td class="text-end">@invoice.AmountCurrency</td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-secondary my-3">
There are no recent invoices.
</p>
<a asp-controller="UIInvoice" asp-action="CreateInvoice" asp-route-storeId="@Model.Store.Id" class="fw-semibold">
Create Invoice
</a>
}
</div>

View File

@ -0,0 +1,13 @@
using System;
using BTCPayServer.Services.Invoices;
namespace BTCPayServer.Components.StoreRecentInvoices;
public class StoreRecentInvoiceViewModel
{
public string InvoiceId { get; set; }
public string OrderId { get; set; }
public string AmountCurrency { get; set; }
public InvoiceState Status { get; set; }
public DateTimeOffset Date { get; set; }
}

View File

@ -0,0 +1,64 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Components.StoreRecentInvoices;
public class StoreRecentInvoices : ViewComponent
{
private readonly StoreRepository _storeRepo;
private readonly InvoiceRepository _invoiceRepo;
private readonly CurrencyNameTable _currencyNameTable;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContextFactory _dbContextFactory;
public StoreRecentInvoices(
StoreRepository storeRepo,
InvoiceRepository invoiceRepo,
CurrencyNameTable currencyNameTable,
UserManager<ApplicationUser> userManager,
ApplicationDbContextFactory dbContextFactory)
{
_storeRepo = storeRepo;
_invoiceRepo = invoiceRepo;
_userManager = userManager;
_currencyNameTable = currencyNameTable;
_dbContextFactory = dbContextFactory;
}
public async Task<IViewComponentResult> InvokeAsync(StoreData store)
{
var userId = _userManager.GetUserId(UserClaimsPrincipal);
var invoiceEntities = await _invoiceRepo.GetInvoices(new InvoiceQuery
{
UserId = userId,
StoreId = new [] { store.Id },
Take = 5
});
var invoices = new List<StoreRecentInvoiceViewModel>();
foreach (var invoice in invoiceEntities)
{
var state = invoice.GetInvoiceState();
invoices.Add(new StoreRecentInvoiceViewModel
{
Date = invoice.InvoiceTime,
Status = state,
InvoiceId = invoice.Id,
OrderId = invoice.Metadata.OrderId ?? string.Empty,
AmountCurrency = _currencyNameTable.DisplayFormatCurrency(invoice.Price, invoice.Currency),
});
}
var vm = new StoreRecentInvoicesViewModel
{
Store = store,
Invoices = invoices
};
return View(vm);
}
}

View File

@ -0,0 +1,10 @@
using System.Collections.Generic;
using BTCPayServer.Data;
namespace BTCPayServer.Components.StoreRecentInvoices;
public class StoreRecentInvoicesViewModel
{
public StoreData Store { get; set; }
public IEnumerable<StoreRecentInvoiceViewModel> Invoices { get; set; }
}

View File

@ -0,0 +1,51 @@
@using BTCPayServer.Abstractions.Extensions
@model BTCPayServer.Components.StoreRecentTransactions.StoreRecentTransactionsViewModel
<div class="widget store-recent-transactions">
<header>
<h3>Recent Transactions</h3>
@if (Model.Transactions.Any())
{
<a asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@Model.WalletId">View All</a>
}
</header>
@if (Model.Transactions.Any())
{
<table class="table table-hover">
<thead>
<tr>
<th class="w-125px">Date</th>
<th>Transaction</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
@foreach (var tx in Model.Transactions)
{
<tr>
<td>@tx.Timestamp.ToTimeAgo()</td>
<td>
<a href="@tx.Link" target="_blank" rel="noreferrer noopener" class="text-break">
@tx.Id
</a>
</td>
@if (tx.Positive)
{
<td class="text-end text-success">@tx.Balance</td>
}
else
{
<td class="text-end text-danger">@tx.Balance</td>
}
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-secondary mt-3 mb-0">
There are no recent transactions.
</p>
}
</div>

View File

@ -0,0 +1,13 @@
using System;
namespace BTCPayServer.Components.StoreRecentTransactions;
public class StoreRecentTransactionViewModel
{
public string Id { get; set; }
public string Balance { get; set; }
public bool Positive { get; set; }
public bool IsConfirmed { get; set; }
public string Link { get; set; }
public DateTimeOffset Timestamp { get; set; }
}

View File

@ -0,0 +1,116 @@
using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Data;
using BTCPayServer.Services;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Services.Stores;
using Dapper;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NBXplorer.Client;
using static BTCPayServer.Components.StoreRecentTransactions.StoreRecentTransactionsViewModel;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
namespace BTCPayServer.Components.StoreRecentTransactions;
public class StoreRecentTransactions : ViewComponent
{
private const string CryptoCode = "BTC";
private readonly StoreRepository _storeRepo;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly BTCPayWalletProvider _walletProvider;
public BTCPayNetworkProvider NetworkProvider { get; }
public NBXplorerConnectionFactory ConnectionFactory { get; }
public StoreRecentTransactions(
StoreRepository storeRepo,
BTCPayNetworkProvider networkProvider,
NBXplorerConnectionFactory connectionFactory,
BTCPayWalletProvider walletProvider,
ApplicationDbContextFactory dbContextFactory)
{
_storeRepo = storeRepo;
NetworkProvider = networkProvider;
ConnectionFactory = connectionFactory;
_walletProvider = walletProvider;
_dbContextFactory = dbContextFactory;
}
public async Task<IViewComponentResult> InvokeAsync(StoreData store)
{
var walletId = new WalletId(store.Id, CryptoCode);
var derivationSettings = store.GetDerivationSchemeSettings(NetworkProvider, walletId.CryptoCode);
var transactions = new List<StoreRecentTransactionViewModel>();
if (derivationSettings?.AccountDerivation is not null)
{
if (ConnectionFactory.Available)
{
var wallet_id = derivationSettings.GetNBXWalletId();
await using var conn = await ConnectionFactory.OpenConnection();
var rows = await conn.QueryAsync(
"SELECT t.tx_id, t.seen_at, to_btc(balance_change::NUMERIC) balance_change, (t.blk_id IS NOT NULL) confirmed " +
"FROM get_wallets_recent(@wallet_id, @code, @interval, 5, 0) " +
"JOIN txs t USING (code, tx_id) " +
"ORDER BY seen_at DESC;",
new
{
wallet_id,
code = CryptoCode,
interval = TimeSpan.FromDays(31)
});
var network = derivationSettings.Network;
foreach (var r in rows)
{
var seenAt = new DateTimeOffset(((DateTime)r.seen_at));
var balanceChange = new Money((decimal)r.balance_change, MoneyUnit.BTC);
transactions.Add(new StoreRecentTransactionViewModel()
{
Timestamp = seenAt,
Id = r.tx_id,
Balance = balanceChange.ShowMoney(network),
IsConfirmed = r.confirmed,
Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, r.tx_id),
Positive = balanceChange.GetValue(network) >= 0,
});
}
}
else
{
var network = derivationSettings.Network;
var wallet = _walletProvider.GetWallet(network);
var allTransactions = await wallet.FetchTransactions(derivationSettings.AccountDerivation);
transactions = allTransactions.UnconfirmedTransactions.Transactions
.Concat(allTransactions.ConfirmedTransactions.Transactions).ToArray()
.OrderByDescending(t => t.Timestamp)
.Take(5)
.Select(tx => new StoreRecentTransactionViewModel
{
Id = tx.TransactionId.ToString(),
Positive = tx.BalanceChange.GetValue(network) >= 0,
Balance = tx.BalanceChange.ShowMoney(network),
IsConfirmed = tx.Confirmations != 0,
Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, tx.TransactionId.ToString()),
Timestamp = tx.Timestamp
})
.ToList();
}
}
var vm = new StoreRecentTransactionsViewModel
{
Store = store,
WalletId = walletId,
Transactions = transactions
};
return View(vm);
}
}

View File

@ -0,0 +1,12 @@
using System.Collections;
using System.Collections.Generic;
using BTCPayServer.Data;
namespace BTCPayServer.Components.StoreRecentTransactions;
public class StoreRecentTransactionsViewModel
{
public StoreData Store { get; set; }
public IList<StoreRecentTransactionViewModel> Transactions { get; set; } = new List<StoreRecentTransactionViewModel>();
public WalletId WalletId { get; set; }
}

View File

@ -47,11 +47,7 @@ else
@foreach (var option in Model.Options)
{
<li>
@if (option.IsOwner && option.WalletId != null)
{
<a asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@option.WalletId" class="dropdown-item@(option.Selected ? " active" : "")" id="StoreSelectorMenuItem-@option.Value">@StoreName(option.Text)</a>
}
else if (option.IsOwner)
@if (option.IsOwner)
{
<a asp-controller="UIStores" asp-action="Dashboard" asp-route-storeId="@option.Value" class="dropdown-item@(option.Selected ? " active" : "")" id="StoreSelectorMenuItem-@option.Value">@StoreName(option.Text)</a>
}

View File

@ -0,0 +1,57 @@
@using BTCPayServer.Services.Wallets
@model BTCPayServer.Components.StoreWalletBalance.StoreWalletBalanceViewModel
<div id="StoreWalletBalance-@Model.Store.Id" class="widget store-wallet-balance">
<h6 class="mb-2">Wallet Balance</h6>
<header class="mb-3">
<div class="balance">
<h3 class="d-inline-block me-1">@Model.Balance</h3>
<span class="text-secondary fw-semibold">@Model.CryptoCode</span>
</div>
<div class="btn-group mt-1" role="group" aria-label="Filter">
<input type="radio" class="btn-check" name="filter" id="filter-week" value="week" @(Model.Type == WalletHistogramType.Week ? "checked" : "")>
<label class="btn btn-link" for="filter-week">1W</label>
<input type="radio" class="btn-check" name="filter" id="filter-month" value="month" @(Model.Type == WalletHistogramType.Month ? "checked" : "")>
<label class="btn btn-link" for="filter-month">1M</label>
<input type="radio" class="btn-check" name="filter" id="filter-year" value="year" @(Model.Type == WalletHistogramType.Year ? "checked" : "")>
<label class="btn btn-link" for="filter-year">1Y</label>
</div>
</header>
<div class="ct-chart ct-major-eleventh"></div>
<script>
(function () {
const id = 'StoreWalletBalance-@Model.Store.Id';
const baseUrl = @Safe.Json(Url.Action("WalletHistogram", "UIWallets", new { walletId = Model.WalletId, type = WalletHistogramType.Week }));
const render = data => {
const { series, labels, balance } = data;
document.querySelector(`#${id} h3`).innerText = balance;
const min = Math.min(...series);
const max = Math.max(...series);
const low = Math.max(min - ((max - min) / 5), 0);
new Chartist.Line(`#${id} .ct-chart`, {
labels,
series: [series]
}, {
low,
fullWidth: true,
showArea: true
});
};
const update = async type => {
const url = baseUrl.replace(/\/week$/gi, `/${type}`);
const response = await fetch(url);
if (response.ok) {
const json = await response.json();
render(json);
}
};
render({ series: @Safe.Json(Model.Series), labels: @Safe.Json(Model.Labels), balance: @Safe.Json(Model.Balance) });
document.addEventListener('DOMContentLoaded', () => {
delegate('change', `#${id} [name="filter"]`, async e => {
const type = e.target.value;
await update(type);
})
})
})();
</script>
</div>

View File

@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Dapper;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NBXplorer;
using NBXplorer.Client;
namespace BTCPayServer.Components.StoreWalletBalance;
public class StoreWalletBalance : ViewComponent
{
private const string CryptoCode = "BTC";
private const WalletHistogramType DefaultType = WalletHistogramType.Week;
private readonly StoreRepository _storeRepo;
private readonly WalletHistogramService _walletHistogramService;
public StoreWalletBalance(StoreRepository storeRepo, WalletHistogramService walletHistogramService)
{
_storeRepo = storeRepo;
_walletHistogramService = walletHistogramService;
}
public async Task<IViewComponentResult> InvokeAsync(StoreData store)
{
var walletId = new WalletId(store.Id, CryptoCode);
var data = await _walletHistogramService.GetHistogram(store, walletId, DefaultType);
var vm = new StoreWalletBalanceViewModel
{
Store = store,
CryptoCode = CryptoCode,
WalletId = walletId,
Series = data?.Series,
Labels = data?.Labels,
Balance = data?.Balance ?? 0,
Type = DefaultType
};
return View(vm);
}
}

View File

@ -0,0 +1,16 @@
using System.Collections.Generic;
using BTCPayServer.Data;
using BTCPayServer.Services.Wallets;
namespace BTCPayServer.Components.StoreWalletBalance;
public class StoreWalletBalanceViewModel
{
public decimal Balance { get; set; }
public string CryptoCode { get; set; }
public StoreData Store { get; set; }
public WalletId WalletId { get; set; }
public WalletHistogramType Type { get; set; }
public IList<string> Labels { get; set; } = new List<string>();
public IList<decimal> Series { get; set; } = new List<decimal>();
}

View File

@ -50,6 +50,8 @@ namespace BTCPayServer.Configuration
app.Option("--recommended-plugins", "Plugins which would be marked as recommended to be installed. Separated by newline or space", CommandOptionType.MultipleValue);
app.Option("--xforwardedproto", "If specified, set X-Forwarded-Proto to the specified value, this may be useful if your reverse proxy handle https but is not configured to add X-Forwarded-Proto (example: --xforwardedproto https)", CommandOptionType.SingleValue);
app.Option("--cheatmode", "Add elements in the UI to facilitate dev-time testing (Default false)", CommandOptionType.BoolValue);
app.Option("--explorerpostgres", $"Connection string to the postgres database of NBXplorer. (optional, used for dashboard and reporting features)", CommandOptionType.SingleValue);
foreach (var network in provider.GetAll().OfType<BTCPayNetwork>())
{
var crypto = network.CryptoCode.ToLowerInvariant();

View File

@ -9,5 +9,6 @@ namespace BTCPayServer.Configuration
get;
set;
} = new List<NBXplorerConnectionSetting>();
public string ConnectionString { get; set; }
}
}

View File

@ -134,31 +134,44 @@ namespace BTCPayServer.Controllers
}).ToList();
}
public StoreData CurrentStore
{
get
{
return this.HttpContext.GetStoreData();
}
}
public StoreData CurrentStore => HttpContext.GetStoreData();
[HttpGet("{storeId}")]
public IActionResult Dashboard()
public async Task<IActionResult> Dashboard()
{
var store = CurrentStore;
var storeBlob = store.GetStoreBlob();
AddPaymentMethods(store, storeBlob,
out var derivationSchemes, out var lightningNodes);
var walletEnabled = derivationSchemes.Any(scheme => !string.IsNullOrEmpty(scheme.Value) && scheme.Enabled);
var lightningEnabled = lightningNodes.Any(ln => !string.IsNullOrEmpty(ln.Address) && ln.Enabled);
var vm = new StoreDashboardViewModel
{
WalletEnabled = derivationSchemes.Any(scheme => !string.IsNullOrEmpty(scheme.Value) && scheme.Enabled),
LightningEnabled = lightningNodes.Any(ln => !string.IsNullOrEmpty(ln.Address) && ln.Enabled),
WalletEnabled = walletEnabled,
LightningEnabled = lightningEnabled,
StoreId = CurrentStore.Id,
StoreName = CurrentStore.StoreName
StoreName = CurrentStore.StoreName,
IsSetUp = walletEnabled || lightningEnabled
};
// Widget data
if (vm.WalletEnabled || vm.LightningEnabled)
{
var userId = GetUserId();
var apps = await _appService.GetAllApps(userId, false, store.Id);
vm.Apps = apps
.Where(a => a.AppType == AppType.Crowdfund.ToString())
.Select(a =>
{
var appData = _appService.GetAppDataIfOwner(userId, a.Id, AppType.Crowdfund).Result;
appData.StoreData = store;
return appData;
})
.ToList();
}
return View("Dashboard", vm);
}

View File

@ -22,11 +22,14 @@ using BTCPayServer.Services.Labels;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Dapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using NBitcoin;
using NBXplorer;
using NBXplorer.Client;
using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
using Newtonsoft.Json;
@ -43,7 +46,7 @@ namespace BTCPayServer.Controllers
private WalletRepository WalletRepository { get; }
private BTCPayNetworkProvider NetworkProvider { get; }
private ExplorerClientProvider ExplorerClientProvider { get; }
public IServiceProvider ServiceProvider { get; }
public RateFetcher RateFetcher { get; }
private readonly UserManager<ApplicationUser> _userManager;
@ -62,6 +65,8 @@ namespace BTCPayServer.Controllers
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly PullPaymentHostedService _pullPaymentService;
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
private readonly NBXplorerConnectionFactory _connectionFactory;
private readonly WalletHistogramService _walletHistogramService;
readonly CurrencyNameTable _currencyTable;
public UIWalletsController(StoreRepository repo,
@ -71,6 +76,8 @@ namespace BTCPayServer.Controllers
UserManager<ApplicationUser> userManager,
MvcNewtonsoftJsonOptions mvcJsonOptions,
NBXplorerDashboard dashboard,
WalletHistogramService walletHistogramService,
NBXplorerConnectionFactory connectionFactory,
RateFetcher rateProvider,
IAuthorizationService authorizationService,
ExplorerClientProvider explorerProvider,
@ -85,7 +92,8 @@ namespace BTCPayServer.Controllers
ApplicationDbContextFactory dbContextFactory,
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
PullPaymentHostedService pullPaymentService,
IEnumerable<IPayoutHandler> payoutHandlers)
IEnumerable<IPayoutHandler> payoutHandlers,
IServiceProvider serviceProvider)
{
_currencyTable = currencyTable;
Repository = repo;
@ -109,6 +117,9 @@ namespace BTCPayServer.Controllers
_jsonSerializerSettings = jsonSerializerSettings;
_pullPaymentService = pullPaymentService;
_payoutHandlers = payoutHandlers;
ServiceProvider = serviceProvider;
_connectionFactory = connectionFactory;
_walletHistogramService = walletHistogramService;
}
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
@ -351,6 +362,19 @@ namespace BTCPayServer.Controllers
return View(model);
}
[HttpGet("{walletId}/histogram/{type}")]
public async Task<IActionResult> WalletHistogram(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletHistogramType type)
{
var store = GetCurrentStore();
var data = await _walletHistogramService.GetHistogram(store, walletId, type);
return data == null
? NotFound()
: Json(data);
}
private static string GetLabelTarget(WalletId walletId, uint256 txId)
{
@ -416,10 +440,48 @@ namespace BTCPayServer.Controllers
case "generate-new-address":
await _walletReceiveService.GetOrGenerate(walletId, true);
break;
case "fill-wallet":
var cheater = ServiceProvider.GetService<Cheater>();
if (cheater != null)
await SendFreeMoney(cheater, walletId, paymentMethod);
break;
}
return RedirectToAction(nameof(WalletReceive), new { walletId });
}
private async Task SendFreeMoney(Cheater cheater, WalletId walletId, DerivationSchemeSettings paymentMethod)
{
var c = this.ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
var addresses = Enumerable.Range(0, 200).Select(_ => c.GetUnusedAsync(paymentMethod.AccountDerivation, DerivationFeature.Deposit, reserve: true)).ToArray();
await Task.WhenAll(addresses);
await cheater.CashCow.GenerateAsync(addresses.Length / 8);
var b = cheater.CashCow.PrepareBatch();
Random r = new Random();
List<Task<uint256>> sending = new List<Task<uint256>>();
foreach (var a in addresses)
{
sending.Add(b.SendToAddressAsync((await a).Address, Money.Coins(0.1m) + Money.Satoshis(r.Next(0, 90_000_000))));
}
await b.SendBatchAsync();
await cheater.CashCow.GenerateAsync(1);
var factory = ServiceProvider.GetService<NBXplorerConnectionFactory>();
// Wait it sync...
await Task.Delay(1000);
await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode).WaitServerStartedAsync();
await Task.Delay(1000);
await using var conn = await factory.OpenConnection();
var wallet_id = paymentMethod.GetNBXWalletId();
var txIds = sending.Select(s => s.Result.ToString()).ToArray();
await conn.ExecuteAsync(
"UPDATE txs t SET seen_at=(NOW() - (random() * (interval '90 days'))) " +
"FROM unnest(@txIds) AS r (tx_id) WHERE r.tx_id=t.tx_id;", new { txIds });
await Task.Delay(1000);
await conn.ExecuteAsync("REFRESH MATERIALIZED VIEW wallets_history;");
}
private async Task<bool> CanUseHotWallet()
{
var policies = await _settingsRepository.GetSettingAsync<PoliciesSettings>();

View File

@ -5,6 +5,7 @@ using System.Text;
using BTCPayServer.Payments;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBXplorer.Client;
using NBXplorer.DerivationStrategy;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -42,6 +43,10 @@ namespace BTCPayServer
return strategy != null;
}
public string GetNBXWalletId()
{
return AccountDerivation is null ? null : DBUtils.nbxv1_get_wallet_id(Network.CryptoCode, AccountDerivation.ToString());
}
private static bool TryParseXpub(string xpub, DerivationSchemeParser derivationSchemeParser, ref DerivationSchemeSettings derivationSchemeSettings, bool electrum = true)
{
if (!electrum)

View File

@ -117,6 +117,7 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<EventAggregator>();
services.TryAddSingleton<PaymentRequestService>();
services.TryAddSingleton<UserService>();
services.TryAddSingleton<WalletHistogramService>();
services.AddSingleton<ApplicationDbContextFactory>();
services.AddOptions<BTCPayServerOptions>().Configure(
(options) =>
@ -176,6 +177,7 @@ namespace BTCPayServer.Hosting
btcPayNetwork.NBXplorerNetwork.DefaultSettings.DefaultCookieFile)
};
options.NBXplorerConnectionSettings.Add(setting);
options.ConnectionString = configuration.GetOrDefault<string>("explorer.postgres", null);
}
});
services.AddOptions<LightningNetworkOptions>().Configure<BTCPayNetworkProvider>(
@ -310,6 +312,8 @@ namespace BTCPayServer.Hosting
o.ModelMetadataDetailsProviders.Add(new SuppressChildValidationMetadataProvider(typeof(DerivationStrategyBase)));
});
services.AddSingleton<Services.NBXplorerConnectionFactory>();
services.AddSingleton<IHostedService, Services.NBXplorerConnectionFactory>(o => o.GetRequiredService<Services.NBXplorerConnectionFactory>());
services.AddSingleton<HostedServices.CheckConfigurationHostedService>();
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<CheckConfigurationHostedService>());
services.AddSingleton<HostedServices.WebhookSender>();

View File

@ -1,3 +1,6 @@
using System.Collections.Generic;
using BTCPayServer.Data;
namespace BTCPayServer.Models.StoreViewModels;
public class StoreDashboardViewModel
@ -6,4 +9,6 @@ public class StoreDashboardViewModel
public string StoreName { get; set; }
public bool WalletEnabled { get; set; }
public bool LightningEnabled { get; set; }
public bool IsSetUp { get; set; }
public List<AppData> Apps { get; set; }
}

View File

@ -29,7 +29,8 @@
"BTCPAY_UPDATEURL": "",
"BTCPAY_DOCKERDEPLOYMENT": "true",
"BTCPAY_RECOMMENDED-PLUGINS": "BTCPayServer.Plugins.Test",
"BTCPAY_CHEATMODE": "true"
"BTCPAY_CHEATMODE": "true",
"BTCPAY_EXPLORERPOSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=nbxplorer"
},
"applicationUrl": "http://127.0.0.1:14142/"
},
@ -66,7 +67,8 @@
"BTCPAY_SOCKSENDPOINT": "localhost:9050",
"BTCPAY_DOCKERDEPLOYMENT": "true",
"BTCPAY_RECOMMENDED-PLUGINS": "BTCPayServer.Plugins.Test",
"BTCPAY_CHEATMODE": "true"
"BTCPAY_CHEATMODE": "true",
"BTCPAY_EXPLORERPOSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=nbxplorer"
},
"applicationUrl": "https://localhost:14142/"
},
@ -105,7 +107,8 @@
"BTCPAY_SOCKSENDPOINT": "localhost:9050",
"BTCPAY_DOCKERDEPLOYMENT": "true",
"BTCPAY_RECOMMENDED-PLUGINS": "BTCPayServer.Plugins.Test",
"BTCPAY_CHEATMODE": "true"
"BTCPAY_CHEATMODE": "true",
"BTCPAY_EXPLORERPOSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=nbxplorer"
},
"applicationUrl": "https://localhost:14142/"
}

View File

@ -90,9 +90,9 @@ namespace BTCPayServer.Services.Apps
}
var invoices = await GetInvoicesForApp(appData, lastResetDate);
var completeInvoices = invoices.Where(entity => entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed).ToArray();
var pendingInvoices = invoices.Where(entity => !(entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed)).ToArray();
var paidInvoices = invoices.Where(entity => entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed || entity.Status == InvoiceStatusLegacy.Paid).ToArray();
var completeInvoices = invoices.Where(IsComplete).ToArray();
var pendingInvoices = invoices.Where(IsPending).ToArray();
var paidInvoices = invoices.Where(IsPaid).ToArray();
var pendingPayments = GetContributionsByPaymentMethodId(settings.TargetCurrency, pendingInvoices, !settings.EnforceTargetAmount);
var currentPayments = GetContributionsByPaymentMethodId(settings.TargetCurrency, completeInvoices, !settings.EnforceTargetAmount);
@ -102,11 +102,12 @@ namespace BTCPayServer.Services.Apps
.GroupBy(entity => entity.Metadata.ItemCode)
.ToDictionary(entities => entities.Key, entities => entities.Count());
Dictionary<string, decimal> perkValue = new Dictionary<string, decimal>();
Dictionary<string, decimal> perkValue = new();
if (settings.DisplayPerksValue)
{
perkValue = paidInvoices
.Where(entity => entity.Currency.Equals(settings.TargetCurrency, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(entity.Metadata.ItemCode))
.Where(entity => entity.Currency.Equals(settings.TargetCurrency, StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrEmpty(entity.Metadata.ItemCode))
.GroupBy(entity => entity.Metadata.ItemCode)
.ToDictionary(entities => entities.Key, entities =>
entities.Sum(entity => entity.GetPayments(true).Sum(pay =>
@ -117,6 +118,7 @@ namespace BTCPayServer.Services.Apps
return rate * value;
})));
}
var perks = Parse(settings.PerksTemplate, settings.TargetCurrency);
if (settings.SortPerksByPopularity)
{
@ -160,10 +162,9 @@ namespace BTCPayServer.Services.Apps
Sounds = settings.Sounds,
AnimationColors = settings.AnimationColors,
CurrencyData = _Currencies.GetCurrencyData(settings.TargetCurrency, true),
CurrencyDataPayments = currentPayments.Select(pair => pair.Key)
.Concat(pendingPayments.Select(pair => pair.Key))
.Select(id => _Currencies.GetCurrencyData(id.CryptoCode, true))
.DistinctBy(data => data.Code)
CurrencyDataPayments = Enumerable.DistinctBy(currentPayments.Select(pair => pair.Key)
.Concat(pendingPayments.Select(pair => pair.Key))
.Select(id => _Currencies.GetCurrencyData(id.CryptoCode, true)), data => data.Code)
.ToDictionary(data => data.Code, data => data),
Info = new CrowdfundInfo
{
@ -181,12 +182,101 @@ namespace BTCPayServer.Services.Apps
};
}
private static bool IsPending(InvoiceEntity entity)
{
return !(entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed);
}
private static bool IsComplete(InvoiceEntity entity)
{
return entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed;
}
public async Task<IEnumerable<ItemStats>> GetPerkStats(AppData appData)
{
var settings = appData.GetSettings<CrowdfundSettings>();
var invoices = await GetInvoicesForApp(appData);
var paidInvoices = invoices.Where(IsPaid).ToArray();
var currencyData = _Currencies.GetCurrencyData(settings.TargetCurrency, true);
var perks = Parse(settings.PerksTemplate, settings.TargetCurrency);
var perkCount = paidInvoices
.Where(entity => !string.IsNullOrEmpty(entity.Metadata.ItemCode) &&
entity.Currency.Equals(settings.TargetCurrency, StringComparison.OrdinalIgnoreCase))
.GroupBy(entity => entity.Metadata.ItemCode)
.Select(entities =>
{
var total = entities
.Sum(entity => entity.GetPayments(true)
.Sum(pay => {
var paymentMethodId = pay.GetPaymentMethodId();
var value = pay.GetCryptoPaymentData().GetValue() - pay.NetworkFee;
var rate = entity.GetPaymentMethod(paymentMethodId).Rate;
return rate * value;
}));
var itemCode = entities.Key;
var perk = perks.First(p => p.Id == itemCode);
return new ItemStats
{
ItemCode = itemCode,
Title = perk.Title,
SalesCount = entities.Count(),
Total = total,
TotalFormatted = $"{total.ShowMoney(currencyData.Divisibility)} {settings.TargetCurrency}"
};
})
.OrderByDescending(stats => stats.SalesCount);
return perkCount;
}
public async Task<SalesStats> GetSalesStats(AppData appData, int numberOfDays = 7)
{
var invoices = await GetInvoicesForApp(appData);
var paidInvoices = invoices.Where(IsPaid).ToArray();
var series = paidInvoices
.Where(entity => !string.IsNullOrEmpty(entity.Metadata.ItemCode) &&
entity.InvoiceTime > DateTimeOffset.UtcNow - TimeSpan.FromDays(numberOfDays))
.GroupBy(entity => entity.InvoiceTime.Date)
.Select(entities => new SalesStatsItem
{
Date = entities.Key,
Label = entities.Key.ToString("MMM dd", CultureInfo.InvariantCulture),
SalesCount = entities.Count()
});
// fill up the gaps
foreach (var i in Enumerable.Range(0, numberOfDays))
{
var date = (DateTimeOffset.UtcNow - TimeSpan.FromDays(i)).Date;
if (!series.Any(e => e.Date == date))
{
series = series.Append(new SalesStatsItem
{
Date = date,
Label = date.ToString("MMM dd", CultureInfo.InvariantCulture)
});
}
}
return new SalesStats
{
SalesCount = paidInvoices.Length,
Series = series.OrderBy(i => i.Label)
};
}
private static bool IsPaid(InvoiceEntity entity)
{
return entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed || entity.Status == InvoiceStatusLegacy.Paid;
}
public static string GetCrowdfundOrderId(string appId) => $"crowdfund-app_{appId}";
public static string GetAppInternalTag(string appId) => $"APP#{appId}";
public static string[] GetAppInternalTags(InvoiceEntity invoice)
{
return invoice.GetInternalTags("APP#");
}
private async Task<InvoiceEntity[]> GetInvoicesForApp(AppData appData, DateTime? startDate = null)
{
var invoices = await _InvoiceRepository.GetInvoices(new InvoiceQuery()
@ -572,4 +662,26 @@ namespace BTCPayServer.Services.Apps
return true;
}
}
public class ItemStats
{
public string ItemCode { get; set; }
public string Title { get; set; }
public int SalesCount { get; set; }
public decimal Total { get; set; }
public string TotalFormatted { get; set; }
}
public class SalesStats
{
public int SalesCount { get; set; }
public IEnumerable<SalesStatsItem> Series { get; set; }
}
public class SalesStatsItem
{
public DateTime Date { get; set; }
public string Label { get; set; }
public int SalesCount { get; set; }
}
}

View File

@ -0,0 +1,70 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using BTCPayServer.Configuration;
using Microsoft.Extensions.Options;
using System.Data.Common;
using System;
using BTCPayServer.Logging;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace BTCPayServer.Services
{
public class NBXplorerConnectionFactory : IHostedService
{
public NBXplorerConnectionFactory(IOptions<NBXplorerOptions> nbXplorerOptions, Logs logs)
{
connectionString = nbXplorerOptions.Value.ConnectionString;
Logs = logs;
}
string connectionString;
public bool Available { get; set; }
public Logs Logs { get; }
async Task IHostedService.StartAsync(CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(connectionString))
{
Available = true;
try
{
await using var conn = await OpenConnection();
Logs.Configuration.LogInformation("Connection to NBXplorer's database successful, dashboard and reporting features activated.");
}
catch (Exception ex)
{
throw new ConfigException("Error while trying to connection to explorer.postgres: " + ex.Message);
}
}
}
public async Task<DbConnection> OpenConnection()
{
int maxRetries = 10;
int retries = maxRetries;
retry:
var conn = new Npgsql.NpgsqlConnection(connectionString);
try
{
await conn.OpenAsync();
}
catch (PostgresException ex) when (ex.IsTransient && retries > 0)
{
retries--;
await conn.DisposeAsync();
await Task.Delay((maxRetries - retries) * 100);
goto retry;
}
return conn;
}
Task IHostedService.StopAsync(CancellationToken cancellationToken)
{
Npgsql.NpgsqlConnection.ClearAllPools();
return Task.CompletedTask;
}
}
}

View File

@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.Data;
using Dapper;
namespace BTCPayServer.Services.Wallets;
public enum WalletHistogramType
{
Week,
Month,
Year
}
public class WalletHistogramService
{
private readonly BTCPayNetworkProvider _networkProvider;
private readonly NBXplorerConnectionFactory _connectionFactory;
public WalletHistogramService(
BTCPayNetworkProvider networkProvider,
NBXplorerConnectionFactory connectionFactory)
{
_networkProvider = networkProvider;
_connectionFactory = connectionFactory;
}
public async Task<WalletHistogramData> GetHistogram(StoreData store, WalletId walletId, WalletHistogramType type)
{
// https://github.com/dgarage/NBXplorer/blob/master/docs/Postgres-Schema.md
if (_connectionFactory.Available)
{
var derivationSettings = store.GetDerivationSchemeSettings(_networkProvider, walletId.CryptoCode);
if (derivationSettings != null)
{
var wallet_id = derivationSettings.GetNBXWalletId();
await using var conn = await _connectionFactory.OpenConnection();
var code = walletId.CryptoCode;
var to = DateTimeOffset.UtcNow;
var labelCount = 6;
(var days, var pointCount) = type switch
{
WalletHistogramType.Week => (7, 30),
WalletHistogramType.Month => (30, 30),
WalletHistogramType.Year => (365, 30),
_ => throw new ArgumentException($"WalletHistogramType {type} does not exist.")
};
var from = to - TimeSpan.FromDays(days);
var interval = TimeSpan.FromTicks((to - from).Ticks / pointCount);
var balance = await conn.ExecuteScalarAsync<decimal>(
"SELECT to_btc(available_balance) FROM wallets_balances WHERE wallet_id=@wallet_id AND code=@code AND asset_id=''",
new { code, wallet_id });
var rows = await conn.QueryAsync("SELECT date, to_btc(balance) balance FROM get_wallets_histogram(@wallet_id, @code, '', @from, @to, @interval)",
new { code, wallet_id, from, to, interval });
var data = rows.AsList();
var series = new List<decimal>(pointCount);
var labels = new List<string>(labelCount);
var labelEvery = pointCount / labelCount;
for (int i = 0; i < data.Count; i++)
{
var r = data[i];
series.Add((decimal)r.balance);
labels.Add((i % labelEvery == 0)
? ((DateTime)r.date).ToString("MMM dd", CultureInfo.InvariantCulture)
: null);
}
series[^1] = balance;
return new WalletHistogramData
{
Series = series,
Labels = labels,
Balance = balance,
Type = type
};
}
}
return null;
}
}
public class WalletHistogramData
{
public WalletHistogramType Type { get; set; }
public List<decimal> Series { get; set; }
public List<string> Labels { get; set; }
public decimal Balance { get; set; }
}

View File

@ -39,31 +39,6 @@
.dropdown-item {
cursor: pointer;
}
.badge-new {
background: #d4edda;
color: #000;
}
.badge-expired {
background: #eee;
color: #000;
}
.badge-invalid {
background: #c94a47;
color: #fff;
}
.badge-processing {
background: #f1c332;
color: #000;
}
.badge-settled {
background: #329f80;
color: #fff;
}
/* pull mass action form up, so that it is besides the search form */
@@media (min-width: 1200px) {

View File

@ -2,67 +2,150 @@
@inject BTCPayNetworkProvider networkProvider
@{
ViewData.SetActivePage(StoreNavPages.Dashboard, Model.StoreName, Model.StoreId);
var isReady = Model.WalletEnabled || Model.LightningEnabled;
var defaultCryptoCode = networkProvider.DefaultNetwork.CryptoCode;
ViewData.SetActivePage(StoreNavPages.Dashboard, Model.StoreName, Model.StoreId);
var defaultCryptoCode = networkProvider.DefaultNetwork.CryptoCode;
var store = ViewContext.HttpContext.GetStoreData();
}
<partial name="_StatusMessage" />
<h2 class="mt-1 mb-3">@ViewData["Title"]</h2>
<div class="d-flex align-items-center justify-content-between">
<h2 class="mb-0">@ViewData["Title"]</h2>
<button type="button" class="btn btn-secondary only-for-js" data-bs-toggle="modal" data-bs-target="#WhatsNew">What's New</button>
</div>
@if (isReady)
<div class="modal fade" id="WhatsNew" tabindex="-1" aria-labelledby="WhatsNewTitle" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="WhatsNewTitle">What's New</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body">
<h5 class="alert-heading">Updated in v1.5.0</h5>
<p class="mb-0">Stores now have a neat dashboard like the one you see here! 🗠🎉</p>
<hr style="height:1px;background-color:var(--btcpay-body-text-muted);margin:var(--btcpay-space-m) 0;" />
<h5 class="alert-heading">Updated in v1.4.0</h5>
<p class="mb-2">Invoice states have been updated to match the Greenfield API:</p>
<ul class="list-unstyled mb-md-0">
<li>
<span class="badge badge-processing">Paid</span>
<span class="mx-1">is now shown as</span>
<span class="badge badge-processing">Processing</span>
</li>
<li class="mt-2">
<span class="badge badge-settled">Completed</span>
<span class="mx-1">is now shown as</span>
<span class="badge badge-settled">Settled</span>
</li>
<li class="mt-2">
<span class="badge badge-settled">Confirmed</span>
<span class="mx-1">is now shown as</span>
<span class="badge badge-settled">Settled</span>
</li>
</ul>
</div>
</div>
</div>
</div>
@if (Model.IsSetUp)
{
<p class="lead text-secondary">This store is ready to accept transactions, good job!</p>
/* include chart library inline so that it instantly renders */
<link rel="stylesheet" href="~/vendor/chartist/chartist.css" asp-append-version="true">
<script src="~/vendor/chartist/chartist.min.js" asp-append-version="true"></script>
<div id="Dashboard" class="mt-4">
@if (Model.WalletEnabled)
{
<vc:store-wallet-balance store="@store"/>
}
else
{
<div class="widget setup-guide">
<header>
<h5 class="mb-4 text-muted">This store is ready to accept transactions, good job!</h5>
</header>
<div class="list-group" id="SetupGuide">
<div class="list-group-item d-flex align-items-center" id="SetupGuide-LightningDone">
<vc:icon symbol="done"/>
<div class="content">
<h5 class="mb-0 text-success">Set up a Lightning node</h5>
</div>
</div>
<a asp-controller="UIStores" asp-action="SetupWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@defaultCryptoCode" id="SetupGuide-Wallet" class="list-group-item list-group-item-action d-flex align-items-center">
<vc:icon symbol="new-wallet"/>
<div class="content">
<h5 class="mb-0">Set up a wallet</h5>
</div>
<vc:icon symbol="caret-right"/>
</a>
</div>
</div>
}
<vc:store-numbers store="@store"/>
@if (Model.WalletEnabled)
{
<vc:store-recent-transactions store="@store"/>
}
<vc:store-recent-invoices store="@store"/>
@foreach (var app in Model.Apps)
{
<vc:app-sales app="@app"/>
<vc:app-top-items app="@app"/>
}
</div>
}
else
{
<p class="lead text-secondary">To start accepting payments, set up a wallet or a Lightning node.</p>
<div class="list-group" id="SetupGuide">
<div class="list-group-item d-flex align-items-center" id="SetupGuide-StoreDone">
<vc:icon symbol="done"/>
<div class="content">
<h5 class="mb-0 text-success">Create your store</h5>
</div>
</div>
@if (!Model.WalletEnabled)
{
<a asp-controller="UIStores" asp-action="SetupWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@defaultCryptoCode" id="SetupGuide-Wallet" class="list-group-item list-group-item-action d-flex align-items-center order-1">
<vc:icon symbol="new-wallet"/>
<div class="content">
<h5 class="mb-0">Set up a wallet</h5>
</div>
<vc:icon symbol="caret-right"/>
</a>
}
else
{
<div class="list-group-item d-flex align-items-center" id="SetupGuide-WalletDone">
<vc:icon symbol="done"/>
<div class="content">
<h5 class="mb-0 text-success">Set up a wallet</h5>
</div>
</div>
}
@if (!Model.LightningEnabled)
{
<a asp-controller="UIStores" asp-action="SetupLightningNode" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@defaultCryptoCode" id="SetupGuide-Lightning" class="list-group-item list-group-item-action d-flex align-items-center order-1">
<vc:icon symbol="new-wallet"/>
<div class="content">
<h5 class="mb-0">Set up a Lightning node</h5>
</div>
<vc:icon symbol="caret-right"/>
</a>
}
else
{
<div class="list-group-item d-flex align-items-center" id="SetupGuide-LightningDone">
<vc:icon symbol="done"/>
<div class="content">
<h5 class="mb-0 text-success">Set up a Lightning node</h5>
</div>
</div>
}
</div>
}
<div class="list-group" id="SetupGuide">
<div class="list-group-item d-flex align-items-center" id="SetupGuide-StoreDone">
<vc:icon symbol="done"/>
<div class="content">
<h5 class="mb-0 text-success">Create your store</h5>
</div>
</div>
@if (!Model.WalletEnabled)
{
<a asp-controller="UIStores" asp-action="SetupWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@defaultCryptoCode" id="SetupGuide-Wallet" class="list-group-item list-group-item-action d-flex align-items-center order-1">
<vc:icon symbol="new-wallet"/>
<div class="content">
<h5 class="mb-0">Set up a wallet</h5>
</div>
<vc:icon symbol="caret-right"/>
</a>
}
else
{
<div class="list-group-item d-flex align-items-center" id="SetupGuide-WalletDone">
<vc:icon symbol="done"/>
<div class="content">
<h5 class="mb-0 text-success">Set up a wallet</h5>
</div>
</div>
}
@if (!Model.LightningEnabled)
{
<a asp-controller="UIStores" asp-action="SetupLightningNode" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@defaultCryptoCode" id="SetupGuide-Lightning" class="list-group-item list-group-item-action d-flex align-items-center order-1">
<vc:icon symbol="new-wallet"/>
<div class="content">
<h5 class="mb-0">Set up a Lightning node</h5>
</div>
<vc:icon symbol="caret-right"/>
</a>
}
else
{
<div class="list-group-item d-flex align-items-center" id="SetupGuide-LightningDone">
<vc:icon symbol="done"/>
<div class="content">
<h5 class="mb-0 text-success">Set up a Lightning node</h5>
</div>
</div>
}
</div>

View File

@ -1,4 +1,5 @@
@addTagHelper *, BundlerMinifier.TagHelpers
@inject BTCPayServer.Services.BTCPayServerEnvironment env
@model BTCPayServer.Controllers.WalletReceiveViewModel
@{
var walletId = Context.GetRouteValue("walletId").ToString();
@ -17,6 +18,10 @@
@if (string.IsNullOrEmpty(Model.Address))
{
<button id="generateButton" class="btn btn-primary" type="submit" name="command" value="generate-new-address">Generate next available @Model.CryptoCode address</button>
@if (env.CheatMode)
{
<button type="submit" name="command" value="fill-wallet" class="btn btn-info ms-3">Cheat Mode: Send transactions to this wallet</button>
}
}
else
{
@ -84,7 +89,7 @@
<div class="col-12 col-sm-6 mt-4 mt-sm-0">
<button type="submit" name="command" value="unreserve-current-address" class="btn btn-secondary w-100">Unreserve this address</button>
</div>
</div>
</div>
</div>
}
</form>

View File

@ -70,6 +70,32 @@ a.unobtrusive-link {
text-decoration: inherit;
}
/* Badges */
.badge-new {
background: #d4edda;
color: #000;
}
.badge-expired {
background: #eee;
color: #000;
}
.badge-invalid {
background: #c94a47;
color: #fff;
}
.badge-processing {
background: #f1c332;
color: #000;
}
.badge-settled {
background: #329f80;
color: #fff;
}
/* Info icons in main headline */
h2 small .fa-question-circle-o {
position: relative;
@ -227,3 +253,172 @@ svg.icon-note {
flex: 1;
padding: 1rem 0;
}
/* Dashboard */
#Dashboard {
display: grid;
gap: var(--btcpay-space-m);
grid-template-columns: repeat(12, 1fr);
}
.widget {
--widget-padding: var(--btcpay-space-m);
--widget-chart-width: 100vw;
border: 1px solid var(--btcpay-body-border-light);
border-radius: var(--btcpay-border-radius);
padding: var(--widget-padding);
background: var(--btcpay-bg-tile);
grid-column-start: 1;
grid-column-end: 13;
}
.widget header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-bottom: var(--btcpay-space-s);
gap: var(--btcpay-space-s);
line-height: 1.2;
}
.widget header a,
.widget header .btn-link {
margin-top: var(--btcpay-space-xs);
font-weight: var(--btcpay-font-weight-semibold);
}
.widget h3,
.widget .h3 {
font-weight: var(--btcpay-font-weight-bold);
margin-bottom: 0;
}
.widget h6,
.widget .h6 {
color: var(--btcpay-body-text-muted);
margin-bottom: 0;
}
.widget .btn-group {
display: inline-flex;
gap: var(--btcpay-space-m);
align-items: center;
justify-content: space-between;
}
.widget .btn-link {
color: var(--btcpay-body-text-muted);
padding: 0;
font-weight: var(--btcpay-font-weight-semibold);
box-shadow: none !important;
text-decoration: none !important;
}
.widget input:checked + .btn-link {
color: var(--btcpay-body-link-accent);
}
.widget.store-numbers {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--btcpay-space-l) var(--btcpay-space-xl);
}
.widget.store-numbers header {
justify-content: space-between;
}
.widget.store-numbers header h6 {
margin-right: var(--btcpay-space-s);
}
.widget header a,
.widget header .btn-link {
margin-top: 0;
}
.widget .store-number {
flex: 0 1 calc(50% - var(--btcpay-space-xl) / 2);
}
.widget .number {
font-weight: var(--btcpay-font-weight-bold);
}
.widget .table {
margin-left: -.5rem;
margin-right: -.5rem;
width: calc(100% + 1rem);
}
.widget .table th {
color: var(--btcpay-body-text-muted);
font-weight: var(--btcpay-font-weight-semibold);
}
.widget.app-top-items .app-items {
display: flex;
flex-direction: column;
gap: var(--btcpay-space-s);
}
.widget.app-top-items .app-item {
display: flex;
align-items: end;
justify-content: space-between;
}
.widget.app-top-items .app-item-value {
font-weight: var(--btcpay-font-weight-semibold);
}
@media (max-width: 575px) {
.widget .store-number {
flex: 0 1 100%;
}
}
@media (min-width: 576px) {
.widget {
--widget-padding: var(--btcpay-space-l);
}
}
@media (min-width: 1200px) {
.widget.app-sales,
.widget.setup-guide,
.widget.store-wallet-balance {
--widget-chart-width: 80vw;
grid-column-start: 1;
grid-column-end: 9;
}
.widget.app-top-items,
.widget.store-numbers {
grid-column-start: 9;
grid-column-end: 13;
}
.widget.store-numbers {
flex-direction: column;
justify-content: start;
}
.widget .store-number {
flex: 0 1;
width: 100%;
}
.widget.store-numbers header {
justify-content: space-between;
}
.widget.store-numbers header h6 {
margin-right: 0;
}
}

View File

@ -0,0 +1,557 @@
.ct-label {
fill: rgba(128, 128, 128, .4);
color: var(--btcpay-body-text-muted);
font-size: 0.75rem;
line-height: 1; }
.ct-label.ct-horizontal {
min-width: 3rem; }
.ct-chart-line .ct-label,
.ct-chart-bar .ct-label {
display: block;
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox;
display: -webkit-flex;
display: flex; }
.ct-chart-pie .ct-label,
.ct-chart-donut .ct-label {
dominant-baseline: central; }
.ct-label.ct-horizontal.ct-start {
-webkit-box-align: flex-end;
-webkit-align-items: flex-end;
-ms-flex-align: flex-end;
align-items: flex-end;
-webkit-box-pack: flex-start;
-webkit-justify-content: flex-start;
-ms-flex-pack: flex-start;
justify-content: flex-start;
text-align: left;
text-anchor: start; }
.ct-label.ct-horizontal.ct-end {
-webkit-box-align: flex-start;
-webkit-align-items: flex-start;
-ms-flex-align: flex-start;
align-items: flex-start;
-webkit-box-pack: flex-start;
-webkit-justify-content: flex-start;
-ms-flex-pack: flex-start;
justify-content: flex-start;
text-align: left;
text-anchor: start; }
.ct-label.ct-vertical.ct-start {
-webkit-box-align: flex-end;
-webkit-align-items: flex-end;
-ms-flex-align: flex-end;
align-items: flex-end;
-webkit-box-pack: flex-end;
-webkit-justify-content: flex-end;
-ms-flex-pack: flex-end;
justify-content: flex-end;
text-align: right;
text-anchor: end; }
.ct-label.ct-vertical.ct-end {
-webkit-box-align: flex-end;
-webkit-align-items: flex-end;
-ms-flex-align: flex-end;
align-items: flex-end;
-webkit-box-pack: flex-start;
-webkit-justify-content: flex-start;
-ms-flex-pack: flex-start;
justify-content: flex-start;
text-align: left;
text-anchor: start; }
.ct-chart-bar .ct-label.ct-horizontal.ct-start {
-webkit-box-align: flex-end;
-webkit-align-items: flex-end;
-ms-flex-align: flex-end;
align-items: flex-end;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
text-align: center;
text-anchor: start; }
.ct-chart-bar .ct-label.ct-horizontal.ct-end {
-webkit-box-align: flex-start;
-webkit-align-items: flex-start;
-ms-flex-align: flex-start;
align-items: flex-start;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
text-align: center;
text-anchor: start; }
.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-start {
-webkit-box-align: flex-end;
-webkit-align-items: flex-end;
-ms-flex-align: flex-end;
align-items: flex-end;
-webkit-box-pack: flex-start;
-webkit-justify-content: flex-start;
-ms-flex-pack: flex-start;
justify-content: flex-start;
text-align: left;
text-anchor: start; }
.ct-chart-bar.ct-horizontal-bars .ct-label.ct-horizontal.ct-end {
-webkit-box-align: flex-start;
-webkit-align-items: flex-start;
-ms-flex-align: flex-start;
align-items: flex-start;
-webkit-box-pack: flex-start;
-webkit-justify-content: flex-start;
-ms-flex-pack: flex-start;
justify-content: flex-start;
text-align: left;
text-anchor: start; }
.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-start {
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: flex-end;
-webkit-justify-content: flex-end;
-ms-flex-pack: flex-end;
justify-content: flex-end;
text-align: right;
text-anchor: end; }
.ct-chart-bar.ct-horizontal-bars .ct-label.ct-vertical.ct-end {
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: flex-start;
-webkit-justify-content: flex-start;
-ms-flex-pack: flex-start;
justify-content: flex-start;
text-align: left;
text-anchor: end; }
.ct-grid {
stroke: rgba(128, 128, 128, .125);
stroke-width: 1px;
stroke-dasharray: 2px; }
.ct-grid-background {
fill: none; }
.ct-point {
stroke-width: 7px;
stroke-linecap: round; }
.ct-line {
fill: none;
stroke-width: 2px; }
.ct-area {
stroke: none;
fill-opacity: 0.1; }
.ct-bar {
fill: none;
stroke-width: 1.5rem; }
.ct-slice-donut {
fill: none;
stroke-width: 60px; }
.ct-series-a .ct-point, .ct-series-a .ct-line, .ct-series-a .ct-bar, .ct-series-a .ct-slice-donut {
stroke: rgba(68,164,49, 1); }
.ct-series-a .ct-slice-pie, .ct-series-a .ct-slice-donut-solid, .ct-series-a .ct-area {
fill: rgba(68,164,49, 0.75); }
.ct-series-b .ct-point, .ct-series-b .ct-line, .ct-series-b .ct-bar, .ct-series-b .ct-slice-donut {
stroke: #f05b4f; }
.ct-series-b .ct-slice-pie, .ct-series-b .ct-slice-donut-solid, .ct-series-b .ct-area {
fill: #f05b4f; }
.ct-series-c .ct-point, .ct-series-c .ct-line, .ct-series-c .ct-bar, .ct-series-c .ct-slice-donut {
stroke: #f4c63d; }
.ct-series-c .ct-slice-pie, .ct-series-c .ct-slice-donut-solid, .ct-series-c .ct-area {
fill: #f4c63d; }
.ct-series-d .ct-point, .ct-series-d .ct-line, .ct-series-d .ct-bar, .ct-series-d .ct-slice-donut {
stroke: #d17905; }
.ct-series-d .ct-slice-pie, .ct-series-d .ct-slice-donut-solid, .ct-series-d .ct-area {
fill: #d17905; }
.ct-series-e .ct-point, .ct-series-e .ct-line, .ct-series-e .ct-bar, .ct-series-e .ct-slice-donut {
stroke: #453d3f; }
.ct-series-e .ct-slice-pie, .ct-series-e .ct-slice-donut-solid, .ct-series-e .ct-area {
fill: #453d3f; }
.ct-square {
display: block;
position: relative;
width: 100%; }
.ct-square:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 100%; }
.ct-square:after {
content: "";
display: table;
clear: both; }
.ct-square > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-minor-second {
display: block;
position: relative;
width: 100%; }
.ct-minor-second:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 93.75%; }
.ct-minor-second:after {
content: "";
display: table;
clear: both; }
.ct-minor-second > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-second {
display: block;
position: relative;
width: 100%; }
.ct-major-second:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 88.8888888889%; }
.ct-major-second:after {
content: "";
display: table;
clear: both; }
.ct-major-second > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-minor-third {
display: block;
position: relative;
width: 100%; }
.ct-minor-third:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 83.3333333333%; }
.ct-minor-third:after {
content: "";
display: table;
clear: both; }
.ct-minor-third > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-third {
display: block;
position: relative;
width: 100%; }
.ct-major-third:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 80%; }
.ct-major-third:after {
content: "";
display: table;
clear: both; }
.ct-major-third > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-perfect-fourth {
display: block;
position: relative;
width: 100%; }
.ct-perfect-fourth:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 75%; }
.ct-perfect-fourth:after {
content: "";
display: table;
clear: both; }
.ct-perfect-fourth > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-perfect-fifth {
display: block;
position: relative;
width: 100%; }
.ct-perfect-fifth:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 66.6666666667%; }
.ct-perfect-fifth:after {
content: "";
display: table;
clear: both; }
.ct-perfect-fifth > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-minor-sixth {
display: block;
position: relative;
width: 100%; }
.ct-minor-sixth:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 62.5%; }
.ct-minor-sixth:after {
content: "";
display: table;
clear: both; }
.ct-minor-sixth > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-golden-section {
display: block;
position: relative;
width: 100%; }
.ct-golden-section:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 61.804697157%; }
.ct-golden-section:after {
content: "";
display: table;
clear: both; }
.ct-golden-section > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-sixth {
display: block;
position: relative;
width: 100%; }
.ct-major-sixth:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 60%; }
.ct-major-sixth:after {
content: "";
display: table;
clear: both; }
.ct-major-sixth > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-minor-seventh {
display: block;
position: relative;
width: 100%; }
.ct-minor-seventh:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 56.25%; }
.ct-minor-seventh:after {
content: "";
display: table;
clear: both; }
.ct-minor-seventh > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-seventh {
display: block;
position: relative;
width: 100%; }
.ct-major-seventh:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 53.3333333333%; }
.ct-major-seventh:after {
content: "";
display: table;
clear: both; }
.ct-major-seventh > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-octave {
display: block;
position: relative;
width: 100%; }
.ct-octave:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 50%; }
.ct-octave:after {
content: "";
display: table;
clear: both; }
.ct-octave > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-tenth {
display: block;
position: relative;
width: 100%; }
.ct-major-tenth:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 40%; }
.ct-major-tenth:after {
content: "";
display: table;
clear: both; }
.ct-major-tenth > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-eleventh {
display: block;
position: relative;
width: 100%; }
.ct-major-eleventh:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 37.5%; }
.ct-major-eleventh:after {
content: "";
display: table;
clear: both; }
.ct-major-eleventh > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-major-twelfth {
display: block;
position: relative;
width: 100%; }
.ct-major-twelfth:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 33.3333333333%; }
.ct-major-twelfth:after {
content: "";
display: table;
clear: both; }
.ct-major-twelfth > svg {
display: block;
position: absolute;
top: 0;
left: 0; }
.ct-double-octave {
display: block;
position: relative;
width: 100%; }
.ct-double-octave:before {
display: block;
float: left;
content: "";
width: 0;
height: 0;
padding-bottom: 25%; }
.ct-double-octave:after {
content: "";
display: table;
clear: both; }
.ct-double-octave > svg {
display: block;
position: absolute;
top: 0;
left: 0; }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long