Skip to content

Commit

Permalink
feat: add fiber statistics history (#1864)
Browse files Browse the repository at this point in the history
* feat: add fiber statistics history

* feat: add fiber node map
  • Loading branch information
Keith-CY authored Feb 7, 2025
1 parent a34a68b commit 0e8dc11
Show file tree
Hide file tree
Showing 17 changed files with 1,026 additions and 1 deletion.
174 changes: 174 additions & 0 deletions src/components/GraphNodeIps/NodesMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { useEffect, useRef } from 'react'
import 'echarts/lib/component/tooltip'
import 'echarts-gl'
import 'echarts/map/js/world' // Load the world map
import echarts from 'echarts/lib/echarts'
import styles from './index.module.scss'
import { getPrimaryColor } from '../../constants/common'

const primaryColor = getPrimaryColor()

export type IpPoint = {
id: string
ip: string
lon: number
lat: number
city: string
connections: string[] // other node id
}

type EchartPoint = [long: number, lat: number, city: string]
type EchartLine = [EchartPoint, EchartPoint]

export const isValidIpPoint = (ip: unknown): ip is IpPoint => {
if (typeof ip !== 'object' || ip === null) return false
if (typeof (ip as IpPoint).ip !== 'string') return false
if (typeof (ip as IpPoint).lon !== 'number') return false
if (typeof (ip as IpPoint).lat !== 'number') return false
if (typeof (ip as IpPoint).city !== 'string') return false
return true
}

const option = {
backgroundColor: '#000',
geo: {
silent: true,
map: 'world',
roam: true,
zoom: 2,
label: {
show: false,
},
itemStyle: {
areaColor: '#1b1b1b',
borderColor: '#555',
},
emphasis: {
areaColor: '#444',
},
environment: '#333',
},
}

export const NodesMap = ({ ips }: { ips: IpPoint[] }) => {
const containerRef = useRef<HTMLDivElement | null>(null)

useEffect(() => {
if (!containerRef.current) return
const points: EchartPoint[] = ips.map(i => [i.lon, i.lat, i.city])
if (!points.length) return
let ins = echarts.getInstanceByDom(containerRef.current)
if (!ins) {
ins = echarts.init(containerRef.current)
}

const lines: EchartLine[] = []

ips.forEach(ip => {
ip.connections.forEach(connId => {
const conn = ips.find(i => i.id === connId)

if (!conn) {
return
}

lines.push([
[ip.lon, ip.lat, ip.city],
[conn.lon, conn.lat, conn.city],
])
})
})

const series = [
{
type: 'effectScatter',
coordinateSystem: 'geo',
data: points,
symbolSize: 8,
rippleEffect: {
scale: 3,
brushType: 'stroke',
},
itemStyle: {
color: primaryColor,
shadowBlur: 10,
shadowColor: primaryColor,
},
tooltip: {
show: true,
},
label: {
show: true,
position: 'right',
formatter: (p: { data: EchartPoint }) => {
return p.data[2]
},
color: '#fff',
fontSize: 8,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
padding: [4, 6],
broderRadius: 3,
},
},
{
type: 'lines',
coordinateSystem: 'geo',
zlevel: 2,
effect: {
show: true,
period: 1,
trailLength: 0.1, // Shorter trail
symbol: 'arrow',
symbolSize: 3,
},
animationEasing: 'cubicOut',

lineStyle: {
curveness: 0.2,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 1,
y2: 0,
colorStops: [
{ offset: 0, color: primaryColor },
{ offset: 0.5, color: '#00ffea' },
{ offset: 1, color: primaryColor },
],
},
width: 1,
opacity: 0.4,
},
data: lines,
},
]

ins.setOption({
...option,
series,
} as any)
}, [ips])

useEffect(() => {
if (!containerRef.current) return
const ins = echarts.getInstanceByDom(containerRef.current)
const handleResize = () => {
if (ins) {
ins.resize()
}
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})

if (!ips) {
return <div>Data not found</div>
}

return <div className={styles.container} ref={containerRef} />
}

export default NodesMap
6 changes: 6 additions & 0 deletions src/components/GraphNodeIps/index.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.container {
width: 100%;
height: 400px;
border-radius: 4px;
overflow: hidden;
}
59 changes: 59 additions & 0 deletions src/components/GraphNodeIps/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useQuery } from '@tanstack/react-query'
import { explorerService } from '../../services/ExplorerService'
import { fetchIpsInfo } from '../../services/UtilityService'
import NodesMap, { isValidIpPoint, type IpPoint } from './NodesMap'

const IP_REGEXP = /\/ip4\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/

