diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index 25a9b02..caf97d3 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -24,6 +24,7 @@ import { 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'; @@ -402,6 +403,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { recentIntentRequests, recentAlertTransitions, serviceSnapshots, + nearIntentsStatus, ] = await Promise.all([ safeSourceLoad('portfolio_metric', () => loadLatestPortfolioMetric(pool), null, sourceErrors), safeSourceLoad('latest_inventory', () => loadLatestInventorySnapshot(pool), null, sourceErrors), @@ -483,6 +485,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { sourceErrors, ), loadServiceSnapshots(), + safeSourceLoad('near_intents_status', () => loadNearIntentsStatus(), null, sourceErrors), ]); const payload = buildDashboardBootstrap({ @@ -503,6 +506,7 @@ async function loadBootstrapPayload({ auth, page, pageSize }) { recentIntentRequests, recentAlertTransitions, serviceSnapshots, + nearIntentsStatus, sourceErrors, }); dashboardRuntimeState.last_bootstrap_at = new Date().toISOString(); @@ -544,6 +548,27 @@ async function fetchUpstreamJson(url) { }); } +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}`, diff --git a/src/core/near-intents-status.mjs b/src/core/near-intents-status.mjs new file mode 100644 index 0000000..4389780 --- /dev/null +++ b/src/core/near-intents-status.mjs @@ -0,0 +1,116 @@ +const TERMINAL_STATUS_NAMES = new Set([ + 'resolved', + 'completed', + 'complete', + 'done', +]); + +export function normalizeNearIntentsStatus({ + postsResponse = null, + servicesResponse = null, + postEnumsResponse = null, + observedAt = new Date().toISOString(), +} = {}) { + const posts = Array.isArray(postsResponse?.posts) + ? postsResponse.posts + : Array.isArray(postsResponse) + ? postsResponse + : []; + const services = Array.isArray(servicesResponse?.services) + ? servicesResponse.services + : Array.isArray(servicesResponse) + ? servicesResponse + : []; + const postEnums = Array.isArray(postEnumsResponse?.post_enums) + ? postEnumsResponse.post_enums + : Array.isArray(postEnumsResponse) + ? postEnumsResponse + : []; + + const statusById = new Map( + postEnums + .filter((entry) => entry?.post_enum_type === 'status') + .map((entry) => [entry.id, entry]), + ); + const severityById = new Map( + postEnums + .filter((entry) => entry?.post_enum_type === 'severity') + .map((entry) => [entry.id, entry]), + ); + const serviceById = new Map(services.map((entry) => [entry.id, entry])); + + const incidents = posts + .map((post) => normalizePost(post, { statusById, severityById, serviceById })) + .filter(Boolean) + .filter((post) => post.active); + + const affectedServices = [...new Set( + incidents.flatMap((incident) => incident.impacts.map((impact) => impact.service_name || impact.service_id)), + )].filter(Boolean); + const primaryIncident = incidents[0] || null; + + return { + observed_at: observedAt, + source: 'near_intents_status_page', + status: incidents.length > 0 ? 'disrupted' : 'operational', + label: incidents.length > 0 ? 'upstream paused' : 'operational', + current_incident_count: incidents.length, + current_incidents: incidents, + affected_services: affectedServices, + quoting_stopped: incidents.some((incident) => /1click|quoting|solver|swap/i.test( + `${incident.title || ''} ${incident.message_text || ''}`, + )), + decisive_reason: primaryIncident + ? [primaryIncident.title, primaryIncident.message_text].filter(Boolean).join(': ') + : 'NEAR Intents status page reports no active incident.', + }; +} + +function normalizePost(post, { statusById, severityById, serviceById }) { + if (!post || typeof post !== 'object') return null; + const latestUpdate = post.latest_update || [...(post.updates || [])].pop() || {}; + const status = statusById.get(latestUpdate.status_id) || {}; + const severity = severityById.get(latestUpdate.severity_id) || {}; + const statusName = normalizeName(status.name || status.description || latestUpdate.status || 'unknown'); + const ended = post.ends_at && Number(post.ends_at) <= Date.now(); + const active = !ended && !TERMINAL_STATUS_NAMES.has(statusName); + + return { + id: post.id || null, + title: post.title || null, + post_type: post.post_type || null, + status: statusName, + severity: normalizeName(severity.name || severity.description || 'unknown'), + active, + first_update_at: toIso(post.first_update_at || post.starts_at), + last_update_at: toIso(post.last_update_at || latestUpdate.reported_at), + message_text: stripHtml(latestUpdate.message || post.message || ''), + impacts: (latestUpdate.impacts || []).map((impact) => { + const service = serviceById.get(impact.service_id) || {}; + const impactSeverity = severityById.get(impact.severity_id) || {}; + return { + service_id: impact.service_id || null, + service_name: service.display_name || service.name || impact.service_id || null, + severity: normalizeName(impactSeverity.name || impactSeverity.description || 'unknown'), + }; + }), + }; +} + +function normalizeName(value) { + return String(value || '').trim().toLowerCase().replaceAll(' ', '_') || 'unknown'; +} + +function stripHtml(value) { + return String(value || '') + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function toIso(value) { + if (value == null || value === '') return null; + const numeric = Number(value); + const date = Number.isFinite(numeric) ? new Date(numeric) : new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index 4da6c5f..345d4f4 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -454,6 +454,7 @@ export function buildDashboardBootstrap({ recentIntentRequests = [], recentAlertTransitions, serviceSnapshots, + nearIntentsStatus = null, sourceErrors = [], } = {}) { const servicesByName = Object.fromEntries( @@ -498,6 +499,7 @@ export function buildDashboardBootstrap({ marketPrice, activeAlerts, servicesByName, + nearIntentsStatus, }), funds: { profitability, @@ -536,6 +538,7 @@ export function buildDashboardBootstrap({ servicesByName, activeAlerts, recentAlerts, + nearIntentsStatus, }), }; } @@ -653,9 +656,14 @@ function buildStatusBar({ marketPrice, activeAlerts, servicesByName, + nearIntentsStatus = null, }) { return { active_pair: config.activePair, + near_intents_upstream_status: nearIntentsStatus?.status || null, + near_intents_upstream_label: nearIntentsStatus?.label || null, + near_intents_upstream_reason: nearIntentsStatus?.decisive_reason || null, + near_intents_upstream_observed_at: nearIntentsStatus?.observed_at || null, latest_reference_price_eure_per_btc: marketPrice?.payload?.eure_per_btc || null, market_observed_at: marketPrice?.payload?.observed_at || marketPrice?.ingested_at || null, market_freshness_ms: ageMs(marketPrice?.payload?.observed_at || marketPrice?.ingested_at), @@ -1445,7 +1453,7 @@ function summarizeGrossEdgeEstimate(rows = []) { }; } -function buildSystemSummary({ servicesByName, activeAlerts, recentAlerts }) { +function buildSystemSummary({ servicesByName, activeAlerts, recentAlerts, nearIntentsStatus = null }) { const historyWriterState = servicesByName['history-writer']?.state || {}; void activeAlerts; void recentAlerts; @@ -1455,6 +1463,7 @@ function buildSystemSummary({ servicesByName, activeAlerts, recentAlerts }) { summarizeServiceSnapshot(snapshot, { authoritativeHealth: null, activeAlerts: [], + nearIntentsStatus, }) )), alerts: { @@ -1478,7 +1487,7 @@ function buildSystemSummary({ servicesByName, activeAlerts, recentAlerts }) { }; } -function summarizeServiceSnapshot(snapshot, { authoritativeHealth = null, activeAlerts = [] } = {}) { +function summarizeServiceSnapshot(snapshot, { authoritativeHealth = null, activeAlerts = [], nearIntentsStatus = null } = {}) { const state = snapshot.state || {}; const health = snapshot.health || {}; void authoritativeHealth; @@ -1486,17 +1495,30 @@ function summarizeServiceSnapshot(snapshot, { authoritativeHealth = null, active const freshnessAt = inferServiceFreshnessTimestamp(snapshot.service, state, health); const reachable = snapshot.reachable !== false; const online = reachable && health.ok !== false; - const healthStatus = online ? 'online' : reachable ? 'reachable' : 'offline'; + const upstreamStatus = resolveServiceUpstreamStatus(snapshot.service, nearIntentsStatus); + const upstreamDisrupted = upstreamStatus?.status === 'disrupted'; + const healthStatus = upstreamDisrupted + ? 'upstream_paused' + : online + ? 'online' + : reachable + ? 'reachable' + : 'offline'; + const healthLabel = upstreamDisrupted ? 'upstream paused' : healthStatus; + const healthReasons = upstreamDisrupted && upstreamStatus.decisive_reason + ? [upstreamStatus.decisive_reason] + : []; return { service: snapshot.service, label: snapshot.label, base_url: snapshot.base_url, reachable, - health_ok: online, + health_ok: healthStatus === 'online', health_status: healthStatus, - health_label: healthStatus, - health_reasons: [], + health_label: healthLabel, + health_reasons: healthReasons, + upstream_status: upstreamStatus, highest_alert_severity: null, paused: state.paused ?? health.paused ?? null, armed: state.armed ?? null, @@ -1508,6 +1530,22 @@ function summarizeServiceSnapshot(snapshot, { authoritativeHealth = null, active }; } +const NEAR_INTENTS_RELAY_SERVICES = new Set(['near-intents-ingest', 'trade-executor']); + +function resolveServiceUpstreamStatus(service, nearIntentsStatus) { + if (!NEAR_INTENTS_RELAY_SERVICES.has(service) || !nearIntentsStatus) return null; + return { + source: nearIntentsStatus.source || 'near_intents_status_page', + status: nearIntentsStatus.status || 'unknown', + label: nearIntentsStatus.label || nearIntentsStatus.status || 'unknown', + observed_at: nearIntentsStatus.observed_at || null, + decisive_reason: nearIntentsStatus.decisive_reason || null, + current_incident_count: nearIntentsStatus.current_incident_count || 0, + affected_services: nearIntentsStatus.affected_services || [], + quoting_stopped: nearIntentsStatus.quoting_stopped ?? null, + }; +} + function buildServiceSummary(service, state) { switch (service) { case 'near-intents-ingest': diff --git a/src/lib/config.mjs b/src/lib/config.mjs index 207d048..049b837 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -105,6 +105,10 @@ const DEFAULTS = { operatorDashboardQuoteLimit: 10, operatorDashboardTradePageSize: 20, operatorDashboardUpstreamTimeoutMs: 3_000, + nearIntentsStatusServicesUrl: 'https://status.near-intents.org/api/services', + nearIntentsStatusPostsUrl: 'https://status.near-intents.org/api/posts?is_featured=true&limit=500', + nearIntentsStatusPostEnumsUrl: 'https://status.near-intents.org/api/post_enums', + nearIntentsStatusTimeoutMs: 3_000, notificationNtfyBaseUrl: '', notificationNtfyTopic: 'unrip', notificationNtfyToken: '', @@ -575,6 +579,16 @@ export function loadConfig({ envPath = '.env' } = {}) { process.env.OPERATOR_DASHBOARD_UPSTREAM_TIMEOUT_MS, DEFAULTS.operatorDashboardUpstreamTimeoutMs, ), + nearIntentsStatusServicesUrl: + process.env.NEAR_INTENTS_STATUS_SERVICES_URL || DEFAULTS.nearIntentsStatusServicesUrl, + nearIntentsStatusPostsUrl: + process.env.NEAR_INTENTS_STATUS_POSTS_URL || DEFAULTS.nearIntentsStatusPostsUrl, + nearIntentsStatusPostEnumsUrl: + process.env.NEAR_INTENTS_STATUS_POST_ENUMS_URL || DEFAULTS.nearIntentsStatusPostEnumsUrl, + nearIntentsStatusTimeoutMs: parseNumber( + process.env.NEAR_INTENTS_STATUS_TIMEOUT_MS, + DEFAULTS.nearIntentsStatusTimeoutMs, + ), notificationNtfyBaseUrl: process.env.NOTIFICATION_NTFY_BASE_URL || DEFAULTS.notificationNtfyBaseUrl, notificationNtfyTopic: diff --git a/src/operator-dashboard/static/components/ServiceCard.jsx b/src/operator-dashboard/static/components/ServiceCard.jsx index 8de78df..c1967fd 100644 --- a/src/operator-dashboard/static/components/ServiceCard.jsx +++ b/src/operator-dashboard/static/components/ServiceCard.jsx @@ -33,6 +33,15 @@ export default function ServiceCard({ service }) {
{`Armed ${formatBoolean(service.armed)}`}
{`Freshness ${freshnessAge}${freshnessAge === 'Unavailable' ? '' : ' ago'}`}
{`Freshness at ${formatTimestamp(service.freshness_at)}`}
+ {service.upstream_status ? ( + <> +
{`Upstream ${service.upstream_status.label || service.upstream_status.status || 'unknown'}`}
+
{`Upstream at ${formatTimestamp(service.upstream_status.observed_at)}`}
+ {service.upstream_status.decisive_reason ? ( +
{service.upstream_status.decisive_reason}
+ ) : null} + + ) : null}
{service.base_url}
{service.last_error ?
{JSON.stringify(service.last_error)}
: null} diff --git a/src/operator-dashboard/static/components/StatusBar.jsx b/src/operator-dashboard/static/components/StatusBar.jsx index 9ad39c7..dede672 100644 --- a/src/operator-dashboard/static/components/StatusBar.jsx +++ b/src/operator-dashboard/static/components/StatusBar.jsx @@ -9,6 +9,10 @@ function statusSubtitle(label, status, websocketState) { return formatTimestamp(status.market_observed_at); case 'Inventory Freshness': return formatTimestamp(status.inventory_observed_at); + case 'NEAR Intents': + return status.near_intents_upstream_observed_at + ? `Official status at ${formatTimestamp(status.near_intents_upstream_observed_at)}` + : 'Official status page'; case SUBMISSION_COPY.statusTileLabel: return SUBMISSION_COPY.statusTileSubtitle; default: @@ -17,8 +21,16 @@ function statusSubtitle(label, status, websocketState) { } export default function StatusBar({ status, websocketState }) { + const nearIntentsTile = status.near_intents_upstream_label + ? [[ + 'NEAR Intents', + status.near_intents_upstream_label, + status.near_intents_upstream_reason || status.near_intents_upstream_label, + ]] + : []; const tiles = [ ['Pair', truncateMiddle(status.active_pair, 40), status.active_pair], + ...nearIntentsTile, ['Portfolio', formatEur(status.current_total_portfolio_value_eure)], ['Reference BTC/EUR', formatEur(status.latest_reference_price_eure_per_btc)], ['Market Freshness', formatAge(status.market_freshness_ms)], diff --git a/test/near-intents-status.test.mjs b/test/near-intents-status.test.mjs new file mode 100644 index 0000000..5c9bf41 --- /dev/null +++ b/test/near-intents-status.test.mjs @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { normalizeNearIntentsStatus } from '../src/core/near-intents-status.mjs'; + +const postEnumsResponse = { + post_enums: [ + { id: 'PSCS3IV', post_enum_type: 'status', name: 'investigating' }, + { id: 'PP34365', post_enum_type: 'status', name: 'detected' }, + { id: 'P8TG2TF', post_enum_type: 'status', name: 'resolved' }, + { id: 'P187122', post_enum_type: 'severity', name: 'minor' }, + { id: 'PCIGMKW', post_enum_type: 'severity', name: 'degraded' }, + ], +}; + +const servicesResponse = { + services: [ + { id: 'PXQFSY1', name: 'Cross-Chain Bridging', display_name: 'Cross-Chain Bridging' }, + { id: 'PLT88AT', name: 'Solvers Network', display_name: 'Solvers Network' }, + ], +}; + +test('NEAR Intents status normalizer exposes current quoting disruption as upstream paused evidence', () => { + const normalized = normalizeNearIntentsStatus({ + observedAt: '2026-04-16T12:40:00.000Z', + servicesResponse, + postEnumsResponse, + postsResponse: { + posts: [{ + id: 'PM7LK6N', + title: '1Click Quoting is temporarily stopped', + post_type: 'incident', + latest_update: { + status_id: 'PSCS3IV', + severity_id: 'P187122', + reported_at: 1776342420000, + impacts: [{ service_id: 'PXQFSY1', severity_id: 'PCIGMKW' }], + message: '

The protocol is paused due to a security incident. Swaps are paused.

', + }, + }], + }, + }); + + assert.equal(normalized.source, 'near_intents_status_page'); + assert.equal(normalized.status, 'disrupted'); + assert.equal(normalized.label, 'upstream paused'); + assert.equal(normalized.quoting_stopped, true); + assert.deepEqual(normalized.affected_services, ['Cross-Chain Bridging']); + assert.equal(normalized.current_incident_count, 1); + assert.equal(normalized.current_incidents[0].status, 'investigating'); + assert.equal(normalized.current_incidents[0].severity, 'minor'); + assert.equal(normalized.current_incidents[0].impacts[0].severity, 'degraded'); + assert.match(normalized.decisive_reason, /1Click Quoting is temporarily stopped/); + assert.match(normalized.decisive_reason, /Swaps are paused/); +}); + +test('resolved NEAR Intents status posts do not make the relay look disrupted', () => { + const normalized = normalizeNearIntentsStatus({ + observedAt: '2026-04-16T13:00:00.000Z', + servicesResponse, + postEnumsResponse, + postsResponse: { + posts: [{ + id: 'PM7LK6N', + title: '1Click Quoting is temporarily stopped', + post_type: 'incident', + latest_update: { + status_id: 'P8TG2TF', + severity_id: 'P187122', + reported_at: 1776346020000, + impacts: [{ service_id: 'PXQFSY1', severity_id: 'PCIGMKW' }], + message: '

Resolved.

', + }, + }], + }, + }); + + assert.equal(normalized.status, 'operational'); + assert.equal(normalized.label, 'operational'); + assert.equal(normalized.quoting_stopped, false); + assert.equal(normalized.current_incident_count, 0); + assert.match(normalized.decisive_reason, /no active incident/i); +}); diff --git a/test/operator-dashboard-ui-static.test.mjs b/test/operator-dashboard-ui-static.test.mjs index f253552..5aeef1e 100644 --- a/test/operator-dashboard-ui-static.test.mjs +++ b/test/operator-dashboard-ui-static.test.mjs @@ -6,6 +6,7 @@ const strategySource = readFileSync(new URL('../src/operator-dashboard/static/pa const fundsSource = readFileSync(new URL('../src/operator-dashboard/static/pages/FundsPage.jsx', import.meta.url), 'utf8'); const stylesSource = readFileSync(new URL('../src/operator-dashboard/static/styles.css', import.meta.url), 'utf8'); const serviceCardSource = readFileSync(new URL('../src/operator-dashboard/static/components/ServiceCard.jsx', import.meta.url), 'utf8'); +const statusBarSource = readFileSync(new URL('../src/operator-dashboard/static/components/StatusBar.jsx', import.meta.url), 'utf8'); test('strategy page owns consolidated quote lifecycle and successful trade tables', () => { assert.match(strategySource, /Quote lifecycle/); @@ -43,3 +44,14 @@ test('mobile status bar uses normal document flow instead of sticky viewport pos /@media \(max-width: 720px\)[\s\S]*?\.status-bar \{[\s\S]*?position: static;[\s\S]*?top: auto;[\s\S]*?z-index: auto;[\s\S]*?\}/, ); }); + + +test('dashboard UI exposes official NEAR upstream status separately from local freshness', () => { + assert.match(statusBarSource, /NEAR Intents/); + assert.match(statusBarSource, /near_intents_upstream_label/); + assert.match(statusBarSource, /near_intents_upstream_reason/); + assert.match(statusBarSource, /Official status at/); + assert.match(serviceCardSource, /upstream_status/); + assert.match(serviceCardSource, /Upstream at/); + assert.match(serviceCardSource, /decisive_reason/); +}); diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index ff27d8c..c01b5c2 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -1533,3 +1533,87 @@ test('own request dashboard rows do not label relay accepted evidence as complet /successful trade|completed trade|asset delta/i, ); }); + + +test('dashboard surfaces NEAR upstream disruption without calling submitted work completed', () => { + const config = buildConfig(); + const nearIntentsStatus = { + source: 'near_intents_status_page', + status: 'disrupted', + label: 'upstream paused', + observed_at: '2026-04-16T12:40:00.000Z', + decisive_reason: '1Click Quoting is temporarily stopped: The protocol is paused.', + current_incident_count: 1, + affected_services: ['Cross-Chain Bridging'], + quoting_stopped: true, + }; + + const dashboard = buildDashboardBootstrap({ + config, + auth: { authenticated: true }, + portfolioMetric: null, + inventorySnapshot: null, + marketPrice: null, + recentQuotes: [], + submissionPage: { page: 1, page_size: 20, total: 0, total_pages: 1, items: [] }, + submissionSummary: { total: 0, last_submission_at: null }, + fundingObservations: [], + recentDepositStatuses: [], + recentTradeDecisions: [], + recentExecuteTradeCommands: [], + recentExecutionResults: [{ + command_id: 'cmd-submitted', + decision_id: 'decision-submitted', + quote_id: 'quote-submitted', + status: 'submitted', + result_code: 'quote_response_ok', + }], + recentQuoteOutcomes: [], + recentIntentRequests: [], + recentAlertTransitions: [], + nearIntentsStatus, + serviceSnapshots: [ + { + service: 'near-intents-ingest', + label: 'NEAR Intents Ingest', + base_url: 'http://near-intents-ingest', + reachable: true, + health: { ok: true }, + state: { ingest: { connected: false, last_message_at: null } }, + }, + { + service: 'trade-executor', + label: 'Trade Executor', + base_url: 'http://trade-executor', + reachable: true, + health: { ok: true }, + state: { relay: { connected: false } }, + }, + { + service: 'history-writer', + label: 'History Writer', + base_url: 'http://history-writer', + reachable: true, + health: { ok: true }, + state: {}, + }, + ], + }); + + assert.equal(dashboard.status_bar.near_intents_upstream_status, 'disrupted'); + assert.equal(dashboard.status_bar.near_intents_upstream_label, 'upstream paused'); + assert.match(dashboard.status_bar.near_intents_upstream_reason, /protocol is paused/); + + const services = Object.fromEntries( + dashboard.system.service_health.map((service) => [service.service, service]), + ); + assert.equal(services['near-intents-ingest'].health_status, 'upstream_paused'); + assert.equal(services['near-intents-ingest'].health_label, 'upstream paused'); + assert.equal(services['near-intents-ingest'].health_ok, false); + assert.match(services['near-intents-ingest'].health_reasons[0], /1Click Quoting/); + assert.equal(services['near-intents-ingest'].upstream_status.status, 'disrupted'); + assert.equal(services['trade-executor'].health_status, 'upstream_paused'); + assert.equal(services['trade-executor'].upstream_status.quoting_stopped, true); + assert.equal(services['history-writer'].health_status, 'online'); + assert.equal(services['history-writer'].upstream_status, null); +});