perf(ui): app-launch-speed doctrine for data panels — parallel RPCs + instant last-known paint #120

Merged
lfg2025 merged 1 commits from perf/data-panels into main 2026-07-24 10:22:54 +00:00
3 changed files with 91 additions and 28 deletions

View File

@ -522,8 +522,10 @@ const cloudStorageDisplay = computed(() => cloudStorageUsed.value !== null ? for
const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? String(cloudFolderCount.value) : '...')
onMounted(async () => {
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
// Paint last-known wallet figures BEFORE any network round-trip.
hydrateWalletSnapshot()
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 10000); checkUpdateStatus(); loadWeb5Status()
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
// Poll wallet balances/transactions like Web5.vue does without this a
// pending on-chain receive (or a fresh instant payment) only shows up
// after a manual wallet action or a remount.
@ -583,25 +585,78 @@ function ecashToWalletTransaction(tx: EcashTransaction): WalletTransaction {
}
}
// Last-known wallet snapshot, hydrated before ANY network round-trip so the
// card paints real figures instantly (app-launch-speed doctrine: over the
// mesh every serialized RPC costs a full RTT never make the user watch it).
const WALLET_SNAPSHOT_KEY = 'archy-wallet-snapshot-v1'
function hydrateWalletSnapshot() {
try {
const raw = localStorage.getItem(WALLET_SNAPSHOT_KEY)
if (!raw) return
const s = JSON.parse(raw)
walletOnchain.value = s.onchain ?? 0
walletLightning.value = s.lightning ?? 0
walletEcash.value = s.ecash ?? 0
walletFedimint.value = s.fedimint ?? 0
walletArk.value = s.ark ?? 0
walletConnected.value = s.connected === true
if (Array.isArray(s.transactions)) walletTransactions.value = s.transactions
} catch { /* corrupt/absent snapshot — fresh load fills in */ }
}
function persistWalletSnapshot() {
try {
localStorage.setItem(WALLET_SNAPSHOT_KEY, JSON.stringify({
onchain: walletOnchain.value,
lightning: walletLightning.value,
ecash: walletEcash.value,
fedimint: walletFedimint.value,
ark: walletArk.value,
connected: walletConnected.value,
// Enough for the Transactions modal's first paint; refresh replaces it.
transactions: walletTransactions.value.slice(0, 50),
}))
} catch { /* storage full — snapshot is best-effort */ }
}
async function loadWeb5Status() {
// A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when
// there is a balance"). On failure keep the last-known value the refs start
// at 0, so only the very first load before any success shows 0.
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
try { const res = await rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000 }); walletArk.value = res.spendable_sats ?? 0 } catch { /* keep last-known balance */ }
// from the persisted snapshot, so 0 only ever shows on a genuinely fresh node.
//
// All seven calls are independent fire them TOGETHER. Serialized, this
// block cost 7 × (mesh RTT + backend time); parallel it costs one slowest
// call, which is what makes the card feel like an app launch.
const balances = Promise.allSettled([
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
.then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true })
.catch(() => { walletConnected.value = false }),
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 })
.then(res => { walletEcash.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }),
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 })
.then(res => { walletFedimint.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }),
rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000 })
.then(res => { walletArk.value = res.spendable_sats ?? 0 }).catch(() => { /* keep last-known */ }),
])
// Merge LND transactions with ecash/Fedimint history (wallet.ecash-history
// already unifies both) previously only LND transactions were fetched
// here, so any Cashu or Fedimint receive (e.g. a TollGate payment) never
// appeared in the Transactions modal even though the balance included it.
let lndTxs: WalletTransaction[] = []
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); lndTxs = (res.transactions || []).map(tx => ({ ...tx, kind: 'onchain' as const })) } catch { /* keep last-known transactions */ }
let lightningTxs: WalletTransaction[] = []
try { const res = await rpcClient.call<{ transactions: WalletTransaction[] }>({ method: 'lnd.lightning-history', timeout: 5000 }); lightningTxs = res.transactions || [] } catch { /* keep last-known transactions */ }
let ecashTxs: WalletTransaction[] = []
try { const res = await rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 }); ecashTxs = (res.transactions || []).map(ecashToWalletTransaction) } catch { /* keep last-known transactions */ }
walletTransactions.value = [...lndTxs, ...lightningTxs, ...ecashTxs].sort((a, b) => b.time_stamp - a.time_stamp)
// already unifies both) so Cashu/Fedimint receives appear in the modal.
const histories = Promise.allSettled([
rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 })
.then(res => (res.transactions || []).map(tx => ({ ...tx, kind: 'onchain' as const }))).catch(() => [] as WalletTransaction[]),
rpcClient.call<{ transactions: WalletTransaction[] }>({ method: 'lnd.lightning-history', timeout: 5000 })
.then(res => res.transactions || []).catch(() => [] as WalletTransaction[]),
rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 })
.then(res => (res.transactions || []).map(ecashToWalletTransaction)).catch(() => [] as WalletTransaction[]),
]).then((results) => {
const merged = results.flatMap(r => (r.status === 'fulfilled' ? r.value : []))
// Keep last-known list when every history call failed this round.
if (merged.length > 0 || results.some(r => r.status === 'fulfilled')) {
walletTransactions.value = merged.sort((a, b) => b.time_stamp - a.time_stamp)
}
})
await Promise.allSettled([balances, histories])
persistWalletSnapshot()
}
// System stats

View File

@ -794,15 +794,19 @@ function goBack() {
onMounted(async () => {
if (props.peerId) {
// Find the peer by onion address
try {
const result = await rpcClient.federationListNodes()
const peers = result?.nodes ?? []
currentPeer.value = peers.find((p: PeerNode) => p.onion === props.peerId) || null
} catch {
// Continue with just the onion address
}
await Promise.all([loadCatalog(), loadOwned()])
// The peer-name lookup is cosmetic the catalog only needs the onion we
// already have. Serialized, it added a full mesh round-trip before the
// files even started loading.
await Promise.all([
rpcClient.federationListNodes()
.then((result) => {
const peers = result?.nodes ?? []
currentPeer.value = peers.find((p: PeerNode) => p.onion === props.peerId) || null
})
.catch(() => { /* continue with just the onion address */ }),
loadCatalog(),
loadOwned(),
])
} else {
loading.value = false
}

View File

@ -350,13 +350,17 @@ async function loadPeers() {
const hadPeers = peers.value.length > 0 || observers.value.length > 0
loadingPeers.value = true
try {
const res = await rpcClient.listPeers()
// Independent RPCs fetched together (serialized they stacked two full
// mesh round-trips before anything rendered).
const [res, fedSettled] = await Promise.all([
rpcClient.listPeers(),
rpcClient.federationListNodes().catch(() => null),
])
const peerList = res.peers || []
const observerList: Peer[] = []
try {
const fedRes = await rpcClient.federationListNodes()
const fedNodes = fedRes.nodes || []
const fedNodes = fedSettled?.nodes || []
for (const n of fedNodes) {
if (!n.onion || n.trust_level === 'untrusted') {
continue