const getIpFromP2pAddr = (p2pAddr: string) => {
const match = p2pAddr.match(IP_REGEXP)
if (match) {
return match[1]
}
return null
}

const GraphNodeIps = () => {
const { data: nodes } = useQuery({
queryKey: ['fiber_graph_node_addresses'],
queryFn: explorerService.api.getGraphNodeIPs,
refetchInterval: 60 * 1000 * 10, // 10 minutes
})

const ips =
(nodes?.data
.map(i => i.addresses)
.flat()
.map(getIpFromP2pAddr)
.filter(ip => !!ip) as string[]) ?? []

const { data: ipInfos } = useQuery({
queryKey: ['fiber_graph_ips_info', ips.join(',')],
queryFn: () => (ips.length ? fetchIpsInfo(ips) : undefined),
enabled: !!ips.length,
})

const list =
nodes?.data
?.map(node => {
const ips = node.addresses.map(getIpFromP2pAddr)

const infos = ips
.map(ip => {
const ipInfo = ipInfos?.ips[ip as keyof typeof ipInfos.ips]
return {
...ipInfo,
ip,
connections: node.connections,
id: node.nodeId,
}
})
.filter(p => isValidIpPoint(p)) as IpPoint[]
return infos
})
.flat() ?? []

return <NodesMap ips={list} />
}

export default GraphNodeIps
12 changes: 12 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,18 @@
"close": "Close"
},
"graph": {
"total_capacity": "Total Capacity",
"total_liquidity": "Total Liquidity",
"total_nodes": "Total Nodes",
"total_channels": "Total Channels",
"mean": "Mean",
"median": "Median",
"mean_locked_capacity": "Mean Locked Capacity",
"mean_fee_rate": "Mean Fee Rate",
"median_locked_capacity": "Median Locked Capacity",
"median_fee_rate": "Median Fee Rate",
"public_fiber_node_world_map": "Public Fiber Node World Map",
"public_fiber_nodes": "Public Fiber Nodes",
"node": {
"id": "Node ID",
"name": "Name",
Expand Down
12 changes: 12 additions & 0 deletions src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,18 @@
"close": "关闭"
},
"graph": {
"total_capacity": "总 Capacity",
"total_liquidity": "总流动性",
"total_nodes": "总节点数",
"total_channels": "总通道数",
"mean": "均值",
"median": "中位数",
"mean_locked_capacity": "平均锁定 Capacity",
"mean_fee_rate": "平均费率",
"median_locked_capacity": "中位数锁定 Capacity",
"median_fee_rate": "中位数费率",
"public_fiber_node_world_map": "公开 Fiber 节点地图",
"public_fiber_nodes": "公开 Fiber 节点",
"node": {
"id": "节点 ID",
"name": "名称",
Expand Down
76 changes: 76 additions & 0 deletions src/pages/Fiber/Graph/HistoryChart/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import echarts from 'echarts'
import dayjs from 'dayjs'
import { SmartChartPage } from '../../../StatisticsChart/common'

export type Dataset = Record<'timestamp' | 'value', string>[]

const getOption =
(seriaName: string, color: string) =>
(dataset: Dataset): echarts.EChartOption => {
return {
color: [color],
tooltip: {
trigger: 'axis',
formatter: data => {
if (!Array.isArray(data)) return ''
const v = data[0].value
return `${data[0].name}: ${typeof v === 'number' ? v.toLocaleString('en') : v} ${data[0].seriesName}`
},
},
grid: {
left: '10',
right: '10',
top: '10',
bottom: '10',
},
xAxis: [
{
type: 'category',
boundaryGap: false,
data: dataset.map(item => dayjs(+item.timestamp * 1000).format('YYYY/MM/DD')),
show: false,
},
],
yAxis: [
{
show: false,
type: 'value',
},
],
series: [
{
name: seriaName,
type: 'line',
yAxisIndex: 0,
symbol: 'none',
symbolSize: 3,
areaStyle: {
opacity: 0.2,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color }, // Top color (red, semi-transparent)
{ offset: 0.7, color: '#fff' }, // Bottom color (transparent)
{ offset: 1, color: '#fff' }, // Bottom color (transparent)
]) as any,
},
lineStyle: {
width: 1,
},
data: dataset.map(i => +i.value),
},
],
}
}

export const HistoryChart = ({ color, seriaName, dataset }: { color: string; seriaName: string; dataset: Dataset }) => {
return (
<SmartChartPage
title=""
isThumbnail
fetchData={() => Promise.resolve(dataset)}
getEChartOption={getOption(seriaName, color)}
queryKey={Math.random().toString()}
/>
)
}

export default HistoryChart
Loading

0 comments on commit 0e8dc11

Please sign in to comment.