diff --git a/.eslintrc.js b/.eslintrc.js index 47549e586..86d7a97b2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -115,6 +115,7 @@ module.exports = { 'jsx-a11y/label-has-associated-control': 'off', 'jsx-a11y/no-static-element-interactions': 'off', 'jsx-a11y/no-noninteractive-element-interactions': 'off', + 'jsx-a11y/click-events-have-key-events': 'off', }, env: { jest: true, diff --git a/public/images/tokens/ckb_token.svg b/public/images/tokens/ckb_token.svg new file mode 100644 index 000000000..27302bd6f --- /dev/null +++ b/public/images/tokens/ckb_token.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/src/components/GraphChannelList/index.module.scss b/src/components/GraphChannelList/index.module.scss new file mode 100644 index 000000000..399a0e332 --- /dev/null +++ b/src/components/GraphChannelList/index.module.scss @@ -0,0 +1,139 @@ +@import '../../styles/variables.module'; +@import '../../styles/text.module'; + +.container { + font-size: 0.875rem; + + a { + color: var(--primary-color); + } + + svg { + pointer-events: none; + } + + dl { + display: flex; + gap: 4px; + + @media screen and (width <= $mobileBreakPoint) { + display: block; + } + } + + dl, + dd, + dt { + margin: 0; + + /* white-space: pre; */ + + /* flex-wrap: wrap; */ + } + + dt { + &::after { + content: ':'; + } + } + + dd { + display: flex; + align-items: center; + gap: 4px; + } + + .channel { + margin-bottom: 4px; + background: #fff; + padding: 8px 40px; + + @media screen and (width <= $largeBreakPoint) { + padding: 8px; + } + + h1 { + font-size: 1.2rem; + } + } + + .funding { + display: flex; + gap: 4px; + flex-wrap: nowrap; + overflow: hidden; + + dd { + overflow: hidden; + } + + a.address { + @extend %hash; + + min-width: 180px; + } + } + + .outPoint { + dd { + overflow: hidden; + } + + a { + @extend %hash; + } + } + + .nodesContainer { + border-radius: 6px; + border: 1px solid #ccc; + padding: 8px; + margin-top: 8px; + background: rgb(0 0 0 / 3%); + + dt, + dd { + display: flex; + flex-wrap: nowrap; + } + } + + .nodes { + display: flex; + + &[data-is-full-width='false'] { + flex-direction: column; + } + + h3 { + display: flex; + align-items: center; + gap: 4px; + font-size: 1rem; + + span { + display: flex; + align-items: center; + } + } + + gap: 20px; + + .node { + flex: 1; + overflow: hidden; + + dd { + overflow: hidden; + } + + a { + @extend %hash; + } + } + + @media screen and (width <= $mobileBreakPoint) { + flex-direction: column; + } + } +} diff --git a/src/components/GraphChannelList/index.tsx b/src/components/GraphChannelList/index.tsx new file mode 100644 index 000000000..a32a02b11 --- /dev/null +++ b/src/components/GraphChannelList/index.tsx @@ -0,0 +1,156 @@ +import { CopyIcon, HomeIcon, GlobeIcon } 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[]; node?: string }> = ({ list, node }) => { + if (!list.length) { + return
No Channels
+ } + + return ( +
+ {list.map((channel, i) => { + const outPoint = { + txHash: channel.channelOutpoint.slice(0, -8), + index: parseInt(channel.channelOutpoint.slice(-8), 16), + } + + const ckb = shannonToCkb(channel.capacity) + const amount = parseNumericAbbr(ckb) + + const fundingCkb = shannonToCkb(channel.openTransactionInfo.capacity) + const fundingCkbAmount = parseNumericAbbr(fundingCkb) + + const fundingUdtAmount = channel.openTransactionInfo.udtAmount + ? parseNumericAbbr(channel.openTransactionInfo.udtAmount) + : null + + const outpoint = `${outPoint.txHash}#${outPoint.index}` + + return ( +
+

Channel #{i + 1}

+
+
+
Out Point
+
+ + +
{outpoint.slice(0, -15)}
+
{outpoint.slice(-15)}
+ +
+ +
+
+ +
+
Capacity
+
+ + {`${amount} CKB`} + +
+
+
+
Source
+
+ {fundingUdtAmount || ( + + {`${fundingCkbAmount} CKB`} + + )} + from + + +
{channel.openTransactionInfo.address.slice(0, -15)}
+
{channel.openTransactionInfo.address.slice(-15)}
+ +
+
+
+
+
Position
+
+ On + + + {localeNumberString(channel.fundingTxBlockNumber)} + + +
+
+
+ +
+

Nodes

+
+
+

+ First Node + {node ? {node === channel.node1 ? : } : null} +

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

+ Second Node + {node ? {node === channel.node2 ? : } : null} +

+
+
ID
+
+ + +
{`0x${channel.node2.slice(0, -8)}`}
+
{channel.node2.slice(-8)}
+ +
+ +
+
+
+
Fee Rate
+
{`${localeNumberString(channel.node2ToNode1FeeRate)} shannon/kB`}
+
+
+
+
+
+ ) + })} +
+ ) +} + +export default GraphChannelList diff --git a/src/components/Search/AggregateSearchResults.tsx b/src/components/Search/AggregateSearchResults.tsx index de54979bc..6f0d451ff 100644 --- a/src/components/Search/AggregateSearchResults.tsx +++ b/src/components/Search/AggregateSearchResults.tsx @@ -259,6 +259,22 @@ const SearchResultItem: FC<{ keyword?: string; item: AggregateSearchResult }> = ) } + if (item.type === SearchResultType.FiberGraphNode) { + return ( + +
+ + +
+ + {t('search.fiber_graph_node')} # {localeNumberString(item.attributes.alias)} + +
+
+ + ) + } + return (
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 8123799b5..8d628193a 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -45,6 +45,7 @@ const ALLOW_SEARCH_TYPES = [ SearchResultType.UDT, SearchResultType.DID, SearchResultType.BtcAddress, + SearchResultType.FiberGraphNode, ] async function fetchAggregateSearchResult(searchValue: string): Promise { diff --git a/src/components/Search/utils.ts b/src/components/Search/utils.ts index 0425169ef..0bb9527b8 100644 --- a/src/components/Search/utils.ts +++ b/src/components/Search/utils.ts @@ -54,6 +54,9 @@ export const getURLByAggregateSearchResult = (result: AggregateSearchResult) => case SearchResultType.BtcAddress: return `/address/${attributes.addressHash}` + case SearchResultType.FiberGraphNode: + return `/fiber/graph/node/${attributes.nodeId}` + default: break } @@ -97,4 +100,7 @@ export const getDisplayNameByAggregateSearchResult = (result: AggregateSearchRes if (type === SearchResultType.BtcAddress) { return attributes.addressHash } + if (type === SearchResultType.FiberGraphNode) { + return attributes.peerId + } } diff --git a/src/constants/fiberChainHash.ts b/src/constants/fiberChainHash.ts new file mode 100644 index 000000000..eaad4f749 --- /dev/null +++ b/src/constants/fiberChainHash.ts @@ -0,0 +1,4 @@ +export const ChainHash = new Map([ + ['0x0000000000000000000000000000000000000000000000000000000000000000', 'CKB Testnet'], + ['0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606', 'CKB Testnet'], +]) diff --git a/src/locales/en.json b/src/locales/en.json index 07fdf8438..fe766623a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -177,7 +177,7 @@ "sUDT": "sUDT", "inscriptions": "Inscriptions", "docs": "Docs", - "search_placeholder": "Block/Transaction/Address/Script Hash/Args/BTC Address/BTC Txid/DID", + "search_placeholder": "Block/Transaction/Address/Script Hash/Args/BTC Address/BTC Txid/DID/Fiber Peer Id/Fiber Node Id", "search_by_name_placeholder": "Token Name", "more": "More", "mainnet": "LINA", @@ -205,6 +205,13 @@ "wallets_memo": "In alphabetical order", "nft_collections": "NFT Collections" }, + "banner": { + "fiber_title": "Fiber Network is making Satoshi’s vision a reality", + "fiber_subtitle": "The Next-generation Common Lightning Network", + "learn_more": "Learn More", + "find_nodes": "Find Nodes", + "coming_soon": "Coming soon" + }, "blockchain": { "latest_block": "Latest Block", "epoch": "Epoch", @@ -220,7 +227,7 @@ "testnet": "Testnet" }, "statistic": { - "transaction_num": "BTC Transaction Count", + "btc_transaction_count": "BTC Transaction Count", "new_btc_address": "New BTC Address", "rgbpp_transaction_list": "RGB++ Transaction List", "transaction_count": "Transaction Count", @@ -370,7 +377,7 @@ "loading": "Loading...", "no_search_result": "Oops! Your search did not match any record.", "empty_result": "Oops! Your search did not match any record. \n\nPlease make sure input contains only one of the following items:\n", - "empty_result_items": "Block Number/ Block Hash/ Transaction Hash/ Address/ Script Hash/ Args/ BTC Address/ BTC Txid/DID", + "empty_result_items": "Block Number/ Block Hash/ Transaction Hash/ Address/ Script Hash/ Args/ BTC Address/ BTC Txid/DID/Fiber Peer Id/Fiber Node Id", "address_type_testnet_error": "Testnet address detected,please goto", "address_type_mainnet_error": "Mainnet address detected,please goto", "address_type_testnet_url": "testnet explorer", @@ -394,7 +401,8 @@ "bitcoin_address": "BTC Address", "token_collection": "Token Collection", "token_item": "Token Item", - "did": "DID" + "did": "DID", + "fiber_graph_node": "Fiber Graph Node" }, "cell": { "live_cell": "Live Cell", @@ -1078,6 +1086,51 @@ "view_address": "View Address", "view_btc_utxo": "View BTC UTXO", "view_cell_info": "View Cell Info" + }, + "fiber": { + "peer": { + "peer_id": "ID", + "name": "Name", + "channels_count": "Channels", + "channels": "Channels", + "open_time": "First Channel", + "update_time": "Last Update", + "total_local_balance": "Local Balance", + "rpc_addr": "RPC Address", + "connect_id": "Connect" + }, + "channel": { + "channel_id": "Channel ID", + "fiber_peers": "Fiber Peers", + "state": "State", + "open_time": "Open Time", + "update_time": "Last Update Time", + "shutdown_time": "Shutdown Time", + "balance": "Balance", + "local": "Local", + "remote": "Remote", + "tlc_balance": "TLC Balance", + "offered": "Offered", + "received": "Received" + }, + "graph": { + "node": { + "id": "Node ID", + "name": "Name", + "alias": "Alias", + "auto_accept_funding_amount": "Auto-accepting Threshold", + "first_seen": "First Seen", + "node_id": "Node ID", + "chain": "Chain", + "chain_hash": "Chain Hash", + "addresses": "Addresses", + "total_capacity": "Capacity", + "open_channels": "Open Channels" + }, + "channel": { + "connected_node": "Connected Node" + } + } } } } diff --git a/src/locales/zh.json b/src/locales/zh.json index f19701f60..605431e21 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -220,6 +220,13 @@ "wallets_memo": "In alphabetical order", "nft_collections": "NFT 藏品" }, + "banner": { + "fiber_title": "Fiber Network 不忘初心", + "fiber_subtitle": "下一代通用闪电网络", + "learn_more": "了解更多", + "find_nodes": "更多节点", + "coming_soon": "敬请期待" + }, "blockchain": { "latest_block": "最高块", "epoch": "Epoch", @@ -235,7 +242,7 @@ "testnet": "Testnet" }, "statistic": { - "transaction_num": "BTC 交易数量", + "btc_transaction_count": "BTC 交易数量", "new_btc_address": "新增加 BTC 地址", "rgbpp_transaction_list": "RGB++ 交易列表", "transaction_count": "交易数量", @@ -409,7 +416,8 @@ "bitcoin_address": "BTC 地址", "token_collection": "藏品集", "token_item": "藏品", - "did": "分布式数字身份(DID)" + "did": "分布式数字身份(DID)", + "fiber_graph_node": "Fiber Graph Node" }, "address": { "address": "地址", diff --git a/src/pages/Fiber/Channel/fiber.module.scss b/src/pages/Fiber/Channel/fiber.module.scss new file mode 100644 index 000000000..d2e8e270a --- /dev/null +++ b/src/pages/Fiber/Channel/fiber.module.scss @@ -0,0 +1,11 @@ +.container { + display: flex; + flex-direction: column; + + .id { + display: flex; + align-items: center; + gap: 4px; + font-size: 14px; + } +} diff --git a/src/pages/Fiber/Channel/fiber.tsx b/src/pages/Fiber/Channel/fiber.tsx new file mode 100644 index 000000000..b0c9462b5 --- /dev/null +++ b/src/pages/Fiber/Channel/fiber.tsx @@ -0,0 +1,25 @@ +import { CopyIcon } from '@radix-ui/react-icons' +import { Tooltip } from 'antd' +import { Link } from 'react-router-dom' +import type { Fiber } from '../../../services/ExplorerService/fetcher' +import styles from './fiber.module.scss' + +const FiberPeerInfo = ({ peer }: { peer: Fiber.Channel.Peer }) => { + return ( +
+ + {peer.name || 'Untitled Node'} + +
+ + {`${peer.peerId.slice(0, 8)}...${peer.peerId.slice(-8)}`} + + +
+
+ ) +} + +export default FiberPeerInfo diff --git a/src/pages/Fiber/Channel/index.module.scss b/src/pages/Fiber/Channel/index.module.scss new file mode 100644 index 000000000..9d063904b --- /dev/null +++ b/src/pages/Fiber/Channel/index.module.scss @@ -0,0 +1,114 @@ +@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); + } + + svg { + pointer-events: none; + } + + dl { + display: flex; + + dt, + dd { + display: flex; + align-items: center; + gap: 4px; + margin: 0; + padding: 0; + } + + dt::after { + content: ':'; + margin-right: 4px; + } + } + + 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; + } + + .id { + overflow: hidden; + + & > span:first-child { + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 1; + } + } + + .transactions { + @extend %base-card; + + margin-top: 8px; + padding-top: 16px; + + h3 { + margin: 0; + padding: 0; + } + } + + .peers { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + + .local, + .remote { + @extend %base-card; + + flex: 1 0 40%; + border: 1px solid #ccc; + padding: 32px; + + dl:first-child { + dt { + align-items: flex-start; + } + + a { + font-weight: 400; + } + } + + dl:last-child { + margin: 0; + } + } + } + + @media screen and (width < $extraLargeBreakPoint) { + margin: 24px 20px; + } + + @media screen and (width < 1030px) { + font-size: 14px; + } +} diff --git a/src/pages/Fiber/Channel/index.tsx b/src/pages/Fiber/Channel/index.tsx new file mode 100644 index 000000000..d5787feda --- /dev/null +++ b/src/pages/Fiber/Channel/index.tsx @@ -0,0 +1,150 @@ +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { CopyIcon } from '@radix-ui/react-icons' +import BigNumber from 'bignumber.js' +import dayjs from 'dayjs' +import Content from '../../../components/Content' +import { useSetToast } from '../../../components/Toast' +import Loading from '../../../components/Loading' +import { explorerService } from '../../../services/ExplorerService' +import styles from './index.module.scss' +import { shannonToCkb } from '../../../utils/util' +import { localeNumberString } from '../../../utils/number' +import FiberPeerInfo from './fiber' + +const TIME_TEMPLATE = 'YYYY/MM/DD hh:mm:ss' + +const Channel = () => { + const [t] = useTranslation() + const { id } = useParams<{ id: string }>() + const setToast = useSetToast() + + const { data, isLoading } = useQuery({ + queryKey: ['fiber', 'channels', id], + queryFn: () => { + return explorerService.api.getFiberChannel(id) + }, + enabled: !!id, + }) + + if (isLoading) { + return + } + + if (!data) { + return
Fiber Peer Not Found
+ } + const channel = data.data + + 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') })) + } + + const totalBalance = BigNumber(channel.localBalance).plus(BigNumber(channel.remoteBalance)) + const totalTLCBalance = BigNumber(channel.offeredTlcBalance).plus(BigNumber(channel.receivedTlcBalance)) + + return ( + +
+
+
+
+
{t('fiber.channel.channel_id')}
+
+ {channel.channelId} + +
+
+
+
{t('fiber.channel.state')}
+
{channel.stateName}
+
+ +
+
{t('fiber.channel.balance')}
+
{`${localeNumberString( + shannonToCkb(totalBalance.toFormat({ groupSeparator: '' })), + )} CKB(Total) | ${localeNumberString( + shannonToCkb(totalTLCBalance.toFormat({ groupSeparator: '' })), + )} CKB(TLC)`}
+
+
+
{t('fiber.channel.open_time')}
+
+ +
+
+
+
{t('fiber.channel.update_time')}
+
+ +
+
+ {channel.shutdownAt ? ( +
+
{t('fiber.channel.shutdown_time')}
+
+ +
+
+ ) : null} +
+
+
+
+
Fiber Peer
+
+ +
+
+ +
+
{t('fiber.channel.balance')}
+
{`${localeNumberString(shannonToCkb(channel.localBalance))} CKB`}
+
+ +
+
{t('fiber.channel.tlc_balance')}
+
{`${localeNumberString(shannonToCkb(channel.offeredTlcBalance))} CKB`}
+
+
+ +
+
+
Fiber Peer
+
+ +
+
+ +
+
{t('fiber.channel.balance')}
+
{`${localeNumberString(shannonToCkb(channel.remoteBalance))} CKB`}
+
+ +
+
{t('fiber.channel.tlc_balance')}
+
{`${localeNumberString(shannonToCkb(channel.receivedTlcBalance))} CKB`}
+
+
+
+
+
+

Open | Close Transactions

+
Coming soon
+
+
+
+ ) +} + +export default Channel diff --git a/src/pages/Fiber/GraphChannelList/index.module.scss b/src/pages/Fiber/GraphChannelList/index.module.scss new file mode 100644 index 000000000..48060a63e --- /dev/null +++ b/src/pages/Fiber/GraphChannelList/index.module.scss @@ -0,0 +1,46 @@ +@import '../../../styles/variables.module'; +@import '../../../styles/table.module'; + +.container { + margin: 24px 120px; + font-size: 1rem; + + .channels { + border-radius: 6px; + box-shadow: rgb(0 0 0 / 12%) 0 2px 6px 0; + overflow: hidden; + } + + button { + display: flex; + align-items: center; + appearance: none; + padding: 0; + border: none; + background: none; + cursor: pointer; + + &:hover { + color: var(--primary-color); + } + } + + .header { + font-size: 1.5rem; + margin-bottom: 20px; + } + + .pagination { + background: #fff; + padding: 8px 40px; + margin-top: 4px; + } + + @media screen and (width < $extraLargeBreakPoint) { + margin: 24px 20px; + } + + @media screen and (width < 1330px) { + font-size: 14px; + } +} diff --git a/src/pages/Fiber/GraphChannelList/index.tsx b/src/pages/Fiber/GraphChannelList/index.tsx new file mode 100644 index 000000000..9d928ab8b --- /dev/null +++ b/src/pages/Fiber/GraphChannelList/index.tsx @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import Content from '../../../components/Content' +import { useSetToast } from '../../../components/Toast' +import { explorerService } from '../../../services/ExplorerService' +import styles from './index.module.scss' +import Pagination from '../Pagination' +import { PAGE_SIZE } from '../../../constants/common' +import GraphChannelListComp from '../../../components/GraphChannelList' +import { useSearchParams } from '../../../hooks' + +const GraphChannelList = () => { + const [t] = useTranslation() + const setToast = useSetToast() + const { page = 1, page_size: pageSize = PAGE_SIZE } = useSearchParams('page', 'page_size') + + const { data } = useQuery({ + queryKey: ['fiber', 'graph', 'channels', +page, +pageSize], + queryFn: () => explorerService.api.getGraphChannels(+page, +pageSize), + }) + + const list = data?.data.fiberGraphChannels ?? [] + const pageInfo = 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 Channels +

+
+ +
+ +
+
+
+
+ ) +} + +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..d93a43d4f --- /dev/null +++ b/src/pages/Fiber/GraphNode/index.module.scss @@ -0,0 +1,231 @@ +@import '../../../styles/variables.module'; +@import '../../../styles/card.module'; +@import '../../../styles/text.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; + } + } + + .thresholds { + dt { + align-items: start; + } + + dd { + margin-left: 8px; + display: flex; + flex-direction: column; + align-items: flex-start; + + img { + flex: 0 0; + } + + .token { + display: flex; + gap: 8px; + align-items: center; + } + } + } + + .activities { + display: flex; + gap: 16px; + margin-top: 16px; + + .channels, + .transactions { + overflow: hidden; + flex-basis: 50%; + background: #fff; + border-radius: 6px; + padding: 16px; + box-shadow: 0 2px 6px 0 #4d4d4d33; + font-size: 0.8em; + + * { + font-size: inherit; + } + + h3 { + margin: 0; + padding: 0; + } + } + + @media screen and (width < $mobileBreakPoint) { + 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; + } +} + +.addresses { + select { + max-width: 80%; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.tx { + padding: 8px 40px; + display: flex; + flex-direction: column; + + @media screen and (width < $extraLargeBreakPoint) { + padding: 8px; + } + + time { + margin-right: auto; + } + + & > div { + display: flex; + align-items: center; + gap: 4px; + } + + .addr { + @extend %hash; + } + + a { + @extend %monospace; + + display: flex; + align-items: center; + overflow: hidden; + user-select: none; + + div { + font-family: inherit; + } + + div:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + } +} diff --git a/src/pages/Fiber/GraphNode/index.tsx b/src/pages/Fiber/GraphNode/index.tsx new file mode 100644 index 000000000..cc3ff4390 --- /dev/null +++ b/src/pages/Fiber/GraphNode/index.tsx @@ -0,0 +1,340 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { CopyIcon, Link1Icon, LinkBreak1Icon, 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 GraphChannelList from '../../../components/GraphChannelList' +import { getFundingThreshold } from '../utils' +import { shannonToCkb } from '../../../utils/util' +import { parseNumericAbbr } from '../../../utils/chart' +import { ChainHash } from '../../../constants/fiberChainHash' +import { Link } from '../../../components/Link' +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]) + + const openAndClosedTxs = useMemo(() => { + const list: { + hash: string + index?: string + block: { + number: number + timestamp: number + } + isUdt: boolean + isOpen: boolean + accounts: Record<'amount' | 'address', string>[] + }[] = [] + + if (!node?.fiberGraphChannels) return list + + node.fiberGraphChannels.forEach(c => { + const isUdt = !!c.openTransactionInfo.udtAmount + const open = { + isOpen: true, + isUdt, + hash: c.openTransactionInfo.txHash, + index: c.fundingTxIndex, + block: { + number: c.openTransactionInfo.blockNumber, + timestamp: c.openTransactionInfo.blockTimestamp, + }, + accounts: [ + { + address: c.openTransactionInfo.address, + amount: + c.openTransactionInfo.udtAmount ?? + `${localeNumberString(shannonToCkb(c.openTransactionInfo.capacity))} CKB`, + }, + ], + } + + list.push(open) + + const close = c.closedTransactionInfo?.txHash + ? { + isOpen: false, + hash: c.closedTransactionInfo.txHash, + block: { + number: c.closedTransactionInfo.blockNumber, + timestamp: c.closedTransactionInfo.blockTimestamp, + }, + isUdt, + accounts: c.closedTransactionInfo.closeAccounts.map(acc => { + return { + amount: acc.udtAmount ?? `${localeNumberString(shannonToCkb(acc.capacity))} CKB`, + address: acc.address, + } + }), + } + : null + if (close) { + list.push(close) + } + }) + return list.sort((a, b) => a.block.timestamp - b.block.timestamp) + }, [node]) + + if (isLoading) { + return + } + + if (!node) { + return
Fiber Peer Not Found
+ } + const channels = node.fiberGraphChannels.filter(c => !c.closedTransactionInfo) + + const thresholds = getFundingThreshold(node) + + const totalCkb = parseNumericAbbr(shannonToCkb(node.totalCapacity)) + + 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') })) + } + + const chain = ChainHash.get(node.chainHash) ?? '-' + + return ( + +
+
+
+ {node.alias ? ( +
+
{t('fiber.graph.alias')}
+
+ {node.alias} + +
+
+ ) : null} +
+
{t('fiber.graph.node.id')}
+
+ {`0x${node.nodeId}`} + +
+
+
+
+ +
+
+ + + + + +
+
+
+
{t('fiber.graph.node.first_seen')}
+
{dayjs(+node.timestamp).format(TIME_TEMPLATE)}
+
+
+
{t('fiber.graph.node.chain')}
+
+ {chain} +
+
+
+
{t('fiber.graph.node.total_capacity')}
+
{totalCkb}
+
+
+
{t('fiber.graph.node.auto_accept_funding_amount')}
+
+ {thresholds.map(threshold => { + return ( + + + icon + {threshold.display} + + + ) + })} +
+
+
+ {connectId ? ( +
+ +
+ ) : null} +
+
+
+

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

