// file: kb_app/frontend/ts/demo_pipeline2.ts import * as bootstrap from "bootstrap"; import "simplebar"; import ResizeObserver from "resize-observer-polyfill"; import * as echarts from "echarts"; import { invoke } from "@tauri-apps/api/core"; import { debug, takeoverConsole } from "@fltsci/tauri-plugin-tracing"; import type { KbDemoPipeline2CatalogPayload } from "./bindings/KbDemoPipeline2CatalogPayload.ts"; import type { KbDemoPipeline2BackfillTokenRequest } from "./bindings/KbDemoPipeline2BackfillTokenRequest.ts"; import type { KbDemoPipeline2BackfillPoolRequest } from "./bindings/KbDemoPipeline2BackfillPoolRequest.ts"; import type { KbDemoPipeline2BackfillPayload } from "./bindings/KbDemoPipeline2BackfillPayload.ts"; import type { KbDemoPipeline2PairCandlesRequest } from "./bindings/KbDemoPipeline2PairCandlesRequest.ts"; import type { KbDemoPipeline2PairCandlesPayload } from "./bindings/KbDemoPipeline2PairCandlesPayload.ts"; (window as Window & typeof globalThis & { bootstrap?: typeof bootstrap }).bootstrap = bootstrap; (window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver; interface PairCandle { id: number | null; pair_id: number; timeframe_seconds: number; bucket_start_unix: number; bucket_end_unix: number; open_price_quote_per_base: number; high_price_quote_per_base: number; low_price_quote_per_base: number; close_price_quote_per_base: number; trade_count: number; buy_count: number; sell_count: number; base_volume_raw: string | null; quote_volume_raw: string | null; first_trade_signature: string | null; last_trade_signature: string | null; created_at: string; updated_at: string; } interface KbLocalPipelineReplayResult { selectedTransactionCount: number; replayedTransactionCount: number; decodeErrorCount: number; detectErrorCount: number; tradeAggregationErrorCount: number; pairCandleErrorCount: number; analyticSignalErrorCount: number; decodedEventCount: number; detectionCount: number; tradeEventCount: number; pairCandleCount: number; analyticSignalCount: number; tokenMetadataUpdatedCount: number; pairSymbolUpdatedCount: number; globalErrorCount: number; } function appendLogLine(textarea: HTMLTextAreaElement, line: string): void { const now = new Date(); const timestamp = now.toLocaleTimeString("fr-CH", { hour12: false }); const lines = textarea.value === "" ? [] : textarea.value.split("\n"); lines.push(`[${timestamp}] ${line}`); textarea.value = lines.slice(-300).join("\n"); textarea.scrollTop = textarea.scrollHeight; } function setEmptyChart( chart: echarts.ECharts, chartMeta: HTMLElement, message: string, ): void { chartMeta.textContent = message; chart.setOption({ animation: false, title: { text: message, left: "center", top: "middle", textStyle: { fontSize: 14, fontWeight: "normal", }, }, tooltip: {}, xAxis: { show: false, type: "category", data: [] }, yAxis: { show: false, type: "value" }, series: [], }, true); } function readPositiveIntegerInput( input: HTMLInputElement, logTextarea: HTMLTextAreaElement, label: string, ): number | undefined { const text = input.value.trim(); if (text === "") { appendLogLine(logTextarea, `[ui] ${label} is required`); return undefined; } const parsed = Number.parseInt(text, 10); if (Number.isNaN(parsed) || parsed <= 0) { appendLogLine(logTextarea, `[ui] invalid ${label} '${text}'`); return undefined; } return parsed; } function readOptionalPositiveIntegerInput( input: HTMLInputElement, logTextarea: HTMLTextAreaElement, label: string, ): number | null | undefined { const text = input.value.trim(); if (text === "") { return null; } const parsed = Number.parseInt(text, 10); if (Number.isNaN(parsed) || parsed <= 0) { appendLogLine(logTextarea, `[ui] invalid ${label} '${text}'`); return undefined; } return parsed; } function refreshPairSelect( catalog: KbDemoPipeline2CatalogPayload, select: HTMLSelectElement, ): void { const previousValue = select.value; select.innerHTML = ""; const emptyOption = document.createElement("option"); emptyOption.value = ""; emptyOption.textContent = "Aucune"; select.appendChild(emptyOption); for (const pair of catalog.pairs) { const option = document.createElement("option"); option.value = pair.pairId.toString(); option.textContent = `#${pair.pairId.toString()} ${pair.symbol ?? ""} ${pair.poolAddress}`.trim(); if (option.value === previousValue) { option.selected = true; } select.appendChild(option); } } function renderCatalogTextareas( catalog: KbDemoPipeline2CatalogPayload, tokensTextarea: HTMLTextAreaElement, poolsTextarea: HTMLTextAreaElement, pairsTextarea: HTMLTextAreaElement, ): void { tokensTextarea.value = JSON.stringify(catalog.tokens, null, 2); poolsTextarea.value = JSON.stringify(catalog.pools, null, 2); pairsTextarea.value = JSON.stringify(catalog.pairs, null, 2); } function parseCandlesJson(raw: string): PairCandle[] { if (raw.trim() === "") { return []; } try { return JSON.parse(raw) as PairCandle[]; } catch { return []; } } function parseVolume(text: string | null, fallback: number): number { if (text === null || text.trim() === "") { return Number(fallback); } const parsed = Number.parseFloat(text); if (Number.isNaN(parsed)) { return Number(fallback); } return parsed; } function renderCandlesChart( chart: echarts.ECharts, chartMeta: HTMLElement, pairId: number, timeframeSeconds: number, candles: PairCandle[], ): void { if (candles.length === 0) { setEmptyChart(chart, chartMeta, "Aucune candle disponible."); return; } const sorted = [...candles].sort( (left, right) => left.bucket_start_unix - right.bucket_start_unix, ); const categoryData = sorted.map((candle) => new Date(Number(candle.bucket_start_unix) * 1000).toLocaleString("fr-CH", { hour12: false, year: "2-digit", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }), ); const ohlcData = sorted.map((candle) => [ candle.open_price_quote_per_base, candle.close_price_quote_per_base, candle.low_price_quote_per_base, candle.high_price_quote_per_base, ]); const volumeData = sorted.map((candle) => parseVolume(candle.quote_volume_raw, candle.trade_count), ); chartMeta.textContent = `Pair #${pairId.toString()} • timeframe ${timeframeSeconds.toString()}s • ${sorted.length} candles`; chart.setOption({ animation: false, legend: { data: ["OHLC", "Volume"], top: 0, }, tooltip: { trigger: "axis", axisPointer: { type: "cross", }, }, axisPointer: { link: [{ xAxisIndex: "all" }], }, grid: [ { left: 60, right: 24, top: 40, height: "58%" }, { left: 60, right: 24, top: "74%", height: "16%" }, ], xAxis: [ { type: "category", data: categoryData, boundaryGap: true, axisLine: { onZero: false }, splitLine: { show: false }, min: "dataMin", max: "dataMax", }, { type: "category", gridIndex: 1, data: categoryData, boundaryGap: true, axisLine: { onZero: false }, axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, min: "dataMin", max: "dataMax", }, ], yAxis: [ { scale: true, splitArea: { show: false }, }, { gridIndex: 1, scale: true, splitNumber: 2, }, ], dataZoom: [ { type: "inside", xAxisIndex: [0, 1], start: 0, end: 100, }, { show: true, type: "slider", xAxisIndex: [0, 1], bottom: 6, start: 0, end: 100, }, ], series: [ { name: "OHLC", type: "candlestick", data: ohlcData, itemStyle: { color: "#16a34a", color0: "#dc2626", borderColor: "#15803d", borderColor0: "#b91c1c", }, }, { name: "Volume", type: "bar", xAxisIndex: 1, yAxisIndex: 1, data: volumeData, }, ], }, true); } document.addEventListener("DOMContentLoaded", async () => { void takeoverConsole(); debug("demo_pipeline2 window loaded"); const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); Array.from(tooltipTriggerList).map((tooltipTriggerEl) => new bootstrap.Tooltip(tooltipTriggerEl)); const refreshCatalogButton = document.querySelector("#demoPipeline2RefreshCatalogButton"); const tokensTextarea = document.querySelector("#demoPipeline2TokensTextarea"); const poolsTextarea = document.querySelector("#demoPipeline2PoolsTextarea"); const pairsTextarea = document.querySelector("#demoPipeline2PairsTextarea"); const httpRoleInput = document.querySelector("#demoPipeline2HttpRoleInput"); const mintInput = document.querySelector("#demoPipeline2MintInput"); const mintSignatureLimitInput = document.querySelector("#demoPipeline2MintSignatureLimitInput"); const mintPoolLimitInput = document.querySelector("#demoPipeline2MintPoolLimitInput"); const backfillMintButton = document.querySelector("#demoPipeline2BackfillMintButton"); const poolInput = document.querySelector("#demoPipeline2PoolInput"); const poolSignatureLimitInput = document.querySelector("#demoPipeline2PoolSignatureLimitInput"); const backfillPoolButton = document.querySelector("#demoPipeline2BackfillPoolButton"); const replayLimitInput = document.querySelector("#demoPipeline2ReplayLimitInput"); const replayMetadataCheckbox = document.querySelector("#demoPipeline2ReplayMetadataCheckbox"); const replayMetadataLimitInput = document.querySelector("#demoPipeline2ReplayMetadataLimitInput"); const replayLocalPipelineButton = document.querySelector("#demoPipeline2ReplayLocalPipelineButton"); const pairSelect = document.querySelector("#demoPipeline2PairSelect"); const timeframeSelect = document.querySelector("#demoPipeline2TimeframeSelect"); const customTimeframeInput = document.querySelector("#demoPipeline2CustomTimeframeInput"); const preferMaterializedInput = document.querySelector("#demoPipeline2PreferMaterializedInput"); const loadCandlesButton = document.querySelector("#demoPipeline2LoadCandlesButton"); const backfillSummaryTextarea = document.querySelector("#demoPipeline2BackfillSummaryTextarea"); const chartElement = document.querySelector("#demoPipeline2Chart"); const chartMeta = document.querySelector("#demoPipeline2ChartMeta"); const clearLogButton = document.querySelector("#demoPipeline2ClearLogButton"); const logTextarea = document.querySelector("#demoPipeline2LogTextarea"); if ( !refreshCatalogButton || !tokensTextarea || !poolsTextarea || !pairsTextarea || !httpRoleInput || !mintInput || !mintSignatureLimitInput || !mintPoolLimitInput || !backfillMintButton || !poolInput || !poolSignatureLimitInput || !backfillPoolButton || !replayLimitInput || !replayMetadataCheckbox || !replayMetadataLimitInput || !replayLocalPipelineButton || !pairSelect || !timeframeSelect || !customTimeframeInput || !preferMaterializedInput || !loadCandlesButton || !backfillSummaryTextarea || !chartElement || !chartMeta || !clearLogButton || !logTextarea ) { console.error("demo_pipeline2 DOM is incomplete"); return; } const safeTokensTextarea = tokensTextarea; const safePoolsTextarea = poolsTextarea; const safePairsTextarea = pairsTextarea; const safePairSelect = pairSelect; const safeChartElement = chartElement; const safeChartMeta = chartMeta; const safeLogTextarea = logTextarea; const chart = echarts.init(safeChartElement); setEmptyChart(chart, safeChartMeta, "Aucune candle disponible."); window.addEventListener("resize", () => chart.resize()); clearLogButton.addEventListener("click", () => { logTextarea.value = ""; }); let currentCatalog: KbDemoPipeline2CatalogPayload | null = null; async function refreshCatalog(): Promise { appendLogLine(safeLogTextarea, "[ui] refreshing local catalog"); try { const catalog = await invoke("demo_pipeline2_get_catalog"); currentCatalog = catalog; renderCatalogTextareas(catalog, safeTokensTextarea, safePoolsTextarea, safePairsTextarea); refreshPairSelect(catalog, safePairSelect); appendLogLine( safeLogTextarea, `[ui] catalog refreshed: ${catalog.tokens.length} tokens, ${catalog.pools.length} pools, ${catalog.pairs.length} pairs`, ); } catch (error) { appendLogLine(safeLogTextarea, `[ui] catalog refresh error: ${String(error)}`); } } refreshCatalogButton.addEventListener("click", () => { void refreshCatalog(); }); backfillMintButton.addEventListener("click", async () => { const tokenMint = mintInput.value.trim(); if (tokenMint === "") { appendLogLine(logTextarea, "[ui] token mint is required"); return; } const mintSignatureLimit = readPositiveIntegerInput( mintSignatureLimitInput, logTextarea, "mintSignatureLimit", ); if (mintSignatureLimit === undefined) { return; } const poolSignatureLimit = readPositiveIntegerInput( mintPoolLimitInput, logTextarea, "poolSignatureLimit", ); if (poolSignatureLimit === undefined) { return; } const httpRoleText = httpRoleInput.value.trim(); const httpRole = httpRoleText === "" ? null : httpRoleText; appendLogLine( logTextarea, `[ui] launching token backfill for '${tokenMint}' with role '${httpRole ?? "history_backfill"}'`, ); const request: KbDemoPipeline2BackfillTokenRequest = { tokenMint, httpRole, mintSignatureLimit, poolSignatureLimit, }; try { const payload = await invoke( "demo_pipeline2_backfill_token_mint", { request }, ); backfillSummaryTextarea.value = payload.summaryJson; currentCatalog = payload.catalog; renderCatalogTextareas(payload.catalog, tokensTextarea, poolsTextarea, pairsTextarea); refreshPairSelect(payload.catalog, pairSelect); appendLogLine(logTextarea, `[ui] token backfill completed for '${payload.objectKey}'`); } catch (error) { appendLogLine(logTextarea, `[ui] token backfill error: ${String(error)}`); } }); backfillPoolButton.addEventListener("click", async () => { const poolAddress = poolInput.value.trim(); if (poolAddress === "") { appendLogLine(logTextarea, "[ui] pool address is required"); return; } const poolSignatureLimit = readPositiveIntegerInput( poolSignatureLimitInput, logTextarea, "poolSignatureLimit", ); if (poolSignatureLimit === undefined) { return; } const httpRoleText = httpRoleInput.value.trim(); const httpRole = httpRoleText === "" ? null : httpRoleText; appendLogLine( logTextarea, `[ui] launching pool backfill for '${poolAddress}' with role '${httpRole ?? "history_backfill"}'`, ); const request: KbDemoPipeline2BackfillPoolRequest = { poolAddress, httpRole, poolSignatureLimit, }; try { const payload = await invoke( "demo_pipeline2_backfill_pool_address", { request }, ); backfillSummaryTextarea.value = payload.summaryJson; currentCatalog = payload.catalog; renderCatalogTextareas(payload.catalog, tokensTextarea, poolsTextarea, pairsTextarea); refreshPairSelect(payload.catalog, pairSelect); appendLogLine(logTextarea, `[ui] pool backfill completed for '${payload.objectKey}'`); } catch (error) { appendLogLine(logTextarea, `[ui] pool backfill error: ${String(error)}`); } }); replayLocalPipelineButton.addEventListener("click", async () => { const replayLimit = readOptionalPositiveIntegerInput( replayLimitInput, logTextarea, "replayLimit", ); if (replayLimit === undefined) { return; } const tokenMetadataLimit = readOptionalPositiveIntegerInput( replayMetadataLimitInput, logTextarea, "tokenMetadataLimit", ); if (tokenMetadataLimit === undefined) { return; } appendLogLine( logTextarea, `[ui] launching local pipeline replay limit='${replayLimit ?? "none"}' metadata='${replayMetadataCheckbox.checked ? "yes" : "no"}'`, ); try { const result = await invoke( "demo_pipeline2_replay_local_pipeline", { limit: replayLimit, refreshMissingTokenMetadata: replayMetadataCheckbox.checked, tokenMetadataLimit, }, ); backfillSummaryTextarea.value = JSON.stringify(result, null, 2); appendLogLine( logTextarea, `[ui] local pipeline replay completed: ${result.replayedTransactionCount.toString()} replayed, ${result.tradeEventCount.toString()} trades, ${result.pairCandleCount.toString()} candles`, ); await refreshCatalog(); } catch (error) { appendLogLine(logTextarea, `[ui] local pipeline replay error: ${String(error)}`); } }); loadCandlesButton.addEventListener("click", async () => { const pairIdText = pairSelect.value.trim(); if (pairIdText === "") { appendLogLine(logTextarea, "[ui] pair selection is required"); return; } const parsedPairId = Number.parseInt(pairIdText, 10); if (Number.isNaN(parsedPairId) || parsedPairId <= 0) { appendLogLine(logTextarea, `[ui] invalid pair id '${pairIdText}'`); return; } let timeframeSeconds = Number.parseInt(timeframeSelect.value.trim(), 10); const customTimeframeText = customTimeframeInput.value.trim(); if (customTimeframeText !== "") { const parsedCustom = Number.parseInt(customTimeframeText, 10); if (Number.isNaN(parsedCustom) || parsedCustom <= 0) { appendLogLine(logTextarea, `[ui] invalid custom timeframe '${customTimeframeText}'`); return; } timeframeSeconds = parsedCustom; } appendLogLine( logTextarea, `[ui] loading candles for pair '${parsedPairId}' timeframe '${timeframeSeconds}s'`, ); const request: KbDemoPipeline2PairCandlesRequest = { pairId: parsedPairId, timeframeSeconds, preferMaterialized: preferMaterializedInput.checked, }; try { const payload = await invoke( "demo_pipeline2_get_pair_candles", { request }, ); const candles = parseCandlesJson(payload.candlesJson); renderCandlesChart( chart, chartMeta, payload.pairId, payload.timeframeSeconds, candles, ); appendLogLine( logTextarea, `[ui] loaded ${candles.length} candles for pair '${payload.pairId.toString()}'`, ); } catch (error) { appendLogLine(logTextarea, `[ui] load candles error: ${String(error)}`); setEmptyChart(chart, chartMeta, "Erreur lors du chargement des candles."); } }); await refreshCatalog(); if (currentCatalog !== null && currentCatalog.pairs.length > 0) { pairSelect.value = currentCatalog.pairs[0].pairId.toString(); } });