dynamically update blockexplorer compoenent with websockets

This commit is contained in:
dni
2026-07-13 09:09:41 +02:00
parent e3ce6b9fef
commit 843afd33e5
3 changed files with 184 additions and 42 deletions
+1
View File
@@ -864,6 +864,7 @@ window.localisation.en = {
inputs: 'Inputs', inputs: 'Inputs',
outputs: 'Outputs', outputs: 'Outputs',
confirmations: 'Confirmations', confirmations: 'Confirmations',
confirmed: 'Confirmed',
unconfirmed: 'Unconfirmed', unconfirmed: 'Unconfirmed',
no_transactions: 'No transactions found', no_transactions: 'No transactions found',
address: 'Address' address: 'Address'
+56 -2
View File
@@ -10,6 +10,7 @@ window.PageBlockExplorer = {
selectedBlock: null, selectedBlock: null,
blockDialog: false, blockDialog: false,
txResult: null, txResult: null,
txStatus: null,
addressResult: null, addressResult: null,
currentAddress: '' currentAddress: ''
} }
@@ -35,8 +36,46 @@ window.PageBlockExplorer = {
}, },
async created() { async created() {
await Promise.all([this.loadTip(), this.loadFees(), this.loadBlocks()]) await Promise.all([this.loadTip(), this.loadFees(), this.loadBlocks()])
this._blockWsActive = true
this._connectBlocksWs()
},
beforeUnmount() {
this._blockWsActive = false
if (this._blockWs) this._blockWs.close()
if (this._searchWs) this._searchWs.close()
}, },
methods: { methods: {
_wsUrl(path) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${window.location.host}/blockexplorer/api/v1${path}`
},
_connectBlocksWs() {
const ws = new WebSocket(this._wsUrl('/ws/blocks'))
ws.onmessage = e => {
const block = JSON.parse(e.data)
const rest = this.blocks.filter(b => b.height !== block.height)
this.blocks = [block, ...rest].slice(0, 5)
}
ws.onerror = () => ws.close()
ws.onclose = () => {
if (this._blockWsActive) setTimeout(() => this._connectBlocksWs(), 5000)
}
this._blockWs = ws
},
_connectSearchWs(path, onMessage) {
if (this._searchWs) {
this._searchWs.close()
this._searchWs = null
}
const ws = new WebSocket(this._wsUrl(path))
ws.onmessage = e => {
try {
onMessage(JSON.parse(e.data))
} catch (_) {}
}
ws.onerror = () => ws.close()
this._searchWs = ws
},
_timeAgo(seconds) { _timeAgo(seconds) {
if (seconds < 60) return seconds + 's ago' if (seconds < 60) return seconds + 's ago'
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago' if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'
@@ -59,7 +98,10 @@ window.PageBlockExplorer = {
}, },
async loadBlocks() { async loadBlocks() {
try { try {
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/blocks') const r = await LNbits.api.request(
'GET',
'/blockexplorer/api/v1/blocks'
)
this.blocks = r.data this.blocks = r.data
} catch (_) {} } catch (_) {}
}, },
@@ -81,6 +123,7 @@ window.PageBlockExplorer = {
const q = this.query.trim() const q = this.query.trim()
if (!q) return if (!q) return
this.txResult = null this.txResult = null
this.txStatus = null
this.addressResult = null this.addressResult = null
this.loading = true this.loading = true
try { try {
@@ -95,10 +138,17 @@ window.PageBlockExplorer = {
}, },
async loadTx(txid) { async loadTx(txid) {
try { try {
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/tx/' + txid) const r = await LNbits.api.request(
'GET',
'/blockexplorer/api/v1/tx/' + txid
)
this.txResult = r.data this.txResult = r.data
this.txStatus = null
this.addressResult = null this.addressResult = null
this.query = txid this.query = txid
this._connectSearchWs(`/ws/tx/${txid}`, data => {
if (!data.error) this.txStatus = data
})
} catch (e) { } catch (e) {
LNbits.utils.notifyApiError(e) LNbits.utils.notifyApiError(e)
} }
@@ -111,8 +161,12 @@ window.PageBlockExplorer = {
) )
this.addressResult = r.data this.addressResult = r.data
this.txResult = null this.txResult = null
this.txStatus = null
this.currentAddress = address this.currentAddress = address
this.query = address this.query = address
this._connectSearchWs(`/ws/address/${address}`, data => {
if (!data.error) this.addressResult = data
})
} catch (e) { } catch (e) {
LNbits.utils.notifyApiError(e) LNbits.utils.notifyApiError(e)
} }
+127 -40
View File
@@ -1,13 +1,14 @@
<template id="page-blockexplorer"> <template id="page-blockexplorer">
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<!-- Left column: blocks + search + results --> <!-- Left column: blocks + search + results -->
<div class="col-12 col-md-7 q-gutter-y-md"> <div class="col-12 col-md-7 q-gutter-y-md">
<!-- Recent blocks (mempool-style squares) --> <!-- Recent blocks (mempool-style squares) -->
<q-card v-if="blocks.length"> <q-card v-if="blocks.length">
<q-card-section> <q-card-section>
<div class="text-subtitle1 q-mb-md" v-text="$t('recent_blocks')"></div> <div
class="text-subtitle1 q-mb-md"
v-text="$t('recent_blocks')"
></div>
<div class="row q-gutter-sm"> <div class="row q-gutter-sm">
<q-card <q-card
v-for="b in formattedBlocks" v-for="b in formattedBlocks"
@@ -18,9 +19,14 @@
@click="openBlock(b)" @click="openBlock(b)"
> >
<q-card-section class="q-pa-sm"> <q-card-section class="q-pa-sm">
<div class="text-subtitle1 text-weight-bold" v-text="'#' + b.height.toLocaleString()"></div> <div
class="text-subtitle1 text-weight-bold"
v-text="'#' + b.height.toLocaleString()"
></div>
<div class="text-caption q-mt-xs" v-text="b.timeAgo"></div> <div class="text-caption q-mt-xs" v-text="b.timeAgo"></div>
<div class="text-caption q-mt-sm"><code v-text="b.shortHash"></code></div> <div class="text-caption q-mt-sm">
<code v-text="b.shortHash"></code>
</div>
<div class="text-caption" v-text="'diff ' + b.difficulty"></div> <div class="text-caption" v-text="'diff ' + b.difficulty"></div>
</q-card-section> </q-card-section>
</q-card> </q-card>
@@ -32,21 +38,33 @@
<q-dialog v-model="blockDialog"> <q-dialog v-model="blockDialog">
<q-card v-if="selectedBlock"> <q-card v-if="selectedBlock">
<q-card-section class="bg-primary text-white q-pb-sm"> <q-card-section class="bg-primary text-white q-pb-sm">
<div class="text-h6" v-text="'Block #' + selectedBlock.height.toLocaleString()"></div> <div
class="text-h6"
v-text="'Block #' + selectedBlock.height.toLocaleString()"
></div>
<div class="text-caption" v-text="selectedBlock.utcTime"></div> <div class="text-caption" v-text="selectedBlock.utcTime"></div>
</q-card-section> </q-card-section>
<q-card-section> <q-card-section>
<div class="q-mb-md"> <div class="q-mb-md">
<div class="text-caption text-grey q-mb-xs">Hash</div> <div class="text-caption text-grey q-mb-xs">Hash</div>
<code class="text-caption be-wrap" v-text="selectedBlock.hash"></code> <code
class="text-caption be-wrap"
v-text="selectedBlock.hash"
></code>
</div> </div>
<div class="q-mb-md"> <div class="q-mb-md">
<div class="text-caption text-grey q-mb-xs">Previous block</div> <div class="text-caption text-grey q-mb-xs">Previous block</div>
<code class="text-caption be-wrap" v-text="selectedBlock.prev_hash"></code> <code
class="text-caption be-wrap"
v-text="selectedBlock.prev_hash"
></code>
</div> </div>
<div class="q-mb-lg"> <div class="q-mb-lg">
<div class="text-caption text-grey q-mb-xs">Merkle root</div> <div class="text-caption text-grey q-mb-xs">Merkle root</div>
<code class="text-caption be-wrap" v-text="selectedBlock.merkle_root"></code> <code
class="text-caption be-wrap"
v-text="selectedBlock.merkle_root"
></code>
</div> </div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-6 col-sm-3"> <div class="col-6 col-sm-3">
@@ -86,7 +104,14 @@
@keyup.enter="search" @keyup.enter="search"
> >
<template v-slot:append> <template v-slot:append>
<q-btn flat round dense icon="search" :loading="loading" @click="search" /> <q-btn
flat
round
dense
icon="search"
:loading="loading"
@click="search"
/>
</template> </template>
</q-input> </q-input>
</q-card-section> </q-card-section>
@@ -95,29 +120,38 @@
<!-- Transaction result --> <!-- Transaction result -->
<q-card v-if="txResult"> <q-card v-if="txResult">
<q-card-section> <q-card-section>
<div class="text-subtitle1 q-mb-sm" v-text="$t('transaction')"></div> <div class="row items-center q-mb-sm q-gutter-sm">
<div class="q-mb-xs"> <div class="text-subtitle1" v-text="$t('transaction')"></div>
<q-badge
v-if="txStatus"
:color="txStatus.confirmed ? 'positive' : 'orange'"
:label="txStatus.confirmed ? $t('confirmed') : $t('unconfirmed')"
/>
<q-spinner-dots v-else size="1em" color="grey" />
</div>
<div class="q-mb-sm">
<span class="text-caption text-grey">txid: </span> <span class="text-caption text-grey">txid: </span>
<code class="text-caption be-wrap" v-text="txResult.txid"></code> <code class="text-caption be-wrap" v-text="txResult.txid"></code>
</div> </div>
<div class="row q-col-gutter-md q-mb-sm"> <div class="row q-col-gutter-md q-mb-sm">
<div class="col-auto" v-if="txResult.blockheight"> <div class="col-auto" v-if="txStatus && txStatus.height">
<div class="text-caption text-grey" v-text="$t('block_height')"></div>
<div v-text="txResult.blockheight.toLocaleString()"></div>
</div>
<div class="col-auto" v-if="txResult.confirmations !== undefined">
<div class="text-caption text-grey" v-text="$t('confirmations')"></div>
<div <div
v-text="txResult.confirmations > 0 ? txResult.confirmations : $t('unconfirmed')" class="text-caption text-grey"
v-text="$t('block_height')"
></div> ></div>
<div v-text="txStatus.height.toLocaleString()"></div>
</div>
<div class="col-auto" v-if="txStatus && txStatus.fee !== null">
<div class="text-caption text-grey">fee</div>
<div v-text="txStatus.fee + ' sat'"></div>
</div> </div>
<div class="col-auto" v-if="txResult.vsize || txResult.size"> <div class="col-auto" v-if="txResult.vsize || txResult.size">
<div class="text-caption text-grey">vsize</div> <div class="text-caption text-grey">vsize</div>
<div v-text="(txResult.vsize || txResult.size) + ' vB'"></div> <div v-text="(txResult.vsize || txResult.size) + ' vB'"></div>
</div> </div>
<div class="col-auto" v-if="txResult.fee !== undefined"> <div class="col-auto" v-if="txResult.weight">
<div class="text-caption text-grey">fee</div> <div class="text-caption text-grey">weight</div>
<div v-text="txResult.fee + ' sat'"></div> <div v-text="txResult.weight + ' WU'"></div>
</div> </div>
</div> </div>
<q-expansion-item <q-expansion-item
@@ -129,7 +163,11 @@
<q-list dense separator> <q-list dense separator>
<q-item v-for="(vin, i) in txResult.vin" :key="i"> <q-item v-for="(vin, i) in txResult.vin" :key="i">
<q-item-section> <q-item-section>
<q-item-label v-if="vin.coinbase" class="text-grey" v-text="$t('coinbase')"> <q-item-label
v-if="vin.coinbase"
class="text-grey"
v-text="$t('coinbase')"
>
</q-item-label> </q-item-label>
<q-item-label v-else class="be-wrap"> <q-item-label v-else class="be-wrap">
<a <a
@@ -152,7 +190,9 @@
<q-item v-for="(vout, i) in txResult.vout" :key="i"> <q-item v-for="(vout, i) in txResult.vout" :key="i">
<q-item-section> <q-item-section>
<q-item-label> <q-item-label>
<template v-if="vout.scriptPubKey && vout.scriptPubKey.address"> <template
v-if="vout.scriptPubKey && vout.scriptPubKey.address"
>
<a <a
href="#" href="#"
@click.prevent="loadAddress(vout.scriptPubKey.address)" @click.prevent="loadAddress(vout.scriptPubKey.address)"
@@ -164,7 +204,10 @@
<span v-text="vout.scriptPubKey.type"></span> <span v-text="vout.scriptPubKey.type"></span>
</template> </template>
</q-item-label> </q-item-label>
<q-item-label caption v-text="vout.value + ' BTC'"></q-item-label> <q-item-label
caption
v-text="vout.value + ' BTC'"
></q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@@ -176,20 +219,44 @@
<q-card v-if="addressResult"> <q-card v-if="addressResult">
<q-card-section> <q-card-section>
<div class="text-subtitle1 q-mb-xs" v-text="$t('address')"></div> <div class="text-subtitle1 q-mb-xs" v-text="$t('address')"></div>
<div class="text-caption q-mb-sm"><code class="be-wrap" v-text="currentAddress"></code></div> <div class="text-caption q-mb-sm">
<code class="be-wrap" v-text="currentAddress"></code>
</div>
<div class="row q-col-gutter-md q-mb-md"> <div class="row q-col-gutter-md q-mb-md">
<div class="col-auto"> <div class="col-auto">
<div class="text-caption text-grey" v-text="$t('confirmed_balance')"></div> <div
<div v-text="addressResult.balance.confirmed.toLocaleString() + ' sat'"></div> class="text-caption text-grey"
v-text="$t('confirmed_balance')"
></div>
<div
v-text="
addressResult.balance.confirmed.toLocaleString() + ' sat'
"
></div>
</div> </div>
<div class="col-auto" v-if="addressResult.balance.unconfirmed !== 0"> <div
<div class="text-caption text-grey" v-text="$t('unconfirmed_balance')"></div> class="col-auto"
<div v-text="addressResult.balance.unconfirmed.toLocaleString() + ' sat'"></div> v-if="addressResult.balance.unconfirmed !== 0"
>
<div
class="text-caption text-grey"
v-text="$t('unconfirmed_balance')"
></div>
<div
v-text="
addressResult.balance.unconfirmed.toLocaleString() + ' sat'
"
></div>
</div> </div>
</div> </div>
<div <div
class="text-subtitle2 q-mb-xs" class="text-subtitle2 q-mb-xs"
v-text="$t('transaction_history') + ' (' + addressResult.history.length + ')'" v-text="
$t('transaction_history') +
' (' +
addressResult.history.length +
')'
"
></div> ></div>
<q-list dense separator> <q-list dense separator>
<q-item <q-item
@@ -200,10 +267,17 @@
@click="loadTx(h.tx_hash)" @click="loadTx(h.tx_hash)"
> >
<q-item-section> <q-item-section>
<q-item-label class="text-primary be-wrap" v-text="h.tx_hash"></q-item-label> <q-item-label
class="text-primary be-wrap"
v-text="h.tx_hash"
></q-item-label>
<q-item-label <q-item-label
caption caption
v-text="h.height > 0 ? $t('block_height') + ': ' + h.height.toLocaleString() : $t('unconfirmed')" v-text="
h.height > 0
? $t('block_height') + ': ' + h.height.toLocaleString()
: $t('unconfirmed')
"
></q-item-label> ></q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
@@ -226,16 +300,28 @@
<q-card-section> <q-card-section>
<div class="text-subtitle1 q-mb-sm" v-text="$t('chain_tip')"></div> <div class="text-subtitle1 q-mb-sm" v-text="$t('chain_tip')"></div>
<div class="text-caption text-grey" v-text="$t('block_height')"></div> <div class="text-caption text-grey" v-text="$t('block_height')"></div>
<div class="text-h6 q-mb-md" v-text="tip.height.toLocaleString()"></div> <div
class="text-h6 q-mb-md"
v-text="tip.height.toLocaleString()"
></div>
<template v-if="feeList.length"> <template v-if="feeList.length">
<div class="text-subtitle2 q-mb-sm" v-text="$t('fee_estimates')"></div> <div
class="text-subtitle2 q-mb-sm"
v-text="$t('fee_estimates')"
></div>
<q-list dense> <q-list dense>
<q-item v-for="f in feeList" :key="f.label" class="q-px-none"> <q-item v-for="f in feeList" :key="f.label" class="q-px-none">
<q-item-section> <q-item-section>
<q-item-label class="text-caption text-grey" v-text="f.label"></q-item-label> <q-item-label
class="text-caption text-grey"
v-text="f.label"
></q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
<q-item-label class="text-body2" v-text="f.rate"></q-item-label> <q-item-label
class="text-body2"
v-text="f.rate"
></q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@@ -243,10 +329,11 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
</div> </div>
</template> </template>
<style> <style>
.be-wrap { word-break: break-all; } .be-wrap {
word-break: break-all;
}
</style> </style>