+ +
+
+

Open & Closed Transactions

+
+ {openAndClosedTxs.map(tx => { + const key = tx.isOpen ? `${tx.hash}#${tx.index}` : tx.hash + if (tx.isOpen) { + const account = tx.accounts[0]! + return ( +
+
+ + at + + + + + + +
+
+ By + + + +
{account.address.slice(0, -8)}
+
{account.address.slice(-8)}
+ +
+
+ ({account.amount}) +
+
+ ) + } + const [acc1, acc2] = tx.accounts + return ( +
+
+ + at + + + + + + +
+
+ To + + + +
{acc1.address.slice(0, -8)}
+
{acc1.address.slice(-8)}
+ +
+
+ ({acc1.amount}) +
+
+ And + + + +
{acc2.address.slice(0, -8)}
+
{acc2.address.slice(-8)}
+ +
+
+ ({acc2.amount}) +
+
+ ) + })} +
+
+
+
+
+ ) +} + +export default GraphNode diff --git a/src/pages/Fiber/GraphNodeList/index.module.scss b/src/pages/Fiber/GraphNodeList/index.module.scss new file mode 100644 index 000000000..4356a5086 --- /dev/null +++ b/src/pages/Fiber/GraphNodeList/index.module.scss @@ -0,0 +1,167 @@ +@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; + } + + .funding { + display: flex; + flex-direction: column; + + .token { + display: flex; + gap: 8px; + align-items: center; + } + } + + .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..0dcc283d4 --- /dev/null +++ b/src/pages/Fiber/GraphNodeList/index.tsx @@ -0,0 +1,221 @@ +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 Pagination from '../Pagination' +import { PAGE_SIZE } from '../../../constants/common' +import { useSearchParams } from '../../../hooks' +import { getFundingThreshold } from '../utils' +import styles from './index.module.scss' +import { shannonToCkb } from '../../../utils/util' +import { parseNumericAbbr } from '../../../utils/chart' +import { localeNumberString } from '../../../utils/number' +import { ChainHash } from '../../../constants/fiberChainHash' + +const TIME_TEMPLATE = 'YYYY/MM/DD hh:mm:ss' + +const fields = [ + { + key: 'alias', + label: 'alias', + transformer: (v: unknown, i: Fiber.Graph.Node) => { + if (typeof v !== 'string') return v + return ( + +
+ {v || Untitled} +
+
+ ) + }, + }, + { + key: 'autoAcceptMinCkbFundingAmount', + label: 'auto_accept_funding_amount', + transformer: (_: unknown, n: Fiber.Graph.Node) => { + const thresholds = getFundingThreshold(n) + + return ( +
+ {thresholds.map(threshold => { + return ( + + + icon + {threshold.display} + + + ) + })} +
+ ) + }, + }, + { + key: 'totalCapacity', + label: 'total_capacity', + transformer: (v: unknown) => { + if (typeof v !== 'string') return v + + const ckb = shannonToCkb(v) + const amount = parseNumericAbbr(ckb) + return ( + + {`${amount} CKB`} + + ) + }, + }, + { + key: 'openChannelsCount', + label: 'open_channels', + transformer: (v: unknown) => { + if (typeof v !== 'string') return v + + return localeNumberString(v) + }, + }, + { + 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 ? `0x${v.slice(0, 8)}...${v.slice(-8)}` : `0x${v}`} + + + + + ) + }, + }, + { + key: 'chainHash', + label: 'chain', + transformer: (v: unknown) => { + if (typeof v !== 'string') return v + const chain = ChainHash.get(v) ?? '-' + return ( + + {chain} + + + ) + }, + }, + { + 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 { page = 1, page_size: pageSize = PAGE_SIZE } = useSearchParams('page', 'page_size') + + const { data } = useQuery({ + queryKey: ['fiber', 'graph', 'nodes', +page, +pageSize], + queryFn: () => explorerService.api.getGraphNodes(+page, +pageSize), + }) + + const list = data?.data.fiberGraphNodes ?? [] + const pageInfo = 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/Pagination/index.module.scss b/src/pages/Fiber/Pagination/index.module.scss new file mode 100644 index 000000000..2a189997a --- /dev/null +++ b/src/pages/Fiber/Pagination/index.module.scss @@ -0,0 +1,95 @@ +@import '../../../styles/variables.module'; + +.container { + display: flex; + background: #fff; + justify-content: space-between; + height: 34px; + + form { + display: flex; + gap: 20px; + + label { + display: flex; + align-items: center; + } + + button { + display: flex; + justify-content: center; + width: 50px; + background: #f5f5f5; + border-radius: 6px; + } + + input { + width: 100; + padding: 0 10px; + color: #969696; + border-radius: 6px; + background: #f5f5f5; + border: none; + } + } + + .pager { + display: flex; + gap: 20px; + + .pageNo { + display: flex; + align-items: center; + } + } + + a { + display: flex; + justify-content: center; + align-items: center; + color: #969696; + + &:hover { + color: var(--primary-color); + } + + &[aria-disabled='true'] { + pointer-events: none; + opacity: 0.5; + } + + &[data-role='first-page'], + &[data-role='last-page'] { + width: 50px; + background: #f5f5f5; + border-radius: 6px; + } + + &[data-role='prev-page'], + &[data-role='next-page'] { + width: 30px; + height: 30px; + background: #f5f5f5; + border-radius: 6px; + } + } + + @media screen and (width < $mobileBreakPoint) { + font-size: 12px; + + .pager { + gap: 4px; + } + + a { + &[data-role='first-page'], + &[data-role='last-page'] { + display: none; + } + } + + .pageNo { + order: 3; + } + } +} diff --git a/src/pages/Fiber/Pagination/index.tsx b/src/pages/Fiber/Pagination/index.tsx new file mode 100644 index 000000000..5e60d5cba --- /dev/null +++ b/src/pages/Fiber/Pagination/index.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { Link, useHistory } from 'react-router-dom' +import { ChevronLeftIcon, ChevronRightIcon } from '@radix-ui/react-icons' +import { useSearchParams } from '../../../hooks' +import styles from './index.module.scss' + +interface PaginationProps { + totalPages: number +} + +const getPageUrl = (page: number, search: URLSearchParams) => { + search.set('page', page.toString()) + return `${window.location.pathname}?${search.toString()}` +} + +const Pagination: React.FC = ({ totalPages }) => { + const history = useHistory() + const { page: p } = useSearchParams('page') + + // Get the current page from the URL query parameter, defaulting to 1 if not set + const currentPage = Number(p) || 1 + const search = new URLSearchParams(window.location.search) + + const handleGo = (e: React.SyntheticEvent) => { + e.stopPropagation() + e.preventDefault() + + const { page } = e.currentTarget + if (!(page instanceof HTMLInputElement)) return + const go = page.value + if (+go < 1) { + history.push(getPageUrl(1, search)) + return + } + if (+go > totalPages) { + history.push(getPageUrl(totalPages, search)) + return + } + history.push(getPageUrl(+go, search)) + } + + return ( +
+
+ + First + + + + + {`Page ${currentPage} of ${totalPages}`} + + + + + Last + +
+
+ + + +
+
+ ) +} + +export default Pagination diff --git a/src/pages/Fiber/Peer/index.module.scss b/src/pages/Fiber/Peer/index.module.scss new file mode 100644 index 000000000..ace74b7cd --- /dev/null +++ b/src/pages/Fiber/Peer/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/Peer/index.tsx b/src/pages/Fiber/Peer/index.tsx new file mode 100644 index 000000000..e096dee4d --- /dev/null +++ b/src/pages/Fiber/Peer/index.tsx @@ -0,0 +1,194 @@ +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Link, useParams } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { CopyIcon, OpenInNewWindowIcon } from '@radix-ui/react-icons' +import QRCode from 'qrcode' +import { Tooltip } from 'antd' +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' + +const Peer = () => { + const [t] = useTranslation() + const [rpcAddr, setRpcAddr] = useState('') + const { id } = useParams<{ id: string }>() + const qrRef = useRef(null) + + const setToast = useSetToast() + + const { data, isLoading } = useQuery({ + queryKey: ['fiber', 'peers', id], + queryFn: () => { + return explorerService.api.getFiberPeerDetail(id) + }, + enabled: !!id, + }) + + const peer = data?.data + + const connectId = peer && rpcAddr ? `${peer.peerId}@${rpcAddr}` : null + + const handleRpcAddrSelect = (e: React.ChangeEvent) => { + e.stopPropagation() + e.preventDefault() + const r = e.currentTarget.value + if (r) { + setRpcAddr(r) + } + } + + useEffect(() => { + const firstRpcAddr = peer?.rpcListeningAddr[0] + if (firstRpcAddr) { + setRpcAddr(firstRpcAddr) + } + }, [peer, setRpcAddr]) + + 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 (!peer) { + return
Fiber Peer Not Found
+ } + const channels = peer.fiberChannels + + 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 ( + +
+
+
+
+
{t('fiber.peer.peer_id')}
+
+ {peer.peerId} + +
+
+
+
+ +
+
+ + + + + +
+
+ {connectId ? ( +
+
{t('fiber.peer.connect_id')}
+
+ + {connectId} + + +
+
+ ) : null} +
+
{t('fiber.peer.open_time')}
+
+ Coming soon +
+
+
+
{t('fiber.peer.update_time')}
+
+ Coming soon +
+
+
+ {connectId ? ( +
+ +
+ ) : null} +
+
+
+

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

+ + + + + + + + + {channels.map(c => { + return ( + + + + + ) + })} + +
{t('fiber.channel.channel_id')}{t('fiber.channel.state')}
+ + + {`${c.channelId.slice(0, 10)}...${c.channelId.slice(-10)}`} + + + {c.stateName}
+
+
+

Open | Close Transactions

+ Coming soon +
+
+
+
+ ) +} + +export default Peer diff --git a/src/pages/Fiber/PeerList/AddPeerForm.module.scss b/src/pages/Fiber/PeerList/AddPeerForm.module.scss new file mode 100644 index 000000000..2fa4288b5 --- /dev/null +++ b/src/pages/Fiber/PeerList/AddPeerForm.module.scss @@ -0,0 +1,74 @@ +.container { + font-size: 1rem; + + &::backdrop { + background: rgb(0 0 0 / 40%); + } + + form { + background: #fff; + position: fixed; + transform: translateX(-50%) translateY(-50%); + font-size: 0.875rem; + width: min-content; + margin: 16px auto; + top: 50%; + left: 50%; + padding: 23px 40px; + border-radius: 4px; + } + + h3 { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 22px; + border-bottom: 1px solid #e5e5e5; + margin-bottom: 16px; + + button { + svg { + color: #000; + } + } + } + + fieldset { + display: flex; + flex-direction: column; + margin-bottom: 16px; + } + + label { + display: flex; + align-items: center; + gap: 4px; + color: #666; + margin-bottom: 12px; + + &[data-required]::after { + content: '*'; + color: var(--accent-color); + } + } + + input { + width: 320px; + padding: 9px 12px; + border: 1px solid #e5e5e5; + border-radius: 4px; + font-size: inherit; + } + + button[type='submit'] { + padding: 14px 40px; + color: #fff; + background: var(--primary-color); + border-radius: 4px; + font-size: 1rem; + } + + @media screen and (width < 1030px) { + font-size: 14px; + } +} diff --git a/src/pages/Fiber/PeerList/AddPeerForm.tsx b/src/pages/Fiber/PeerList/AddPeerForm.tsx new file mode 100644 index 000000000..a0d2ae5d2 --- /dev/null +++ b/src/pages/Fiber/PeerList/AddPeerForm.tsx @@ -0,0 +1,105 @@ +import { Cross2Icon } from '@radix-ui/react-icons' +import { type FC, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useSetToast } from '../../../components/Toast' +import { explorerService } from '../../../services/ExplorerService' +import styles from './AddPeerForm.module.scss' + +interface AddPeerFormProps { + onSuccess: () => void +} +const AddPeerForm: FC = ({ onSuccess }) => { + const dialogRef = useRef(null) + const [t] = useTranslation() + const setToast = useSetToast() + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + dialogRef.current?.close() + } + } + + window.addEventListener('keydown', handleKeyDown) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, []) + + const handleClose = () => { + dialogRef.current?.close() + } + + const handleSubmit = async (e: React.SyntheticEvent) => { + e.preventDefault() + e.stopPropagation() + const form = e.currentTarget + + const { peer_id, peer_name, rpc } = form + const params: Parameters[0] = { + rpc: rpc instanceof HTMLInputElement ? rpc.value : '', + id: peer_id instanceof HTMLInputElement ? peer_id.value : '', + name: peer_name instanceof HTMLInputElement ? peer_name.value : undefined, + } + + if (params.rpc && params.id) { + try { + await explorerService.api.addFiberPeer(params) + setToast({ message: 'submitted' }) + onSuccess() + } catch (e) { + const message = e instanceof Error ? e.message : JSON.stringify(e) + setToast({ message }) + } + } + } + + const handleClickOutside = (e: React.MouseEvent) => { + if (e.target === dialogRef.current) { + dialogRef.current?.close() + } + } + + const handleOpen = () => { + dialogRef.current?.showModal() + } + + return ( + <> + + + +
+

+ Add Fiber Peer + +

+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + ) +} + +export default AddPeerForm diff --git a/src/pages/Fiber/PeerList/index.module.scss b/src/pages/Fiber/PeerList/index.module.scss new file mode 100644 index 000000000..25e11803e --- /dev/null +++ b/src/pages/Fiber/PeerList/index.module.scss @@ -0,0 +1,144 @@ +@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; + } + + .peerId { + display: flex; + gap: 4px; + } + + .rpc { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: nowrap; + gap: 4px; + + & > span:first-child { + display: block; + max-width: 500px; + 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; + } + } + + .balance { + display: flex; + flex-direction: column; + } + + @media screen and (width < $extraLargeBreakPoint) { + margin: 24px 20px; + } + + @media screen and (width < 1030px) { + font-size: 14px; + + table { + th, + td { + &:nth-child(4) { + display: none; + } + } + } + } + + @media screen and (width < 810px) { + table { + tr:not([data-role='pagination']) { + th, + td { + &:last-child { + display: none; + } + } + } + } + } + + @media screen and (width < 600px) { + table { + th, + td { + &:nth-child(6) { + display: none; + } + } + } + } + + @media screen and (width < 420px) { + table { + th, + td { + &:nth-child(5) { + display: none; + } + } + } + } +} diff --git a/src/pages/Fiber/PeerList/index.tsx b/src/pages/Fiber/PeerList/index.tsx new file mode 100644 index 000000000..35aa9476a --- /dev/null +++ b/src/pages/Fiber/PeerList/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, OpenInNewWindowIcon } from '@radix-ui/react-icons' +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 AddPeerForm from './AddPeerForm' +import Pagination from '../Pagination' +import { PAGE_SIZE } from '../../../constants/common' +import { useSearchParams } from '../../../hooks' + +const fields = [ + { + key: 'name', + label: 'name', + transformer: (v: unknown, i: Fiber.Peer.ItemInList) => { + if (typeof v !== 'string') return v + return ( + +
+ {v} +
+
+ ) + }, + }, + { + key: 'channelsCount', + label: 'channels_count', + transformer: (v: unknown) => { + if (typeof v !== 'number') return v + return localeNumberString(v) + }, + }, + { + key: 'totalLocalBalance', + label: 'total_local_balance', + transformer: (v: unknown) => { + if (typeof v !== 'string' || Number.isNaN(+v)) return v + const ckb = shannonToCkb(v) + const amount = parseNumericAbbr(ckb) + return ( +
+ + {`${amount} CKB`} + + Share: coming soon +
+ ) + }, + }, + { + key: 'firstChannelOpenedAt', + label: 'open_time', + transformer: () => { + return Coming soon + }, + }, + { + key: 'lastChannelUpdatedAt', + label: 'update_time', + transformer: () => { + return Coming soon + }, + }, + { + key: 'peerId', + label: 'peer_id', + transformer: (v: unknown) => { + if (typeof v !== 'string') return v + return ( + + + + {v.length > 16 ? `${v.slice(0, 8)}...${v.slice(-8)}` : v} + + + + + ) + }, + }, + { + key: 'rpcListeningAddr', + label: 'rpc_addr', + transformer: (v: unknown) => { + if (!Array.isArray(v)) return v + const rpcAddr = v[0] + if (!rpcAddr || typeof rpcAddr !== 'string') return v + return ( + + + {rpcAddr} + + + + + + {v.length > 1 ? ( + + + + + + ) : null} + + ) + }, + }, +] + +const PeerList = () => { + const [t] = useTranslation() + const setToast = useSetToast() + const { page = 1, page_size: pageSize = PAGE_SIZE } = useSearchParams('page', 'page_size') + + const { data, refetch: refetchList } = useQuery({ + queryKey: ['fiber', 'peers', +page, +pageSize], + queryFn: () => explorerService.api.getFiberPeerList(+page, +pageSize), + }) + + const list = data?.data.fiberPeers ?? [] + const pageInfo = 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 Peers + + refetchList()} /> +

