feat(ui): lightning channels All/Active/Pending/Closed tabs + closed-channel history
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m6s
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m6s
Consumes the lnd.closedchannels RPC and closing/force_closing statuses that shipped backend-side in 00b7e179 but never reached the panel: - tab bar with per-state counts; pending covers pending_open/closing/force_closing - closed-channel cards with close type, settled balance, block height and closing-tx explorer link - closing/force_closing status dots + closing-tx link on in-flight closes - Close button hidden for channels already mid-close Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2609f60e6c
commit
a26090e561
@ -3453,12 +3453,26 @@ app.post('/rpc/v1', (req, res) => {
|
||||
{ chan_id: '840921088114689', remote_pubkey: '03abcdef12345678901234567890123456789012345678901234567890abcdef12', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, status: 'active', channel_point: randomHex(32) + ':1', peer_alias: 'WalletOfSatoshi' },
|
||||
{ chan_id: '840921088114690', remote_pubkey: '02fedcba98765432109876543210987654321098765432109876543210fedcba98', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Voltage' },
|
||||
{ chan_id: '840921088114691', remote_pubkey: '03456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: false, status: 'inactive', channel_point: randomHex(32) + ':0', peer_alias: 'Kraken' },
|
||||
{ chan_id: '', remote_pubkey: '028d98b9969fbed53784a36617eb489a59ab6dc9b9d77fcdca9ff55307cd98e3c4', capacity: 500000, local_balance: 500000, remote_balance: 0, active: false, status: 'pending_open', channel_point: randomHex(32) + ':0', peer_alias: 'ACINQ' },
|
||||
{ chan_id: '', remote_pubkey: '02c16cca44562b590dd279c942200bdccfd4f990c3a69fad620c10ef2f8228eaff', capacity: 800000, local_balance: 350000, remote_balance: 450000, active: false, status: 'closing', channel_point: randomHex(32) + ':0', closing_txid: randomHex(32), peer_alias: 'Bitrefill' },
|
||||
]
|
||||
return res.json({
|
||||
result: {
|
||||
channels,
|
||||
total_outbound: channels.reduce((s, c) => s + c.local_balance, 0),
|
||||
total_inbound: channels.reduce((s, c) => s + c.remote_balance, 0),
|
||||
// Totals sum open channels only, matching the real backend.
|
||||
total_outbound: channels.filter(c => ['active', 'inactive'].includes(c.status)).reduce((s, c) => s + c.local_balance, 0),
|
||||
total_inbound: channels.filter(c => ['active', 'inactive'].includes(c.status)).reduce((s, c) => s + c.remote_balance, 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case 'lnd.closedchannels': {
|
||||
return res.json({
|
||||
result: {
|
||||
channels: [
|
||||
{ chan_id: '840921088110001', remote_pubkey: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f', capacity: 1000000, settled_balance: 612000, close_type: 'COOPERATIVE_CLOSE', closing_tx_hash: randomHex(32), channel_point: randomHex(32) + ':0', close_height: 903112 },
|
||||
{ chan_id: '840921088110002', remote_pubkey: '0298f6074a454a1f5345cb2a7c6f9fce206cd0bf675d177cdbf0ca7508dd28852f', capacity: 250000, settled_balance: 0, close_type: 'REMOTE_FORCE_CLOSE', closing_tx_hash: randomHex(32), channel_point: randomHex(32) + ':1', close_height: 897540 },
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -75,7 +75,7 @@
|
||||
</div>
|
||||
|
||||
<!-- No Channels -->
|
||||
<div v-else-if="channels.length === 0" key="empty" class="glass-card p-8 text-center">
|
||||
<div v-else-if="channels.length === 0 && closedChannels.length === 0" key="empty" class="glass-card p-8 text-center">
|
||||
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
@ -85,6 +85,23 @@
|
||||
|
||||
<!-- Channel List -->
|
||||
<div v-else key="channels" class="space-y-3">
|
||||
<!-- Status tabs -->
|
||||
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors flex items-center justify-center gap-1.5"
|
||||
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded-full text-[10px] leading-none"
|
||||
:class="activeTab === tab.key ? 'bg-white/15 text-white/80' : 'bg-white/10 text-white/40'"
|
||||
>{{ tab.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
@ -96,7 +113,7 @@
|
||||
{{ error }}
|
||||
</div>
|
||||
<div
|
||||
v-for="ch in channels"
|
||||
v-for="ch in filteredChannels"
|
||||
:key="ch.chan_id || ch.channel_point"
|
||||
class="glass-card p-4"
|
||||
:class="{ 'bg-white/5': compact }"
|
||||
@ -109,12 +126,14 @@
|
||||
'bg-green-400': channelStatus(ch) === 'active',
|
||||
'bg-yellow-400': channelStatus(ch) === 'pending_open',
|
||||
'bg-red-400': channelStatus(ch) === 'inactive',
|
||||
'bg-orange-400': channelStatus(ch) === 'closing',
|
||||
'bg-purple-400': channelStatus(ch) === 'force_closing',
|
||||
}"
|
||||
></span>
|
||||
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="channelStatus(ch) !== 'pending_open'"
|
||||
v-if="!['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))"
|
||||
@click="confirmClose(ch)"
|
||||
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
|
||||
>
|
||||
@ -148,9 +167,10 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Funding tx -->
|
||||
<div v-if="fundingTxid(ch)" class="flex justify-end">
|
||||
<!-- Funding / closing tx -->
|
||||
<div v-if="fundingTxid(ch) || ch.closing_txid" class="flex justify-end gap-3">
|
||||
<button
|
||||
v-if="fundingTxid(ch)"
|
||||
@click="openInMempool(fundingTxid(ch))"
|
||||
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
|
||||
:title="fundingTxid(ch)"
|
||||
@ -160,8 +180,66 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="ch.closing_txid"
|
||||
@click="openInMempool(ch.closing_txid!)"
|
||||
class="flex items-center gap-1 text-orange-400/70 hover:text-orange-400 text-xs transition-colors"
|
||||
:title="ch.closing_txid"
|
||||
>
|
||||
Closing tx in Mempool
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Closed channel history (All + Closed tabs) -->
|
||||
<div
|
||||
v-for="ch in filteredClosed"
|
||||
:key="'closed-' + (ch.chan_id || ch.channel_point || ch.closing_tx_hash)"
|
||||
class="glass-card p-4 opacity-75"
|
||||
:class="{ 'bg-white/5': compact }"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-white/30"></span>
|
||||
<span class="text-white/60 text-sm font-medium">Closed</span>
|
||||
<span v-if="closeTypeLabel(ch)" class="text-white/40 text-xs">· {{ closeTypeLabel(ch) }}</span>
|
||||
</div>
|
||||
<span v-if="ch.close_height" class="text-white/35 text-xs">Block {{ ch.close_height.toLocaleString() }}</span>
|
||||
</div>
|
||||
|
||||
<p class="text-white/40 text-xs font-mono mb-3 truncate" :title="ch.remote_pubkey">
|
||||
{{ ch.remote_pubkey }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-between text-xs text-white/50 mb-2">
|
||||
<span>Settled: {{ formatSats(ch.settled_balance) }}</span>
|
||||
<span>Capacity: {{ formatSats(ch.capacity) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ch.closing_tx_hash" class="flex justify-end">
|
||||
<button
|
||||
@click="openInMempool(ch.closing_tx_hash)"
|
||||
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
|
||||
:title="ch.closing_tx_hash"
|
||||
>
|
||||
Closing tx in Mempool
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-tab empty state -->
|
||||
<div
|
||||
v-if="filteredChannels.length === 0 && filteredClosed.length === 0"
|
||||
class="glass-card p-6 text-center"
|
||||
>
|
||||
<p class="text-white/50 text-sm">{{ emptyTabMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@ -299,7 +377,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useTxExplorer } from '@/composables/useTxExplorer'
|
||||
|
||||
@ -314,6 +392,18 @@ interface Channel {
|
||||
active: boolean
|
||||
status?: string
|
||||
channel_point?: string
|
||||
closing_txid?: string
|
||||
}
|
||||
|
||||
interface ClosedChannel {
|
||||
chan_id?: string
|
||||
remote_pubkey: string
|
||||
capacity: number
|
||||
settled_balance: number
|
||||
close_type?: string
|
||||
closing_tx_hash?: string
|
||||
channel_point?: string
|
||||
close_height?: number
|
||||
}
|
||||
|
||||
/** Status with a fallback derived from `active` for backends that omit it */
|
||||
@ -321,6 +411,48 @@ function channelStatus(ch: Channel): string {
|
||||
return ch.status ?? (ch.active ? 'active' : 'inactive')
|
||||
}
|
||||
|
||||
type ChannelTab = 'all' | 'active' | 'pending' | 'closed'
|
||||
const activeTab = ref<ChannelTab>('all')
|
||||
|
||||
/** pending_open, closing and force_closing all live on the Pending tab */
|
||||
function isPendingState(ch: Channel): boolean {
|
||||
return ['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))
|
||||
}
|
||||
|
||||
const tabs = computed((): { key: ChannelTab; label: string; count: number }[] => [
|
||||
{ key: 'all', label: 'All', count: channels.value.length + closedChannels.value.length },
|
||||
{ key: 'active', label: 'Active', count: channels.value.filter(ch => !isPendingState(ch)).length },
|
||||
{ key: 'pending', label: 'Pending', count: channels.value.filter(isPendingState).length },
|
||||
{ key: 'closed', label: 'Closed', count: closedChannels.value.length },
|
||||
])
|
||||
|
||||
const filteredChannels = computed((): Channel[] => {
|
||||
switch (activeTab.value) {
|
||||
case 'closed': return []
|
||||
case 'active': return channels.value.filter(ch => !isPendingState(ch))
|
||||
case 'pending': return channels.value.filter(isPendingState)
|
||||
default: return channels.value
|
||||
}
|
||||
})
|
||||
|
||||
const filteredClosed = computed((): ClosedChannel[] =>
|
||||
activeTab.value === 'all' || activeTab.value === 'closed' ? closedChannels.value : []
|
||||
)
|
||||
|
||||
const emptyTabMessage = computed((): string => {
|
||||
switch (activeTab.value) {
|
||||
case 'active': return 'No open channels.'
|
||||
case 'pending': return 'No pending or closing channels.'
|
||||
case 'closed': return 'No closed channels yet.'
|
||||
default: return 'No channels yet.'
|
||||
}
|
||||
})
|
||||
|
||||
/** "COOPERATIVE_CLOSE" / "cooperative_close" → "cooperative close" */
|
||||
function closeTypeLabel(ch: ClosedChannel): string {
|
||||
return (ch.close_type || '').toLowerCase().replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
|
||||
|
||||
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
|
||||
@ -333,6 +465,7 @@ const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: n
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const channels = ref<Channel[]>([])
|
||||
const closedChannels = ref<ClosedChannel[]>([])
|
||||
const summary = ref({ total_inbound: 0, total_outbound: 0 })
|
||||
|
||||
// Olympus by ZEUS — the LSP node behind the Zeus mobile wallet.
|
||||
@ -419,6 +552,17 @@ async function loadChannels() {
|
||||
total_inbound: result.total_inbound || 0,
|
||||
total_outbound: result.total_outbound || 0,
|
||||
}
|
||||
// Closed history is a separate RPC — a failure here keeps the previous
|
||||
// list rather than blanking the main channel view.
|
||||
try {
|
||||
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
|
||||
method: 'lnd.closedchannels',
|
||||
timeout: 15000,
|
||||
})
|
||||
closedChannels.value = closed.channels || []
|
||||
} catch {
|
||||
/* keep previous closed list */
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load channels'
|
||||
if (!hadChannels) channels.value = []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user