Wallet polish (#2942)

This commit is contained in:
Vlad Stan 2025-02-11 15:19:41 +02:00 committed by GitHub
parent 4d490506f3
commit 2d41a1bed3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 565 additions and 311 deletions

View file

@ -397,8 +397,8 @@ async def get_daily_stats(
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance, SUM(apipayments.amount - ABS(apipayments.fee)) AS balance,
ABS(SUM(apipayments.fee)) as fee, ABS(SUM(apipayments.fee)) as fee,
COUNT(*) as payments_count COUNT(*) as payments_count
FROM apipayments FROM wallets
RIGHT JOIN wallets ON apipayments.wallet_id = wallets.id LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
{clause} {clause}
AND (wallets.deleted = false OR wallets.deleted is NULL) AND (wallets.deleted = false OR wallets.deleted is NULL)
GROUP BY date GROUP BY date

View file

@ -125,7 +125,7 @@ class Payment(BaseModel):
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount", "wallet_id", "tag"] __search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"] __sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]

View file

@ -399,14 +399,17 @@ async def calculate_fiat_amounts(
amount_sat = int(amount) amount_sat = int(amount)
if wallet_currency: if wallet_currency:
if wallet_currency == currency: try:
fiat_amount = amount if wallet_currency == currency:
else: fiat_amount = amount
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency) else:
fiat_amounts["wallet_fiat_currency"] = wallet_currency fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
fiat_amounts["wallet_fiat_amount"] = round(fiat_amount, ndigits=3) fiat_amounts["wallet_fiat_currency"] = wallet_currency
fiat_amounts["wallet_fiat_rate"] = amount_sat / fiat_amount fiat_amounts["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
fiat_amounts["wallet_btc_rate"] = (fiat_amount / amount_sat) * 100_000_000 fiat_amounts["wallet_fiat_rate"] = amount_sat / fiat_amount
fiat_amounts["wallet_btc_rate"] = (fiat_amount / amount_sat) * 100_000_000
except Exception as e:
logger.error(f"Error calculating fiat amount for wallet '{wallet.id}': {e}")
logger.debug( logger.debug(
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {fiat_amounts=}" f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {fiat_amounts=}"

View file

@ -199,6 +199,7 @@
v-model="formData.lnbits_default_bgimage" v-model="formData.lnbits_default_bgimage"
label="Background Image" label="Background Image"
@update:model-value="applyGlobalBgimage" @update:model-value="applyGlobalBgimage"
hint="This must be a trusted source. It can change the content and it can log your IP address."
> >
</q-input> </q-input>
</div> </div>

View file

@ -48,7 +48,7 @@
'q-pt-sm': g.fiatTracking, 'q-pt-sm': g.fiatTracking,
'q-pt-lg': !g.fiatTracking 'q-pt-lg': !g.fiatTracking
}" }"
v-if="!isPrioritySwapped || !g.fiatTracking" v-if="!isFiatPriority || !g.fiatTracking"
style="height: 100px" style="height: 100px"
> >
<div class="col-7"> <div class="col-7">
@ -82,7 +82,7 @@
<div <div
class="column" class="column"
v-if="isPrioritySwapped && g.fiatTracking" v-if="isFiatPriority && g.fiatTracking"
:class="{ :class="{
'q-pt-sm': g.fiatTracking, 'q-pt-sm': g.fiatTracking,
'q-pt-lg': !g.fiatTracking 'q-pt-lg': !g.fiatTracking
@ -189,6 +189,7 @@
> >
<q-card-section> <q-card-section>
<payment-list <payment-list
@filter-changed="handleFilterChange"
:update="updatePayments" :update="updatePayments"
:mobile-simple="mobileSimple" :mobile-simple="mobileSimple"
:expand-details="expandDetails" :expand-details="expandDetails"
@ -256,114 +257,191 @@
<q-expansion-item <q-expansion-item
group="extras" group="extras"
icon="settings_cell" icon="phone_android"
:label="$t('export_to_phone')" :label="$t('access_wallet_on_mobile')"
>
<q-card>
<q-card-section class="text-center">
<p v-text="$t('export_to_phone_desc')"></p>
<lnbits-qrcode
:value="`${baseUrl}/wallet?usr=${g.user.id}&wal=${g.wallet.id}`"
></lnbits-qrcode>
</q-card-section>
<span v-text="exportWalletQR"></span>
<q-card-actions class="flex-center q-pb-md">
<q-btn
outline
color="grey"
:label="$t('copy_wallet_url')"
@click="copyText(`${baseUrl}/wallet?usr=${g.user.id}&wal=${g.wallet.id}`)"
></q-btn>
</q-card-actions>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="edit"
:label="$t('rename_wallet')"
> >
<q-card> <q-card>
<q-card-section> <q-card-section>
<div class="" style="max-width: 320px"> You can connect to this wallet from a mobile app:
<q-input <ul>
filled <li>
v-model.trim="update.name" Download
label="Name" <a class="text-secondary" href="https://zeusln.app"
dense >Zeus</a
/> >
</div> or
<q-btn <a
:disable="!update.name.length" class="text-secondary"
unelevated href="https://bluewallet.io/"
class="q-mt-sm" >BlueWallet</a
color="primary" >
:label="$t('update_name')" from App Store or Google Play
@click="updateWallet({ name: update.name })" </li>
></q-btn> <li>
Enable the
<a class="text-secondary" href="/lndhub">LndHub </a>
extension for this account
</li>
<li>
Scan the QR code in the
<a class="text-secondary" href="/lndhub">LndHub </a>
extensions with your mobile app
</li>
</ul>
</q-card-section> </q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="attach_money"
:label="$t('fiat_tracking')"
>
<q-card>
<q-card-section> <q-card-section>
<div style="max-width: 360px"> Or you can access the wallet directly from your mobile
<div class="row"> browser using:
<div class="col"> <q-expansion-item
<q-select icon="mobile_friendly"
filled :label="$t('export_to_phone')"
dense >
v-model="update.currency" <q-card>
type="text" <q-card-section class="text-center">
:disable="g.fiatTracking" <p v-text="$t('export_to_phone_desc')"></p>
:options="receive.units.filter((u) => u !== 'sat')" <lnbits-qrcode
></q-select> :value="`${baseUrl}/wallet?usr=${g.user.id}&wal=${g.wallet.id}`"
</div> ></lnbits-qrcode>
<div class="col-auto"> </q-card-section>
<span v-text="exportWalletQR"></span>
<q-card-actions class="flex-center q-pb-md">
<q-btn <q-btn
color="primary" outline
@click="handleFiatTracking()" color="grey"
:disable="update.currency == ''" :label="$t('copy_wallet_url')"
:label="g.fiatTracking ? 'Remove' : 'Add'" @click="copyText(`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`)"
></q-btn> ></q-btn>
</div> </q-card-actions>
</q-card>
</q-expansion-item>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="settings"
:label="$t('wallet_config')"
>
<q-card>
<q-card-section>
<div class="row">
<div class="col-6">
<q-input
filled
v-model.trim="update.name"
label="Name"
dense
/>
</div>
<div class="col-4 q-pl-sm">
<q-btn
:disable="!update.name.length"
unelevated
class="q-mt-xs full-width"
color="primary"
:label="$t('update_name')"
dense
@click="updateWallet({ name: update.name })"
></q-btn>
</div>
<div class="col-2"></div>
</div>
</q-card-section>
<q-card-section>
<div class="row">
<div class="col-6">
<q-select
filled
dense
v-model="update.currency"
type="text"
:disable="g.fiatTracking"
:options="receive.units.filter((u) => u !== 'sat')"
></q-select>
</div>
<div class="col-4 q-pl-sm">
<q-btn
dense
color="primary"
class="q-mt-xs full-width"
@click="handleFiatTracking()"
:disable="update.currency == ''"
:label="g.fiatTracking ? 'Remove' : 'Add'"
></q-btn>
</div>
<div class="col-2">
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
class="float-right q-mb-lg"
to="/admin#exchange_providers"
><q-tooltip
v-text="$t('exchange_providers')"
></q-tooltip
></q-btn>
</div> </div>
</div> </div>
<q-btn </q-card-section>
v-if="g.user.admin" <q-card-section>
class="absolute-top-right" <div class="row">
flat <div class="col-6">
round <p v-text="$t('delete_wallet_desc')"></p>
icon="settings" </div>
to="/admin#exchange_providers" <div class="col-4 q-pl-sm">
><q-tooltip <q-btn
v-text="$t('exchange_providers')" unelevated
></q-tooltip color="red-10"
></q-btn> class="full-width"
@click="deleteWallet()"
:label="$t('delete_wallet')"
></q-btn>
</div>
<div class="col-2"></div>
</div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
<q-separator></q-separator> <q-separator></q-separator>
<q-expansion-item <q-expansion-item
group="extras" group="charts"
icon="remove_circle" icon="insights"
:label="$t('delete_wallet')" :label="$t('wallet_charts')"
> >
<q-card> <q-card>
<q-card-section> <q-card-section>
<p v-text="$t('delete_wallet_desc')"></p> <div class="row">
<q-btn <div class="col-md-4 col-sm-12">
unelevated <q-checkbox
color="red-10" dense
@click="deleteWallet()" @click="saveChartsPreferences"
:label="$t('delete_wallet')" v-model="chartConfig.showBalance"
></q-btn> :label="$t('payments_balance_chart')"
>
</q-checkbox>
</div>
<div class="col-md-4 col-sm-12">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartConfig.showBalanceInOut"
:label="$t('payments_balance_in_out_chart')"
>
</q-checkbox>
</div>
<div class="col-md-4 col-sm-12">
<q-checkbox
dense
@click="saveChartsPreferences"
v-model="chartConfig.showPaymentCountInOut"
:label="$t('payments_count_in_out_chart')"
>
</q-checkbox>
</div>
</div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
@ -403,6 +481,27 @@
>{% endfor %} >{% endfor %}
</q-card> </q-card>
{% endif %} {% endif %}
<q-card v-if="chartConfig.showBalance">
<q-card-section class="q-pa-none">
<div style="height: 200px" class="q-pa-sm">
<canvas ref="walletBalanceChart"></canvas>
</div>
</q-card-section>
</q-card>
<q-card v-if="chartConfig.showBalanceInOut">
<q-card-section class="q-pa-none">
<div style="height: 200px" class="q-pa-sm">
<canvas ref="walletBalanceInOut"></canvas>
</div>
</q-card-section>
</q-card>
<q-card v-if="chartConfig.showPaymentCountInOut">
<q-card-section class="q-pa-none">
<div style="height: 200px" class="q-pa-sm">
<canvas ref="walletPaymentsInOut"></canvas>
</div>
</q-card-section>
</q-card>
</div> </div>
</div> </div>

View file

@ -35,11 +35,13 @@ from lnbits.core.models import (
PaymentWalletStats, PaymentWalletStats,
Wallet, Wallet,
) )
from lnbits.core.models.users import User
from lnbits.core.services.payments import get_payments_daily_stats from lnbits.core.services.payments import get_payments_daily_stats
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
check_admin, check_admin,
check_user_exists,
parse_filters, parse_filters,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
@ -135,13 +137,29 @@ async def api_payments_wallets_stats(
@payment_router.get( @payment_router.get(
"/stats/daily", "/stats/daily",
name="Get payments history per day", name="Get payments history per day",
dependencies=[Depends(check_admin)],
response_model=List[PaymentDailyStats], response_model=List[PaymentDailyStats],
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_daily_stats( async def api_payments_daily_stats(
user: User = Depends(check_user_exists),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
): ):
if not user.admin:
exc = HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Missing wallet id.",
)
wallet_filter = next(
(f for f in filters.filters if f.field == "wallet_id"), None
)
if not wallet_filter:
raise exc
wallet_id = list((wallet_filter.values or {}).values())
if len(wallet_id) == 0:
raise exc
if not user.get_wallet(wallet_id[0]):
raise exc
return await get_payments_daily_stats(filters) return await get_payments_daily_stats(filters)

View file

@ -263,9 +263,9 @@ class ThemesSettings(LNbitsSettings):
lnbits_default_accounting_currency: Optional[str] = Field(default=None) lnbits_default_accounting_currency: Optional[str] = Field(default=None)
lnbits_qr_logo: str = Field(default="/static/images/logos/lnbits.png") lnbits_qr_logo: str = Field(default="/static/images/logos/lnbits.png")
lnbits_default_reaction: str = Field(default="confettiBothSides") lnbits_default_reaction: str = Field(default="confettiBothSides")
lnbits_default_theme: str = Field(default="classic") lnbits_default_theme: str = Field(default="salvador")
lnbits_default_border: str = Field(default="hard-border") lnbits_default_border: str = Field(default="hard-border")
lnbits_default_gradient: bool = Field(default=False) lnbits_default_gradient: bool = Field(default=True)
lnbits_default_bgimage: str = Field(default=None) lnbits_default_bgimage: str = Field(default=None)
@ -342,13 +342,6 @@ class ExchangeProvidersSettings(LNbitsSettings):
exclude_to=[], exclude_to=[],
ticker_conversion=[], ticker_conversion=[],
), ),
ExchangeRateProvider(
name="CoinMate",
api_url="https://coinmate.io/api/ticker?currencyPair=BTC_{TO}",
path="$.data.last",
exclude_to=[],
ticker_conversion=["USD:USDT"],
),
ExchangeRateProvider( ExchangeRateProvider(
name="Kraken", name="Kraken",
api_url="https://api.kraken.com/0/public/Ticker?pair=XBT{TO}", api_url="https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -46,6 +46,7 @@ window.localisation.en = {
export_to_phone: 'Export to Phone with QR Code', export_to_phone: 'Export to Phone with QR Code',
export_to_phone_desc: export_to_phone_desc:
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.', 'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
access_wallet_on_mobile: 'Mobile Access',
wallet: 'Wallet: ', wallet: 'Wallet: ',
wallets: 'Wallets', wallets: 'Wallets',
add_wallet: 'Add a new wallet', add_wallet: 'Add a new wallet',
@ -248,6 +249,8 @@ window.localisation.en = {
enter_ip: 'Enter IP and hit enter', enter_ip: 'Enter IP and hit enter',
rate_limiter: 'Rate Limiter', rate_limiter: 'Rate Limiter',
wallet_limiter: 'Wallet Limiter', wallet_limiter: 'Wallet Limiter',
wallet_config: 'Wallet Config',
wallet_charts: 'Wallet Charts',
wallet_limit_max_withdraw_per_day: wallet_limit_max_withdraw_per_day:
'Max daily wallet withdrawal in sats (0 for no limit, -1 to block withdrawal)', 'Max daily wallet withdrawal in sats (0 for no limit, -1 to block withdrawal)',
wallet_max_ballance: 'Wallet max balance in sats (0 to disable)', wallet_max_ballance: 'Wallet max balance in sats (0 to disable)',

View file

@ -1,133 +0,0 @@
function generateChart(canvas, rawData) {
const data = rawData.reduce(
(previous, current) => {
previous.labels.push(current.date)
previous.income.push(current.income)
previous.spending.push(current.spending)
previous.cumulative.push(current.balance)
return previous
},
{
labels: [],
income: [],
spending: [],
cumulative: []
}
)
return new Chart(canvas.getContext('2d'), {
type: 'bar',
data: {
labels: data.labels,
datasets: [
{
data: data.cumulative,
type: 'line',
label: 'balance',
backgroundColor: '#673ab7', // deep-purple
borderColor: '#673ab7',
borderWidth: 4,
pointRadius: 3,
fill: false
},
{
data: data.income,
type: 'bar',
label: 'in',
barPercentage: 0.75,
backgroundColor: 'rgba(76, 175, 80, 0.5)' // green
},
{
data: data.spending,
type: 'bar',
label: 'out',
barPercentage: 0.75,
backgroundColor: 'rgba(233, 30, 99, 0.5)' // pink
}
]
},
options: {
title: {
text: 'Chart.js Combo Time Scale'
},
tooltips: {
mode: 'index',
intersect: false
},
scales: {
xAxes: [
{
type: 'time',
display: true,
//offset: true,
time: {
minUnit: 'hour',
stepSize: 3
}
}
]
},
// performance tweaks
animation: {
duration: 0
},
elements: {
line: {
tension: 0
}
}
}
})
}
window.app.component('payment-chart', {
template: '#payment-chart',
name: 'payment-chart',
props: ['wallet'],
mixins: [window.windowMixin],
data() {
return {
paymentsChart: {
show: false,
group: {
value: 'hour',
label: 'Hour'
},
groupOptions: [
{value: 'hour', label: 'Hour'},
{value: 'day', label: 'Day'},
{value: 'week', label: 'Week'},
{value: 'month', label: 'Month'},
{value: 'year', label: 'Year'}
],
instance: null
}
}
},
methods: {
showChart() {
this.paymentsChart.show = true
LNbits.api
.request(
'GET',
'/api/v1/payments/history?group=' + this.paymentsChart.group.value,
this.g.wallet.adminkey
)
.then(response => {
this.$nextTick(() => {
if (this.paymentsChart.instance) {
this.paymentsChart.instance.destroy()
}
this.paymentsChart.instance = generateChart(
this.$refs.canvas,
response.data
)
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
this.paymentsChart.show = false
})
}
}
})

View file

@ -38,6 +38,7 @@ window.app.component('payment-list', {
}, },
loading: false loading: false
}, },
searchDate: {from: null, to: null},
exportTagName: '', exportTagName: '',
exportPaymentTagList: [], exportPaymentTagList: [],
paymentsCSV: { paymentsCSV: {
@ -136,7 +137,31 @@ window.app.component('payment-list', {
} }
}, },
methods: { methods: {
searchByDate() {
if (typeof this.searchDate === 'string') {
this.searchDate = {
from: this.searchDate,
to: this.searchDate
}
}
if (this.searchDate.from) {
this.paymentsTable.filter['time[ge]'] =
this.searchDate.from + 'T00:00:00'
}
if (this.searchDate.to) {
this.paymentsTable.filter['time[le]'] = this.searchDate.to + 'T23:59:59'
}
this.fetchPayments()
},
clearDateSeach() {
this.searchDate = {from: null, to: null}
delete this.paymentsTable.filter['time[ge]']
delete this.paymentsTable.filter['time[le]']
this.fetchPayments()
},
fetchPayments(props) { fetchPayments(props) {
this.$emit('filter-changed', {...this.paymentsTable.filter})
const params = LNbits.utils.prepareFilterQuery(this.paymentsTable, props) const params = LNbits.utils.prepareFilterQuery(this.paymentsTable, props)
return LNbits.api return LNbits.api
.getPayments(this.currentWallet, params) .getPayments(this.currentWallet, params)
@ -223,14 +248,22 @@ window.app.component('payment-list', {
watch: { watch: {
failedPaymentsToggle(newVal) { failedPaymentsToggle(newVal) {
if (newVal === false) { if (newVal === false) {
this.paymentsTable.filter = { this.paymentsTable.filter['status[ne]'] = 'failed'
'status[ne]': 'failed'
}
} else { } else {
this.paymentsTable.filter = null delete this.paymentsTable.filter['status[ne]']
} }
this.paymentsTable.pagination.page = 1
this.fetchPayments() this.fetchPayments()
}, },
'paymentsTable.search': {
handler() {
const props = {}
if (this.paymentsTable.search) {
props['search'] = this.paymentsTable.search
}
this.fetchPayments()
}
},
lazy(newVal) { lazy(newVal) {
if (newVal === true) this.fetchPayments() if (newVal === true) this.fetchPayments()
}, },

View file

@ -44,6 +44,7 @@ window.WalletPageLogic = {
show: false, show: false,
location: window.location location: window.location
}, },
mobileSimple: this.$q.screen.lt.md,
icon: { icon: {
show: false, show: false,
data: {}, data: {},
@ -104,14 +105,23 @@ window.WalletPageLogic = {
name: null, name: null,
currency: null currency: null
}, },
walletBalanceChart: null,
inkeyHidden: true, inkeyHidden: true,
adminkeyHidden: true, adminkeyHidden: true,
hasNfc: false, hasNfc: false,
nfcReaderAbortController: null, nfcReaderAbortController: null,
isPrioritySwapped: false, isFiatPriority: false,
formattedFiatAmount: 0, formattedFiatAmount: 0,
formattedExchange: null, formattedExchange: null,
primaryColor: this.$q.localStorage.getItem('lnbits.primaryColor') primaryColor: this.$q.localStorage.getItem('lnbits.primaryColor'),
secondaryColor: this.$q.localStorage.getItem('lnbits.secondaryColor'),
chartData: [],
chartConfig: {
showBalance: true,
showBalanceInOut: true,
showPaymentCountInOut: true
},
paymentsFilter: {}
} }
}, },
computed: { computed: {
@ -174,7 +184,9 @@ window.WalletPageLogic = {
this.receive.paymentHash = null this.receive.paymentHash = null
this.receive.data.amount = null this.receive.data.amount = null
this.receive.data.memo = null this.receive.data.memo = null
this.receive.unit = 'sat' this.receive.unit = this.isFiatPriority
? this.g.wallet.currency || 'sat'
: 'sat'
this.receive.minMax = [0, 2100000000000000] this.receive.minMax = [0, 2100000000000000]
this.receive.lnurl = null this.receive.lnurl = null
this.focusInput('setAmount') this.focusInput('setAmount')
@ -770,17 +782,14 @@ window.WalletPageLogic = {
}) })
}, },
swapBalancePriority() { swapBalancePriority() {
this.isPrioritySwapped = !this.isPrioritySwapped this.isFiatPriority = !this.isFiatPriority
this.$q.localStorage.setItem( this.$q.localStorage.setItem('lnbits.isFiatPriority', this.isFiatPriority)
'lnbits.isPrioritySwapped',
this.isPrioritySwapped
)
}, },
handleFiatTracking() { handleFiatTracking() {
this.g.fiatTracking = !this.g.fiatTracking this.g.fiatTracking = !this.g.fiatTracking
if (!this.g.fiatTracking) { if (!this.g.fiatTracking) {
this.$q.localStorage.setItem('lnbits.isPrioritySwapped', false) this.$q.localStorage.setItem('lnbits.isFiatPriority', false)
this.isPrioritySwapped = false this.isFiatPriority = false
this.update.currency = '' this.update.currency = ''
this.g.wallet.currency = '' this.g.wallet.currency = ''
this.updateWallet({currency: ''}) this.updateWallet({currency: ''})
@ -800,6 +809,220 @@ window.WalletPageLogic = {
this.update.currency = '' this.update.currency = ''
this.g.fiatTracking = false this.g.fiatTracking = false
} }
},
handleFilterChange(value = {}) {
if (
this.paymentsFilter['time[ge]'] !== value['time[ge]'] ||
this.paymentsFilter['time[le]'] !== value['time[le]']
) {
this.refreshCharts()
}
this.paymentsFilter = value
},
async fetchChartData() {
if (this.mobileSimple) {
this.chartConfig = {}
return
}
if (
!this.chartConfig.showBalance &&
!this.chartConfig.showBalanceInOut &&
!this.chartConfig.showPaymentCountInOut
) {
return
}
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/payments/stats/daily?wallet_id=${this.g.wallet.id}`
)
this.chartData = data
this.refreshCharts()
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
filterChartData(data) {
const timeFrom = this.paymentsFilter['time[ge]'] + 'T00:00:00'
const timeTo = this.paymentsFilter['time[le]'] + 'T23:59:59'
data = data.filter(p => {
if (
this.paymentsFilter['time[ge]'] &&
this.paymentsFilter['time[le]']
) {
return p.date >= timeFrom && p.date <= timeTo
}
if (this.paymentsFilter['time[ge]']) {
return p.date >= timeFrom
}
if (this.paymentsFilter['time[le]']) {
return p.date <= timeTo
}
return true
})
const labels = data.map(s =>
new Date(s.date).toLocaleString('default', {
month: 'short',
day: 'numeric'
})
)
return {data, labels}
},
refreshCharts() {
const originalChartConfig = this.chartConfig || {}
this.chartConfig = {}
setTimeout(() => {
const chartConfig =
this.$q.localStorage.getItem('lnbits.wallets.chartConfig') ||
originalChartConfig
this.chartConfig = {...originalChartConfig, ...chartConfig}
}, 10)
setTimeout(() => {
this.drawCharts(this.chartData)
}, 100)
},
drawCharts(allData) {
try {
const {data, labels} = this.filterChartData(allData)
if (this.chartConfig.showBalance) {
if (this.walletBalanceChart) {
this.walletBalanceChart.destroy()
}
this.walletBalanceChart = new Chart(
this.$refs.walletBalanceChart.getContext('2d'),
{
type: 'line',
options: {
responsive: true,
maintainAspectRatio: false
},
data: {
labels,
datasets: [
{
label: 'Balance',
data: data.map(s => s.balance),
pointStyle: false,
backgroundColor: LNbits.utils.hexAlpha(
this.primaryColor,
0.3
),
borderColor: this.primaryColor,
borderWidth: 2,
fill: true,
tension: 0.7,
fill: 1
},
{
label: 'Fees',
data: data.map(s => s.fee),
pointStyle: false,
backgroundColor: LNbits.utils.hexAlpha(
this.secondaryColor,
0.3
),
borderColor: this.secondaryColor,
borderWidth: 1,
fill: true,
tension: 0.7,
fill: 1
}
]
}
}
)
}
if (this.chartConfig.showBalanceInOut) {
if (this.walletBalanceInOut) {
this.walletBalanceInOut.destroy()
}
this.walletBalanceInOut = new Chart(
this.$refs.walletBalanceInOut.getContext('2d'),
{
type: 'bar',
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true
},
y: {
stacked: true
}
}
},
data: {
labels,
datasets: [
{
label: 'Balance In',
borderRadius: 5,
data: data.map(s => s.balance_in)
},
{
label: 'Balance Out',
borderRadius: 5,
data: data.map(s => s.balance_out)
}
]
}
}
)
}
if (this.chartConfig.showPaymentCountInOut) {
if (this.walletPaymentsInOut) {
this.walletPaymentsInOut.destroy()
}
this.walletPaymentsInOut = new Chart(
this.$refs.walletPaymentsInOut.getContext('2d'),
{
type: 'bar',
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true
},
y: {
stacked: true
}
}
},
data: {
labels,
datasets: [
{
label: 'Payments In',
data: data.map(s => s.count_in)
},
{
label: 'Payments Out',
data: data.map(s => -s.count_out)
}
]
}
}
)
}
} catch (error) {
console.warn(error)
}
},
saveChartsPreferences() {
this.$q.localStorage.set('lnbits.wallets.chartConfig', this.chartConfig)
this.refreshCharts()
} }
}, },
created() { created() {
@ -811,8 +1034,20 @@ window.WalletPageLogic = {
this.parse.show = true this.parse.show = true
} }
this.createdTasks() this.createdTasks()
try {
this.fetchChartData()
} catch (error) {
console.warn(`Chart creation failed: ${error}`)
}
}, },
watch: { watch: {
'g.wallet.id'(newVal, oldVal) {
try {
this.fetchChartData()
} catch (error) {
console.warn(`Chart creation failed: ${error}`)
}
},
'g.updatePayments'(newVal, oldVal) { 'g.updatePayments'(newVal, oldVal) {
console.log('updatePayments changed:', {newVal, oldVal}) console.log('updatePayments changed:', {newVal, oldVal})
this.parse.show = false this.parse.show = false
@ -846,19 +1081,17 @@ window.WalletPageLogic = {
deep: true deep: true
} }
}, },
mounted() { async mounted() {
if (!Quasar.LocalStorage.getItem('lnbits.disclaimerShown')) { if (!Quasar.LocalStorage.getItem('lnbits.disclaimerShown')) {
this.disclaimerDialog.show = true this.disclaimerDialog.show = true
Quasar.LocalStorage.setItem('lnbits.disclaimerShown', true) Quasar.LocalStorage.setItem('lnbits.disclaimerShown', true)
Quasar.LocalStorage.setItem('lnbits.reactions', 'confettiTop') Quasar.LocalStorage.setItem('lnbits.reactions', 'confettiTop')
} }
if (Quasar.LocalStorage.getItem('lnbits.isPrioritySwapped')) { if (Quasar.LocalStorage.getItem('lnbits.isFiatPriority')) {
this.isPrioritySwapped = Quasar.LocalStorage.getItem( this.isFiatPriority = Quasar.LocalStorage.getItem('lnbits.isFiatPriority')
'lnbits.isPrioritySwapped'
)
} else { } else {
this.isPrioritySwapped = false this.isFiatPriority = false
Quasar.LocalStorage.setItem('lnbits.isPrioritySwapped', false) Quasar.LocalStorage.setItem('lnbits.isFiatPriority', false)
} }
} }
} }

View file

@ -41,7 +41,6 @@
"js/components/lnbits-funding-sources.js", "js/components/lnbits-funding-sources.js",
"js/components/extension-settings.js", "js/components/extension-settings.js",
"js/components/payment-list.js", "js/components/payment-list.js",
"js/components/payment-chart.js",
"js/components.js", "js/components.js",
"js/init-app.js" "js/init-app.js"
], ],

View file

@ -538,7 +538,7 @@
size="sm" size="sm"
icon="add" icon="add"
> >
<q-popup-edit class="bg-accent text-white" v-slot="scope" v-model="credit"> <q-popup-edit class="text-white" v-slot="scope" v-model="credit">
<q-input <q-input
filled filled
:label="$t('credit_label', {denomination: denomination})" :label="$t('credit_label', {denomination: denomination})"
@ -562,7 +562,7 @@
class="float-right q-mt-sm" class="float-right q-mt-sm"
size="sm" size="sm"
> >
<q-popup-edit class="bg-accent text-white" v-slot="scope" v-model="credit"> <q-popup-edit class="text-white" v-slot="scope" v-model="credit">
<q-input <q-input
filled filled
:label="$t('credit_label', {denomination: denomination})" :label="$t('credit_label', {denomination: denomination})"
@ -649,6 +649,7 @@
<q-btn-dropdown <q-btn-dropdown
outline outline
persistent persistent
dense
class="q-mr-sm" class="q-mr-sm"
color="grey" color="grey"
label="Export" label="Export"
@ -700,7 +701,42 @@
</q-item> </q-item>
</q-list> </q-list>
</q-btn-dropdown> </q-btn-dropdown>
<payment-chart :wallet="wallet"></payment-chart> <q-btn icon="event" outline flat color="grey">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date v-model="searchDate" mask="YYYY-MM-DD" range />
<div class="row">
<div class="col-6">
<q-btn
label="Search"
@click="searchByDate()"
color="primary"
flat
class="float-left"
v-close-popup
/>
</div>
<div class="col-6">
<q-btn
v-close-popup
@click="clearDateSeach()"
label="Clear"
class="float-right"
color="grey"
flat
/>
</div>
</div>
</q-popup-proxy>
<q-badge
v-if="searchDate?.to || searchDate?.from"
class="q-mt-lg q-mr-md"
color="primary"
rounded
floating
style="border-radius: 6px"
/>
</q-btn>
<q-checkbox <q-checkbox
v-model="failedPaymentsToggle" v-model="failedPaymentsToggle"
checked-icon="warning" checked-icon="warning"
@ -709,7 +745,7 @@
size="xs" size="xs"
> >
<q-tooltip> <q-tooltip>
<span v-text="`View failed payments`"></span> <span v-text="`Include failed payments`"></span>
</q-tooltip> </q-tooltip>
</q-checkbox> </q-checkbox>
</div> </div>
@ -722,7 +758,7 @@
:row-key="paymentTableRowKey" :row-key="paymentTableRowKey"
:columns="paymentsTable.columns" :columns="paymentsTable.columns"
:no-data-label="$t('no_transactions')" :no-data-label="$t('no_transactions')"
:filter="paymentsTable.search" :filter="paymentsTable.filter"
:loading="paymentsTable.loading" :loading="paymentsTable.loading"
:hide-header="mobileSimple" :hide-header="mobileSimple"
:hide-bottom="mobileSimple" :hide-bottom="mobileSimple"
@ -742,7 +778,7 @@
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width class="text-center"> <q-td auto-width class="text-center cursor-pointer">
<q-icon <q-icon
v-if="props.row.isPaid" v-if="props.row.isPaid"
size="14px" size="14px"
@ -1025,36 +1061,6 @@
</div> </div>
</template> </template>
<template id="payment-chart">
<span id="payment-chart">
<q-btn dense flat round icon="show_chart" color="grey" @click="showChart">
<q-tooltip>
<span v-text="$t('chart_tooltip')"></span>
</q-tooltip>
</q-btn>
<q-dialog v-model="paymentsChart.show" position="top">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<div class="row q-gutter-sm justify-between">
<div class="text-h6">Payments Chart</div>
<q-select
label="Group"
filled
dense
v-model="paymentsChart.group"
style="min-width: 120px"
:options="paymentsChart.groupOptions"
>
</q-select>
</div>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section>
</q-card>
</q-dialog>
</span>
</template>
<template id="user-id-only"> <template id="user-id-only">
<div v-if="authAction === 'login' && authMethod === 'user-id-only'"> <div v-if="authAction === 'login' && authMethod === 'user-id-only'">
<q-card-section class="q-pb-none"> <q-card-section class="q-pb-none">
@ -1129,7 +1135,7 @@
<div class="text-body2 text-center q-mt-md"> <div class="text-body2 text-center q-mt-md">
<q-badge <q-badge
@click="showLogin('user-id-only')" @click="showLogin('user-id-only')"
color="accent" color="primary"
class="cursor-pointer" class="cursor-pointer"
rounded rounded
> >
@ -1141,7 +1147,7 @@
<span v-text="$t('or')" class="q-mx-sm text-grey"></span> <span v-text="$t('or')" class="q-mx-sm text-grey"></span>
<q-badge <q-badge
@click="showRegister('user-id-only')" @click="showRegister('user-id-only')"
color="accent" color="primary"
class="cursor-pointer" class="cursor-pointer"
rounded rounded
> >

View file

@ -239,7 +239,7 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
async def btc_price(currency: str) -> float: async def btc_price(currency: str) -> float:
rates = await btc_rates(currency) rates = await btc_rates(currency)
if not rates: if not rates:
return 9999999999 raise ValueError("Could not fetch any Bitcoin price.")
elif len(rates) == 1: elif len(rates) == 1:
logger.warning("Could only fetch one Bitcoin price.") logger.warning("Could only fetch one Bitcoin price.")

View file

@ -93,7 +93,6 @@
"js/components/lnbits-funding-sources.js", "js/components/lnbits-funding-sources.js",
"js/components/extension-settings.js", "js/components/extension-settings.js",
"js/components/payment-list.js", "js/components/payment-list.js",
"js/components/payment-chart.js",
"js/components.js", "js/components.js",
"js/init-app.js" "js/init-app.js"
], ],