Reduce dashboard bootstrap timeout risk
Some checks failed
deploy / deploy (push) Failing after 39s

Proof: npm test passed 173/173; npm run operator-dashboard:build passed; regression tests cover omitting raw asset payloads from bootstrap rows and using an explicit 45s bootstrap timeout.

Assumptions: The timeout observed by the operator is the browser-side 15000ms fetch limit on /api/bootstrap; trimming asset rows and extending only the bootstrap timeout keeps the dashboard available while preserving visible asset and pair config.

Still fake: raw imported token payloads remain stored in Postgres but are not exposed through a per-asset dashboard detail view in this fix.
This commit is contained in:
philipp 2026-05-13 13:00:00 +02:00
parent edfa14f37e
commit da2202c455
4 changed files with 124 additions and 5 deletions

View file

@ -635,8 +635,14 @@ export function createTradingConfigStore({
export async function loadAssetCatalogSummary(pool, { limit = 250 } = {}) { export async function loadAssetCatalogSummary(pool, { limit = 250 } = {}) {
await ensureTradingConfigSchema(pool); await ensureTradingConfigSchema(pool);
const [snapshot, countResult] = await Promise.all([ const boundedLimit = Math.max(1, Number(limit) || 50);
loadTradingConfig(pool), 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(` pool.query(`
SELECT SELECT
COUNT(*)::INT AS known_count, 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 COUNT(*) FILTER (WHERE enabled_for_inventory)::INT AS inventory_enabled_count
FROM ${TRADING_ASSETS_TABLE} 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] || {}; const counts = countResult.rows[0] || {};
return { return {
latest_import: snapshot.latestImportRun, latest_import: normalizeAssetImportRunRow(latestImportResult.rows[0] || null),
counts: { counts: {
known: Number(counts.known_count || 0), known: Number(counts.known_count || 0),
supported: Number(counts.supported_count || 0), supported: Number(counts.supported_count || 0),
retired: Number(counts.retired_count || 0), retired: Number(counts.retired_count || 0),
inventory_enabled: Number(counts.inventory_enabled_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) { function normalizeTradingPairRow(row) {
return { return {
pairId: row.pair_id, pairId: row.pair_id,

View file

@ -10,6 +10,7 @@ import SystemPage from './pages/SystemPage.jsx';
import { dashboardReducer, initialDashboardState } from './state/dashboardReducer.js'; import { dashboardReducer, initialDashboardState } from './state/dashboardReducer.js';
const BOOTSTRAP_PAGE_SIZE = 20; const BOOTSTRAP_PAGE_SIZE = 20;
const BOOTSTRAP_TIMEOUT_MS = 45_000;
function LoadingPanel({ error, onRetry }) { function LoadingPanel({ error, onRetry }) {
return ( return (
@ -34,7 +35,9 @@ export default function App() {
const criticalBanner = null; const criticalBanner = null;
async function loadBootstrap(page = 1) { 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 }); dispatch({ type: 'bootstrap.loaded', dashboard });
return dashboard; return dashboard;
} }

View file

@ -86,3 +86,8 @@ test('dashboard loading state exposes API failures and retry action', () => {
assert.match(appSource, /Retry/); assert.match(appSource, /Retry/);
assert.match(appSource, /bootDashboard\(\)\.catch/); 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/);
});

View file

@ -12,6 +12,7 @@ import {
createPairStrategyConfigVersion, createPairStrategyConfigVersion,
enableObserveOnlyPair, enableObserveOnlyPair,
importSupportedAssets, importSupportedAssets,
loadAssetCatalogSummary,
loadTradingConfig, loadTradingConfig,
pauseTradingPair, pauseTradingPair,
seedTradingConfig, 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'); 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 () => { test('seeded DB config preserves current nBTC/EURe pair, 49 bps edge, and legacy BTC tracking', async () => {
const pool = createMemoryPool(); const pool = createMemoryPool();
await seedTradingConfig(pool, { now: '2026-05-12T16:35:00.000Z' }); await seedTradingConfig(pool, { now: '2026-05-12T16:35:00.000Z' });
@ -327,6 +348,9 @@ function createMemoryPool() {
async query(sql, params = []) { async query(sql, params = []) {
if (/CREATE TABLE|CREATE (UNIQUE )?INDEX/i.test(sql)) return { rows: [], rowCount: 0 }; 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 \* 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 (/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 (/INSERT INTO trading_assets/i.test(sql)) return insertAsset(this, params);
if (/UPDATE trading_assets/i.test(sql)) return retireAssets(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 }; 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) { function insertAsset(pool, params) {
const seed = params.length === 16; const seed = params.length === 16;
const [ const [