Some checks failed
deploy / deploy (push) Failing after 29s
Proof: npm test; npm run operator-dashboard:build; node --test test/near-intents-status.test.mjs test/environment-status-history.test.mjs test/operator-dashboard.test.mjs test/operator-dashboard-ui-static.test.mjs test/ops-sentinel-static.test.mjs; PYTHONPATH=. python3 test/repo_deployments_test.py; kubectl kustomize deploy/k8s/base. Assumptions: NEAR Intents public status page API remains the official upstream environmental-status source; status fingerprint changes are the durable boundary for saving environmental history. Still fake: This stores and displays official upstream status changes, but it does not create an alternate quote source or make NEAR quoting operational during an upstream pause.
751 lines
22 KiB
JavaScript
751 lines
22 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 {
|
|
applyDashboardLiveEvent,
|
|
buildDashboardBootstrap,
|
|
buildDashboardControlErrorResponse,
|
|
buildLiveQuoteLifecycleRows,
|
|
buildLiveStatusBar,
|
|
createDashboardLiveState,
|
|
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 {
|
|
createPostgresPool,
|
|
ensureHistorySchema,
|
|
loadCurrentFundingObservations,
|
|
loadLatestInventorySnapshot,
|
|
loadLatestMarketPrice,
|
|
loadLatestPortfolioMetric,
|
|
loadRecentAlertTransitions,
|
|
loadRecentDepositStatuses,
|
|
loadRecentEnvironmentStatuses,
|
|
loadRecentExecuteTradeCommands,
|
|
loadRecentExecutionResults,
|
|
loadRecentIntentRequests,
|
|
loadRecentQuoteOutcomes,
|
|
loadRecentTradeDecisions,
|
|
loadRecentQuotes,
|
|
loadSubmissionPage,
|
|
loadSubmissionSummary,
|
|
} 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,
|
|
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);
|
|
|
|
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(),
|
|
null,
|
|
);
|
|
|
|
const liveState = createDashboardLiveState({
|
|
config,
|
|
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
|
|
|| [],
|
|
});
|
|
|
|
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) {
|
|
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;
|
|
socket.send(JSON.stringify({
|
|
type: 'session.ready',
|
|
session: authContext,
|
|
live: {
|
|
recent_quotes: liveState.recent_quotes,
|
|
recent_lifecycle_rows: buildLiveQuoteLifecycleRows(liveState),
|
|
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,
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 = await loadServiceSnapshot(serviceDefinition);
|
|
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 = await loadServiceSnapshot(serviceDefinition).catch((snapshotError) => ({
|
|
...serviceDefinition,
|
|
reachable: false,
|
|
state: null,
|
|
health: null,
|
|
error: serializeError(snapshotError),
|
|
}));
|
|
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 [
|
|
portfolioMetric,
|
|
inventorySnapshot,
|
|
marketPrice,
|
|
recentQuotes,
|
|
submissionSummary,
|
|
submissionPage,
|
|
fundingObservations,
|
|
recentDepositStatuses,
|
|
recentTradeDecisions,
|
|
recentExecuteTradeCommands,
|
|
recentExecutionResults,
|
|
recentQuoteOutcomes,
|
|
recentIntentRequests,
|
|
recentAlertTransitions,
|
|
recentEnvironmentStatuses,
|
|
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: config.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(
|
|
'recent_intent_requests',
|
|
() => loadRecentIntentRequests(pool, {
|
|
limit: 20,
|
|
btcAsset: config.tradingBtc,
|
|
eureAsset: config.tradingEure,
|
|
}),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'recent_alert_transitions',
|
|
() => loadRecentAlertTransitions(pool, { limit: 20 }),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
safeSourceLoad(
|
|
'recent_environment_statuses',
|
|
() => loadRecentEnvironmentStatuses(pool, { limit: 20 }),
|
|
[],
|
|
sourceErrors,
|
|
),
|
|
loadServiceSnapshots(),
|
|
safeSourceLoad('near_intents_status', () => loadNearIntentsStatus(), null, sourceErrors),
|
|
]);
|
|
|
|
const payload = buildDashboardBootstrap({
|
|
config,
|
|
auth,
|
|
portfolioMetric,
|
|
inventorySnapshot,
|
|
marketPrice,
|
|
recentQuotes,
|
|
submissionPage,
|
|
submissionSummary,
|
|
fundingObservations,
|
|
recentDepositStatuses,
|
|
recentTradeDecisions,
|
|
recentExecuteTradeCommands,
|
|
recentExecutionResults,
|
|
recentQuoteOutcomes,
|
|
recentIntentRequests,
|
|
recentAlertTransitions,
|
|
recentEnvironmentStatuses,
|
|
serviceSnapshots,
|
|
nearIntentsStatus,
|
|
sourceErrors,
|
|
});
|
|
dashboardRuntimeState.last_bootstrap_at = new Date().toISOString();
|
|
dashboardRuntimeState.last_bootstrap_error = null;
|
|
return payload;
|
|
}
|
|
|
|
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() {
|
|
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(),
|
|
});
|
|
}
|
|
|
|
async function fetchNearIntentsStatusJson(url) {
|
|
return fetchJson(url, {
|
|
signal: AbortSignal.timeout(config.nearIntentsStatusTimeoutMs),
|
|
});
|
|
}
|
|
|
|
async function invokeControl(control, body) {
|
|
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 broadcast(payload) {
|
|
const encoded = JSON.stringify(payload);
|
|
for (const socket of webSockets) {
|
|
if (socket.readyState !== 1) continue;
|
|
socket.send(encoded);
|
|
}
|
|
}
|
|
|
|
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,
|
|
}));
|
|
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);
|