Files
AtreidesAssetsFree/client/pages/tracking.js
T
2026-10-29 15:24:06 +01:00

608 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Generated, do not edit. sha256:bcb22b0408193ecc7c34d5e55a86c15a89be89fdd2d96b8d21f03771f4179be1
import { localDate } from '../../shared/time.js';
import { listAccounts } from '../data/accounts.js';
import { listLabels, createLabel } from '../data/labels.js';
import { getPref, setPref } from '../data/preferences.js';
import { walletTxEventMap, onchainAsset, syncAddressLinks } from '../data/tx-links.js';
import { sourceBalance, accountOnchainBalance } from '../data/wallet-addresses.js';
import { listWalletSources } from '../data/wallet-sources.js';
import { listSourceTxs, setTxLabels, labelChannelTxs } from '../data/wallet-txs.js';
import { syncTxProposals } from '../data/proposals.js';
import { t, localeTag } from '../i18n/index.js';
import { fmtInt, fmtUnit, val } from '../ui/format.js';
import { matchDot, openMatchPopover } from '../ui/match-popover.js';
import { copyButton } from '../ui/copy-button.js';
import { CHEVRON, ARROW_DOWN, ARROW_UP } from '../ui/icons.js';
import { isOnchainSource, scanSource } from '../onchain.js';
import { loadFiat, fiatValue, fiatValueOrigin } from '../ui/fiat.js';
import { originHtml } from '../ui/price-origin.js';
import { labelComboMarkup, labelComboMount } from '../ui/label-combobox.js';
import { resolveSelection } from '../ui/filter.js';
import { volgMasonry } from '../ui/masonry.js';
import { latestScanLog } from '../data/scan-logs.js';
import { TARGETS, targetHash } from '../../shared/config.js';
import { esc } from '../ui/escape.js';
import { emptyStateHtml } from '../ui/empty-state.js';
import { holdAttention } from '../ui/attention-ring.js';
import { sourceBadgeText } from '../ui/source-badge.js';
import { isLightningSource, scanLightningSource, lightningAccountSats, countMovements } from '../lightning/lightning-scan.js';
import { memoLine } from '../lightning/memo-line.js';
const ACCOUNTS_HASH = targetHash(TARGETS.ACCOUNTS);
const trunc = s => (s && s.length > 18) ? `${s.slice(0, 8)}…${s.slice(-6)}` : (s || '');
let selectedAccounts = null;
let eventMap = new Map();
let btc = null;
let shownTxIds = new Set();
let newTxIds = new Set();
let scanBaseline = null;
const FILTER_SCOPE = 'table.tracking';
const FILTER_KEY = 'accounts';
function saveFilter() {
setPref(FILTER_SCOPE, FILTER_KEY, [...selectedAccounts]).catch(() => { });
}
const COLLAPSE_KEY = 'collapsed';
let collapsedSet = null;
let stopMasonry = null;
async function loadCollapsed() {
const ids = await getPref(FILTER_SCOPE, COLLAPSE_KEY, null);
return new Set(Array.isArray(ids) ? ids : []);
}
function saveCollapsed() {
setPref(FILTER_SCOPE, COLLAPSE_KEY, [...collapsedSet]).catch(() => { });
}
export const title = () => t('tracking.title');
export const headerInColumn = true;
function btcAsset() {
return btc;
}
async function accountsWithSources() {
const accounts = await listAccounts();
const bronnen = await Promise.all(accounts.map(a => listWalletSources(a.id)));
return accounts.filter((a, i) => bronnen[i].length > 0);
}
function fmtDate(key) {
const d = new Date(key + 'T00:00:00');
return isNaN(d) ? key : d.toLocaleDateString(localeTag(), { day: 'numeric', month: 'short', year: 'numeric' });
}
function fmtDateLong(key) {
const d = new Date(key + 'T00:00:00');
return isNaN(d) ? key : d.toLocaleDateString(localeTag(), { day: 'numeric', month: 'long', year: 'numeric' });
}
function amt(amount) {
const a = btcAsset();
return val(a ? fmtUnit(amount, a) : fmtInt(amount));
}
function fiatSub(amount) {
const f = fiatValue(amount, btcAsset());
return f ? val(f) + originHtml(fiatValueOrigin(btcAsset()), btcAsset()) : '';
}
function amountHtml(amount, numClass = 't-num-md') {
return `<div class="${numClass}">${amt(amount)}</div>
<div class="t-muted-cell po-host">${fiatSub(amount)}</div>`;
}
function asideHtml(items) {
const rows = items.map(i => `
<button type="button" class="trk-acct${selectedAccounts.has(i.id) ? ' is-on' : ''}" data-acct="${i.id}"
aria-pressed="${selectedAccounts.has(i.id)}" title="${esc(i.label)}">
<span class="trk-acct-name">${esc(i.label)}</span>
<span class="trk-acct-line">
<span class="trk-acct-sub">${fmtInt(i.wallets)} ${i.wallets === 1 ? t('tracking.walletOne') : t('tracking.walletMany')}</span>
<span class="trk-acct-amt" data-acct-amt="${i.id}">…</span>
</span>
</button>`).join('');
return `<aside class="page-aside trk-aside">
<div class="trk-block trk-total">
<div class="t-label">${t('tracking.accountTotal')}</div>
<div class="trk-total-amt">
<div class="t-num-lg" data-trk="total">…</div>
<div class="t-muted-cell" data-trk="fiat"></div>
</div>
<div class="trk-stats">
${stat(t('tracking.statWallets'), 'wallets')}
${stat(t('tracking.statTxs'), 'txs')}
${stat(t('tracking.statLinked'), 'linked')}
</div>
</div>
<div class="trk-block trk-accounts">
<div class="trk-acct-list">${rows}</div>
<div class="trk-acct-foot">
<span class="t-muted-cell trk-selection" data-trk="selection"></span>
<button type="button" class="trk-act" data-acct-all>${t('filter.none')}</button>
</div>
</div>
<div class="trk-block trk-scan">
<button class="btn btn-primary btn-sm trk-scan-btn" id="scan-all">${t('tracking.scanAll')}</button>
<div class="t-muted-cell trk-scan-status" data-trk="scanstatus"></div>
<ul class="trk-scan-log" data-trk="scanlog"></ul>
<div class="t-muted-cell trk-scan-when" data-trk="scanned"></div>
</div>
</aside>`;
}
function stat(label, sleutel) {
return `<div class="trk-stat"><div class="t-muted-cell">${label}</div><div class="t-value" data-trk="${sleutel}">…</div></div>`;
}
export async function render(el) {
btc = await onchainAsset();
const accts = await accountsWithSources();
if (accts.length === 0) {
el.innerHTML = `<div class="page-header"><h1 class="page-title">${t('tracking.title')}</h1></div>`
+ emptyStateHtml(t('tracking.noAccountsWithSources'), {
href: ACCOUNTS_HASH, linkText: t('tracking.manageAccounts'),
});
return;
}
const wallets = await Promise.all(accts.map(a => accountWallets(a.id)));
const items = accts.map((a, i) => ({ id: a.id, label: a.name, wallets: wallets[i].length }));
if (!selectedAccounts)
selectedAccounts = resolveSelection({ saved: await getPref(FILTER_SCOPE, FILTER_KEY, null), items });
if (!collapsedSet) collapsedSet = await loadCollapsed();
newTxIds = new Set();
scanBaseline = null;
el.innerHTML = `<div class="page-layout">${asideHtml(items)}`
+ `<div class="page-body">`
+ `<div class="page-header"><h1 class="page-title">${t('tracking.title')}</h1></div>`
+ `<div id="acc-body"></div></div></div>`;
bindAside(el, items);
renderBody(el);
loadFiat().then(() => renderBody(el)).catch(() => {});
}
function bindAside(el, items) {
const aside = el.querySelector('.trk-aside');
const alle = aside.querySelector('[data-acct-all]');
const syncAct = () => {
const allOn = items.every(i => selectedAccounts.has(i.id));
alle.textContent = allOn ? t('filter.none') : t('filter.all');
};
const zet = (sel) => {
selectedAccounts = sel;
for (const b of aside.querySelectorAll('[data-acct]')) {
const on = selectedAccounts.has(Number(b.dataset.acct));
b.classList.toggle('is-on', on);
b.setAttribute('aria-pressed', String(on));
}
syncAct();
saveFilter();
renderBody(el);
};
syncAct();
alle.onclick = () => zet(items.every(i => selectedAccounts.has(i.id))
? new Set() : new Set(items.map(i => i.id)));
aside.onclick = (e) => {
const b = e.target.closest('[data-acct]');
if (!b) return;
const id = Number(b.dataset.acct);
const next = new Set(selectedAccounts);
if (next.has(id)) next.delete(id); else next.add(id);
zet(next);
};
el.querySelector('#scan-all').onclick = () => scanAll(el);
}
async function sourcesOf(accounts) {
const bronnen = await Promise.all(accounts.map(a => listWalletSources(a.id)));
return accounts.flatMap((a, i) => bronnen[i].map(s => ({ ...s, _account: a })));
}
async function selectedSources() {
return sourcesOf((await accountsWithSources()).filter(a => selectedAccounts.has(a.id)));
}
async function allSources() {
return sourcesOf(await accountsWithSources());
}
function groupWallets(sources) {
const byKey = new Map();
for (const s of sources) {
const named = (s.name || '').trim();
const key = named ? `a${s._account.id}:${named}` : `s${s.id}`;
if (!byKey.has(key)) byKey.set(key, { account: s._account, name: named, sources: [] });
byKey.get(key).sources.push(s);
}
const groups = [...byKey.values()];
for (const g of groups) g.id = g.sources[0].id;
return groups;
}
const allWallets = async () => groupWallets(await allSources());
const groupedWallets = async () => groupWallets(await selectedSources());
function sourceBadge(s) {
const { label, title } = sourceBadgeText(s);
return `<span class="badge badge-neutral"${title ? ` title="${esc(title)}"` : ''}>${esc(label)}</span>`;
}
async function accountWallets(accountId) {
const byKey = new Map();
for (const s of await listWalletSources(accountId)) {
const named = (s.name || '').trim();
const key = named || `s${s.id}`;
if (!byKey.has(key)) byKey.set(key, { name: named, sources: [] });
byKey.get(key).sources.push(s);
}
return [...byKey.values()];
}
async function renderBody(el) {
const body = el.querySelector('#acc-body');
if (!body) return;
eventMap = await walletTxEventMap();
const wallets = await allWallets();
const perWallet = await Promise.all(wallets.map(g => walletTxs(g.sources.map(s => s.id))));
const ids = perWallet.flat().map(tx => tx.id);
if (scanBaseline) {
for (const id of ids) if (!scanBaseline.has(id)) newTxIds.add(id);
scanBaseline = null;
}
shownTxIds = new Set(ids);
const shown =wallets.map((g, i) => [g, perWallet[i]]).filter(([g]) => selectedAccounts.has(g.account.id));
body.innerHTML = `<div class="oca-grid">${
(await Promise.all(shown.map(([g, txs]) => walletBlock(g, txs)))).join('')}</div>`;
await vulKolom(el, wallets, perWallet);
if (stopMasonry) stopMasonry();
stopMasonry = volgMasonry(body.querySelector('.oca-grid'), ':scope > .oca-source');
for (const card of body.querySelectorAll('.oca-source[data-new]')) holdAttention(card);
const btc = await btcAsset();
const openMatch = dot => openMatchPopover(dot, {
kind: 'tx', id: Number(dot.dataset.matchId),
assetId: btc ? btc.id : null,
onChange: () => syncAddressLinks().catch(() => 0).then(() => renderBody(el)),
});
body.onclick = e => {
const collapseBtn = e.target.closest('[data-collapse]');
if (collapseBtn) {
const id = Number(collapseBtn.dataset.collapse);
const card = collapseBtn.closest('.oca-source');
const nowCollapsed = card.classList.toggle('is-collapsed');
if (nowCollapsed) collapsedSet.add(id); else collapsedSet.delete(id);
collapseBtn.classList.toggle('is-open', !nowCollapsed);
saveCollapsed();
return;
}
if (e.target.closest('summary') &&
e.target.closest('[data-edit-label],[data-match-trigger],[data-label-combo]')) {
e.preventDefault();
}
const dot = e.target.closest('[data-match-trigger]');
if (dot) { openMatch(dot); return; }
const trigger = e.target.closest('[data-edit-label]');
if (trigger) openLabelEditor(el, trigger);
};
body.onkeydown = e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const dot = e.target.closest('[data-match-trigger]');
if (dot) { e.preventDefault(); openMatch(dot); }
};
}
async function vulKolom(el, wallets, perWallet) {
const zet = (sleutel, html) => {
const e = el.querySelector(`[data-trk="${sleutel}"]`);
if (e) e.innerHTML = html;
};
const alle = await accountsWithSources();
const bronnen = await sourcesOf(alle);
const bronnenVan = id => bronnen.filter(s => s._account.id === id);
const saldi = new Map();
for (const a of alle) saldi.set(a.id, await accountOnchainBalance(a.id) + lightningAccountSats(bronnenVan(a.id)));
for (const a of alle) {
const cel = el.querySelector(`[data-acct-amt="${a.id}"]`);
if (cel) cel.innerHTML = amt(saldi.get(a.id));
}
const total = alle.reduce((sum, a) => sum + saldi.get(a.id), 0);
zet('total', amt(total));
zet('fiat', fiatSub(total));
const { total: nTx, linked: nLinked } =
countMovements(perWallet.flat(), tx => (eventMap.get(tx.id) || []).length > 0);
zet('wallets', fmtInt(wallets.length));
zet('txs', fmtInt(nTx));
zet('linked', nTx ? `${fmtInt(nLinked)} / ${fmtInt(nTx)}` : '-');
const gekozen = alle.filter(a => selectedAccounts.has(a.id));
const deel = gekozen.reduce((sum, a) => sum + saldi.get(a.id), 0);
zet('selection', gekozen.length === alle.length ? ''
: `${t('tracking.selectedSum')} ${amt(deel)} · ${t('tracking.selectedOf', { n: gekozen.length, m: alle.length })}`);
const logs = await Promise.all(gekozen.map(a => latestScanLog(a.id)));
const tijden = gekozen.map((a, i) => [logs[i]?.scannedAt, ...bronnenVan(a.id).map(s => s.lastScannedAt)]
.filter(Boolean).sort().at(-1)).filter(Boolean);
zet('scanned', (!gekozen.length || tijden.length < gekozen.length)
? t('tracking.scanNever')
: `${t('tracking.scanLast')} ${fmtDateLong(localDate(tijden.sort()[0]))}`);
}
async function walletBlock(g, txs) {
const name = g.name || t('tracking.sourceFallback');
const saldi = await Promise.all(g.sources.filter(s => !isLightningSource(s)).map(s => sourceBalance(s.id)));
const sats = saldi.reduce((sum, b) => sum + b.sats, 0) + lightningAccountSats(g.sources);
const badges = g.sources.map(sourceBadge).join('');
const collapsed = collapsedSet.has(g.id);
const isNew = txs.some(tx => newTxIds.has(tx.id));
return `<div class="oca-source card${collapsed ? ' is-collapsed' : ''}"${isNew ? ' data-new' : ''}>
<button type="button" class="chev-btn oca-collapse${collapsed ? '' : ' is-open'}" data-collapse="${g.id}" title="${esc(t('tracking.collapseToggle'))}" aria-label="${esc(t('tracking.collapseToggle'))}">${CHEVRON}</button>
<div class="oca-source-head">
<div class="oca-source-meta">
<div class="oca-source-name">${esc(name)}</div>
<div class="oca-source-acct">${esc(g.account ? g.account.name : '')}</div>
</div>
<div class="oca-wallet-tags">${badges}</div>
<div class="oca-source-total">${amountHtml(sats)}</div>
</div>
<div class="oca-movements">${walletMovements(txs)}</div>
</div>`;
}
async function walletTxs(sourceIds) {
const seen = new Map();
for (const id of sourceIds) for (const tx of await listSourceTxs(id)) if (!seen.has(tx.id)) seen.set(tx.id, tx);
return [...seen.values()];
}
function walletMovements(txs) {
if (txs.length === 0) {
return `<div class="mv-group"><div class="mv-card"><div class="mv-empty t-body-sm t-muted">${t('tracking.noTxs')}</div></div></div>`;
}
const groups = new Map();
for (const tx of txs) {
const key = tx.blockTime ? localDate(tx.blockTime) : '';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(tx);
}
const keys = [...groups.keys()].sort((a, b) => {
if (a === '') return -1;
if (b === '') return 1;
return a < b ? 1 : (a > b ? -1 : 0);
});
return keys.map(k => {
const label = k === '' ? t('tracking.unconfirmed') : fmtDate(k);
return `<div class="mv-group">
<div class="mv-card"><div class="mv-date">${label}</div>${dayRows(groups.get(k))}</div>
</div>`;
}).join('');
}
function dayRows(txs) {
const byEvent = new Map();
for (const tx of txs) {
for (const ev of (eventMap.get(tx.id) || [])) {
if (!byEvent.has(ev.eventId)) byEvent.set(ev.eventId, { ev, list: [] });
byEvent.get(ev.eventId).list.push(tx);
}
}
const inGroup = new Set();
for (const { list } of byEvent.values()) if (list.length >= 2) for (const tx of list) inGroup.add(tx.id);
const emitted = new Set();
return txs.map(tx => {
const groups = (eventMap.get(tx.id) || []).filter(ev => byEvent.get(ev.eventId).list.length >= 2);
if (groups.length === 0) return inGroup.has(tx.id) ? '' : txRow(tx);
return groups.map(ev => {
if (emitted.has(ev.eventId)) return '';
emitted.add(ev.eventId);
const g = byEvent.get(ev.eventId);
return eventGroupRow(g.ev, g.list);
}).join('');
}).join('');
}
function eventGroupRow(ev, list) {
const out = ev.fromAccountId === list[0].accountId;
const dirTitle = out ? t('tracking.dirOut') : t('tracking.dirIn');
const arrow = out ? ARROW_UP : ARROW_DOWN;
const typeLabel = t('enums.event_type.' + ev.type);
const other = out ? ev.to_name : ev.from_name;
const desc = ev.type === 'transfer' && other ? `${typeLabel} ${out ? '→' : '←'} ${other}` : typeLabel;
const allSame = list.every(x => x.labelId && x.labelId === list[0].labelId);
const shown = allSame ? list[0].label_name : desc;
const idsAttr = esc(JSON.stringify(list.map(x => x.id)));
const feeBlock = ev.unitFee
? `<div class="mv-fee">
<div class="num"><span class="mv-feecap">${t('tracking.fee')}</span> ${amt(ev.unitFee)}</div>
<div class="t-muted-cell">${fiatSub(ev.unitFee)}</div>
</div>`
: '';
return `<details class="mv-grp">
<summary class="mv-row">
<div class="mv-dir" title="${dirTitle}" aria-label="${dirTitle}">${arrow}</div>
<div class="mv-mid" data-cell-tx="${list[0].id}">
<span class="mv-chev">›</span>
<span class="mv-label" data-edit-label data-tx-ids="${idsAttr}" data-label-id="${allSame ? list[0].labelId : ''}" title="${esc(`${desc} · ${list.length} ${t('tracking.txCount')}`)}">${esc(shown)}</span>
${matchDot({ matched: true, method: ev.method, kind: 'tx', id: list[0].id })}
</div>
<div class="mv-amounts">${feeBlock}
<div class="mv-amt"><div class="num">${amt(ev.amount)}</div><div class="t-muted-cell">${fiatSub(ev.amount)}</div></div>
</div>
</summary>
<div class="mv-sub">${list.map(txRow).join('')}</div>
</details>`;
}
function txRow(tx) {
const dirTitle = tx.direction === 'in' ? t('tracking.dirIn') : t('tracking.dirOut');
const arrow = tx.direction === 'in' ? ARROW_DOWN : ARROW_UP;
const links = eventMap.get(tx.id) || [];
const memo = !tx.address ? memoLine(tx.memo, tx.direction) : null;
const leeg = memo ? esc(memo.text) : t('tracking.addLabel');
const mid = tx.labelId
? `<span class="mv-label" data-edit-label data-tx-id="${tx.id}" data-label-id="${tx.labelId}" title="${esc(tx.address || memo?.title || '')}">${esc(tx.label_name)}</span>`
: `<span class="mv-addr" data-edit-label data-tx-id="${tx.id}" data-label-id="" title="${esc(tx.address || memo?.title || '')}">${tx.address ? trunc(tx.address) : leeg}</span>`;
const copyBtn = tx.address ? copyButton(tx.address, t('tracking.copyAddr')) : '';
const feeBlock = (tx.feeSats && tx.feeSats > 0)
? `<div class="mv-fee">
<div class="num"><span class="mv-feecap">${t('tracking.fee')}</span> ${amt(tx.feeSats)}</div>
<div class="t-muted-cell">${fiatSub(tx.feeSats)}</div>
</div>`
: '';
return `<div class="mv-row">
<div class="mv-dir" title="${dirTitle}" aria-label="${dirTitle}">${arrow}</div>
<div class="mv-mid" data-cell-tx="${tx.id}">${mid}${copyBtn}${matchDot({ matched: links.length > 0, method: links.length ? links[0].method : undefined, kind: 'tx', id: tx.id })}</div>
<div class="mv-amounts">
${feeBlock}
<div class="mv-amt">
<div class="num">${amt(tx.valueSats)}</div>
<div class="t-muted-cell">${fiatSub(tx.valueSats)}</div>
</div>
</div>
</div>`;
}
function openLabelEditor(el, trigger) {
const ids = trigger.dataset.txIds ? JSON.parse(trigger.dataset.txIds) : [Number(trigger.dataset.txId)];
const cur = trigger.dataset.labelId ? Number(trigger.dataset.labelId) : '';
const cell = trigger.closest('[data-cell-tx]');
if (!cell) return;
cell.innerHTML = `<span class="tx-label" data-label-combo>${labelComboMarkup('labelId', cur)}</span>`;
const host = cell.querySelector('[data-label-combo]');
labelComboMount(host, { listLabels, createLabel }, async id => {
await setTxLabels(ids, id);
renderBody(el);
});
const input = host.querySelector('[data-label-input]');
if (input) input.focus();
}
const logRow = (el, id) => el.querySelector(`[data-trk="scanlog"] [data-log="${id}"] .trk-scan-out`);
function logGroup(lijst, account) {
const key = String(account ? account.id : '');
const bestaand = [...lijst.children].find(e => e.dataset.logAcct === key);
if (bestaand) return bestaand.querySelector('.trk-scan-wallets');
const groep = document.createElement('li');
groep.className = 'trk-scan-group';
groep.dataset.logAcct = key;
const kop = document.createElement('div');
kop.className = 't-label t-label--ter trk-scan-acct';
kop.textContent = account ? account.name : '';
const wallets = document.createElement('ul');
wallets.className = 'trk-scan-wallets';
groep.append(kop, wallets);
lijst.append(groep);
return wallets;
}
function addLogRow(el, g) {
const lijst = el.querySelector('[data-trk="scanlog"]');
if (!lijst) return;
const li = document.createElement('li');
li.className = 'trk-scan-row';
li.dataset.log = g.id;
const naam = document.createElement('span');
naam.className = 'trk-scan-name';
naam.textContent = g.name || t('tracking.sourceFallback');
const uit = document.createElement('span');
uit.className = 'trk-scan-out t-muted';
uit.textContent = t('tracking.scanning');
li.append(naam, uit);
logGroup(lijst, g.account).append(li);
}
function setLogRow(el, id, cls, txt) {
const e = logRow(el, id);
if (e) { e.className = `trk-scan-out ${cls}`; e.textContent = txt; }
}
async function scanOne(el, source, logId = source.id) {
return scanSource(source, txt => { const e = logRow(el, logId); if (e) e.textContent = txt; });
}
async function scanAll(el) {
const wallets = await groupedWallets();
if (!wallets.length) return;
const knop = el.querySelector('#scan-all');
const zetStatus = txt => {
const e = el.querySelector('[data-trk="scanstatus"]');
if (e) e.textContent = txt;
};
const results = {};
const kanalen = [];
const voorstellen = new Map();
const lnAccounts = new Set();
let nWallets = 0, nMislukt = 0;
if (knop) knop.disabled = true;
const lijst = el.querySelector('[data-trk="scanlog"]');
if (lijst) lijst.replaceChildren();
const voor = shownTxIds;
newTxIds = new Set();
try {
for (const [i, g] of wallets.entries()) {
zetStatus(t('tracking.scanProgress', { i: i + 1, n: wallets.length }));
addLogRow(el, g);
let count = 0, failed = 0, error = false, geslagen = 0, ln = 0, lnBronnen = 0;
for (const s of g.sources) {
if (isLightningSource(s)) {
lnBronnen++;
lnAccounts.add(s.accountId);
try {
const r = await scanLightningSource(s);
ln += r.count;
if (r.channelTxids.length) kanalen.push([s.accountId, r.channelTxids]);
} catch (err) { console.error('[lightning-scan]', err); error = err.message || true; }
continue;
}
if (!isOnchainSource(s)) { geslagen++; continue; }
try {
const r = await scanOne(el, s, g.id);
count += r.count; failed += r.failed; voorstellen.set(s.accountId, r.proposals || 0);
} catch (err) { console.error('[onchain-scan-all]', err); error = err.message || true; }
}
const skipped = geslagen && !lnBronnen && !count && !failed;
if (error) nMislukt++; else if (!skipped) nWallets++;
results[g.id] = error ? { error } : (skipped ? { skipped: true } : { count, failed, ln, lnBronnen });
}
if (Object.values(results).some(r => r.lnBronnen)) await syncAddressLinks().catch(err => console.error('[lightning-links]', err));
for (const [accountId, txids] of kanalen) {
await labelChannelTxs(accountId, txids).catch(err => console.error('[lightning-channel-label]', err));
}
const btc = lnAccounts.size ? await btcAsset() : null;
if (btc) for (const accountId of lnAccounts) {
try { voorstellen.set(accountId, await syncTxProposals(accountId, btc.id)); }
catch (err) { console.error('[lightning-proposals]', err); }
}
} finally {
if (knop) knop.disabled = false;
}
const nVoorstellen = [...voorstellen.values()].reduce((s, n) => s + n, 0);
zetStatus(scanSummary(nWallets, nVoorstellen, nMislukt));
for (const id in results) {
const r = results[id];
if (r.error) setLogRow(el, id, 't-neg', typeof r.error === 'string'
? t('tracking.scanErrorWhy', { reden: r.error }) : t('tracking.scanError'));
else if (r.skipped) setLogRow(el, id, 't-muted', t('tracking.scanNotOnchain'));
else setLogRow(el, id, r.failed ? 't-neg' : 't-pos', scanDoneText(r));
}
scanBaseline = voor;
await renderBody(el);
}
function scanSummary(nWallets, nVoorstellen, nMislukt) {
const deel = [`${fmtInt(nWallets)} ${nWallets === 1 ? t('tracking.walletOne') : t('tracking.walletMany')} ${t('tracking.scanUpdated')}`];
if (nVoorstellen) deel.push(`${fmtInt(nVoorstellen)} ${nVoorstellen === 1 ? t('tracking.proposalOne') : t('tracking.proposalMany')}`);
if (nMislukt) deel.push(`${fmtInt(nMislukt)} ${t('tracking.scanFailedN')}`);
return deel.join(' · ');
}
function scanDoneText(r) {
const delen = [];
if (r.count || r.failed || !r.lnBronnen) delen.push(`${r.count} ${t('tracking.addressesScanned')}`);
if (r.failed) delen.push(`${r.failed} ${t('tracking.addressesFailed')}`);
if (r.lnBronnen) delen.push(`${fmtInt(r.ln)} ${t('tracking.lightningMovements')}`);
return delen.join(' · ');
}