Files
explorer-monorepo/frontend/src/services/api/missionControl.ts

253 lines
6.8 KiB
TypeScript
Raw Normal View History

import { getExplorerApiBase } from './blockscout'
export interface MissionControlRelaySummary {
text: string
tone: 'normal' | 'warning' | 'danger'
items: MissionControlRelayItemSummary[]
}
export interface MissionControlRelayItemSummary {
key: string
label: string
status: string
text: string
tone: 'normal' | 'warning' | 'danger'
}
export interface MissionControlRelaySnapshot {
status?: string
service?: {
profile?: string
}
source?: {
chain_name?: string
bridge_filter?: string
}
destination?: {
chain_name?: string
relay_bridge?: string
relay_bridge_default?: string
}
queue?: {
size?: number
processed?: number
failed?: number
}
last_source_poll?: {
at?: string
ok?: boolean
logs_fetched?: number
}
}
export interface MissionControlRelayProbe {
ok?: boolean
body?: MissionControlRelaySnapshot
}
export interface MissionControlRelayPayload {
url_probe?: MissionControlRelayProbe
file_snapshot?: MissionControlRelaySnapshot
file_snapshot_error?: string
}
export interface MissionControlChainStatus {
status?: string
name?: string
head_age_sec?: number
latency_ms?: number
block_number?: string
}
export interface MissionControlBridgeStatusResponse {
data?: {
status?: string
checked_at?: string
chains?: Record<string, MissionControlChainStatus>
ccip_relay?: MissionControlRelayPayload
ccip_relays?: Record<string, MissionControlRelayPayload>
}
}
const missionControlRelayLabels: Record<string, string> = {
mainnet: 'Mainnet',
mainnet_weth: 'Mainnet WETH',
mainnet_cw: 'Mainnet cW',
bsc: 'BSC',
avax: 'Avalanche',
avalanche: 'Avalanche',
avax_cw: 'Avalanche cW',
avax_to_138: 'Avalanche -> 138',
}
function getMissionControlStreamUrl(): string {
return `${getExplorerApiBase()}/explorer-api/v1/mission-control/stream`
}
function getMissionControlBridgeStatusUrl(): string {
return `${getExplorerApiBase()}/explorer-api/v1/track1/bridge/status`
}
function relativeAge(isoString?: string): string {
if (!isoString) return ''
const parsed = Date.parse(isoString)
if (!Number.isFinite(parsed)) return ''
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.round(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.round(minutes / 60)
return `${hours}h ago`
}
export function summarizeMissionControlRelay(
response: MissionControlBridgeStatusResponse | null | undefined
): MissionControlRelaySummary | null {
const relays = getMissionControlRelays(response)
if (!relays) return null
const items = Object.entries(relays)
.map(([key, relay]): MissionControlRelayItemSummary | null => {
const label = getMissionControlRelayLabel(key)
if (relay.url_probe && relay.url_probe.ok === false) {
return {
key,
label,
status: 'down',
text: `${label}: probe failed`,
tone: 'danger',
}
}
if (relay.file_snapshot_error) {
return {
key,
label,
status: 'snapshot-error',
text: `${label}: snapshot error`,
tone: 'danger',
}
}
const snapshot = relay.url_probe?.body || relay.file_snapshot
if (!snapshot) {
return {
key,
label,
status: 'configured',
text: `${label}: probe configured`,
tone: 'normal',
}
}
const status = String(snapshot.status || 'unknown').toLowerCase()
const destination = snapshot.destination?.chain_name
const queueSize = snapshot.queue?.size
const pollAge = relativeAge(snapshot.last_source_poll?.at)
let text = `${label}: ${status}`
if (destination) text += ` -> ${destination}`
if (queueSize != null) text += ` · queue ${queueSize}`
if (pollAge) text += ` · polled ${pollAge}`
let tone: MissionControlRelaySummary['tone'] = 'normal'
if (['paused', 'starting'].includes(status)) {
tone = 'warning'
}
if (['degraded', 'stale', 'stopped', 'down'].includes(status)) {
tone = 'danger'
}
return { key, label, status, text, tone }
})
.filter((item): item is MissionControlRelayItemSummary => item !== null)
if (items.length === 0) return null
const tone: MissionControlRelaySummary['tone'] = items.some((item) => item.tone === 'danger')
? 'danger'
: items.some((item) => item.tone === 'warning')
? 'warning'
: 'normal'
const text =
items.length === 1
? items[0].text
: tone === 'normal'
? `${items.length} relay lanes operational`
: `Relay lanes need attention`
return { text, tone, items }
}
export function getMissionControlRelays(
response: MissionControlBridgeStatusResponse | null | undefined
): Record<string, MissionControlRelayPayload> | null {
return response?.data?.ccip_relays && Object.keys(response.data.ccip_relays).length > 0
? response.data.ccip_relays
: response?.data?.ccip_relay
? { mainnet: response.data.ccip_relay }
: null
}
export function getMissionControlRelayLabel(key: string): string {
return missionControlRelayLabels[key] || key.toUpperCase()
}
export const missionControlApi = {
getBridgeStatus: async (): Promise<MissionControlBridgeStatusResponse> => {
const response = await fetch(getMissionControlBridgeStatusUrl())
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return (await response.json()) as MissionControlBridgeStatusResponse
},
getRelaySummary: async (): Promise<MissionControlRelaySummary | null> => {
const json = await missionControlApi.getBridgeStatus()
return summarizeMissionControlRelay(json)
},
subscribeBridgeStatus: (
onStatus: (status: MissionControlBridgeStatusResponse) => void,
onError?: (error: unknown) => void
): (() => void) => {
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') {
onError?.(new Error('EventSource is not available in this environment'))
return () => {}
}
const eventSource = new window.EventSource(getMissionControlStreamUrl())
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as MissionControlBridgeStatusResponse
onStatus(payload)
} catch (error) {
onError?.(error)
}
}
eventSource.onerror = () => {
onError?.(new Error('Mission-control live stream connection lost'))
}
return () => {
eventSource.close()
}
},
subscribeRelaySummary: (
onSummary: (summary: MissionControlRelaySummary | null) => void,
onError?: (error: unknown) => void
): (() => void) => {
return missionControlApi.subscribeBridgeStatus(
(payload) => {
onSummary(summarizeMissionControlRelay(payload))
},
onError
)
},
}