diff --git a/src/apps/ops-sentinel.mjs b/src/apps/ops-sentinel.mjs index 2198dc5..2e37038 100644 --- a/src/apps/ops-sentinel.mjs +++ b/src/apps/ops-sentinel.mjs @@ -14,6 +14,7 @@ import { import { listDashboardServices } from '../core/operator-dashboard.mjs'; import { ageMs, + buildExecutorRuntimeAlerts, buildMakerCompetitivenessRuntimeAlerts, buildRuntimeAlert, createRuntimeHealthThresholds, @@ -433,6 +434,12 @@ function buildDeterministicRuntimeAlerts({ servicesByName, now, previousRuntimeE }, })); } + alerts.push(...buildExecutorRuntimeAlerts({ + executor, + thresholds, + now, + pair: config.activePair, + })); const writer = servicesByName['history-writer']; const writerState = writer?.state || {}; diff --git a/src/apps/trade-executor.mjs b/src/apps/trade-executor.mjs index a554c00..69a9554 100644 --- a/src/apps/trade-executor.mjs +++ b/src/apps/trade-executor.mjs @@ -5,6 +5,7 @@ import { createConsumer } from '../bus/kafka/consumer.mjs'; import { createProducer } from '../bus/kafka/producer.mjs'; import { createArmedStateStore } from '../core/armed-state-store.mjs'; import { startControlApi } from '../core/control-api.mjs'; +import { createExecutorQueueDelayTracker } from '../core/executor-queue-delay.mjs'; import { classifyExecuteCommandExpiry } from '../core/executor-command-expiry.mjs'; import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs'; import { createExecutorStateStore } from '../core/executor-state-store.mjs'; @@ -107,6 +108,7 @@ const relayClient = await startSolverRelayWs({ }, }); const stateStore = createExecutorStateStore({ stateDir: config.executorStateDir }); +const executorQueueDelayTracker = createExecutorQueueDelayTracker(); const armedStateStore = createArmedStateStore({ stateDir: config.executorStateDir, fileName: 'trade-executor-control.json', @@ -124,6 +126,9 @@ const state = { last_error: null, in_flight_count: 0, submitted_count: 0, + salt_unavailable_reject_count: 0, + last_salt_unavailable_at: null, + last_salt_unavailable_state: null, request_creation: { last_preflight: null, last_submission_result: null, @@ -189,6 +194,7 @@ await consumer.run({ async function handleCommand(event) { const payload = event.payload; const timing = startExecutorTiming(event); + recordExecutorQueueDelay(timing, payload); state.last_command = payload; const existing = stateStore.get(payload.command_id); @@ -254,14 +260,32 @@ async function handleCommand(event) { try { const saltStartMs = performance.now(); let currentSaltHex; - try { - const salt = await verifierSaltCache.getFreshSalt(); - currentSaltHex = salt.currentSaltHex; - timing.current_salt_source = salt.source; - timing.current_salt_age_ms = roundTimingMs(salt.ageMs); - } finally { - recordExecutorTiming(timing, 'current_salt_ms', saltStartMs); + const salt = verifierSaltCache.getCachedFreshSalt(); + recordExecutorTiming(timing, 'current_salt_ms', saltStartMs); + timing.current_salt_source = salt.source; + timing.current_salt_age_ms = roundTimingMs(salt.ageMs); + timing.current_salt_unavailable_reason = salt.available ? null : salt.reason; + timing.verifier_salt_cache_fresh = salt.state?.salt_fresh ?? null; + if (!salt.available) { + recordSaltUnavailable(salt); + stateStore.markFailed(payload.command_id, { + quote_id: payload.quote_id, + error: { + name: 'VerifierSaltUnavailable', + message: salt.reason || 'fresh verifier salt unavailable', + }, + }); + await publishResult(payload, withExecutorTiming({ + status: 'rejected', + result_code: 'verifier_salt_unavailable', + failure_category: 'salt_unavailable', + note: 'fresh verifier salt unavailable; command was not signed or relayed', + verifier_salt_unavailable_reason: salt.reason || 'unavailable', + verifier_salt_cache: salt.state, + }, timing)); + return; } + currentSaltHex = salt.currentSaltHex; const signStartMs = performance.now(); let submission; @@ -336,6 +360,7 @@ function startExecutorTiming(event) { received_at: receivedAt.toISOString(), started_at_ms: performance.now(), maker_timing: makerTiming, + command_to_executor_ms: makerTiming.command_to_executor_ms, quote_age_at_executor_receipt_ms: makerTiming.quote_age_at_executor_receipt_ms, command_event_age_ms: Number.isFinite(commandEventAtMs) ? roundTimingMs(receivedAt.getTime() - commandEventAtMs) @@ -374,11 +399,14 @@ function finishExecutorTiming(timing) { executor_result_at: executorResultAt, relay_result_at: relayResultAt, command_event_age_ms: timing.command_event_age_ms, + command_to_executor_ms: timing.command_to_executor_ms ?? null, quote_age_at_executor_receipt_ms: timing.quote_age_at_executor_receipt_ms ?? null, quote_age_at_relay_result_ms: makerTiming.quote_age_at_relay_result_ms ?? null, current_salt_ms: timing.current_salt_ms ?? null, current_salt_source: timing.current_salt_source ?? null, current_salt_age_ms: timing.current_salt_age_ms ?? null, + current_salt_unavailable_reason: timing.current_salt_unavailable_reason ?? null, + verifier_salt_cache_fresh: timing.verifier_salt_cache_fresh ?? null, sign_ms: timing.sign_ms ?? null, relay_response_ms: timing.relay_response_ms ?? null, executor_total_ms: roundTimingMs(performance.now() - timing.started_at_ms), @@ -391,6 +419,22 @@ function roundTimingMs(value) { return Number.isFinite(number) ? Math.round(number * 1000) / 1000 : null; } +function recordExecutorQueueDelay(timing, payload) { + executorQueueDelayTracker.record({ + commandToExecutorMs: timing.command_to_executor_ms, + commandId: payload?.command_id || null, + quoteId: payload?.quote_id || null, + pair: payload?.pair || null, + at: timing.received_at, + }); +} + +function recordSaltUnavailable(salt) { + state.salt_unavailable_reject_count += 1; + state.last_salt_unavailable_at = new Date().toISOString(); + state.last_salt_unavailable_state = salt.state || null; +} + async function publishResult(command, extraPayload) { const event = buildEventEnvelope({ source: 'trade-executor', @@ -439,6 +483,7 @@ const controlApi = startControlApi({ signer_registered: signerRegistered, relay: relayClient.getState(), verifier_salt_cache: verifierSaltCache.getState(), + executor_queue_delay: executorQueueDelayTracker.getState(), trading_config: tradingConfigStore.getState(), ...state, durable_control_state: armedStateStore.getState(), @@ -450,6 +495,8 @@ const controlApi = startControlApi({ getHealth() { const relay = relayClient.getState(); const freshnessAgeMs = ageMs(relay.last_message_at); + const saltState = verifierSaltCache.getState(); + const queueDelay = executorQueueDelayTracker.getState(); return { ok: relay.connected && tradingConfigStore.getState().ok === true @@ -459,6 +506,14 @@ const controlApi = startControlApi({ trading_config_block_reason: tradingConfigStore.getState().block_reason, relay_last_message_at: relay.last_message_at, relay_freshness_age_ms: freshnessAgeMs, + verifier_salt_cache: saltState, + verifier_salt_fresh: saltState.salt_fresh, + verifier_salt_age_ms: saltState.salt_age_ms, + verifier_salt_refresh_in_flight: saltState.refresh_in_flight, + verifier_salt_last_refresh_error: saltState.last_refresh_error, + executor_queue_delay: queueDelay, + executor_queue_delay_status: queueDelay.status, + executor_queue_delay_warning_reason: queueDelay.warning_reason, paused: state.paused, armed: state.armed, reason: diff --git a/src/core/executor-queue-delay.mjs b/src/core/executor-queue-delay.mjs new file mode 100644 index 0000000..e9aaafa --- /dev/null +++ b/src/core/executor-queue-delay.mjs @@ -0,0 +1,127 @@ +export const DEFAULT_EXECUTOR_QUEUE_DELAY_WARNING_MS = 1_000; +export const DEFAULT_EXECUTOR_QUEUE_DELAY_CRITICAL_MS = 5_000; +export const DEFAULT_EXECUTOR_QUEUE_DELAY_SAMPLE_LIMIT = 50; + +export function createExecutorQueueDelayTracker({ + sampleLimit = DEFAULT_EXECUTOR_QUEUE_DELAY_SAMPLE_LIMIT, + warningMs = DEFAULT_EXECUTOR_QUEUE_DELAY_WARNING_MS, + criticalMs = DEFAULT_EXECUTOR_QUEUE_DELAY_CRITICAL_MS, + now = () => Date.now(), +} = {}) { + let samples = []; + + function record({ + commandToExecutorMs, + commandId = null, + quoteId = null, + pair = null, + at = now(), + } = {}) { + const value = roundTimingMs(commandToExecutorMs); + if (value == null) return getState(); + + samples.push({ + at: toIsoTimestamp(at), + command_id: commandId, + quote_id: quoteId, + pair, + command_to_executor_ms: value, + }); + samples = samples.slice(-positiveInteger(sampleLimit, DEFAULT_EXECUTOR_QUEUE_DELAY_SAMPLE_LIMIT)); + return getState(); + } + + function getState() { + return summarizeExecutorQueueDelaySamples(samples, { + warningMs, + criticalMs, + sampleLimit, + }); + } + + return { + record, + getState, + }; +} + +export function summarizeExecutorQueueDelaySamples(samples = [], { + warningMs = DEFAULT_EXECUTOR_QUEUE_DELAY_WARNING_MS, + criticalMs = DEFAULT_EXECUTOR_QUEUE_DELAY_CRITICAL_MS, + sampleLimit = DEFAULT_EXECUTOR_QUEUE_DELAY_SAMPLE_LIMIT, +} = {}) { + const normalizedSamples = (samples || []) + .map((sample) => ({ + at: toIsoTimestamp(sample.at), + command_id: sample.command_id || null, + quote_id: sample.quote_id || null, + pair: sample.pair || null, + command_to_executor_ms: roundTimingMs(sample.command_to_executor_ms), + })) + .filter((sample) => sample.command_to_executor_ms != null) + .slice(-positiveInteger(sampleLimit, DEFAULT_EXECUTOR_QUEUE_DELAY_SAMPLE_LIMIT)); + const values = normalizedSamples + .map((sample) => sample.command_to_executor_ms) + .sort((left, right) => left - right); + const latest = normalizedSamples.at(-1) || null; + const maxMs = values.length ? values.at(-1) : null; + const status = maxMs == null + ? 'unknown' + : maxMs >= criticalMs + ? 'critical' + : maxMs >= warningMs + ? 'warning' + : 'healthy'; + const thresholdMs = status === 'critical' ? criticalMs : warningMs; + + return { + status, + warning: status === 'warning' || status === 'critical', + critical: status === 'critical', + warning_reason: status === 'warning' || status === 'critical' + ? `recent command_to_executor_ms max ${maxMs}ms exceeds ${thresholdMs}ms` + : null, + warning_after_ms: warningMs, + critical_after_ms: criticalMs, + sample_count: normalizedSamples.length, + latest_ms: latest?.command_to_executor_ms ?? null, + latest_at: latest?.at || null, + latest_command_id: latest?.command_id || null, + latest_quote_id: latest?.quote_id || null, + latest_pair: latest?.pair || null, + max_ms: maxMs, + p50_ms: percentile(values, 0.5), + p90_ms: percentile(values, 0.9), + p99_ms: percentile(values, 0.99), + recent_samples: normalizedSamples.slice(-10), + }; +} + +function percentile(sortedValues, ratio) { + if (!sortedValues.length) return null; + const index = Math.min( + sortedValues.length - 1, + Math.max(0, Math.ceil(sortedValues.length * ratio) - 1), + ); + return sortedValues[index]; +} + +function roundTimingMs(value) { + const number = Number(value); + return Number.isFinite(number) ? Math.round(number * 1000) / 1000 : null; +} + +function positiveInteger(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function toIsoTimestamp(value) { + if (!value) return null; + if (typeof value === 'string') { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null; + } + const date = 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 fe7ab03..d4af30c 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -2093,9 +2093,13 @@ function buildServiceSummary(service, state) { return { in_flight_count: state.in_flight_count ?? 0, submitted_count: state.submitted_count ?? state.completed_count ?? 0, + salt_unavailable_reject_count: state.salt_unavailable_reject_count ?? 0, + last_salt_unavailable_at: state.last_salt_unavailable_at || null, signer_registered: state.signer_registered ?? null, relay_connected: state.relay?.connected ?? null, relay_last_message_at: state.relay?.last_message_at || null, + verifier_salt_cache: state.verifier_salt_cache || null, + executor_queue_delay: state.executor_queue_delay || null, }; case 'operator-dashboard': return { diff --git a/src/core/runtime-health.mjs b/src/core/runtime-health.mjs index a98ebb6..a2199d3 100644 --- a/src/core/runtime-health.mjs +++ b/src/core/runtime-health.mjs @@ -1,4 +1,6 @@ export const SERVICE_HEALTH_LEVELS = ['healthy', 'warning', 'critical', 'offline', 'paused']; +export const DEFAULT_EXECUTOR_QUEUE_DELAY_WARNING_MS = 1_000; +export const DEFAULT_EXECUTOR_QUEUE_DELAY_CRITICAL_MS = 5_000; const HEALTH_RANK = { healthy: 0, @@ -27,6 +29,12 @@ export function createRuntimeHealthThresholds(config = {}) { anomalyQuoteRateCollapseRatio: Number(config.opsSentinelAnomalyQuoteRateCollapseRatio || 0.25), anomalyReconnectSpikeMultiplier: Number(config.opsSentinelAnomalyReconnectSpikeMultiplier || 2), containmentCooldownMs: Number(config.opsSentinelContainmentCooldownMs || 60_000), + executorQueueDelayWarningMs: Number( + config.executorQueueDelayWarningMs || DEFAULT_EXECUTOR_QUEUE_DELAY_WARNING_MS, + ), + executorQueueDelayCriticalMs: Number( + config.executorQueueDelayCriticalMs || DEFAULT_EXECUTOR_QUEUE_DELAY_CRITICAL_MS, + ), }; } @@ -129,6 +137,31 @@ export function deriveServiceHealth({ } } + if (service === 'trade-executor') { + const saltState = state.verifier_salt_cache || health.verifier_salt_cache || null; + const queueDelay = state.executor_queue_delay || health.executor_queue_delay || null; + + if (saltState?.configured && saltState.salt_fresh !== true) { + status = escalateHealth(status, 'warning'); + if (label === 'healthy') label = 'salt unavailable'; + reasons.push('verifier salt cache is stale or unavailable'); + } + if (saltState?.last_refresh_error) { + status = escalateHealth(status, 'warning'); + if (label === 'healthy') label = 'salt refresh failed'; + reasons.push('verifier salt refresh failed'); + } + if (queueDelay?.status === 'critical') { + status = escalateHealth(status, 'critical'); + label = 'queue delayed'; + reasons.push(queueDelay.warning_reason || 'executor queue delay is critical'); + } else if (queueDelay?.status === 'warning') { + status = escalateHealth(status, 'warning'); + if (label === 'healthy') label = 'queue delayed'; + reasons.push(queueDelay.warning_reason || 'executor queue delay is high'); + } + } + if (service === 'history-writer') { if (state.database_connectivity === false) { status = escalateHealth(status, 'critical'); @@ -379,6 +412,90 @@ export function buildMakerCompetitivenessRuntimeAlerts({ }); } +export function buildExecutorRuntimeAlerts({ + executor, + thresholds = createRuntimeHealthThresholds(), + now = new Date().toISOString(), + pair = null, +} = {}) { + void now; + const alerts = []; + const state = executor?.state || {}; + const health = executor?.health || {}; + const saltState = state.verifier_salt_cache || health.verifier_salt_cache || null; + const queueDelay = state.executor_queue_delay || health.executor_queue_delay || null; + + if (saltState?.configured && saltState.salt_fresh !== true) { + alerts.push(buildRuntimeAlert({ + alert_code: 'trade_executor_verifier_salt_unavailable', + severity: 'warning', + reason: saltState.has_cached_salt + ? 'trade-executor verifier salt cache is stale' + : 'trade-executor verifier salt cache is unavailable', + service_scope: 'trade-executor', + pair, + details: { + has_cached_salt: saltState.has_cached_salt ?? null, + salt_fresh: saltState.salt_fresh ?? saltState.fresh ?? null, + salt_age_ms: saltState.salt_age_ms ?? saltState.cached_age_ms ?? null, + max_age_ms: saltState.max_age_ms ?? null, + refresh_in_flight: saltState.refresh_in_flight ?? null, + last_refresh_started_at: saltState.last_refresh_started_at || null, + last_refresh_completed_at: saltState.last_refresh_completed_at || null, + last_refresh_error: saltState.last_refresh_error || null, + }, + })); + } + + if (saltState?.last_refresh_error) { + alerts.push(buildRuntimeAlert({ + alert_code: 'trade_executor_verifier_salt_refresh_failed', + severity: 'warning', + reason: 'trade-executor verifier salt refresh failed', + service_scope: 'trade-executor', + pair, + details: { + last_refresh_error: saltState.last_refresh_error, + last_refresh_started_at: saltState.last_refresh_started_at || null, + last_refresh_completed_at: saltState.last_refresh_completed_at || null, + last_refresh_duration_ms: saltState.last_refresh_duration_ms ?? null, + refresh_in_flight: saltState.refresh_in_flight ?? null, + has_cached_salt: saltState.has_cached_salt ?? null, + salt_fresh: saltState.salt_fresh ?? saltState.fresh ?? null, + }, + })); + } + + const queueMaxMs = Number(queueDelay?.max_ms); + if (Number.isFinite(queueMaxMs) && queueMaxMs >= thresholds.executorQueueDelayWarningMs) { + const critical = queueMaxMs >= thresholds.executorQueueDelayCriticalMs; + alerts.push(buildRuntimeAlert({ + alert_code: 'trade_executor_command_queue_delay_high', + severity: critical ? 'critical' : 'warning', + reason: `trade-executor command queue delay ${queueMaxMs}ms exceeds ${ + critical ? thresholds.executorQueueDelayCriticalMs : thresholds.executorQueueDelayWarningMs + }ms`, + service_scope: 'trade-executor', + pair, + details: { + status: queueDelay.status || (critical ? 'critical' : 'warning'), + latest_ms: queueDelay.latest_ms ?? null, + max_ms: queueDelay.max_ms ?? null, + p50_ms: queueDelay.p50_ms ?? null, + p90_ms: queueDelay.p90_ms ?? null, + p99_ms: queueDelay.p99_ms ?? null, + sample_count: queueDelay.sample_count ?? null, + warning_after_ms: thresholds.executorQueueDelayWarningMs, + critical_after_ms: thresholds.executorQueueDelayCriticalMs, + latest_command_id: queueDelay.latest_command_id || null, + latest_quote_id: queueDelay.latest_quote_id || null, + }, + })); + } + + return alerts; +} + export function shouldRaiseIngestPublishStale({ lastMatchingQuoteAt = null, lastPublishedAt = null, diff --git a/src/core/service-snapshot-summary.mjs b/src/core/service-snapshot-summary.mjs index 236f0d8..f88ed85 100644 --- a/src/core/service-snapshot-summary.mjs +++ b/src/core/service-snapshot-summary.mjs @@ -109,9 +109,14 @@ export function summarizeServiceState(service, state) { 'armed', 'last_error', 'relay', + 'verifier_salt_cache', + 'executor_queue_delay', 'last_result', 'last_quote_status', 'submitted_count', + 'salt_unavailable_reject_count', + 'last_salt_unavailable_at', + 'last_salt_unavailable_state', 'failed_count', 'blocked_count', ]); diff --git a/src/core/verifier-salt-cache.mjs b/src/core/verifier-salt-cache.mjs index 2de7e87..6f48a71 100644 --- a/src/core/verifier-salt-cache.mjs +++ b/src/core/verifier-salt-cache.mjs @@ -18,30 +18,47 @@ export function createVerifierSaltCache({ let cached = null; let refreshInFlight = null; let timer = null; + let lastRefreshStartedAtMs = null; + let lastRefreshStartedAt = null; + let lastRefreshCompletedAt = null; + let lastRefreshDurationMs = null; + let lastRefreshReason = null; + let refreshInFlightReason = null; let lastRefreshError = null; let lastRefreshErrorLoggedAtMs = 0; - async function refresh({ reason = 'manual' } = {}) { + async function refreshNow({ reason = 'manual' } = {}) { if (refreshInFlight) return refreshInFlight; + const startedAtMs = now(); + lastRefreshStartedAtMs = startedAtMs; + lastRefreshStartedAt = toIsoTimestamp(startedAtMs); + lastRefreshReason = reason; + refreshInFlightReason = reason; + refreshInFlight = Promise.resolve() .then(() => loadSalt()) .then((salt) => { + const refreshedAtMs = now(); cached = { currentSaltHex: normalizeSalt(salt), - refreshedAtMs: now(), + refreshedAtMs, + refreshedAt: toIsoTimestamp(refreshedAtMs), refreshedReason: reason, }; lastRefreshError = null; + recordRefreshCompleted(); return buildSaltResult(cached, 'refresh'); }) .catch((error) => { logRefreshError(error, reason); lastRefreshError = serializeSaltError(error); + recordRefreshCompleted(); throw error; }) .finally(() => { refreshInFlight = null; + refreshInFlightReason = null; }); return refreshInFlight; @@ -50,18 +67,55 @@ export function createVerifierSaltCache({ async function getFreshSalt() { if (isFresh(cached)) return buildSaltResult(cached, 'cache'); - await refresh({ reason: cached ? 'stale_cache' : 'empty_cache' }); + await refreshNow({ reason: cached ? 'stale_cache' : 'empty_cache' }); if (!isFresh(cached)) { throw new Error('verifier salt cache refresh did not produce a fresh salt'); } return buildSaltResult(cached, 'refresh'); } + function getCachedFreshSalt(options = {}) { + const readNowMs = options.now == null ? now() : timestampMs(options.now); + const readMaxAgeMs = positiveNumber(options.maxAgeMs, maxAgeMs); + const state = getState({ now: readNowMs, maxAgeMs: readMaxAgeMs }); + + if (!cached) { + return { + available: false, + source: 'unavailable', + reason: 'empty_cache', + ageMs: null, + state, + }; + } + + const ageMs = readNowMs - cached.refreshedAtMs; + if (!isFresh(cached, { nowMs: readNowMs, maxAgeMs: readMaxAgeMs })) { + return { + available: false, + source: 'unavailable', + reason: ageMs < 0 ? 'clock_skew' : 'stale_cache', + ageMs: Number.isFinite(ageMs) ? Math.max(0, ageMs) : null, + state, + }; + } + + return { + available: true, + currentSaltHex: cached.currentSaltHex, + source: 'cache', + ageMs: Math.max(0, ageMs), + refreshedAt: cached.refreshedAt, + refreshedReason: cached.refreshedReason, + state, + }; + } + function start() { if (timer) return; - void refresh({ reason: 'startup' }).catch(() => {}); + void refreshNow({ reason: 'startup' }).catch(() => {}); timer = setIntervalFn(() => { - void refresh({ reason: 'prefetch' }).catch(() => {}); + void refreshNow({ reason: 'prefetch' }).catch(() => {}); }, refreshIntervalMs); timer?.unref?.(); } @@ -72,25 +126,37 @@ export function createVerifierSaltCache({ timer = null; } - function getState() { - const ageMs = cached ? now() - cached.refreshedAtMs : null; + function getState(options = {}) { + const stateNowMs = options.now == null ? now() : timestampMs(options.now); + const stateMaxAgeMs = positiveNumber(options.maxAgeMs, maxAgeMs); + const ageMs = cached ? stateNowMs - cached.refreshedAtMs : null; + const fresh = isFresh(cached, { nowMs: stateNowMs, maxAgeMs: stateMaxAgeMs }); + const roundedAgeMs = Number.isFinite(ageMs) ? Math.max(0, Math.round(ageMs)) : null; return { configured: true, has_cached_salt: Boolean(cached), - fresh: isFresh(cached), - max_age_ms: maxAgeMs, + fresh, + salt_fresh: fresh, + salt_age_ms: roundedAgeMs, + max_age_ms: stateMaxAgeMs, refresh_interval_ms: refreshIntervalMs, - cached_age_ms: ageMs == null ? null : Math.max(0, Math.round(ageMs)), + cached_age_ms: roundedAgeMs, + cached_refreshed_at: cached?.refreshedAt || null, refreshed_reason: cached?.refreshedReason || null, refresh_in_flight: Boolean(refreshInFlight), + refresh_in_flight_reason: refreshInFlightReason, + last_refresh_started_at: lastRefreshStartedAt, + last_refresh_completed_at: lastRefreshCompletedAt, + last_refresh_duration_ms: lastRefreshDurationMs, + last_refresh_reason: lastRefreshReason, last_refresh_error: lastRefreshError, }; } - function isFresh(entry) { + function isFresh(entry, { nowMs = now(), maxAgeMs: readMaxAgeMs = maxAgeMs } = {}) { if (!entry) return false; - const ageMs = now() - entry.refreshedAtMs; - return ageMs >= 0 && ageMs <= maxAgeMs; + const ageMs = nowMs - entry.refreshedAtMs; + return Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= readMaxAgeMs; } function buildSaltResult(entry, source) { @@ -102,6 +168,14 @@ export function createVerifierSaltCache({ }; } + function recordRefreshCompleted() { + const completedAtMs = now(); + lastRefreshCompletedAt = toIsoTimestamp(completedAtMs); + lastRefreshDurationMs = lastRefreshStartedAtMs == null + ? null + : Math.max(0, Math.round((completedAtMs - lastRefreshStartedAtMs) * 1000) / 1000); + } + function logRefreshError(error, reason) { if (!logger?.warn) return; const serialized = serializeSaltError(error); @@ -119,9 +193,11 @@ export function createVerifierSaltCache({ } return { + getCachedFreshSalt, getFreshSalt, getState, - refresh, + refresh: refreshNow, + refreshNow, start, stop, }; @@ -141,3 +217,20 @@ function serializeSaltError(error) { message: error?.message || String(error), }; } + +function positiveNumber(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function timestampMs(value) { + if (value == null) return Number.NaN; + if (typeof value === 'number') return value; + if (value instanceof Date) return value.getTime(); + return Date.parse(value); +} + +function toIsoTimestamp(value) { + const parsed = timestampMs(value); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null; +} diff --git a/src/operator-dashboard/static/components/ServiceCard.jsx b/src/operator-dashboard/static/components/ServiceCard.jsx index c1967fd..1c09ec0 100644 --- a/src/operator-dashboard/static/components/ServiceCard.jsx +++ b/src/operator-dashboard/static/components/ServiceCard.jsx @@ -17,6 +17,9 @@ function useNow(intervalMs = 1000) { export default function ServiceCard({ service }) { const healthLabel = service.health_label || service.health_status || (service.reachable ? 'online' : 'offline'); const now = useNow(); + const summary = service.summary || {}; + const saltCache = summary.verifier_salt_cache || null; + const queueDelay = summary.executor_queue_delay || null; const freshnessAge = service.freshness_at ? formatAgeFromTimestamp(service.freshness_at, now) : formatAge(service.freshness_age_ms); @@ -42,9 +45,43 @@ export default function ServiceCard({ service }) { ) : null} > ) : null} + {saltCache ? ( + <> +