From 26fc5844931b9dabe1cbee2eaae20bf2a55ac3a8 Mon Sep 17 00:00:00 2001 From: Nitesh Balusu <84944042+niteshbalusu11@users.noreply.github.com> Date: Tue, 8 Nov 2022 09:53:49 -0500 Subject: [PATCH 1/2] Add pending htlc/channel monitoring Fixes #125 --- .../dashboard/DefaultDashboardContainer.tsx | 18 ++ src/client/dashboard/PendingChart.tsx | 37 ++++ .../app-components/BasicTable.tsx | 43 ++++ .../app-components/index.ts | 4 +- .../commands/grpc_utils/format_tokens.ts | 33 +++ src/server/commands/grpc_utils/get_pending.ts | 195 ++++++++++++++++++ src/server/commands/grpc_utils/icons.ts | 20 ++ src/server/commands/grpc_utils/index.ts | 3 + .../commands/grpc_utils/pending_payments.ts | 103 +++++++++ .../commands/grpc_utils/pending_summary.ts | 166 +++++++++++++++ src/server/modules/grpc/grpc.controller.ts | 25 ++- src/server/modules/grpc/grpc.service.ts | 29 ++- src/shared/commands.dto.ts | 6 + 13 files changed, 661 insertions(+), 21 deletions(-) create mode 100644 src/client/dashboard/PendingChart.tsx create mode 100644 src/client/standard_components/app-components/BasicTable.tsx create mode 100644 src/server/commands/grpc_utils/format_tokens.ts create mode 100644 src/server/commands/grpc_utils/get_pending.ts create mode 100644 src/server/commands/grpc_utils/icons.ts create mode 100644 src/server/commands/grpc_utils/index.ts create mode 100644 src/server/commands/grpc_utils/pending_payments.ts create mode 100644 src/server/commands/grpc_utils/pending_summary.ts diff --git a/src/client/dashboard/DefaultDashboardContainer.tsx b/src/client/dashboard/DefaultDashboardContainer.tsx index 0d1311d..103815f 100644 --- a/src/client/dashboard/DefaultDashboardContainer.tsx +++ b/src/client/dashboard/DefaultDashboardContainer.tsx @@ -2,6 +2,7 @@ import { Box, Container, Grid, Paper, Toolbar } from '@mui/material'; import BalanceInfo from './BalanceInfo'; import NodeInfo from '~client/dashboard/NodeInfo'; +import PendingChart from './PendingChart'; import RoutingFeeChart from './RoutingFeeChart'; // Renders the default dashboard on page load. @@ -33,6 +34,7 @@ const DefaultDashboardContainer = () => { + {/* Walletinfo */} { + + {/* Pending Chart */} + + + + + + {/* Chart */} { + const [data, setData] = useState(undefined); + + useEffect(() => { + const fetchData = async () => { + const postBody = { + node: selectedSavedNode(), + }; + + useLoading({ isLoading: true }); + const result = await axiosPost({ path: 'grpc/get-pending', postBody }); + console.log(result); + if (!!result) { + setData(result); + } + + useLoading({ isLoading: false }); + }; + + fetchData(); + }, []); + + resgisterCharts(); + return !!data && !!data.length ? : null; +}; + +export default PendingChart; diff --git a/src/client/standard_components/app-components/BasicTable.tsx b/src/client/standard_components/app-components/BasicTable.tsx new file mode 100644 index 0000000..c8b07d8 --- /dev/null +++ b/src/client/standard_components/app-components/BasicTable.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; + +import Paper from '@mui/material/Paper'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; + +function createData(data: string) { + return { data }; +} + +type Args = { + rows: string[]; +}; +const BasicTable = ({ rows }: Args) => { + const data = rows.map(n => createData(n)); + + return ( + + + + + Pending Things + + + + {data.map(row => ( + + + {row.data} + + + ))} + +
+
+ ); +}; + +export default BasicTable; diff --git a/src/client/standard_components/app-components/index.ts b/src/client/standard_components/app-components/index.ts index 6a1e0cd..9a9777c 100644 --- a/src/client/standard_components/app-components/index.ts +++ b/src/client/standard_components/app-components/index.ts @@ -1,4 +1,5 @@ import BasicDatePicker from './BasicDatePicker'; +import BasicTable from './BasicTable'; import CenterFlexBox from './CenterFlexBox'; import ContainerStyle from './ContainerStyle'; import CopyText from './CopyText'; @@ -18,10 +19,11 @@ import StartFlexBoxBlack from './StartFlexBoxBlack'; import Startup from './Startup'; import SubmitButton from './SubmitButton'; export { + BasicDatePicker, + BasicTable, CenterFlexBox, ContainerStyle, CopyText, - BasicDatePicker, PositionedMenu, ProgressBar, ReactCron, diff --git a/src/server/commands/grpc_utils/format_tokens.ts b/src/server/commands/grpc_utils/format_tokens.ts new file mode 100644 index 0000000..8444802 --- /dev/null +++ b/src/server/commands/grpc_utils/format_tokens.ts @@ -0,0 +1,33 @@ +const fullTokensType = 'full'; +const isString = (n: any) => typeof n === 'string'; +const tokensAsBigUnit = (tokens: number) => (tokens / 1e8).toFixed(8); + +/** Format tokens for display + { + [none]: + tokens: + } + @returns + { + display: + } +*/ + +type Args = { + none?: string; + tokens: number; +}; +const formatTokens = ({ none, tokens }: Args) => { + if (isString(none) && !tokens) { + return { display: none }; + } + + // Exit early for tokens environment displays the value with no leading zero + if (process.env.PREFERRED_TOKENS_TYPE === fullTokensType) { + return { display: tokens.toLocaleString() }; + } + + return { display: tokensAsBigUnit(tokens) }; +}; + +export default formatTokens; diff --git a/src/server/commands/grpc_utils/get_pending.ts b/src/server/commands/grpc_utils/get_pending.ts new file mode 100644 index 0000000..381f19f --- /dev/null +++ b/src/server/commands/grpc_utils/get_pending.ts @@ -0,0 +1,195 @@ +import { AuthenticatedLnd, getChannels, getHeight, getPendingChannels } from 'lightning'; +import { auto, map } from 'async'; + +import { getNodeAlias } from 'ln-sync'; +import pendingPayments from './pending_payments'; +import pendingSummary from './pending_summary'; + +const uniq = arr => Array.from(new Set(arr)); + +/** Handle pending command + { + lnd: + } + @returns Promise + { + count: + htlcs: [{ + forwarding: [{ + fee: + in_peer: + out_peer: + tokens: + }] + from: + nodes: [{ + alias: + id: + }] + sending: [{ + out_peer: + }] + }] + pending: [{ + closing: [{ + partner_public_key: + pending_balance: + timelock_expiration: + }] + from: + height: + nodes: [{ + alias: + id: + }] + opening: [{ + is_partner_initiated: + local_balance: + partner_public_key: + remote_balance: + transaction_fee: + transaction_id: + }] + }] + } +*/ +type Args = { + lnd: AuthenticatedLnd; +}; +const getPending = async ({ lnd }: Args) => { + return ( + await auto({ + // Check arguments + validate: (cbk: any) => { + return cbk(); + }, + + // Get HTLCs in channels + getHtlcs: [ + 'validate', + ({}, cbk: any) => { + const nodes = [{ lnd }]; + return map( + nodes, + ({ lnd }, cbk: any) => { + return getChannels({ lnd }, (err, res) => { + if (!!err) { + return cbk(err); + } + + const { forwarding, sending } = pendingPayments({ + channels: res.channels, + }); + + const peers = [] + .concat(forwarding.map(n => n.in_peer)) + .concat(forwarding.map(n => n.out_peer)) + .concat(sending.map(n => n.out_peer)); + + return map( + uniq(peers), + (id, cbk) => { + return getNodeAlias({ id, lnd }, cbk); + }, + (err, nodes) => { + if (!!err) { + return cbk(err); + } + + return cbk(null, { forwarding, nodes, sending }); + } + ); + }); + }, + cbk + ); + }, + ], + + // Get pending channels + getPending: [ + 'validate', + ({}, cbk: any) => { + const nodes = [{ lnd }]; + + return map( + nodes, + ({ lnd }, cbk: any) => { + return getPendingChannels({ lnd }, (err, res) => { + if (!!err) { + return cbk(err); + } + + // Pending closing channels + const closing = res.pending_channels + .filter(n => !!n.is_closing) + .map(channel => ({ + close_transaction_id: channel.close_transaction_id, + is_partner_initiated: channel.is_partner_initiated, + partner_public_key: channel.partner_public_key, + pending_balance: channel.pending_balance, + timelock_expiration: channel.timelock_expiration, + transaction_id: channel.transaction_id, + })); + + // Pending opening channels + const opening = res.pending_channels + .filter(n => !!n.is_opening) + .map(channel => ({ + is_partner_initiated: channel.is_partner_initiated, + local_balance: channel.local_balance, + partner_public_key: channel.partner_public_key, + remote_balance: channel.remote_balance, + transaction_fee: channel.transaction_fee, + transaction_id: channel.transaction_id, + })); + + const peers = [] + .concat(closing.map(n => n.partner_public_key)) + .concat(opening.map(n => n.partner_public_key)); + + return map( + uniq(peers), + (id, cbk) => { + return getNodeAlias({ id, lnd }, cbk); + }, + (err, nodes) => { + if (!!err) { + return cbk(err); + } + + return getHeight({ lnd }, (err, res) => { + if (!!err) { + return cbk(err); + } + + const height = res.current_block_height; + + return cbk(null, { closing, height, nodes, opening }); + }); + } + ); + }); + }, + cbk + ); + }, + ], + // Notify of pending forwards and channels + notify: [ + 'getHtlcs', + 'getPending', + async ({ getHtlcs, getPending }) => { + const summary = pendingSummary({ + htlcs: getHtlcs, + pending: getPending, + }); + + return summary; + }, + ], + }) + ).notify; +}; + +export default getPending; diff --git a/src/server/commands/grpc_utils/icons.ts b/src/server/commands/grpc_utils/icons.ts new file mode 100644 index 0000000..2abba26 --- /dev/null +++ b/src/server/commands/grpc_utils/icons.ts @@ -0,0 +1,20 @@ +const icons = { + balanced_open: '⚖ī¸', + block: '⏚', + bot: '🤖', + chain: '⛓', + closing: 'âŗ', + disconnected: 'đŸ˜ĩ', + earn: '💰', + forwarding: '💸', + info: 'ℹī¸', + liquidity: '🌊', + opening: 'âŗ', + probe: 'đŸ‘Ŋ', + rebalance: '☯ī¸', + receive: 'đŸ’ĩ', + spent: '⚡ī¸', + warning: '⚠ī¸', +}; + +export default icons; diff --git a/src/server/commands/grpc_utils/index.ts b/src/server/commands/grpc_utils/index.ts new file mode 100644 index 0000000..7664679 --- /dev/null +++ b/src/server/commands/grpc_utils/index.ts @@ -0,0 +1,3 @@ +import getPending from './get_pending'; + +export { getPending }; diff --git a/src/server/commands/grpc_utils/pending_payments.ts b/src/server/commands/grpc_utils/pending_payments.ts new file mode 100644 index 0000000..84aebe9 --- /dev/null +++ b/src/server/commands/grpc_utils/pending_payments.ts @@ -0,0 +1,103 @@ +const flatten = arr => [].concat(...arr); + +/** Derive pending forwards frrom a list of pending payments + { + channels: [{ + id: + partner_public_key: + pending_payments: [{ + id: + [in_channel]: + [in_payment]: + [is_forward]: + is_outgoing: + [out_channel]: + [out_payment]: + [payment]: + timeout: + tokens: + }] + }] + } + @returns + { + forwarding: [{ + fee: + in_peer: + out_peer: + payment: + timeout: + tokens: + }] + sending: [{ + out_channel: + out_peer: + timeout: + tokens: + }] + } +*/ +const pendingPayments = ({ channels }) => { + // Collect all the outbound type HTLCs + const sending = flatten( + channels.map(channel => { + return (channel.pending_payments || []) + .filter(n => !n.is_forward && !!n.is_outgoing) + .map(payment => ({ + out_channel: channel.id, + out_peer: channel.partner_public_key, + timeout: payment.timeout, + tokens: payment.tokens, + })); + }) + ); + + // Collect all the forwarding type HTLCs + const forwards = flatten( + channels.map(channel => { + return (channel.pending_payments || []) + .filter(n => !!n.is_forward) + .filter(n => !!n.in_channel || !!n.out_channel) + .filter(n => !!n.in_payment || !!n.out_payment) + .map(payment => ({ + channel: channel.id, + id: payment.id, + in_channel: payment.in_channel, + in_payment: payment.in_payment, + is_outgoing: payment.is_outgoing, + payment: payment.payment, + timeout: payment.timeout, + tokens: payment.tokens, + })); + }) + ); + + // Outbound forwarding HTLCs + const outbound = forwards + // Outbound forwards have inbound channels and inbound payment indexes + .filter(n => !!n.in_channel && !!n.in_payment && !!n.is_outgoing) + // Only evaluate forwards where the inbound channel exists in channels + .filter(htlc => channels.find(channel => channel.id === htlc.in_channel)) + // Only evaluate forwards wherre the inbound payment exists in the payments + .filter(htlc => forwards.find(n => n.payment === htlc.in_payment)); + + // HTLCs that are being routed + const forwarding = outbound.map(htlc => { + const inboundChannel = channels.find(n => n.id === htlc.in_channel); + const inboundPayment = forwards.find(n => n.payment === htlc.in_payment); + const outboundChannel = channels.find(n => n.id === htlc.channel); + + return { + fee: inboundPayment.tokens - htlc.tokens, + in_peer: inboundChannel.partner_public_key, + out_peer: outboundChannel.partner_public_key, + payment: htlc.id, + timeout: inboundPayment.timeout, + tokens: htlc.tokens, + }; + }); + + return { forwarding, sending }; +}; + +export default pendingPayments; diff --git a/src/server/commands/grpc_utils/pending_summary.ts b/src/server/commands/grpc_utils/pending_summary.ts new file mode 100644 index 0000000..30c7ed0 --- /dev/null +++ b/src/server/commands/grpc_utils/pending_summary.ts @@ -0,0 +1,166 @@ +import { DateTime } from 'luxon'; +import formatTokens from './format_tokens'; +import icons from './icons'; + +const asRelative = n => n.toRelative({ locale: 'en' }); +const blocksAsEpoch = blocks => Date.now() + blocks * 1000 * 60 * 10; +const escape = text => text.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, '\\$&'); +const flatten = arr => [].concat(...arr); +const fromNow = ms => (!ms ? undefined : DateTime.fromMillis(ms)); +const nodeAlias = (alias, id) => `${alias} ${id.substring(0, 8)}`.trim(); +const sumOf = arr => arr.reduce((sum, n) => sum + n, Number()); +const uniq = arr => Array.from(new Set(arr)); + +/** Notify of pending channels and HTLCs + { + count: + htlcs: [{ + forwarding: [{ + fee: + in_peer: + out_peer: + tokens: + }] + from: + nodes: [{ + alias: + id: + }] + sending: [{ + out_peer: + }] + }] + pending: [{ + closing: [{ + partner_public_key: + pending_balance: + timelock_expiration: + }] + from: + height: + nodes: [{ + alias: + id: + }] + opening: [{ + is_partner_initiated: + local_balance: + partner_public_key: + remote_balance: + transaction_fee: + transaction_id: + }] + }] + } + @returns + +*/ +const pendingSummary = ({ htlcs, pending }) => { + // Pending closing and opening channels + const channels = pending.map(node => { + // Opening channels, waiting for confirmation + const openingChannels = node.opening.map(opening => { + const direction = !!opening.is_partner_initiated ? 'in' : 'out'; + const funds = [opening.local_balance, opening.remote_balance]; + const peerId = opening.partner_public_key; + const tx = opening.transaction_id; + const waiting = `${icons.opening} Waiting`; + + const capacity = sumOf(funds.concat(opening.transaction_fee)); + const peer = node.nodes.find(n => n.id === peerId); + + const alias = escape(nodeAlias(peer.alias, peer.id)); + const channel = `${formatTokens({ tokens: capacity }).display} channel`; + + const action = `${direction}bound ${channel}`; + + return `${waiting} for ${action} with ${alias} to confirm: \`${tx}\``; + }); + + // Closing channels, waiting for coins to return + const waitingOnFunds = node.closing + .filter(n => !!n.timelock_expiration && !!n.pending_balance) + .filter(n => n.timelock_expiration > node.height) + .map(closing => { + const funds = formatTokens({ tokens: closing.pending_balance }).display; + const peerId = closing.partner_public_key; + const waitBlocks = closing.timelock_expiration - node.height; + const waiting = `${icons.closing} Waiting`; + + const peer = node.nodes.find(n => n.id === peerId); + const time = asRelative(fromNow(blocksAsEpoch(waitBlocks))); + + const action = `recover ${funds} ${time} from closing channel`; + const alias = nodeAlias(peer.alias, peer.id); + + return `${waiting} to ${action} with ${alias}`; + }); + + return { + from: node.from, + channels: flatten([].concat(openingChannels).concat(waitingOnFunds)), + }; + }); + + // HTLCs in flight for probing or for forwarding + const payments = htlcs.map(node => { + // Forwarding an HTLC in one peer and out another + const forwarding = node.forwarding.map(forward => { + const fee = formatTokens({ tokens: forward.fee }).display; + const from = node.nodes.find(n => n.id === forward.in_peer); + const to = node.nodes.find(n => n.id === forward.out_peer); + const tokens = formatTokens({ tokens: forward.tokens }).display; + + const action = `${tokens} for ${fee} fee`; + const forwarding = `${icons.forwarding} Forwarding`; + const inPeer = nodeAlias(from.alias, from.id); + const outPeer = nodeAlias(to.alias, to.id); + + return `${forwarding} ${action} from ${inPeer} to ${outPeer}`; + }); + + // Probing out peers + const probes = uniq(node.sending.map(n => n.out_peer)).map(key => { + const out = node.nodes.find(n => n.id === key); + + return nodeAlias(out.alias, out.id); + }); + + const probing = !probes.length ? [] : [`${icons.probe} Probing out ${probes.join(', ')}`]; + + return { from: node.from, payments: [].concat(forwarding).concat(probing) }; + }); + + const nodes = []; + + // Pending channels for a node + channels + .filter(node => !!node.channels.length) + .forEach(node => { + return node.channels.forEach(item => nodes.push({ item, from: node.from })); + }); + + // Pending payments for a node + payments + .filter(n => !!n.payments.length) + .forEach(node => { + return node.payments.forEach(item => nodes.push({ item, from: node.from })); + }); + + // Exit early when there is nothing pending for any nodes + if (!nodes.length) { + return [`${icons.bot} No pending payments or channels`]; + } + + const sections = uniq(nodes.map(n => n.from)); + + return flatten( + sections.map(from => { + const title = []; + + return title.concat(nodes.filter(n => n.from === from).map(n => n.item)); + }) + ); +}; + +export default pendingSummary; diff --git a/src/server/modules/grpc/grpc.controller.ts b/src/server/modules/grpc/grpc.controller.ts index 6f69b11..bfcc28d 100644 --- a/src/server/modules/grpc/grpc.controller.ts +++ b/src/server/modules/grpc/grpc.controller.ts @@ -1,11 +1,16 @@ -import { Controller, Get, Query } from '@nestjs/common'; -import { grpcDto } from '~shared/commands.dto'; +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { getPendingDto, grpcDto } from '~shared/commands.dto'; import { GrpcService } from './grpc.service'; @Controller() export class GrpcController { constructor(private grpcService: GrpcService) {} + @Get('api/grpc/get-channel-balance') + async getChannelBalance(@Query() args: grpcDto) { + return this.grpcService.getChannelBalance(args); + } + @Get('api/grpc/get-peers') async getPeers(@Query() args: grpcDto) { return this.grpcService.getPeers(args); @@ -16,18 +21,18 @@ export class GrpcController { return this.grpcService.getPeersAllNodes(); } - @Get('api/grpc/get-wallet-info') - async getWalletInfo(@Query() args: grpcDto) { - return this.grpcService.getWalletInfo(args); - } - - @Get('api/grpc/get-channel-balance') - async getChannelBalance(@Query() args: grpcDto) { - return this.grpcService.getChannelBalance(args); + @Post('api/grpc/get-pending') + async getPending(@Body() args: getPendingDto) { + return this.grpcService.getPending(args); } @Get('api/grpc/get-saved-nodes') async getSavedNodes() { return this.grpcService.getSavedNodes(); } + + @Get('api/grpc/get-wallet-info') + async getWalletInfo(@Query() args: grpcDto) { + return this.grpcService.getWalletInfo(args); + } } diff --git a/src/server/modules/grpc/grpc.service.ts b/src/server/modules/grpc/grpc.service.ts index a749dbe..31076f6 100644 --- a/src/server/modules/grpc/grpc.service.ts +++ b/src/server/modules/grpc/grpc.service.ts @@ -1,11 +1,12 @@ import { AuthenticatedLnd, GetChannelBalanceResult, GetWalletInfoResult } from 'lightning'; import { channelBalance, walletInfo } from '~server/commands/grpc_utils/grpc_utils'; +import { getPendingDto, grpcDto } from '~shared/commands.dto'; import { Injectable } from '@nestjs/common'; import { LndService } from '../lnd/lnd.service'; import getPeers from '~server/commands/grpc_utils/get_peers'; +import { getPending } from '~server/commands/grpc_utils'; import { getSavedNodes } from '~server/lnd'; -import { grpcDto } from '~shared/commands.dto'; import { map } from 'async'; type GetPeersArgs = { @@ -14,6 +15,13 @@ type GetPeersArgs = { @Injectable() export class GrpcService { + async getChannelBalance(args: grpcDto): Promise<{ result: GetChannelBalanceResult }> { + const lnd = await LndService.authenticatedLnd({ node: args.node }); + + const { result } = await channelBalance({ lnd }); + return { result }; + } + async getPeers(args: GetPeersArgs): Promise { const lnd = await LndService.authenticatedLnd({ node: args.node }); @@ -37,22 +45,23 @@ export class GrpcService { return { result }; } - async getWalletInfo(args: grpcDto): Promise<{ result: GetWalletInfoResult }> { + async getPending(args: getPendingDto): Promise { const lnd = await LndService.authenticatedLnd({ node: args.node }); - const { result } = await walletInfo({ lnd }); - return { result }; - } + const result = await getPending({ lnd }); - async getChannelBalance(args: grpcDto): Promise<{ result: GetChannelBalanceResult }> { - const lnd = await LndService.authenticatedLnd({ node: args.node }); - - const { result } = await channelBalance({ lnd }); - return { result }; + return result; } async getSavedNodes(): Promise<{ result: any }> { const nodes = await getSavedNodes({}); return { result: nodes.nodes }; } + + async getWalletInfo(args: grpcDto): Promise<{ result: GetWalletInfoResult }> { + const lnd = await LndService.authenticatedLnd({ node: args.node }); + + const { result } = await walletInfo({ lnd }); + return { result }; + } } diff --git a/src/shared/commands.dto.ts b/src/shared/commands.dto.ts index 88bae5a..ee9f696 100644 --- a/src/shared/commands.dto.ts +++ b/src/shared/commands.dto.ts @@ -432,6 +432,12 @@ export class forwardsDto { to: string; } +export class getPendingDto { + @IsOptional() + @IsString() + node: string; +} + export class graphDto { @Transform(({ value }) => toStringArray(value)) @IsOptional() From dd3e15da787a6f1979164a72458c45fefb1da6c0 Mon Sep 17 00:00:00 2001 From: Nitesh Balusu <84944042+niteshbalusu11@users.noreply.github.com> Date: Tue, 8 Nov 2022 09:58:16 -0500 Subject: [PATCH 2/2] make table title dynamic --- src/client/dashboard/PendingChart.tsx | 2 +- .../standard_components/app-components/BasicTable.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/client/dashboard/PendingChart.tsx b/src/client/dashboard/PendingChart.tsx index 16388b7..b827c84 100644 --- a/src/client/dashboard/PendingChart.tsx +++ b/src/client/dashboard/PendingChart.tsx @@ -31,7 +31,7 @@ const PendingChart = () => { }, []); resgisterCharts(); - return !!data && !!data.length ? : null; + return !!data && !!data.length ? : null; }; export default PendingChart; diff --git a/src/client/standard_components/app-components/BasicTable.tsx b/src/client/standard_components/app-components/BasicTable.tsx index c8b07d8..0472f46 100644 --- a/src/client/standard_components/app-components/BasicTable.tsx +++ b/src/client/standard_components/app-components/BasicTable.tsx @@ -8,14 +8,17 @@ import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; +// Creates a basic table with rows and title. + function createData(data: string) { return { data }; } type Args = { rows: string[]; + title: string; }; -const BasicTable = ({ rows }: Args) => { +const BasicTable = ({ rows, title }: Args) => { const data = rows.map(n => createData(n)); return ( @@ -23,7 +26,7 @@ const BasicTable = ({ rows }: Args) => { - Pending Things + {title}