From 95c2b44d1e44bd18c8b0c1e4064a0c8eb730f1eb Mon Sep 17 00:00:00 2001 From: Keith Date: Tue, 15 Oct 2024 04:46:15 +0900 Subject: [PATCH] feat: add graph node page --- .../GraphChannelList/index.module.scss | 106 ++++++++++ src/components/GraphChannelList/index.tsx | 133 +++++++++++++ src/locales/en.json | 2 + .../Fiber/GraphChannelList/index.module.scss | 92 --------- src/pages/Fiber/GraphChannelList/index.tsx | 133 +------------ src/pages/Fiber/GraphNode/index.module.scss | 151 +++++++++++++++ src/pages/Fiber/GraphNode/index.tsx | 182 ++++++++++++++++++ src/pages/Fiber/GraphNodeList/index.tsx | 2 +- src/routes/index.tsx | 5 + 9 files changed, 584 insertions(+), 222 deletions(-) create mode 100644 src/components/GraphChannelList/index.module.scss create mode 100644 src/components/GraphChannelList/index.tsx create mode 100644 src/pages/Fiber/GraphNode/index.module.scss create mode 100644 src/pages/Fiber/GraphNode/index.tsx diff --git a/src/components/GraphChannelList/index.module.scss b/src/components/GraphChannelList/index.module.scss new file mode 100644 index 000000000..239a50023 --- /dev/null +++ b/src/components/GraphChannelList/index.module.scss @@ -0,0 +1,106 @@ +.container { + font-size: 0.875rem; + + a { + color: var(--primary-color); + } + + svg { + pointer-events: none; + } + + dl { + display: flex; + gap: 4px; + } + + dl, + dd, + dt { + margin: 0; + white-space: pre; + flex-wrap: wrap; + } + + dt { + &::after { + content: ':'; + } + } + + dd { + display: flex; + align-items: center; + gap: 4px; + } + + .general { + dd { + .content[data-is-full-width='true'] { + & > *:first-child { + display: none; + } + + @media screen and (width<800px) { + & > *:first-child { + display: flex; + } + + & > *:last-child { + display: none; + } + } + } + + .content[data-is-full-width='false'] { + & > *:first-child { + display: flex; + } + + & > *:last-child { + display: none; + } + } + } + } + + .channel { + margin-bottom: 4px; + background: #fff; + padding: 8px 40px; + + h1 { + font-size: 1.2rem; + } + } + + .nodesContainer { + border-radius: 6px; + border: 1px solid #ccc; + padding: 8px; + margin-top: 8px; + background: rgb(0 0 0 / 3%); + } + + .nodes { + display: flex; + + &[data-is-full-width='false'] { + flex-direction: column; + } + + h3 { + font-size: 1rem; + } + + gap: 20px; + + .node { + flex: 1; + } + + @media screen and (width<670px) { + flex-direction: column; + } + } +} diff --git a/src/components/GraphChannelList/index.tsx b/src/components/GraphChannelList/index.tsx new file mode 100644 index 000000000..e789c396b --- /dev/null +++ b/src/components/GraphChannelList/index.tsx @@ -0,0 +1,133 @@ +import { CopyIcon } from '@radix-ui/react-icons' +import { Tooltip } from 'antd' +import dayjs from 'dayjs' +import type { FC } from 'react' +import { Link } from 'react-router-dom' +import type { Fiber } from '../../services/ExplorerService/fetcher' +import { parseNumericAbbr } from '../../utils/chart' +import { localeNumberString } from '../../utils/number' +import { shannonToCkb } from '../../utils/util' +import styles from './index.module.scss' + +const TIME_TEMPLATE = 'YYYY/MM/DD hh:mm:ss' + +const GraphChannelList: FC<{ list: Fiber.Graph.Channel[]; isFullWidth?: boolean }> = ({ list, isFullWidth = true }) => { + return ( +
+ {list.map(channel => { + const outPoint = { + txHash: channel.channelOutpoint.slice(0, -8), + index: parseInt(channel.channelOutpoint.slice(-8), 16), + } + + const ckb = shannonToCkb(channel.capacity) + const amount = parseNumericAbbr(ckb) + return ( +
+

General

+
+
+
Out Point
+
+
+ + + {`${outPoint.txHash.slice(0, 6)}...${outPoint.txHash.slice(-6)}#${outPoint.index}`} + + + + {`${outPoint.txHash}#${outPoint.index}`} + +
+ +
+
+ +
+
Capacity
+
+ + {`${amount} CKB`} + +
+
+ +
+
Chain Hash
+
+
+ + {`${channel.chainHash.slice(0, 8)}...${channel.chainHash.slice( + -8, + )}`} + + {channel.chainHash} +
+ +
+
+ +
+
Funded at
+
+ + {localeNumberString(channel.fundingTxBlockNumber)} + + (
{dayjs(+channel.lastUpdatedTimestamp).format(TIME_TEMPLATE)}
) +
+
+
+ +
+

Nodes

+
+
+

First Node

+
+
Public Key
+
+ + {`${channel.node1.slice(0, 8)}...${channel.node1.slice(-8)}`} + + +
+
+
+
Fee Rate
+
{`${localeNumberString(channel.node1ToNode2FeeRate)} shannon/kB`}
+
+
+
+

Second Node

+
+
Public Key
+
+ + {`${channel.node2.slice(0, 8)}...${channel.node2.slice(-8)}`} + + +
+
+
+
Fee Rate
+
{`${localeNumberString(channel.node2ToNode1FeeRate)} shannon/kB`}
+
+
+
+
+
+ ) + })} +
+ ) +} + +export default GraphChannelList diff --git a/src/locales/en.json b/src/locales/en.json index 0c5f2279a..5aaf030a7 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1102,7 +1102,9 @@ }, "graph": { "node": { + "id": "Node ID", "name": "Name", + "alias": "Alias", "auto_accept_min_ckb_funding_amount": "Auto-accepting Threshold", "first_seen": "First Seen", "node_id": "Node ID", diff --git a/src/pages/Fiber/GraphChannelList/index.module.scss b/src/pages/Fiber/GraphChannelList/index.module.scss index c5a79afc0..48060a63e 100644 --- a/src/pages/Fiber/GraphChannelList/index.module.scss +++ b/src/pages/Fiber/GraphChannelList/index.module.scss @@ -9,18 +9,6 @@ border-radius: 6px; box-shadow: rgb(0 0 0 / 12%) 0 2px 6px 0; overflow: hidden; - - .list { - font-size: 0.875rem; - - a { - color: var(--primary-color); - } - - svg { - pointer-events: none; - } - } } button { @@ -42,86 +30,6 @@ margin-bottom: 20px; } - .channel { - margin-bottom: 4px; - background: #fff; - padding: 8px 40px; - - h1 { - font-size: 1.2rem; - } - - dl { - display: flex; - gap: 4px; - } - - dl, - dd, - dt { - margin: 0; - white-space: pre; - flex-wrap: wrap; - } - - dt { - &::after { - content: ':'; - } - } - - dd { - display: flex; - align-items: center; - gap: 4px; - } - - .general { - dd { - .content { - & > *:first-child { - display: none; - } - - @media screen and (width<800px) { - & > *:first-child { - display: flex; - } - - & > *:last-child { - display: none; - } - } - } - } - } - - .nodesContainer { - border-radius: 6px; - border: 1px solid #ccc; - padding: 8px; - margin-top: 8px; - } - - .nodes { - display: flex; - - h3 { - font-size: 1rem; - } - - gap: 20px; - - .node { - flex: 1; - } - - @media screen and (width<670px) { - flex-direction: column; - } - } - } - .pagination { background: #fff; padding: 8px 40px; diff --git a/src/pages/Fiber/GraphChannelList/index.tsx b/src/pages/Fiber/GraphChannelList/index.tsx index ce1c91fce..1f62bbe2c 100644 --- a/src/pages/Fiber/GraphChannelList/index.tsx +++ b/src/pages/Fiber/GraphChannelList/index.tsx @@ -1,22 +1,14 @@ import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { Link } from 'react-router-dom' -import { Tooltip } from 'antd' -import { CopyIcon } 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 { 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' +import GraphChannelListComp from '../../../components/GraphChannelList' -const TIME_TEMPLATE = 'YYYY/MM/DD hh:mm:ss' - -const GraphNodeList = () => { +const GraphChannelList = () => { const [t] = useTranslation() const setToast = useSetToast() @@ -46,124 +38,7 @@ const GraphNodeList = () => { CKB Fiber Graph Channels
-
- {list.map(channel => { - const outPoint = { - txHash: channel.channelOutpoint.slice(0, -8), - index: parseInt(channel.channelOutpoint.slice(-8), 16), - } - - const ckb = shannonToCkb(channel.capacity) - const amount = parseNumericAbbr(ckb) - return ( -
-

General

-
-
-
Out Point
-
-
- - - {`${outPoint.txHash.slice(0, 6)}...${outPoint.txHash.slice(-6)}#${outPoint.index}`} - - - - {`${outPoint.txHash}#${outPoint.index}`} - -
- -
-
- -
-
Capacity
-
- - {`${amount} CKB`} - -
-
- -
-
Chain Hash
-
-
- - {`${channel.chainHash.slice(0, 8)}...${channel.chainHash.slice( - -8, - )}`} - - {channel.chainHash} -
- -
-
- -
-
Funded at
-
- - {localeNumberString(channel.fundingTxBlockNumber)} - - (
{dayjs(+channel.lastUpdatedTimestamp).format(TIME_TEMPLATE)}
) -
-
-
- -
-

Nodes

-
-
-

First Node

-
-
Public Key
-
- - {`${channel.node1.slice(0, 8)}...${channel.node1.slice( - -8, - )}`} - - -
-
-
-
Fee Rate
-
{`${localeNumberString(channel.node1ToNode2FeeRate)} shannon/kB`}
-
-
-
-

Second Node

-
-
Public Key
-
- - {`${channel.node2.slice(0, 8)}...${channel.node2.slice( - -8, - )}`} - - -
-
-
-
Fee Rate
-
{`${localeNumberString(channel.node2ToNode1FeeRate)} shannon/kB`}
-
-
-
-
-
- ) - })} -
+
@@ -173,4 +48,4 @@ const GraphNodeList = () => { ) } -export default GraphNodeList +export default GraphChannelList diff --git a/src/pages/Fiber/GraphNode/index.module.scss b/src/pages/Fiber/GraphNode/index.module.scss new file mode 100644 index 000000000..ace74b7cd --- /dev/null +++ b/src/pages/Fiber/GraphNode/index.module.scss @@ -0,0 +1,151 @@ +@import '../../../styles/variables.module'; +@import '../../../styles/card.module'; + +.container { + text-wrap: nowrap; + display: flex; + flex-direction: column; + align-items: stretch; + margin: 24px 120px; + font-size: 1rem; + + a { + color: var(--primary-color); + } + + dl { + display: flex; + + dt, + dd { + display: flex; + align-items: center; + gap: 4px; + margin: 0; + padding: 0; + } + + dt::after { + content: ':'; + margin-right: 4px; + } + } + + table { + width: 100%; + text-align: left; + cursor: default; + + td, + th { + padding: 8px; + padding-right: 16px; + + &:last-child { + text-align: right; + } + } + + tbody { + tr:hover { + background: #ccc; + } + } + } + + 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); + } + } + + .overview { + @extend %base-card; + + display: flex; + justify-content: space-between; + flex-wrap: wrap; + + .fields { + overflow: hidden; + } + } + + .id, + .connectId { + overflow: hidden; + + & > span:first-child { + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 1; + } + } + + .activities { + display: flex; + gap: 16px; + margin-top: 16px; + + .channels, + .transactions { + flex: 1; + background: #fff; + border-radius: 6px; + padding: 16px; + box-shadow: 0 2px 6px 0 #4d4d4d33; + + h3 { + margin: 0; + padding: 0; + } + } + + @media screen and (width < 960px) { + flex-direction: column; + } + + @media screen and (width < 500px) { + thead { + display: none; + } + + tbody { + tr { + display: flex; + flex-direction: column; + padding: 16px 0; + + &:not(:last-child) { + border-bottom: 1px solid #ccc; + } + + td { + text-align: left; + padding: 0; + } + } + } + } + } + + @media screen and (width < $extraLargeBreakPoint) { + margin: 24px 20px; + } + + @media screen and (width < 1030px) { + font-size: 14px; + } +} diff --git a/src/pages/Fiber/GraphNode/index.tsx b/src/pages/Fiber/GraphNode/index.tsx new file mode 100644 index 000000000..50c34238a --- /dev/null +++ b/src/pages/Fiber/GraphNode/index.tsx @@ -0,0 +1,182 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { CopyIcon, OpenInNewWindowIcon } from '@radix-ui/react-icons' +import { Tooltip } from 'antd' +import QRCode from 'qrcode' +import dayjs from 'dayjs' +import Content from '../../../components/Content' +import { explorerService } from '../../../services/ExplorerService' +import { useSetToast } from '../../../components/Toast' +import styles from './index.module.scss' +import Loading from '../../../components/Loading' +import { shannonToCkb } from '../../../utils/util' +import { parseNumericAbbr } from '../../../utils/chart' +import { localeNumberString } from '../../../utils/number' + +const TIME_TEMPLATE = 'YYYY/MM/DD hh:mm:ss' + +const GraphNode = () => { + const [t] = useTranslation() + const [addr, setAddr] = useState('') + const { id } = useParams<{ id: string }>() + const qrRef = useRef(null) + + const setToast = useSetToast() + + const { data, isLoading } = useQuery({ + queryKey: ['fiber', 'graph', 'node', id], + queryFn: () => { + return explorerService.api.getGraphNodeDetail(id) + }, + enabled: !!id, + }) + + const node = data?.data + + const connectId = addr + + const handleAddrSelect = (e: React.ChangeEvent) => { + e.stopPropagation() + e.preventDefault() + const r = e.currentTarget.value + if (r) { + setAddr(r) + } + } + + useEffect(() => { + const firstAddr = node?.addresses[0] + if (firstAddr) { + setAddr(firstAddr) + } + }, [node, setAddr]) + + useEffect(() => { + const cvs = qrRef.current + if (!cvs || !connectId) return + QRCode.toCanvas( + cvs, + connectId, + { + margin: 5, + errorCorrectionLevel: 'H', + width: 144, + }, + err => { + if (err) { + console.error(err) + } + }, + ) + }, [qrRef, connectId]) + + if (isLoading) { + return + } + + if (!node) { + return
Fiber Peer Not Found
+ } + const channels = node.fiberGraphChannels + + const ckb = shannonToCkb(node.autoAcceptMinCkbFundingAmount) + const amount = parseNumericAbbr(ckb) + + 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 ( + +
+
+
+ {node.alias ? ( +
+
{t('fiber.graph.alias')}
+
+ {node.alias} + +
+
+ ) : null} +
+
{t('fiber.graph.node.id')}
+
+ {node.nodeId} + +
+
+
+
+ +
+
+ + + + + +
+
+
+
{t('fiber.graph.node.first_seen')}
+
{dayjs(+node.timestamp).format(TIME_TEMPLATE)}
+
+
+
{t('fiber.graph.node.chain_hash')}
+
{node.chainHash}
+
+
+
{t('fiber.graph.node.auto_accept_min_ckb_funding_amount')}
+
+ + {`${amount} CKB`} + +
+
+
+ {connectId ? ( +
+ +
+ ) : null} +
+
+
+

{`${t('fiber.peer.channels')}(${channels.length})`}

+
TODO
+
+
+

Open | Close Transactions

+ Coming soon +
+
+
+
+ ) +} + +export default GraphNode diff --git a/src/pages/Fiber/GraphNodeList/index.tsx b/src/pages/Fiber/GraphNodeList/index.tsx index ad22a82f0..7546066d2 100644 --- a/src/pages/Fiber/GraphNodeList/index.tsx +++ b/src/pages/Fiber/GraphNodeList/index.tsx @@ -20,7 +20,7 @@ const TIME_TEMPLATE = 'YYYY/MM/DD hh:mm:ss' const fields = [ { key: 'alias', - label: 'name', + label: 'alias', transformer: (v: unknown, i: Fiber.Graph.Node) => { if (typeof v !== 'string') return v return ( diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 138d71142..a9462c580 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -85,6 +85,7 @@ 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 FiberGraphNode = lazy(() => import('../pages/Fiber/GraphNode')) const FiberGraphChannelList = lazy(() => import('../pages/Fiber/GraphChannelList')) // ====== @@ -364,6 +365,10 @@ const routes: RouteProps[] = [ path: '/fiber/graph/nodes', component: FiberGraphNodeList, }, + { + path: '/fiber/graph/node/:id', + component: FiberGraphNode, + }, { path: '/fiber/graph/channels', component: FiberGraphChannelList,