309 lines
14 KiB
JavaScript
309 lines
14 KiB
JavaScript
// Generated, do not edit. sha256:b2711d1eac7d201be0c0b5dbcc1d76495b068fd4828670afac37d02cd756c98f
|
|
import { t } from '../i18n/index.js';
|
|
import { listConnections, saveConnection, setActiveConnection, deleteConnection, recordConnectionStatus } from '../data/connections.js';
|
|
import { localDateTimeNl } from '../../shared/time.js';
|
|
import { CONFIG, ENUMS } from '../../shared/config.js';
|
|
import { originAllowedByCsp } from '../../shared/csp.js';
|
|
import * as dialog from '../ui/dialog.js';
|
|
import * as lnd from '../lightning/lnd.js';
|
|
import * as market from '../market-service.js';
|
|
import { appSetting, setAppSetting } from '../app-settings.js';
|
|
import { clearSpotCache } from '../price/providers.js';
|
|
import { fmtInt } from '../ui/format.js';
|
|
import { esc } from '../ui/escape.js';
|
|
import { locked } from '../license.js';
|
|
import { serviceState } from '../data/integration-log.js';
|
|
import { lastFailureHtml, openFailureLog } from '../integrations/failure-log.js';
|
|
|
|
const KINDS = ['btc_explorer', 'lnd'];
|
|
const KIND_META = {
|
|
btc_explorer: { label: 'connections.kindBtcExplorer', usedBy: 'connections.usedByBtcExplorer' },
|
|
lnd: { label: 'connections.kindLnd', usedBy: 'connections.usedByLnd' },
|
|
};
|
|
|
|
const explorerUrlsAtLoad = Promise.all([listConnections('btc_explorer')])
|
|
.then(([rows]) => rows.map(c => c.url)).catch(() => []);
|
|
|
|
const liveResult = new Map();
|
|
const fadeTimers = new Map();
|
|
|
|
function clearFade(kind) {
|
|
clearTimeout(fadeTimers.get(kind));
|
|
fadeTimers.delete(kind);
|
|
}
|
|
|
|
function scheduleFade(el, kind) {
|
|
clearFade(kind);
|
|
fadeTimers.set(kind, setTimeout(() => {
|
|
if (!isOnsPaneel(el)) { liveResult.delete(kind); return; }
|
|
const host = el.querySelector(`[data-result="${kind}"]`);
|
|
if (host) {
|
|
host.style.transitionDuration = `${CONFIG.CONNECTIONS.RESULT_FADE_MS}ms`;
|
|
host.classList.add('is-fading');
|
|
}
|
|
fadeTimers.set(kind, setTimeout(() => {
|
|
liveResult.delete(kind);
|
|
if (isOnsPaneel(el)) paintResult(el, kind);
|
|
}, CONFIG.CONNECTIONS.RESULT_FADE_MS));
|
|
}, CONFIG.CONNECTIONS.RESULT_HOLD_MS));
|
|
}
|
|
|
|
async function testConnection(conn) {
|
|
if (conn.kind === 'btc_explorer') {
|
|
if (!originAllowedByCsp(conn.url, location.origin, await explorerUrlsAtLoad)) {
|
|
throw new Error(t('connections.cspBlocked'));
|
|
}
|
|
const ctl = new AbortController();
|
|
const timer = setTimeout(() => ctl.abort(), CONFIG.CONNECTIONS.TEST_TIMEOUT_MS);
|
|
try {
|
|
const res = await fetch(`${conn.url}/api/blocks/tip/height`, { signal: ctl.signal });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const height = Number((await res.text()).trim());
|
|
if (!Number.isFinite(height) || height <= 0) throw new Error(t('connections.badResponse'));
|
|
return `${t('connections.blockHeight')} ${fmtInt(height)}`;
|
|
} finally { clearTimeout(timer); }
|
|
}
|
|
if (conn.kind === 'lnd') {
|
|
const r = await lnd.testLnd(conn.id);
|
|
const sync = t(r.synced ? 'connections.lndSynced' : 'connections.lndNotSynced');
|
|
return `${r.alias ? `${esc(r.alias)} · ` : ''}${sync} · ${t('connections.blockHeight')} ${fmtInt(r.blockHeight)}`;
|
|
}
|
|
throw new Error(t('connections.testUnsupported'));
|
|
}
|
|
|
|
async function runTest(el, conn, { footer = false } = {}) {
|
|
if (footer) { clearFade(conn.kind); liveResult.set(conn.kind, { pending: true }); paintResult(el, conn.kind); }
|
|
try {
|
|
const detail = await testConnection(conn);
|
|
await recordConnectionStatus(conn.id, true);
|
|
if (footer) liveResult.set(conn.kind, { ok: true, text: `${esc(conn.name)}: ${t('connections.statusOk')} · ${detail}` });
|
|
} catch (e) {
|
|
await recordConnectionStatus(conn.id, false);
|
|
if (footer) liveResult.set(conn.kind, { ok: false, text: `${esc(conn.name)}: ${t('connections.statusFail')}: ${esc(e.message)}` });
|
|
}
|
|
if (!isOnsPaneel(el)) return;
|
|
draw(el);
|
|
if (footer) scheduleFade(el, conn.kind);
|
|
}
|
|
|
|
function paintResult(el, kind) {
|
|
const host = el.querySelector(`[data-result="${kind}"]`);
|
|
if (!host) return;
|
|
host.style.transitionDuration = '';
|
|
const r = liveResult.get(kind);
|
|
if (!r) { host.className = 'conn-result'; host.innerHTML = ''; return; }
|
|
if (r.pending) { host.className = 'conn-result'; host.textContent = t('connections.testing'); return; }
|
|
host.className = `conn-result ${r.ok ? 'is-ok' : 'is-fail'}`;
|
|
host.innerHTML = r.text;
|
|
}
|
|
|
|
function dotHtml(c) {
|
|
const cls = c.lastStatus === 'ok' ? ' dot-ok' : c.lastStatus === 'fail' ? ' dot-fail' : '';
|
|
const label = c.lastStatus === 'ok' ? t('connections.statusOk')
|
|
: c.lastStatus === 'fail' ? t('connections.statusFail') : t('connections.notTested');
|
|
const when = c.lastCheckedAt ? ` · ${t('connections.lastChecked')} ${localDateTimeNl(c.lastCheckedAt)}` : '';
|
|
return `<span class="connp-dot${cls}" title="${esc(label + when)}"></span>`;
|
|
}
|
|
|
|
function tileHtml(c) {
|
|
const hover = c.isSystem ? '' : `<span class="connp-hover">
|
|
<button type="button" class="connp-mini" data-edit="${c.id}" title="${esc(t('connections.edit'))}">✎</button>
|
|
<button type="button" class="connp-mini" data-del="${c.id}" title="${esc(t('connections.delete'))}">✕</button>
|
|
</span>`;
|
|
return `<div class="connp${c.isActive ? ' is-active' : ''}" role="button" tabindex="0" data-select="${c.id}">
|
|
${dotHtml(c)}
|
|
<div class="connp-name">${esc(c.name)}</div>
|
|
<div class="connp-url" title="${esc(c.url)}">${esc(c.url)}</div>
|
|
${hover}
|
|
</div>`;
|
|
}
|
|
|
|
const KIND_FEATURE = Object.freeze({ btc_explorer: 'ownMempool', lnd: 'lightning', nwc: 'lightning' });
|
|
|
|
async function frameHtml(kind) {
|
|
const list = await listConnections(kind);
|
|
const opSlot = locked(KIND_FEATURE[kind]);
|
|
if (opSlot && !list.length) return '';
|
|
const tiles = list.map(tileHtml).join('');
|
|
return `<div class="card conn-frame" data-kind="${kind}">
|
|
<div class="t-label src-drawer-title">${t(KIND_META[kind].label)}</div>
|
|
<div class="t-body-sm t-muted conn-usedby">${t(KIND_META[kind].usedBy)}</div>
|
|
<div class="connp-row">${tiles}</div>
|
|
<div class="conn-foot">
|
|
${opSlot ? '' : `<button class="btn btn-ghost btn-md" data-new="${kind}" title="${esc(t('connections.newProfile'))}">+</button>`}
|
|
<button class="btn btn-primary btn-md" data-test-active="${kind}">${t('connections.test')}</button>
|
|
<div class="conn-result" data-result="${kind}"></div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
const MARKET = 'market';
|
|
|
|
let marketStatus = null;
|
|
|
|
function tokenLine(st) {
|
|
if (st.token_source === 'license') return t('connections.marketTokenLicense', { tier: t(`license.level_${st.tier}`) });
|
|
if (st.token_source === 'manual') return t('connections.marketTokenManual');
|
|
return t(st.can_register ? 'connections.marketTokenPending' : 'connections.marketTokenNoLicense');
|
|
}
|
|
|
|
async function marketFrameHtml() {
|
|
const st = await market.status();
|
|
let shown = marketStatus;
|
|
if (!shown && st.token_set) {
|
|
try {
|
|
const s = await serviceState();
|
|
if (s.ok != null) shown = { ok: s.ok, at: Date.parse(s.at) };
|
|
} catch { }
|
|
}
|
|
const cls = !st.token_set || !shown ? '' : shown.ok ? ' dot-ok' : ' dot-fail';
|
|
const label = !st.token_set ? t('connections.marketTokenUnset')
|
|
: !shown ? t('connections.notTested') : t(shown.ok ? 'connections.statusOk' : 'connections.statusFail');
|
|
const when = shown ? ` · ${t('connections.lastChecked')} ${localDateTimeNl(new Date(shown.at).toISOString())}` : '';
|
|
const lastFailure = st.token_set ? await lastFailureHtml('service').catch(() => '') : '';
|
|
const url = st.override ? `${st.url} (${t('connections.marketOverrideUrl')})` : '';
|
|
const stand = [tokenLine(st), t('connections.marketSpotShort_' + appSetting('market_spot'))].join(' · ');
|
|
return `<div class="card conn-frame" data-kind="${MARKET}">
|
|
<div class="t-label src-drawer-title">${t('connections.kindMarket')}</div>
|
|
<div class="t-body-sm t-muted conn-usedby">${t('connections.usedByMarket')}</div>
|
|
<div class="connp-row">
|
|
<div class="connp${st.token_set ? ' is-active' : ''}" role="button" tabindex="0" data-market-edit title="${esc(t('connections.marketConfigure'))}">
|
|
<span class="connp-dot${cls}" title="${esc(label + when)}"></span>
|
|
<div class="connp-name">Atreides Services</div>
|
|
${url ? `<div class="connp-url" title="${esc(url)}">${esc(url)}</div>` : ''}
|
|
<div class="connp-url" title="${esc(stand)}">${esc(stand)}</div>
|
|
</div>
|
|
</div>
|
|
${lastFailure}
|
|
<div class="conn-foot">
|
|
<button class="btn btn-primary btn-md" data-market-test>${t('connections.test')}</button>
|
|
<div class="conn-result" data-result="${MARKET}"></div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
async function runMarketTest(el, { footer = false } = {}) {
|
|
if (footer) { clearFade(MARKET); liveResult.set(MARKET, { pending: true }); paintResult(el, MARKET); }
|
|
try {
|
|
const r = await market.test();
|
|
marketStatus = { ok: true, at: Date.now() };
|
|
const detail = t('connections.marketTestOk', { app: r.app, version: r.version || '', n: fmtInt(r.instruments) });
|
|
if (footer) liveResult.set(MARKET, { ok: true, text: `${t('connections.statusOk')} · ${esc(detail)}` });
|
|
} catch (e) {
|
|
marketStatus = { ok: false, at: Date.now() };
|
|
if (footer) liveResult.set(MARKET, { ok: false, text: `${t('connections.statusFail')}: ${esc(e.message)}` });
|
|
}
|
|
if (!isOnsPaneel(el)) return;
|
|
await draw(el);
|
|
if (footer) scheduleFade(el, MARKET);
|
|
}
|
|
|
|
async function marketDialog(el) {
|
|
const res = await dialog.form({
|
|
title: t('connections.marketDialogTitle'),
|
|
confirmLabel: t('connections.save'),
|
|
fields: [
|
|
{ key: 'spot', label: t('connections.marketSpot'), type: 'select', value: appSetting('market_spot'),
|
|
options: ENUMS.MARKET_SPOT.map(v => ({ value: v, label: t('connections.marketSpot_' + v) })) },
|
|
],
|
|
});
|
|
if (!res) return;
|
|
if (res.spot && res.spot !== appSetting('market_spot')) {
|
|
await setAppSetting('market_spot', res.spot);
|
|
clearSpotCache();
|
|
}
|
|
await draw(el);
|
|
runMarketTest(el, { footer: true });
|
|
}
|
|
|
|
const byId = async id => (await listConnections()).find(c => c.id === Number(id));
|
|
const activeOf = async kind => (await listConnections(kind)).find(c => c.isActive);
|
|
|
|
async function profileDialog(el, kind, c = null) {
|
|
const fields = [
|
|
{ key: 'name', label: t('connections.name'), value: c ? c.name : '' },
|
|
{ key: 'url', label: t(kind === 'lnd' ? 'connections.lndUrl' : 'connections.url'),
|
|
value: c ? c.url : '', placeholder: kind === 'lnd' ? '10.8.0.1:8080' : 'https://…' },
|
|
];
|
|
if (kind === 'lnd') {
|
|
const keep = c ? t('connections.secretKeepHint') : '';
|
|
fields.push(
|
|
{ key: 'macaroon', label: t('connections.lndMacaroon'), type: 'password', placeholder: keep || '0201036c6e64…' },
|
|
{ key: 'tls_cert', label: t('connections.lndTlsCert'), type: 'textarea', placeholder: keep || '-----BEGIN CERTIFICATE-----' },
|
|
);
|
|
}
|
|
const res = await dialog.form({
|
|
title: c ? t('connections.editProfile') : t('connections.newProfile'),
|
|
confirmLabel: t('connections.save'),
|
|
fields,
|
|
});
|
|
if (!res) return;
|
|
if (kind === 'lnd' && res.url && !/^[a-z]+:\/\//i.test(res.url.trim())) res.url = `https://${res.url.trim()}`;
|
|
const id = await saveConnection({ id: c ? c.id : null, kind, name: res.name, url: res.url });
|
|
if (kind === 'lnd') await lnd.setConfig(id, { url: res.url, macaroon: res.macaroon, tls_cert: res.tls_cert });
|
|
draw(el);
|
|
}
|
|
|
|
const isOnsPaneel = (el) => !!el && !!el.querySelector('[data-result]');
|
|
|
|
async function draw(el) {
|
|
const [marketFrame, frames] = await Promise.all([marketFrameHtml(), Promise.all(KINDS.map(frameHtml))]);
|
|
el.innerHTML = marketFrame + frames.join('');
|
|
for (const kind of [MARKET, ...KINDS]) paintResult(el, kind);
|
|
|
|
el.onclick = async e => {
|
|
try {
|
|
if (e.target.closest('[data-market-edit]')) { await marketDialog(el); return; }
|
|
if (e.target.closest('[data-market-test]')) { await runMarketTest(el, { footer: true }); return; }
|
|
const ilog = e.target.closest('[data-ilog-open]');
|
|
if (ilog) { await openFailureLog(ilog.dataset.ilogOpen); return; }
|
|
const edit = e.target.closest('[data-edit]');
|
|
if (edit) { const c = await byId(edit.dataset.edit); if (c) await profileDialog(el, c.kind, c); return; }
|
|
const del = e.target.closest('[data-del]');
|
|
if (del) {
|
|
const c = await byId(del.dataset.del);
|
|
if (!c) return;
|
|
if (!(await dialog.confirm({ message: t('connections.confirmDelete'), danger: true }))) return;
|
|
if (c.kind === 'lnd') await lnd.purgeConfig(c.id);
|
|
await deleteConnection(c.id);
|
|
draw(el);
|
|
if (c.isActive) { const a = await activeOf(c.kind); if (a) runTest(el, a); }
|
|
return;
|
|
}
|
|
const nieuw = e.target.closest('[data-new]');
|
|
if (nieuw) {
|
|
await profileDialog(el, nieuw.dataset.new);
|
|
return;
|
|
}
|
|
const testBtn = e.target.closest('[data-test-active]');
|
|
if (testBtn) { const a = await activeOf(testBtn.dataset.testActive); if (a) await runTest(el, a, { footer: true }); return; }
|
|
const tile = e.target.closest('[data-select]');
|
|
if (tile) {
|
|
const c = await byId(tile.dataset.select);
|
|
if (!c || c.isActive) return;
|
|
await setActiveConnection(c.id);
|
|
draw(el);
|
|
runTest(el, c);
|
|
return;
|
|
}
|
|
} catch (err) { dialog.alert({ title: t('dialog.error'), message: err.message }); }
|
|
};
|
|
el.onkeydown = e => {
|
|
if ((e.key === 'Enter' || e.key === ' ') && e.target.matches && e.target.matches('[data-select], [data-market-edit]')) {
|
|
e.preventDefault();
|
|
e.target.click();
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function render(el) {
|
|
for (const kind of [MARKET, ...KINDS]) clearFade(kind);
|
|
liveResult.clear();
|
|
await draw(el);
|
|
const st = await market.status();
|
|
if (st.token_set || st.can_register) runMarketTest(el);
|
|
for (const kind of KINDS) {
|
|
for (const c of await listConnections(kind)) runTest(el, c);
|
|
}
|
|
}
|