+ + + + {fields.map(f => { + return + })} + + +
+
+ {list.map(i => { + return ( + + {fields.map(f => { + const v = i[f.key as keyof typeof i] + return + })} + + ) + })} +
+
+ + + +
{t(`fiber.peer.${f.label}`)}
{f.transformer?.(v, i) ?? v}
+ +
+
+
+ ) +} + +export default PeerList diff --git a/src/pages/Fiber/utils/index.tsx b/src/pages/Fiber/utils/index.tsx new file mode 100644 index 000000000..2513b3bae --- /dev/null +++ b/src/pages/Fiber/utils/index.tsx @@ -0,0 +1,42 @@ +import { scriptToHash } from '@nervosnetwork/ckb-sdk-utils' +import type { Fiber } from '../../../services/ExplorerService/fetcher' +import { parseNumericAbbr } from '../../../utils/chart' +import { localeNumberString, parseUDTAmount } from '../../../utils/number' +import { shannonToCkb } from '../../../utils/util' + +export const getFundingThreshold = (n: Fiber.Graph.Node) => { + const ckb = shannonToCkb(n.autoAcceptMinCkbFundingAmount) + const amount = parseNumericAbbr(ckb) + + const tokens: { title: string; display: string; id: string; icon?: string }[] = [ + { + title: `${localeNumberString(ckb)} CKB`, + display: `${amount} CKB`, + id: 'ckb', + icon: '/images/tokens/ckb_token.svg', + }, + ] + + n.udtCfgInfos.forEach(udt => { + if (udt && udt.autoAcceptAmount && typeof udt.decimal === 'number' && udt.symbol) { + try { + const udtAmount = parseUDTAmount(udt.autoAcceptAmount, udt.decimal) + const id = scriptToHash({ + codeHash: udt.codeHash, + hashType: udt.hashType, + args: udt.args, + }) + tokens.push({ + title: `${localeNumberString(udtAmount)} ${udt.symbol}`, + display: `${parseNumericAbbr(udtAmount)} ${udt.symbol}`, + icon: udt.iconFile, + id, + }) + } catch (e) { + console.error(e) + } + } + }) + + return tokens +} diff --git a/src/pages/Home/Banner/fiber_network.gif b/src/pages/Home/Banner/fiber_network.gif new file mode 100644 index 000000000..a5ab60126 Binary files /dev/null and b/src/pages/Home/Banner/fiber_network.gif differ diff --git a/src/pages/Home/Banner/fiber_network_640.gif b/src/pages/Home/Banner/fiber_network_640.gif new file mode 100644 index 000000000..881470e04 Binary files /dev/null and b/src/pages/Home/Banner/fiber_network_640.gif differ diff --git a/src/pages/Home/Banner/index.module.scss b/src/pages/Home/Banner/index.module.scss index 710ad8997..dd676e17f 100644 --- a/src/pages/Home/Banner/index.module.scss +++ b/src/pages/Home/Banner/index.module.scss @@ -2,12 +2,11 @@ $backgroundColor: #232323; -.root { +.root, +.fiberBanner { width: 100%; - background-color: $backgroundColor; height: 200px; position: relative; - background-image: url('./ckb_explorer_banner.svg'); background-repeat: no-repeat; background-position: center center; background-size: auto 100%; @@ -15,12 +14,175 @@ $backgroundColor: #232323; display: flex; align-items: center; justify-content: center; +} + +.root { + background-color: $backgroundColor; + background-image: url('./ckb_explorer_banner.svg'); @media (width <= $mobileBreakPoint) { background-image: url('./ckb_explorer_banner_phone.svg'); } } +.fiberBanner { + --m: 1rem; + --red: #ff6565; + --pink: #ff64f9; + --purple: #6b5fff; + --blue: #4d8aff; + --green: #5bff89; + --yellow: #fe5; + --orange: #ff6d1b; + + position: relative; + background-color: black; + background-image: url('./fiber_network.gif'); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .slogan { + display: flex; + flex-direction: column; + + h1, + h3 { + line-height: 1; + color: #f3f3f3; + text-align: center; + } + + h3 { + opacity: 0.8; + font-weight: 300; + } + + margin-bottom: 20px; + } + + a { + position: relative; + color: #f3f3f3; + font-size: var(--m); + display: flex; + justify-content: center; + cursor: pointer; + } + + span { + box-sizing: border-box; + border: calc(0.08 * var(--m)) solid transparent; + border-radius: calc(0.7 * var(--m)); + padding: calc(0.5 * var(--m)) calc(1 * var(--m)); + background: linear-gradient(#121213, #121213), + linear-gradient(#121213 50%, rgb(18 18 19 / 60%) 80%, rgb(18 18 19 / 0%)), + linear-gradient( + 90deg, + var(--orange), + var(--yellow), + var(--green), + var(--blue), + var(--purple), + var(--pink), + var(--red) + ); + background-origin: border-box; + background-clip: padding-box, border-box, border-box; + background-size: 200%; + animation: animate 8s infinite linear; + transition-duration: 0.3s; + z-index: 1; + } + + .links { + display: flex; + gap: 80px; + } + + a::before { + content: ''; + background: linear-gradient( + 90deg, + var(--orange), + var(--yellow), + var(--green), + var(--blue), + var(--purple), + var(--pink), + var(--red) + ); + height: 30%; + width: 90%; + position: absolute; + bottom: 0; + background-size: 200%; + animation: animate 8s infinite linear; + filter: blur(calc(0.8 * var(--m))); + } + + a:hover span { + border: calc(0.08 * var(--m)) solid transparent; + transform: perspective(1000px) translateZ(50px); + transform-style: preserve-3d; + } + + a:hover::before { + height: 100%; + width: 120%; + filter: blur(calc(1.5 * var(--m))); + opacity: 0.8; + } + + a[aria-disabled] { + /* pointer-events: none; */ + cursor: not-allowed; + + span { + background: #aaa; + color: #ccc; + border: none; + } + + &::before { + display: none; + } + + &:hover span { + transform: none; + } + + /* opacity: 0.6; */ + } + + @keyframes animate { + 0% { + background-position: 0; + } + + 100% { + background-position: 200%; + } + } + + @media (width <= 640px) { + background-image: url('./fiber_network_640.gif'); + height: 150px; + font-size: 0.6rem; + + .slogan { + margin-bottom: 10px; + } + + .links { + gap: 16px; + } + + --m: 0.6rem; + } +} + .halvingBannerSuccess { background-size: cover; background-position: center; diff --git a/src/pages/Home/Banner/index.tsx b/src/pages/Home/Banner/index.tsx index b9996237b..686576b48 100644 --- a/src/pages/Home/Banner/index.tsx +++ b/src/pages/Home/Banner/index.tsx @@ -1,14 +1,18 @@ import { useQuery } from '@tanstack/react-query' import { BarChartIcon } from '@radix-ui/react-icons' +import { useTranslation } from 'react-i18next' +import { Tooltip } from 'antd' import { Link } from '../../../components/Link' import config from '../../../config' import styles from './index.module.scss' import { getKnowledgeSize } from './utils' import { NumberTicker } from '../../../components/ui/NumberTicker' +import { IS_MAINNET } from '../../../constants/common' const { BACKUP_NODES: backupNodes } = config export default () => { + const [t] = useTranslation() const { data: size } = useQuery( ['backnode_tip_header'], async () => { @@ -24,18 +28,45 @@ export default () => { }, { refetchInterval: 12 * 1000 }, ) + if (IS_MAINNET) { + return ( +
+
+ Knowledge Size +
+
+ + CKBytes + + + +
+
+
+ ) + } + return ( -
-
- Knowledge Size -
-
- - CKBytes - - +
+
+

{t(`banner.fiber_title`)}

+

{t(`banner.fiber_subtitle`)}

+
+
+ + {t(`banner.learn_more`)} + + + ) => { + e.preventDefault() + }} + > + {t('banner.find_nodes')} -
+
) diff --git a/src/pages/RGBPP/TransactionList/Chart/index.tsx b/src/pages/RGBPP/TransactionList/Chart/index.tsx index 62308d5f8..6a6046e88 100644 --- a/src/pages/RGBPP/TransactionList/Chart/index.tsx +++ b/src/pages/RGBPP/TransactionList/Chart/index.tsx @@ -90,7 +90,7 @@ const useOption = ( name: t('statistic.new_btc_address'), }, { - name: t('statistic.transaction_num'), + name: t('statistic.btc_transaction_count'), }, ], }, @@ -120,7 +120,7 @@ const useOption = ( }, { position: 'right', - name: isXXL || isThumbnail ? '' : t('statistic.transaction_num'), + name: isXXL || isThumbnail ? '' : t('statistic.btc_transaction_count'), nameTextStyle: { align: 'right', }, @@ -143,7 +143,7 @@ const useOption = ( data: dataset.map(data => data.addressesCount + 1), }, { - name: t('statistic.transaction_num'), + name: t('statistic.btc_transaction_count'), type: 'line', yAxisIndex: 1, symbol: isThumbnail ? 'none' : 'circle', diff --git a/src/pages/StatisticsChart/activities/ContractResourceDistributed.tsx b/src/pages/StatisticsChart/activities/ContractResourceDistributed.tsx index 769e17c32..f9ee3d22b 100644 --- a/src/pages/StatisticsChart/activities/ContractResourceDistributed.tsx +++ b/src/pages/StatisticsChart/activities/ContractResourceDistributed.tsx @@ -59,7 +59,7 @@ const useOption = ( ${Number(ckbAmount).toLocaleString('en')} - ${t('statistic.transaction_num')}: + ${t('statistic.transaction_count')}: ${Number(txCount).toLocaleString('en')} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 2fbe2276c..a88663c4a 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -81,6 +81,14 @@ const Hasher = lazy(() => import('../pages/Tools/Hasher')) const BroadcastTx = lazy(() => import('../pages/Tools/BroadcastTx')) const CamelCase = lazy(() => import('../pages/Tools/CamelCase')) 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 FiberGraphNode = lazy(() => import('../pages/Fiber/GraphNode')) +const FiberGraphChannelList = lazy(() => import('../pages/Fiber/GraphChannelList')) +// ====== const routes: RouteProps[] = [ { @@ -346,6 +354,30 @@ const routes: RouteProps[] = [ path: '/tools/molecule-parser', component: MoleculeParser, }, + { + path: '/fiber/peers', + component: FiberPeerList, + }, + { + path: '/fiber/peers/:id', + component: FiberPeer, + }, + { + path: '/fiber/channels/:id', + component: FiberChannel, + }, + { + path: '/fiber/graph/nodes', + component: FiberGraphNodeList, + }, + { + path: '/fiber/graph/node/:id', + component: FiberGraphNode, + }, + { + path: '/fiber/graph/channels', + component: FiberGraphChannelList, + }, ] type PageErrorBoundaryState = { diff --git a/src/services/ExplorerService/fetcher.ts b/src/services/ExplorerService/fetcher.ts index bd68e347e..9f0ed87e8 100644 --- a/src/services/ExplorerService/fetcher.ts +++ b/src/services/ExplorerService/fetcher.ts @@ -70,6 +70,7 @@ export enum SearchResultType { TokenItem = 'token_item', DID = 'did', BtcAddress = 'bitcoin_address', + FiberGraphNode = 'fiber_graph_node', } enum SearchQueryType { @@ -113,6 +114,14 @@ export type AggregateSearchResult = }, SearchResultType.BtcAddress > + | Response.Wrapper< + { + alias: string + nodeId: string + peerId: string + }, + SearchResultType.FiberGraphNode + > export const getBtcTxList = (idList: string[]): Promise> => { if (idList.length === 0) return Promise.resolve({}) @@ -1195,6 +1204,108 @@ export const apiFetcher = { }, }), getBtcTxList, + + // ================== + // Fiber + // ================== + getFiberPeerList: (page = 1, pageSize = 10) => { + return requesterV2 + .get( + `/fiber/peers?${new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + })}`, + ) + .then(res => + toCamelcase< + Response.Response<{ + fiberPeers: Fiber.Peer.ItemInList[] + meta: { + total: number + pageSize: number + } + }> + >(res.data), + ) + }, + + getFiberPeerDetail: (id: string) => { + return requesterV2 + .get(`/fiber/peers/${id}`) + .then(res => toCamelcase>(res.data)) + }, + + getFiberChannel: (id: string) => { + return requesterV2 + .get(`/fiber/channels/${id}`) + .then(res => toCamelcase>(res.data)) + }, + + addFiberPeer: (params: { rpc: string; id: string; name?: string }) => { + return requesterV2 + .post(`/fiber/peers`, { + name: params.name, + rpc_listening_addr: params.rpc, + peer_id: params.id, + }) + .catch(e => { + if (Array.isArray(e.response?.data)) { + const res = e.response.data[0] + if (res) { + throw new Error(res.title) + } + } + 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(), + status: 'open', + })}`, + ) + .then(res => + toCamelcase< + Response.Response<{ + fiberGraphChannels: Fiber.Graph.Channel[] + meta: { + total: number + pageSize: number + } + }> + >(res.data), + ) + }, } // ==================== @@ -1386,3 +1497,114 @@ export interface RGBTransaction { rgbCellChanges: number rgbTxid: string } + +export namespace Fiber { + export namespace Peer { + interface Base { + peerId: string + rpcListeningAddr: string[] + firstChannelOpenedAt: null // TODO + lastChannelUpdatedAt: null // TODO + } + export interface ItemInList extends Base { + name: string + channelsCount: number + totalLocalBalance: string // shannon amount + } + + export interface Detail extends Base { + fiberChannels: { + peerId: string + channelId: string + stateName: string // TODO: should be enum + stateFlags: [] // TODO + }[] + } + } + export namespace Channel { + export interface Peer { + name?: string + peerId: string + rpcListeningAddr: string[] + } + export interface Detail { + channelId: string + stateName: string // TODO should be name + stateFlags: [] // TODO + shutdownAt: null // TODO + createdAt: string // utc time + updatedAt: string // utc time + localBalance: string // shannon + offeredTlcBalance: string // shannon + receivedTlcBalance: string // shannon + remoteBalance: string // shannon + localPeer: Peer + remotePeer: Peer + } + } + + export namespace Graph { + interface UdtConfigInfo { + args: string + codeHash: string + hashType: HashType + decimal?: number + fullName?: string + iconFile?: string + symbol?: string + autoAcceptAmount: string + } + + interface OpenTransactionInfo { + address: string + blockNumber: number + blockTimestamp: number + capacity: string + txHash: string + udtAmount?: string + } + + interface ClosedTransactionInfo { + blockNumber: number + blockTimestamp: number + txHash: string + closeAccounts: { + address: string + capacity: string + udtAmount: string | null + }[] + } + + export interface Node { + alias: string + nodeId: string + addresses: string[] + timestamp: string + chainHash: string + autoAcceptMinCkbFundingAmount: string + udtCfgInfos: UdtConfigInfo[] + totalCapacity: string + connectedNodeIds: string[] + openChannelsCount: number + } + + export interface Channel { + channelOutpoint: string + node1: string + node2: string + chainHash: string + fundingTxBlockNumber: string + fundingTxIndex: string // number + lastUpdatedTimestamp: string + node1ToNode2FeeRate: string + node2ToNode1FeeRate: string + capacity: string + openTransactionInfo: OpenTransactionInfo + closedTransactionInfo: ClosedTransactionInfo + } + + export interface NodeDetail extends Node { + fiberGraphChannels: Channel[] + } + } +} diff --git a/src/styles/card.module.scss b/src/styles/card.module.scss new file mode 100644 index 000000000..6d5d707bd --- /dev/null +++ b/src/styles/card.module.scss @@ -0,0 +1,6 @@ +%base-card { + background: #fff; + border-radius: 6px; + padding: 16px; + box-shadow: 0 2px 6px 0 #4d4d4d33; +} diff --git a/src/styles/table.module.scss b/src/styles/table.module.scss new file mode 100644 index 000000000..c5052bc78 --- /dev/null +++ b/src/styles/table.module.scss @@ -0,0 +1,43 @@ +%base-table { + width: 100%; + text-align: left; + cursor: default; + overflow: hidden; + border-radius: 6px; + box-shadow: rgb(0 0 0 / 12%) 0 2px 6px 0; + font-size: 0.875rem; + + tr { + background: #fff; + } + + td, + th { + padding: 8px; + padding-right: 1rem; + + &:first-child { + padding-left: 40px; + } + + &:last-child { + text-align: right; + } + } + + thead { + th { + height: 3.5rem; + } + } + + tbody { + tr:hover { + background: #ccc; + } + } + + .tableSeparator { + height: 4px; + } +} diff --git a/src/styles/text.module.scss b/src/styles/text.module.scss new file mode 100644 index 000000000..cbb2415db --- /dev/null +++ b/src/styles/text.module.scss @@ -0,0 +1,21 @@ +%monospace { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; +} + +%hash { + @extend %monospace; + + display: flex; + align-items: center; + overflow: hidden; + user-select: none; + + div { + font-family: inherit; + } + + div:first-child { + overflow: hidden; + text-overflow: ellipsis; + } +}