All checks were successful
deploy / deploy (push) Successful in 1m10s
Proof: Added an ECharts stacked persisted quote lifecycle statistics chart with range controls, raised dashboard statistics loads to the existing 1000-row clamp, and covered the UI wiring with static regression assertions. Ran targeted dashboard tests, full npm test, and npm run operator-dashboard:build. Assumptions: Persisted quote lifecycle statistics remain the durable source of truth for the chart; live counters stay separate until the history rollup refresh records them. Still fake: Fee-aware PnL, quote size distributions, oracle-deviation analytics, and any already-pruned lifecycle detail remain out of scope; the chart can only show retained persisted statistics.
1031 lines
32 KiB
JavaScript
1031 lines
32 KiB
JavaScript
import http from 'node:http';
|
|
import process from 'node:process';
|
|
import { readdir, readFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
import { WebSocketServer } from 'ws';
|
|
|
|
import { createConsumer } from '../bus/kafka/consumer.mjs';
|
|
import { parseEventMessage } from '../core/event-envelope.mjs';
|
|
import { buildMakerCompetitivenessSummary } from '../core/maker-competitiveness.mjs';
|
|
import {
|
|
DASHBOARD_WEBSOCKET_MAX_BUFFERED_BYTES,
|
|
applyDashboardLiveEvent,
|
|
buildDashboardBootstrap,
|
|
buildDashboardControlErrorResponse,
|
|
buildLiveQuoteLifecycleStatistics,
|
|
buildLiveQuoteLifecycleRows,
|
|
buildQuoteLifecycleLookupResponse,
|
|
buildLiveStatusBar,
|
|
createDashboardLiveState,
|
|
isDashboardWebSocketBackpressured,
|
|
listDashboardServices,
|
|
resolveDashboardControl,
|
|
resolveDashboardControlTimeoutMs,
|
|
} from '../core/operator-dashboard.mjs';
|
|
import {
|
|
buildDashboardAuthChallengeHeader,
|
|
buildDashboardSessionCookie,
|
|
resolveDashboardRequestAuth,
|
|
} from '../core/operator-dashboard-auth.mjs';
|
|
import { createLogger, serializeError } from '../core/log.mjs';
|
|
import { normalizeNearIntentsStatus } from '../core/near-intents-status.mjs';
|
|
import { readJsonBody, sendJson } from '../core/control-api.mjs';
|
|
import { loadConfig } from '../lib/config.mjs';
|
|
import { fetchJson } from '../lib/http.mjs';
|
|
import {
|
|
createPairStrategyConfigVersion,
|
|
createPostgresPool,
|
|
createTradingConfigStore,
|
|
enableObserveOnlyPair,
|
|
ensureHistorySchema,
|
|
importSupportedAssets,
|
|
loadAssetCatalogSummary,
|
|
loadCurrentFundingObservations,
|
|
loadLatestInventorySnapshot,
|
|
loadLatestMarketPrice,
|
|
loadLatestPortfolioMetric,
|
|
loadPairConfigSummary,
|
|
loadQuoteLifecycleLookupEvidence,
|
|
loadQuoteLifecycleRetentionSummary,
|
|
loadQuoteLifecycleStatistics,
|
|
loadRecentAlertTransitions,
|
|
loadRecentDepositStatuses,
|
|
loadRecentEnvironmentStatuses,
|
|
loadRecentExecuteTradeCommands,
|
|
loadRecentExecutionResults,
|
|
loadRecentIntentRequests,
|
|
loadRecentQuoteLifecycleRollups,
|
|
loadRecentQuoteOutcomes,
|
|
loadRecentTradeDecisions,
|
|
loadRecentQuotes,
|
|
loadSubmissionPage,
|
|
loadSubmissionSummary,
|
|
pauseTradingPair,
|
|
refreshQuoteLifecycleStatistics,
|
|
seedTradingConfig,
|
|
setTradingPairMode,
|
|
} from '../lib/postgres.mjs';
|
|
|
|
const config = loadConfig();
|
|
const logger = createLogger({
|
|
service: 'operator-dashboard',
|
|
component: 'dashboard',
|
|
namespace: config.projectNamespace,
|
|
});
|
|
|
|
const dashboardRuntimeState = {
|
|
last_bootstrap_at: null,
|
|
last_bootstrap_error: null,
|
|
source_errors: {},
|
|
last_source_error_at: null,
|
|
last_live_event_error: null,
|
|
latest_maker_competitiveness: null,
|
|
websocket_clients: 0,
|
|
};
|
|
|
|
if (
|
|
config.operatorDashboardAuthMode === 'basic'
|
|
&& (!config.operatorDashboardAuthUsername || !config.operatorDashboardAuthPassword)
|
|
) {
|
|
logger.error('dashboard_basic_auth_config_missing', {
|
|
details: {
|
|
auth_mode: config.operatorDashboardAuthMode,
|
|
},
|
|
});
|
|
process.exit(1);
|
|
}
|
|
|
|
const pool = createPostgresPool({
|
|
connectionString: config.postgresUrl,
|
|
});
|
|
await ensureHistorySchema(pool);
|
|
await seedTradingConfig(pool);
|
|
const tradingConfigStore = createTradingConfigStore({
|
|
pool,
|
|
logger: logger.child({ component: 'trading-config' }),
|
|
});
|
|
const initialTradingConfig = await tradingConfigStore.forceRefresh();
|
|
const initialRuntimeConfig = buildRuntimeConfig(initialTradingConfig);
|
|
|
|
const staticAssets = await loadStaticAssets();
|
|
const initialServiceSnapshots = await loadServiceSnapshots();
|
|
const initialRecentQuotes = await safeSourceLoad(
|
|
'recent_quotes',
|
|
() => loadRecentQuotes(pool, {
|
|
limit: config.operatorDashboardQuoteLimit,
|
|
}),
|
|
[],
|
|
);
|
|
const initialSubmissionSummary = await safeSourceLoad(
|
|
'submission_summary',
|
|
() => loadSubmissionSummary(pool),
|
|
{ total: 0, last_submission_at: null },
|
|
);
|
|
const initialMarketPrice = await safeSourceLoad(
|
|
'latest_market_price',
|
|
() => loadLatestMarketPrice(pool),
|
|
null,
|
|
);
|
|
const initialInventory = await safeSourceLoad(
|
|
'latest_inventory',
|
|
() => loadLatestInventorySnapshot(pool),
|
|
null,
|
|
);
|
|
const initialRecentTradeDecisions = await safeSourceLoad(
|
|
'recent_trade_decisions',
|
|
() => loadRecentTradeDecisions(pool, { limit: 20 }),
|
|
[],
|
|
);
|
|
const initialRecentExecuteTradeCommands = await safeSourceLoad(
|
|
'recent_execute_trade_commands',
|
|
() => loadRecentExecuteTradeCommands(pool, { limit: 40 }),
|
|
[],
|
|
);
|
|
const initialRecentExecutionResults = await safeSourceLoad(
|
|
'recent_execution_results',
|
|
() => loadRecentExecutionResults(pool, { limit: 40 }),
|
|
[],
|
|
);
|
|
const initialRecentQuoteOutcomes = await safeSourceLoad(
|
|
'recent_quote_outcomes',
|
|
() => loadRecentQuoteOutcomes(pool, { limit: 200 }),
|
|
[],
|
|
);
|
|
const initialNearIntentsStatus = await safeSourceLoad(
|
|
'near_intents_status',
|
|
() => loadNearIntentsStatus(initialRuntimeConfig),
|
|
null,
|
|
);
|
|
|
|
const liveState = createDashboardLiveState({
|
|
config: initialRuntimeConfig,
|
|
recentQuotes: initialRecentQuotes,
|
|
recentTradeDecisions: initialRecentTradeDecisions,
|
|
recentExecuteTradeCommands: initialRecentExecuteTradeCommands,
|
|
recentExecutionResults: initialRecentExecutionResults,
|
|
recentQuoteOutcomes: initialRecentQuoteOutcomes,
|
|
latestMarketPrice: initialMarketPrice,
|
|
latestInventory: initialInventory,
|
|
recentSubmissionCount: initialSubmissionSummary.total,
|
|
lastSubmissionAt: initialSubmissionSummary.last_submission_at,
|
|
nearIntentsStatus: initialNearIntentsStatus,
|
|
activeAlerts:
|
|
initialServiceSnapshots.find((snapshot) => snapshot.service === 'ops-sentinel')?.state?.active_alerts
|
|
|| [],
|
|
});
|
|
dashboardRuntimeState.latest_maker_competitiveness = buildMakerCompetitivenessSummary({
|
|
lifecycleRows: buildLiveQuoteLifecycleRows(liveState),
|
|
});
|
|
|
|
const liveConsumer = await createConsumer({
|
|
groupId: config.kafkaConsumerGroupOperatorDashboard,
|
|
brokers: config.kafkaBrokers,
|
|
clientId: config.kafkaClientId,
|
|
logger,
|
|
});
|
|
|
|
const liveTopics = [
|
|
config.kafkaTopicNormSwapDemand,
|
|
config.kafkaTopicDecisionTradeDecision,
|
|
config.kafkaTopicCmdExecuteTrade,
|
|
config.kafkaTopicRefMarketPrice,
|
|
config.kafkaTopicStateIntentInventory,
|
|
config.kafkaTopicOpsAlert,
|
|
config.kafkaTopicOpsEnvironmentStatus,
|
|
config.kafkaTopicExecTradeResult,
|
|
];
|
|
|
|
for (const topic of liveTopics) {
|
|
await liveConsumer.subscribe({ topic, fromBeginning: false });
|
|
}
|
|
|
|
await liveConsumer.run({
|
|
eachMessage: async ({ topic, message }) => {
|
|
if (!message.value) return;
|
|
|
|
try {
|
|
const event = parseEventMessage(message.value.toString());
|
|
const updates = applyDashboardLiveEvent(liveState, { topic, event });
|
|
for (const update of updates) {
|
|
if (update.maker_competitiveness) {
|
|
dashboardRuntimeState.latest_maker_competitiveness = update.maker_competitiveness;
|
|
}
|
|
broadcast(update);
|
|
}
|
|
} catch (error) {
|
|
dashboardRuntimeState.last_live_event_error = serializeError(error);
|
|
logger.error('dashboard_live_event_failed', {
|
|
topic,
|
|
details: {
|
|
error: serializeError(error),
|
|
},
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
const webSockets = new Set();
|
|
const webSocketServer = new WebSocketServer({
|
|
noServer: true,
|
|
});
|
|
|
|
webSocketServer.on('connection', (socket, _req, authContext) => {
|
|
webSockets.add(socket);
|
|
dashboardRuntimeState.websocket_clients = webSockets.size;
|
|
const recentLifecycleRows = buildLiveQuoteLifecycleRows(liveState);
|
|
const quoteLifecycleStatistics = buildLiveQuoteLifecycleStatistics(liveState);
|
|
dashboardRuntimeState.latest_maker_competitiveness = buildMakerCompetitivenessSummary({
|
|
lifecycleRows: recentLifecycleRows,
|
|
});
|
|
socket.send(JSON.stringify({
|
|
type: 'session.ready',
|
|
session: authContext,
|
|
live: {
|
|
recent_quotes: liveState.recent_quotes,
|
|
recent_lifecycle_rows: recentLifecycleRows,
|
|
quote_lifecycle_statistics: quoteLifecycleStatistics,
|
|
maker_competitiveness: dashboardRuntimeState.latest_maker_competitiveness,
|
|
status_bar: buildLiveStatusBar(liveState),
|
|
},
|
|
}));
|
|
|
|
socket.on('close', () => {
|
|
webSockets.delete(socket);
|
|
dashboardRuntimeState.websocket_clients = webSockets.size;
|
|
});
|
|
});
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
try {
|
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
|
|
if (req.method === 'GET' && url.pathname === '/healthz') {
|
|
return sendJson(res, 200, {
|
|
ok: Object.keys(dashboardRuntimeState.source_errors).length === 0 && !dashboardRuntimeState.last_bootstrap_error,
|
|
service: 'operator-dashboard',
|
|
websocket_clients: webSockets.size,
|
|
source_error_count: Object.keys(dashboardRuntimeState.source_errors).length,
|
|
last_source_error_at: dashboardRuntimeState.last_source_error_at,
|
|
last_bootstrap_at: dashboardRuntimeState.last_bootstrap_at,
|
|
last_bootstrap_error: dashboardRuntimeState.last_bootstrap_error,
|
|
last_live_event_error: dashboardRuntimeState.last_live_event_error,
|
|
});
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/state') {
|
|
return sendJson(res, 200, {
|
|
service: 'operator-dashboard',
|
|
namespace: config.projectNamespace,
|
|
websocket_clients: webSockets.size,
|
|
last_bootstrap_at: dashboardRuntimeState.last_bootstrap_at,
|
|
last_bootstrap_error: dashboardRuntimeState.last_bootstrap_error,
|
|
source_errors: Object.values(dashboardRuntimeState.source_errors),
|
|
source_error_count: Object.keys(dashboardRuntimeState.source_errors).length,
|
|
last_source_error_at: dashboardRuntimeState.last_source_error_at,
|
|
last_live_event_error: dashboardRuntimeState.last_live_event_error,
|
|
latest_maker_competitiveness: dashboardRuntimeState.latest_maker_competitiveness,
|
|
});
|
|
}
|
|
|
|
const auth = authenticateHttpRequest(req, res);
|
|
if (!auth) return;
|
|
|
|
if (url.pathname.startsWith('/api/')) {
|
|
return await handleApiRequest({ req, res, url, auth });
|
|
}
|
|
|
|
if (req.method === 'GET' && staticAssets.has(url.pathname)) {
|
|
const asset = staticAssets.get(url.pathname);
|
|
res.statusCode = 200;
|
|
res.setHeader('content-type', asset.contentType);
|
|
res.end(asset.body);
|
|
return;
|
|
}
|
|
|
|
return sendJson(res, 404, { error: 'not_found' });
|
|
} catch (error) {
|
|
logger.error('dashboard_request_failed', {
|
|
details: {
|
|
path: req.url,
|
|
error: serializeError(error),
|
|
},
|
|
});
|
|
return sendJson(res, 500, {
|
|
error: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
server.on('upgrade', (req, socket, head) => {
|
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
if (url.pathname !== '/ws') {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
const auth = resolveDashboardRequestAuth({
|
|
mode: config.operatorDashboardAuthMode,
|
|
authorizationHeader: req.headers.authorization || '',
|
|
cookieHeader: req.headers.cookie || '',
|
|
username: config.operatorDashboardAuthUsername,
|
|
password: config.operatorDashboardAuthPassword,
|
|
});
|
|
if (!auth.authenticated) {
|
|
socket.write(
|
|
`HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: ${buildDashboardAuthChallengeHeader({
|
|
realm: config.operatorDashboardAuthRealm,
|
|
})}\r\n\r\n`,
|
|
);
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
webSocketServer.handleUpgrade(req, socket, head, (ws) => {
|
|
webSocketServer.emit('connection', ws, req, auth);
|
|
});
|
|
});
|
|
|
|
server.listen(config.operatorDashboardControlPort, config.operatorDashboardControlHost, () => {
|
|
logger.info('operator_dashboard_started', {
|
|
details: {
|
|
host: config.operatorDashboardControlHost,
|
|
port: config.operatorDashboardControlPort,
|
|
},
|
|
});
|
|
});
|
|
|
|
async function handleApiRequest({ req, res, url, auth }) {
|
|
if (req.method === 'GET' && url.pathname === '/api/session') {
|
|
return sendJson(res, 200, auth);
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/api/bootstrap') {
|
|
const page = Number(url.searchParams.get('page') || 1);
|
|
const pageSize = Number(
|
|
url.searchParams.get('page_size') || config.operatorDashboardTradePageSize,
|
|
);
|
|
const payload = await loadBootstrapPayload({
|
|
auth,
|
|
page,
|
|
pageSize,
|
|
});
|
|
return sendJson(res, 200, payload);
|
|
}
|
|
|
|
if (req.method === 'GET' && (url.pathname === '/api/submissions' || url.pathname === '/api/trades')) {
|
|
const page = Number(url.searchParams.get('page') || 1);
|
|
const pageSize = Number(
|
|
url.searchParams.get('page_size') || config.operatorDashboardTradePageSize,
|
|
);
|
|
const submissionPage = await loadSubmissionPage(pool, {
|
|
page,
|
|
pageSize,
|
|
});
|
|
return sendJson(res, 200, submissionPage);
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/api/strategy/quote-lifecycle/statistics') {
|
|
const payload = await loadQuoteLifecycleStatisticsPayload({
|
|
grains: url.searchParams.get('grains') || undefined,
|
|
limit: Number(url.searchParams.get('limit') || 1000),
|
|
});
|
|
return sendJson(res, 200, payload);
|
|
}
|
|
|
|
const lifecycleLookupMatch = req.method === 'GET'
|
|
? url.pathname.match(/^\/api\/strategy\/quote-lifecycle\/(.+)$/)
|
|
: null;
|
|
if (lifecycleLookupMatch) {
|
|
const identifier = decodeURIComponent(lifecycleLookupMatch[1] || '').trim();
|
|
if (!identifier) {
|
|
return sendJson(res, 400, {
|
|
ok: false,
|
|
error: 'quote_lifecycle_identifier_required',
|
|
lifecycle_row: null,
|
|
lookup: {
|
|
requested_identifier: '',
|
|
matched_identifier_type: null,
|
|
found: false,
|
|
lookup_at: new Date().toISOString(),
|
|
latest_stage_at: null,
|
|
matched_identifiers: {
|
|
quote_ids: [],
|
|
decision_ids: [],
|
|
command_ids: [],
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
const payload = await loadQuoteLifecycleLookupPayload({ identifier });
|
|
return sendJson(res, payload.ok ? 200 : 404, {
|
|
...payload,
|
|
...(payload.ok ? {} : { error: 'quote_lifecycle_not_found' }),
|
|
});
|
|
}
|
|
|
|
const controlMatch = req.method === 'POST'
|
|
? url.pathname.match(/^\/api\/control\/([^/]+)\/([^/]+)$/)
|
|
: null;
|
|
if (controlMatch) {
|
|
const [, service, action] = controlMatch;
|
|
const body = await readJsonBody(req);
|
|
const control = resolveDashboardControl({ service, action });
|
|
if (!control) {
|
|
return sendJson(res, 404, {
|
|
error: 'unknown_control',
|
|
});
|
|
}
|
|
|
|
const serviceDefinition = listDashboardServices(config)
|
|
.find((definition) => definition.service === control.service);
|
|
try {
|
|
const result = await invokeControl(control, body || {});
|
|
const serviceSnapshot = serviceDefinition
|
|
? await loadServiceSnapshot(serviceDefinition)
|
|
: null;
|
|
return sendJson(res, 200, {
|
|
ok: true,
|
|
control,
|
|
result,
|
|
service_snapshot: serviceSnapshot,
|
|
});
|
|
} catch (error) {
|
|
logger.warn('dashboard_control_failed', {
|
|
details: {
|
|
control,
|
|
error: serializeError(error),
|
|
},
|
|
});
|
|
const serviceSnapshot = serviceDefinition
|
|
? await loadServiceSnapshot(serviceDefinition).catch((snapshotError) => ({
|
|
...serviceDefinition,
|
|
reachable: false,
|
|
state: null,
|
|
health: null,
|
|
error: serializeError(snapshotError),
|
|
}))
|
|
: null;
|
|
const failure = buildDashboardControlErrorResponse(error, { control });
|
|
return sendJson(res, failure.statusCode, {
|
|
...failure.payload,
|
|
service_snapshot: serviceSnapshot,
|
|
});
|
|
}
|
|
}
|
|
|
|
return sendJson(res, 404, { error: 'not_found' });
|
|
}
|
|
|
|
async function loadBootstrapPayload({ auth, page, pageSize }) {
|
|
const sourceErrors = [];
|
|
const tradingConfig = await tradingConfigStore.forceRefresh();
|
|
const runtimeConfig = buildRuntimeConfig(tradingConfig);
|
|
const [
|
|
portfolioMetric,
|
|
inventorySnapshot,
|
|
marketPrice,
|
|
recentQuotes,
|
|
submissionSummary,
|
|
submissionPage,
|
|
fundingObservations,
|
|
recentDepositStatuses,
|
|
recentTradeDecisions,
|
|
recentExecuteTradeCommands,
|
|
recentExecutionResults,
|
|
recentQuoteOutcomes,
|
|
quoteLifecycleRetention,
|
|
quoteLifecycleRollups,
|
|
quoteLifecycleStatistics,
|
|
recentIntentRequests,
|
|
recentAlertTransitions,
|
|
recentEnvironmentStatuses,
|
|
assetCatalog,
|
|
pairConfig,
|
|
serviceSnapshots,
|
|
nearIntentsStatus,
|
|
] = await Promise.all([
|
|
safeSourceLoad('portfolio_metric', () => loadLatestPortfolioMetric(pool), null, sourceErrors),
|
|
safeSourceLoad('latest_inventory', () => loadLatestInventorySnapshot(pool), null, sourceErrors),
|
|
safeSourceLoad('latest_market_price', () => loadLatestMarketPrice(pool), null, sourceErrors),
|
|
safeSourceLoad(
|
|
'recent_quotes',
|
|
() => loadRecentQuotes(pool, {
|
|
limit: runtimeConfig.operatorDashboardQuoteLimit,
|
|
}),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'submission_summary',
|
|
() => loadSubmissionSummary(pool),
|
|
{ total: 0, last_submission_at: null },
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'submission_page',
|
|
() => loadSubmissionPage(pool, {
|
|
page,
|
|
pageSize,
|
|
}),
|
|
{
|
|
page,
|
|
page_size: pageSize,
|
|
total: 0,
|
|
total_pages: 1,
|
|
items: [],
|
|
},
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad('funding_observations', () => loadCurrentFundingObservations(pool), [], sourceErrors),
|
|
safeSourceLoad(
|
|
'recent_deposit_statuses',
|
|
() => loadRecentDepositStatuses(pool, { limit: 20 }),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'recent_trade_decisions',
|
|
() => loadRecentTradeDecisions(pool, { limit: 20 }),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'recent_execute_trade_commands',
|
|
() => loadRecentExecuteTradeCommands(pool, { limit: 40 }),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'recent_execution_results',
|
|
() => loadRecentExecutionResults(pool, { limit: 40 }),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'recent_quote_outcomes',
|
|
() => loadRecentQuoteOutcomes(pool, { limit: 200 }),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'quote_lifecycle_retention',
|
|
() => loadQuoteLifecycleRetentionSummary(pool),
|
|
null,
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'quote_lifecycle_rollups',
|
|
() => loadRecentQuoteLifecycleRollups(pool, { limit: 40 }),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'quote_lifecycle_statistics',
|
|
() => loadQuoteLifecycleStatistics(pool, { limit: 1000 }),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'recent_intent_requests',
|
|
() => loadRecentIntentRequests(pool, {
|
|
limit: 20,
|
|
btcAsset: runtimeConfig.tradingBtc,
|
|
eureAsset: runtimeConfig.tradingEure,
|
|
refreshOutcomes: false,
|
|
}),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'recent_alert_transitions',
|
|
() => loadRecentAlertTransitions(pool, { limit: 20 }),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'recent_environment_statuses',
|
|
() => loadRecentEnvironmentStatuses(pool, { limit: 20 }),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad('asset_catalog', () => loadAssetCatalogSummary(pool, { limit: 250 }), null, sourceErrors),
|
|
safeSourceLoad('pair_config', () => loadPairConfigSummary(pool), null, sourceErrors),
|
|
loadServiceSnapshots(),
|
|
safeSourceLoad('near_intents_status', () => loadNearIntentsStatus(runtimeConfig), null, sourceErrors),
|
|
]);
|
|
|
|
const payload = buildDashboardBootstrap({
|
|
config: runtimeConfig,
|
|
auth,
|
|
portfolioMetric,
|
|
inventorySnapshot,
|
|
marketPrice,
|
|
recentQuotes,
|
|
submissionPage,
|
|
submissionSummary,
|
|
fundingObservations,
|
|
recentDepositStatuses,
|
|
recentTradeDecisions,
|
|
recentExecuteTradeCommands,
|
|
recentExecutionResults,
|
|
recentQuoteOutcomes,
|
|
quoteLifecycleRetention,
|
|
quoteLifecycleRollups,
|
|
quoteLifecycleStatistics: {
|
|
persisted: {
|
|
source: 'persisted',
|
|
generated_at: quoteLifecycleStatistics[0]?.computed_at || null,
|
|
rows: quoteLifecycleStatistics,
|
|
},
|
|
live: buildLiveQuoteLifecycleStatistics(liveState),
|
|
},
|
|
recentIntentRequests,
|
|
recentAlertTransitions,
|
|
recentEnvironmentStatuses,
|
|
assetCatalog,
|
|
pairConfig,
|
|
serviceSnapshots,
|
|
nearIntentsStatus,
|
|
sourceErrors,
|
|
});
|
|
dashboardRuntimeState.last_bootstrap_at = new Date().toISOString();
|
|
dashboardRuntimeState.last_bootstrap_error = null;
|
|
dashboardRuntimeState.latest_maker_competitiveness = (
|
|
payload.strategy?.strategy_state?.maker_competitiveness
|
|
|| dashboardRuntimeState.latest_maker_competitiveness
|
|
);
|
|
return payload;
|
|
}
|
|
|
|
async function loadQuoteLifecycleLookupPayload({ identifier }) {
|
|
const tradingConfig = await tradingConfigStore.forceRefresh();
|
|
const runtimeConfig = buildRuntimeConfig(tradingConfig);
|
|
const evidence = await loadQuoteLifecycleLookupEvidence(pool, {
|
|
identifier,
|
|
});
|
|
return buildQuoteLifecycleLookupResponse({
|
|
config: runtimeConfig,
|
|
identifier,
|
|
evidence,
|
|
});
|
|
}
|
|
|
|
async function loadQuoteLifecycleStatisticsPayload({ grains, limit } = {}) {
|
|
const refreshed = await refreshQuoteLifecycleStatistics(pool, {
|
|
now: new Date().toISOString(),
|
|
grains,
|
|
});
|
|
const rows = await loadQuoteLifecycleStatistics(pool, {
|
|
grains,
|
|
limit,
|
|
});
|
|
return {
|
|
ok: true,
|
|
refreshed_at: refreshed.computed_at,
|
|
persisted: {
|
|
source: 'persisted',
|
|
generated_at: rows[0]?.computed_at || refreshed.computed_at,
|
|
rows,
|
|
},
|
|
live: buildLiveQuoteLifecycleStatistics(liveState),
|
|
refresh: {
|
|
statistic_row_count: refreshed.statistic_row_count,
|
|
quote_count: refreshed.quote_count,
|
|
evidence_counts: refreshed.evidence_counts,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function loadServiceSnapshots() {
|
|
const services = listDashboardServices(config);
|
|
return Promise.all(services.map((service) => loadServiceSnapshot(service)));
|
|
}
|
|
|
|
async function loadServiceSnapshot(service) {
|
|
const [stateResult, healthResult] = await Promise.allSettled([
|
|
fetchUpstreamJson(`${service.base_url}/state`),
|
|
fetchUpstreamJson(`${service.base_url}/healthz`),
|
|
]);
|
|
|
|
const state = stateResult.status === 'fulfilled' ? stateResult.value : null;
|
|
const health = healthResult.status === 'fulfilled' ? healthResult.value : null;
|
|
const error = stateResult.status === 'rejected'
|
|
? serializeError(stateResult.reason)
|
|
: healthResult.status === 'rejected'
|
|
? serializeError(healthResult.reason)
|
|
: null;
|
|
|
|
return {
|
|
...service,
|
|
reachable: Boolean(state || health),
|
|
state,
|
|
health,
|
|
error,
|
|
};
|
|
}
|
|
|
|
async function fetchUpstreamJson(url) {
|
|
return fetchJson(url, {
|
|
signal: AbortSignal.timeout(config.operatorDashboardUpstreamTimeoutMs),
|
|
});
|
|
}
|
|
|
|
async function loadNearIntentsStatus(runtimeConfig = config) {
|
|
const [servicesResponse, postsResponse, postEnumsResponse] = await Promise.all([
|
|
fetchNearIntentsStatusJson(config.nearIntentsStatusServicesUrl),
|
|
fetchNearIntentsStatusJson(config.nearIntentsStatusPostsUrl),
|
|
fetchNearIntentsStatusJson(config.nearIntentsStatusPostEnumsUrl),
|
|
]);
|
|
|
|
return normalizeNearIntentsStatus({
|
|
servicesResponse,
|
|
postsResponse,
|
|
postEnumsResponse,
|
|
observedAt: new Date().toISOString(),
|
|
trackedAssets: runtimeConfig.trackedAssets,
|
|
});
|
|
}
|
|
|
|
async function fetchNearIntentsStatusJson(url) {
|
|
return fetchJson(url, {
|
|
signal: AbortSignal.timeout(config.nearIntentsStatusTimeoutMs),
|
|
});
|
|
}
|
|
|
|
async function invokeControl(control, body) {
|
|
if (control.service === 'operator-dashboard' && control.action === 'import-supported-assets') {
|
|
const result = await importSupportedAssets(pool);
|
|
await tradingConfigStore.forceRefresh();
|
|
return result;
|
|
}
|
|
|
|
if (control.service === 'operator-dashboard' && control.action === 'update-pair-edge') {
|
|
const result = await createPairStrategyConfigVersion(pool, {
|
|
pairId: body.pair_id || body.pair,
|
|
edgeBps: Number(body.edge_bps),
|
|
maxNotional: body.max_notional,
|
|
minNotional: bodyField(body, 'min_notional', 'minNotional'),
|
|
requestDefaultNotional: bodyField(body, 'request_default_notional', 'requestDefaultNotional'),
|
|
requestMaxNotional: bodyField(body, 'request_max_notional', 'requestMaxNotional'),
|
|
requestMaxSlippageBps: bodyField(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'),
|
|
makerMaxQuoteAgeEnabled: bodyField(body, 'maker_max_quote_age_enabled', 'makerMaxQuoteAgeEnabled'),
|
|
makerMaxQuoteAgeMs: bodyField(body, 'maker_max_quote_age_ms', 'makerMaxQuoteAgeMs'),
|
|
makerLatencyPolicyReason: bodyField(body, 'maker_latency_policy_reason', 'makerLatencyPolicyReason'),
|
|
changedBy: body.changed_by || 'operator',
|
|
reason: body.reason || 'dashboard pair strategy config update',
|
|
});
|
|
await tradingConfigStore.forceRefresh();
|
|
return result;
|
|
}
|
|
|
|
if (control.service === 'operator-dashboard' && control.action === 'enable-observe-only-pair') {
|
|
const result = await enableObserveOnlyPair(pool, {
|
|
assetIn: body.asset_in,
|
|
assetOut: body.asset_out,
|
|
changedBy: body.changed_by || 'operator',
|
|
reason: body.reason || 'dashboard observe-only enable',
|
|
});
|
|
await tradingConfigStore.forceRefresh();
|
|
return result;
|
|
}
|
|
|
|
if (control.service === 'operator-dashboard' && control.action === 'set-pair-mode') {
|
|
const result = await setTradingPairMode(pool, {
|
|
pairId: body.pair_id || body.pair,
|
|
assetIn: body.asset_in,
|
|
assetOut: body.asset_out,
|
|
mode: body.mode,
|
|
edgeBps: body.edge_bps,
|
|
maxNotional: body.max_notional,
|
|
minNotional: bodyField(body, 'min_notional', 'minNotional'),
|
|
requestDefaultNotional: bodyField(body, 'request_default_notional', 'requestDefaultNotional'),
|
|
requestMaxNotional: bodyField(body, 'request_max_notional', 'requestMaxNotional'),
|
|
requestMaxSlippageBps: bodyField(body, 'request_max_slippage_bps', 'requestMaxSlippageBps'),
|
|
makerMaxQuoteAgeEnabled: bodyField(body, 'maker_max_quote_age_enabled', 'makerMaxQuoteAgeEnabled'),
|
|
makerMaxQuoteAgeMs: bodyField(body, 'maker_max_quote_age_ms', 'makerMaxQuoteAgeMs'),
|
|
makerLatencyPolicyReason: bodyField(body, 'maker_latency_policy_reason', 'makerLatencyPolicyReason'),
|
|
changedBy: body.changed_by || 'operator',
|
|
reason: body.reason || 'dashboard pair mode update',
|
|
});
|
|
await tradingConfigStore.forceRefresh();
|
|
return result;
|
|
}
|
|
|
|
if (control.service === 'operator-dashboard' && control.action === 'pause-pair') {
|
|
const result = await pauseTradingPair(pool, {
|
|
pairId: body.pair_id || body.pair,
|
|
changedBy: body.changed_by || 'operator',
|
|
reason: body.reason || 'dashboard pair pause',
|
|
});
|
|
await tradingConfigStore.forceRefresh();
|
|
return result;
|
|
}
|
|
|
|
const response = await fetchJson(
|
|
`${lookupServiceBaseUrl(control.service)}${control.path}`,
|
|
{
|
|
method: control.method,
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body || {}),
|
|
signal: AbortSignal.timeout(resolveDashboardControlTimeoutMs({ control, config })),
|
|
},
|
|
);
|
|
|
|
return response;
|
|
}
|
|
|
|
function lookupServiceBaseUrl(serviceName) {
|
|
const service = listDashboardServices(config).find((entry) => entry.service === serviceName);
|
|
if (!service) {
|
|
throw new Error(`unknown service: ${serviceName}`);
|
|
}
|
|
return service.base_url;
|
|
}
|
|
|
|
function bodyField(body, snakeKey, camelKey) {
|
|
if (Object.hasOwn(body || {}, snakeKey)) return body[snakeKey];
|
|
if (Object.hasOwn(body || {}, camelKey)) return body[camelKey];
|
|
return undefined;
|
|
}
|
|
|
|
function buildRuntimeConfig(tradingConfig) {
|
|
return {
|
|
...config,
|
|
...tradingConfig,
|
|
assetRegistry: tradingConfig.assetRegistry || config.assetRegistry,
|
|
trackedAssets: tradingConfig.trackedAssets || config.trackedAssets,
|
|
trackedAssetIds: tradingConfig.trackedAssetIds || config.trackedAssetIds,
|
|
tradingBtc: tradingConfig.tradingBtc || config.tradingBtc,
|
|
tradingBtcAssets: tradingConfig.tradingBtcAssets?.length
|
|
? tradingConfig.tradingBtcAssets
|
|
: config.tradingBtcAssets,
|
|
tradingEure: tradingConfig.tradingEure || config.tradingEure,
|
|
activePair: tradingConfig.activePair || config.activePair,
|
|
};
|
|
}
|
|
|
|
function broadcast(payload) {
|
|
const encoded = JSON.stringify(payload);
|
|
for (const socket of webSockets) {
|
|
if (socket.readyState !== 1) continue;
|
|
if (isDashboardWebSocketBackpressured(socket)) {
|
|
logger.warn('dashboard_websocket_backpressure_disconnect', {
|
|
details: {
|
|
buffered_amount: Number(socket.bufferedAmount || 0),
|
|
max_buffered_bytes: DASHBOARD_WEBSOCKET_MAX_BUFFERED_BYTES,
|
|
},
|
|
});
|
|
webSockets.delete(socket);
|
|
dashboardRuntimeState.websocket_clients = webSockets.size;
|
|
socket.terminate?.();
|
|
continue;
|
|
}
|
|
socket.send(encoded, (error) => {
|
|
if (!error) return;
|
|
dashboardRuntimeState.last_live_event_error = serializeError(error);
|
|
webSockets.delete(socket);
|
|
dashboardRuntimeState.websocket_clients = webSockets.size;
|
|
socket.terminate?.();
|
|
});
|
|
}
|
|
}
|
|
|
|
function authenticateHttpRequest(req, res) {
|
|
const auth = resolveDashboardRequestAuth({
|
|
mode: config.operatorDashboardAuthMode,
|
|
authorizationHeader: req.headers.authorization || '',
|
|
cookieHeader: req.headers.cookie || '',
|
|
username: config.operatorDashboardAuthUsername,
|
|
password: config.operatorDashboardAuthPassword,
|
|
});
|
|
|
|
if (!auth.authenticated) {
|
|
res.statusCode = 401;
|
|
res.setHeader('WWW-Authenticate', buildDashboardAuthChallengeHeader({
|
|
realm: config.operatorDashboardAuthRealm,
|
|
}));
|
|
if ((req.url || '').startsWith('/api/')) {
|
|
return sendJson(res, 401, { error: 'authentication_required' });
|
|
}
|
|
res.end('authentication required\n');
|
|
return null;
|
|
}
|
|
|
|
if (auth.setSessionCookie) {
|
|
res.setHeader('Set-Cookie', buildDashboardSessionCookie({
|
|
sessionCookieName: auth.sessionCookieName,
|
|
sessionToken: auth.sessionToken,
|
|
}));
|
|
}
|
|
|
|
return auth;
|
|
}
|
|
|
|
async function loadStaticAssets() {
|
|
const distDirectory = new URL('../operator-dashboard/dist/', import.meta.url);
|
|
const assets = new Map();
|
|
|
|
await loadStaticAssetDirectory(distDirectory, '', assets);
|
|
|
|
const indexAsset = assets.get('/index.html');
|
|
if (!indexAsset) {
|
|
throw new Error('operator dashboard frontend is missing /index.html; run the dashboard build');
|
|
}
|
|
|
|
assets.set('/', indexAsset);
|
|
return assets;
|
|
}
|
|
|
|
async function loadStaticAssetDirectory(directoryUrl, relativeDirectory, assets) {
|
|
const entries = await readdir(directoryUrl, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
await loadStaticAssetDirectory(
|
|
new URL(`${entry.name}/`, directoryUrl),
|
|
path.posix.join(relativeDirectory, entry.name),
|
|
assets,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const relativePath = path.posix.join(relativeDirectory, entry.name);
|
|
const requestPath = `/${relativePath}`;
|
|
const body = await readFile(new URL(entry.name, directoryUrl));
|
|
assets.set(requestPath, {
|
|
contentType: resolveStaticContentType(entry.name),
|
|
body,
|
|
});
|
|
}
|
|
}
|
|
|
|
function resolveStaticContentType(filename) {
|
|
switch (path.extname(filename)) {
|
|
case '.html':
|
|
return 'text/html; charset=utf-8';
|
|
case '.js':
|
|
return 'text/javascript; charset=utf-8';
|
|
case '.css':
|
|
return 'text/css; charset=utf-8';
|
|
case '.json':
|
|
return 'application/json; charset=utf-8';
|
|
case '.svg':
|
|
return 'image/svg+xml';
|
|
case '.png':
|
|
return 'image/png';
|
|
case '.jpg':
|
|
case '.jpeg':
|
|
return 'image/jpeg';
|
|
case '.webp':
|
|
return 'image/webp';
|
|
case '.ico':
|
|
return 'image/x-icon';
|
|
default:
|
|
return 'application/octet-stream';
|
|
}
|
|
}
|
|
|
|
async function safeSourceLoad(name, loader, fallback, sourceErrors = null) {
|
|
try {
|
|
const result = await loader();
|
|
delete dashboardRuntimeState.source_errors[name];
|
|
return result;
|
|
} catch (error) {
|
|
const serialized = serializeError(error);
|
|
dashboardRuntimeState.source_errors[name] = {
|
|
source: name,
|
|
error: serialized,
|
|
};
|
|
dashboardRuntimeState.last_source_error_at = new Date().toISOString();
|
|
logger.error('dashboard_source_load_failed', {
|
|
details: {
|
|
source: name,
|
|
error: serialized,
|
|
},
|
|
});
|
|
sourceErrors?.push({
|
|
source: name,
|
|
error: serialized,
|
|
});
|
|
dashboardRuntimeState.last_bootstrap_error = serialized;
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
async function shutdown() {
|
|
server.close(() => {});
|
|
for (const socket of webSockets) {
|
|
socket.close();
|
|
}
|
|
await liveConsumer.stop().catch(() => {});
|
|
await liveConsumer.disconnect().catch(() => {});
|
|
await pool.end().catch(() => {});
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on('SIGINT', shutdown);
|
|
process.on('SIGTERM', shutdown);
|