diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index 024bc71..df8c781 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -635,8 +635,14 @@ export function createTradingConfigStore({ export async function loadAssetCatalogSummary(pool, { limit = 250 } = {}) { await ensureTradingConfigSchema(pool); - const [snapshot, countResult] = await Promise.all([ - loadTradingConfig(pool), + const boundedLimit = Math.max(1, Number(limit) || 50); + const [latestImportResult, countResult, assetResult] = await Promise.all([ + pool.query(` + SELECT * + FROM ${SUPPORTED_ASSET_IMPORT_RUNS_TABLE} + ORDER BY fetched_at DESC + LIMIT 1 + `), pool.query(` SELECT COUNT(*)::INT AS known_count, @@ -645,17 +651,40 @@ export async function loadAssetCatalogSummary(pool, { limit = 250 } = {}) { COUNT(*) FILTER (WHERE enabled_for_inventory)::INT AS inventory_enabled_count FROM ${TRADING_ASSETS_TABLE} `), + pool.query(` + SELECT + asset_id, + venue, + symbol, + label, + decimals, + blockchain, + chain, + contract_address, + latest_price, + price_updated_at, + supported, + retired_at, + enabled_for_inventory, + role, + withdraw_address, + raw_payload <> '{}'::jsonb AS raw_payload_available, + updated_at + FROM ${TRADING_ASSETS_TABLE} + ORDER BY symbol ASC, asset_id ASC + LIMIT $1 + `, [boundedLimit]), ]); const counts = countResult.rows[0] || {}; return { - latest_import: snapshot.latestImportRun, + latest_import: normalizeAssetImportRunRow(latestImportResult.rows[0] || null), counts: { known: Number(counts.known_count || 0), supported: Number(counts.supported_count || 0), retired: Number(counts.retired_count || 0), inventory_enabled: Number(counts.inventory_enabled_count || 0), }, - items: snapshot.assets.slice(0, Math.max(1, Number(limit) || 50)), + items: assetResult.rows.map(normalizeAssetCatalogSummaryRow), }; } @@ -1147,6 +1176,36 @@ function normalizeTradingAssetRow(row) { }; } +function normalizeAssetCatalogSummaryRow(row) { + return { + assetId: row.asset_id, + asset_id: row.asset_id, + venue: row.venue, + symbol: row.symbol, + label: row.label || row.symbol, + decimals: Number(row.decimals), + blockchain: row.blockchain || null, + chain: row.chain || row.blockchain || null, + contractAddress: row.contract_address || null, + contract_address: row.contract_address || null, + latestPrice: row.latest_price == null ? null : String(row.latest_price), + latest_price: row.latest_price == null ? null : String(row.latest_price), + priceUpdatedAt: toIsoTimestamp(row.price_updated_at), + price_updated_at: toIsoTimestamp(row.price_updated_at), + supported: row.supported === true, + retiredAt: toIsoTimestamp(row.retired_at), + retired_at: toIsoTimestamp(row.retired_at), + enabledForInventory: row.enabled_for_inventory === true, + enabled_for_inventory: row.enabled_for_inventory === true, + role: row.role || null, + withdrawAddress: row.withdraw_address || '', + withdraw_address: row.withdraw_address || '', + rawPayloadAvailable: row.raw_payload_available === true, + raw_payload_available: row.raw_payload_available === true, + updated_at: toIsoTimestamp(row.updated_at), + }; +} + function normalizeTradingPairRow(row) { return { pairId: row.pair_id, diff --git a/src/operator-dashboard/static/App.jsx b/src/operator-dashboard/static/App.jsx index a7c2f58..8509a45 100644 --- a/src/operator-dashboard/static/App.jsx +++ b/src/operator-dashboard/static/App.jsx @@ -10,6 +10,7 @@ import SystemPage from './pages/SystemPage.jsx'; import { dashboardReducer, initialDashboardState } from './state/dashboardReducer.js'; const BOOTSTRAP_PAGE_SIZE = 20; +const BOOTSTRAP_TIMEOUT_MS = 45_000; function LoadingPanel({ error, onRetry }) { return ( @@ -34,7 +35,9 @@ export default function App() { const criticalBanner = null; async function loadBootstrap(page = 1) { - const dashboard = await fetchJson(`/api/bootstrap?page=${page}&page_size=${BOOTSTRAP_PAGE_SIZE}`); + const dashboard = await fetchJson(`/api/bootstrap?page=${page}&page_size=${BOOTSTRAP_PAGE_SIZE}`, { + timeoutMs: BOOTSTRAP_TIMEOUT_MS, + }); dispatch({ type: 'bootstrap.loaded', dashboard }); return dashboard; } diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index 85b9a49..f3c9ce7 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -86,3 +86,8 @@ test('dashboard loading state exposes API failures and retry action', () => { assert.match(appSource, /Retry/); assert.match(appSource, /bootDashboard\(\)\.catch/); }); + +test('dashboard bootstrap uses an explicit long timeout for slow live state aggregation', () => { + assert.match(appSource, /const BOOTSTRAP_TIMEOUT_MS = 45_000/); + assert.match(appSource, /timeoutMs: BOOTSTRAP_TIMEOUT_MS/); +}); diff --git a/test/trading-config.test.mjs b/test/trading-config.test.mjs index 1468823..b35c617 100644 --- a/test/trading-config.test.mjs +++ b/test/trading-config.test.mjs @@ -12,6 +12,7 @@ import { createPairStrategyConfigVersion, enableObserveOnlyPair, importSupportedAssets, + loadAssetCatalogSummary, loadTradingConfig, pauseTradingPair, seedTradingConfig, @@ -72,6 +73,26 @@ test('supported token import is idempotent, does not enable inventory, and retir assert.equal(pool.assets.get(CURRENT_EURE_ASSET_ID).retired_at, '2026-05-12T16:32:00.000Z'); }); +test('asset catalog summary omits raw token payloads from bootstrap rows', async () => { + const pool = createMemoryPool(); + await importSupportedAssets(pool, { + fetchedAt: '2026-05-12T16:30:00.000Z', + response: [{ + ...token(CURRENT_NBTC_ASSET_ID, 'BTC', 8), + routes: Array.from({ length: 100 }, (_, index) => ({ route: index, detail: 'not for bootstrap' })), + }], + }); + + const summary = await loadAssetCatalogSummary(pool, { limit: 250 }); + const [asset] = summary.items; + + assert.equal(summary.counts.known, 1); + assert.equal(asset.asset_id, CURRENT_NBTC_ASSET_ID); + assert.equal(asset.raw_payload_available, true); + assert.equal(Object.hasOwn(asset, 'raw_payload'), false); + assert.equal(Object.hasOwn(asset, 'rawPayload'), false); +}); + test('seeded DB config preserves current nBTC/EURe pair, 49 bps edge, and legacy BTC tracking', async () => { const pool = createMemoryPool(); await seedTradingConfig(pool, { now: '2026-05-12T16:35:00.000Z' }); @@ -327,6 +348,9 @@ function createMemoryPool() { async query(sql, params = []) { if (/CREATE TABLE|CREATE (UNIQUE )?INDEX/i.test(sql)) return { rows: [], rowCount: 0 }; if (/SELECT \* FROM trading_assets\s*$/i.test(sql)) return rows(this.assets); + if (/SELECT\s+asset_id,[\s\S]+raw_payload_available[\s\S]+FROM trading_assets/i.test(sql)) { + return selectAssetCatalogRows(this, params); + } if (/SELECT \*\s+FROM trading_assets\s+ORDER BY/i.test(sql)) return rows(this.assets); if (/INSERT INTO trading_assets/i.test(sql)) return insertAsset(this, params); if (/UPDATE trading_assets/i.test(sql)) return retireAssets(this, params); @@ -389,6 +413,34 @@ function rows(map) { return { rows: [...map.values()], rowCount: map.size }; } +function selectAssetCatalogRows(pool, params) { + const limit = Math.max(1, Number(params[0]) || 50); + const selected = [...pool.assets.values()] + .sort((left, right) => String(left.symbol).localeCompare(String(right.symbol)) + || String(left.asset_id).localeCompare(String(right.asset_id))) + .slice(0, limit) + .map((row) => ({ + asset_id: row.asset_id, + venue: row.venue, + symbol: row.symbol, + label: row.label, + decimals: row.decimals, + blockchain: row.blockchain, + chain: row.chain, + contract_address: row.contract_address, + latest_price: row.latest_price, + price_updated_at: row.price_updated_at, + supported: row.supported, + retired_at: row.retired_at, + enabled_for_inventory: row.enabled_for_inventory, + role: row.role, + withdraw_address: row.withdraw_address, + raw_payload_available: Object.keys(row.raw_payload || {}).length > 0, + updated_at: row.updated_at, + })); + return { rows: selected, rowCount: selected.length }; +} + function insertAsset(pool, params) { const seed = params.length === 16; const [