diff --git a/src/locales/en.json b/src/locales/en.json index 7a62d3ca4..0c5f2279a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1099,6 +1099,16 @@ "tlc_balance": "TLC Balance", "offered": "Offered", "received": "Received" + }, + "graph": { + "node": { + "name": "Name", + "auto_accept_min_ckb_funding_amount": "Auto-accepting Threshold", + "first_seen": "First Seen", + "node_id": "Node ID", + "chain_hash": "Chain Hash", + "addresses": "Addresses" + } } } } diff --git a/src/pages/Fiber/GraphNodeList/index.module.scss b/src/pages/Fiber/GraphNodeList/index.module.scss new file mode 100644 index 000000000..ba12c0528 --- /dev/null +++ b/src/pages/Fiber/GraphNodeList/index.module.scss @@ -0,0 +1,156 @@ +@import '../../../styles/variables.module'; +@import '../../../styles/table.module'; + +.container { + text-wrap: nowrap; + display: flex; + flex-direction: column; + margin: 24px 120px; + font-size: 1rem; + + a { + color: var(--primary-color); + } + + table { + @extend %base-table; + + tr[data-role='pagination']:hover { + background: #fff; + } + } + + svg { + pointer-events: none; + } + + button { + display: flex; + align-items: center; + appearance: none; + padding: 0; + border: none; + background: none; + cursor: pointer; + + &:hover { + color: var(--primary-color); + } + } + + .name { + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + } + + .nodeId, + .chainHash { + display: flex; + gap: 4px; + } + + .address { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: nowrap; + gap: 4px; + + & > span:first-child { + display: block; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + } + + button, + a, + .more { + display: flex; + align-items: center; + } + } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 1.5rem; + margin-bottom: 20px; + + button { + font-size: 0.875rem; + color: var(--primary-color); + padding-left: 8px; + } + } + + .amount { + display: flex; + flex-direction: column; + } + + @media screen and (width < $extraLargeBreakPoint) { + margin: 24px 20px; + } + + @media screen and (width < 1330px) { + font-size: 14px; + + table { + th, + td { + &:nth-child(6) { + display: none; + } + } + } + } + + @media screen and (width < 810px) { + table { + tr:not([data-role='pagination']) { + th, + td { + &:last-child { + display: none; + } + } + } + } + } + + @media screen and (width < 900px) { + table { + th, + td { + &:nth-child(5) { + display: none; + } + } + } + } + + @media screen and (width < 700px) { + table { + th, + td { + &:nth-child(3) { + display: none; + } + } + } + } + + @media screen and (width < 520px) { + table { + th, + td { + &:first-child { + display: none; + } + } + } + } +} diff --git a/src/pages/Fiber/GraphNodeList/index.tsx b/src/pages/Fiber/GraphNodeList/index.tsx new file mode 100644 index 000000000..ca56cb443 --- /dev/null +++ b/src/pages/Fiber/GraphNodeList/index.tsx @@ -0,0 +1,188 @@ +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { Tooltip } from 'antd' +import { CopyIcon, InfoCircledIcon } from '@radix-ui/react-icons' +import dayjs from 'dayjs' +import Content from '../../../components/Content' +import { useSetToast } from '../../../components/Toast' +import { explorerService } from '../../../services/ExplorerService' +import type { Fiber } from '../../../services/ExplorerService/fetcher' +import { shannonToCkb } from '../../../utils/util' +import { localeNumberString } from '../../../utils/number' +import { parseNumericAbbr } from '../../../utils/chart' +import styles from './index.module.scss' +import Pagination from '../Pagination' +import { PAGE_SIZE } from '../../../constants/common' + +const TIME_TEMPLATE = 'YYYY/MM/DD hh:mm:ss' + +const fields = [ + { + key: 'alias', + label: 'name', + transformer: (v: unknown, i: Fiber.Graph.Node) => { + if (typeof v !== 'string') return v + return ( + +
+ {v || Untitled} +
+
+ ) + }, + }, + { + key: 'autoAcceptMinCkbFundingAmount', + label: 'auto_accept_min_ckb_funding_amount', + transformer: (v: unknown) => { + if (typeof v !== 'string' || Number.isNaN(+v)) return v + const ckb = shannonToCkb(v) + const amount = parseNumericAbbr(ckb) + return ( +
+ + {`${amount} CKB`} + +
+ ) + }, + }, + { + key: 'timestamp', + label: 'first_seen', + transformer: (v: unknown) => { + if (typeof v !== 'string') return v + return dayjs(+v).format(TIME_TEMPLATE) + }, + }, + { + key: 'nodeId', + label: 'node_id', + transformer: (v: unknown) => { + if (typeof v !== 'string') return v + return ( + + + + {v.length > 16 ? `${v.slice(0, 8)}...${v.slice(-8)}` : v} + + + + + ) + }, + }, + { + key: 'chainHash', + label: 'chain_hash', + transformer: (v: unknown) => { + if (typeof v !== 'string') return v + return ( + + + {v.length > 16 ? `${v.slice(0, 8)}...${v.slice(-8)}` : v} + + + + ) + }, + }, + { + key: 'addresses', + label: 'addresses', + transformer: (v: unknown) => { + if (!Array.isArray(v)) return v + const addr = v[0] + if (!addr || typeof addr !== 'string') return v + return ( + + + {addr} + + + {/* */} + {/* */} + {/* */} + {v.length > 1 ? ( + + + + + + ) : null} + + ) + }, + }, +] + +const GraphNodeList = () => { + const [t] = useTranslation() + const setToast = useSetToast() + + const { data } = useQuery({ + queryKey: ['fiber', 'graph', 'nodes'], + queryFn: () => explorerService.api.getGraphNodes(), + }) + + const list = data?.data.fiberGraphNodes ?? [] + const pageInfo = data?.data.meta ?? { total: 1, pageSize: PAGE_SIZE } + const totalPages = Math.ceil(pageInfo.total / pageInfo.pageSize) + + const handleCopy = (e: React.SyntheticEvent) => { + const elm = e.target + if (!(elm instanceof HTMLElement)) return + const { copyText } = elm.dataset + if (!copyText) return + e.stopPropagation() + e.preventDefault() + navigator?.clipboard.writeText(copyText).then(() => setToast({ message: t('common.copied') })) + } + + return ( + +
+

+ CKB Fiber Graph Nodes +

+ + + + {fields.map(f => { + return + })} + + +
+
+ {list.map(i => { + return ( + + {fields.map(f => { + const v = i[f.key as keyof typeof i] + return + })} + + ) + })} +
+
+ + + +
{t(`fiber.graph.node.${f.label}`)}
{f.transformer?.(v, i) ?? v}
+ +
+
+
+ ) +} + +export default GraphNodeList diff --git a/src/pages/Fiber/PeerList/index.tsx b/src/pages/Fiber/PeerList/index.tsx index d2620492c..a0d27c3ad 100644 --- a/src/pages/Fiber/PeerList/index.tsx +++ b/src/pages/Fiber/PeerList/index.tsx @@ -107,7 +107,7 @@ const fields = [ {v.length > 1 ? ( - + diff --git a/src/routes/index.tsx b/src/routes/index.tsx index a2cfb94fc..422f84456 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -84,6 +84,7 @@ const MoleculeParser = lazy(() => import('../pages/Tools/MoleculeParser')) const FiberPeerList = lazy(() => import('../pages/Fiber/PeerList')) const FiberPeer = lazy(() => import('../pages/Fiber/Peer')) const FiberChannel = lazy(() => import('../pages/Fiber/Channel')) +const FiberGraphNodeList = lazy(() => import('../pages/Fiber/GraphNodeList')) // ====== const routes: RouteProps[] = [ @@ -358,6 +359,10 @@ const routes: RouteProps[] = [ path: '/fiber/channels/:id', component: FiberChannel, }, + { + path: '/fiber/graph/nodes', + component: FiberGraphNodeList, + }, ] type PageErrorBoundaryState = { diff --git a/src/services/ExplorerService/fetcher.ts b/src/services/ExplorerService/fetcher.ts index 21e109d99..8e90f0b44 100644 --- a/src/services/ExplorerService/fetcher.ts +++ b/src/services/ExplorerService/fetcher.ts @@ -1244,6 +1244,49 @@ export const apiFetcher = { throw e }) }, + + getGraphNodes: (page = 1, pageSize = 10) => { + return requesterV2 + .get( + `/fiber/graph_nodes?${new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + })}`, + ) + .then(res => + toCamelcase< + Response.Response<{ + fiberGraphNodes: Fiber.Graph.Node[] + meta: { + total: number + pageSize: number + } + }> + >(res.data), + ) + }, + + getGraphNodeDetail: (id: string) => { + return requesterV2 + .get(`/fiber/graph_nodes/${id}`) + .then(res => toCamelcase>(res.data)) + }, + getGraphChannels: (page = 1, pageSize = 10) => { + return requesterV2 + .get( + `/fiber/graph_channels?${new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + })}`, + ) + .then(res => + toCamelcase< + Response.Response<{ + fiberGraphChannels: Fiber.Graph.Channel[] + }> + >(res.data), + ) + }, } // ==================== @@ -1455,7 +1498,7 @@ export namespace Fiber { peerId: string channelId: string stateName: string // TODO: should be enum - state_flags: [] // TODO + stateFlags: [] // TODO }[] } } @@ -1480,4 +1523,31 @@ export namespace Fiber { remotePeer: Peer } } + + export namespace Graph { + export interface Node { + alias: string + nodeId: string + addresses: string[] + timestamp: string + chainHash: string + autoAcceptMinCkbFundingAmount: string + } + + export interface Channel { + channelOutpoint: string + node1: string + node2: string + chainHash: string + fundingTxBlockNumber: string + fundingTxIndex: string // number + lastUpdatedTimestamp: string + node1ToNode2FeeRate: string + node2ToNode1FeeRate: string + capacity: string + } + export interface NodeDetail extends Node { + fiberGraphChannels: Channel[] + } + } }