diff --git a/app/src/__stories__/ChannelBalance.stories.tsx b/app/src/__stories__/ChannelBalance.stories.tsx new file mode 100644 index 00000000..c4905e27 --- /dev/null +++ b/app/src/__stories__/ChannelBalance.stories.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { BalanceLevel } from 'types/state'; +import { StoryContext } from '@storybook/addons'; +import { Store } from 'store'; +import ChannelBalance from 'components/loop/ChannelBalance'; + +export default { + title: 'Channel Balance', + component: ChannelBalance, + parameters: { centered: true }, +}; + +export const Good = (ctx: StoryContext) => { + // grab the store from the Storybook parameter defined in preview.tsx + const store = ctx.parameters.store as Store; + const channel = { + ...store.channels[0], + localPercent: 59, + balanceLevel: BalanceLevel.good, + }; + return ; +}; + +export const Warn = (ctx: StoryContext) => { + // grab the store from the Storybook parameter defined in preview.tsx + const store = ctx.parameters.store as Store; + const channel = { + ...store.channels[0], + localPercent: 28, + balanceLevel: BalanceLevel.warn, + }; + return ; +}; + +export const Bad = (ctx: StoryContext) => { + // grab the store from the Storybook parameter defined in preview.tsx + const store = ctx.parameters.store as Store; + const channel = { + ...store.channels[0], + localPercent: 91, + balanceLevel: BalanceLevel.bad, + }; + return ; +}; + +export const Inactive = (ctx: StoryContext) => { + // grab the store from the Storybook parameter defined in preview.tsx + const store = ctx.parameters.store as Store; + const channel = { + ...store.channels[0], + active: false, + }; + return ; +}; diff --git a/app/src/__stories__/ChannelList.stories.tsx b/app/src/__stories__/ChannelList.stories.tsx index 757d534a..9626c267 100644 --- a/app/src/__stories__/ChannelList.stories.tsx +++ b/app/src/__stories__/ChannelList.stories.tsx @@ -1,5 +1,4 @@ -import React, { useEffect } from 'react'; -import { toJS } from 'mobx'; +import React from 'react'; import { StoryContext } from '@storybook/addons'; import { Store } from 'store'; import ChannelList from 'components/loop/ChannelList'; @@ -10,45 +9,27 @@ export default { parameters: { contained: true }, }; -export const NoChannels = (ctx: StoryContext) => { - // grab the store from the Storybook parameter defined in preview.tsx - const store = ctx.parameters.store as Store; - - useEffect(() => { - // convert the store state to pure JS so it can be reverted on unmount - const channels = toJS(store.channels); - store.channels = []; - - // change back to sample data when the component is unmounted - return () => { - store.channels = channels; - }; - }, []); - - return ; +export const NoChannels = () => { + return ; }; export const FewChannels = (ctx: StoryContext) => { // grab the store from the Storybook parameter defined in preview.tsx const store = ctx.parameters.store as Store; - - useEffect(() => { - // convert the store state to pure JS so it can be reverted on unmount - const channels = toJS(store.channels); - store.channels = channels.slice(0, 5); - - // change back to sample data when the component is unmounted - return () => { - store.channels = channels; - }; - }, []); - - return ; + return ; }; export const ManyChannels = (ctx: StoryContext) => { // grab the store from the Storybook parameter defined in preview.tsx const store = ctx.parameters.store as Store; - return ; }; + +export const SortedChannels = (ctx: StoryContext) => { + // grab the store from the Storybook parameter defined in preview.tsx + const store = ctx.parameters.store as Store; + const channels = store.channels + .slice() + .sort((a, b) => b.balancePercent - a.balancePercent); + return ; +}; diff --git a/app/src/__stories__/LoopPage.stories.tsx b/app/src/__stories__/LoopPage.stories.tsx index e3cf7e48..a394dbde 100644 --- a/app/src/__stories__/LoopPage.stories.tsx +++ b/app/src/__stories__/LoopPage.stories.tsx @@ -1,4 +1,7 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import { toJS } from 'mobx'; +import { StoryContext } from '@storybook/addons'; +import { Store } from 'store'; import LoopPage from 'components/loop/LoopPage'; export default { @@ -6,4 +9,21 @@ export default { component: LoopPage, }; -export const Default = () => ; +export const Default = (ctx: StoryContext) => { + useEffect(() => { + // grab the store from the Storybook parameter defined in preview.tsx + const store = ctx.parameters.store as Store; + const channels = toJS(store.channels); + // only use a small set of channels + store.channels = channels.slice(0, 25); + + // change back to sample data when the component is unmounted + return () => { + store.channels = channels; + }; + }, []); + + return ; +}; + +export const ManyChannels = () => ; diff --git a/app/src/__tests__/action/channel.spec.ts b/app/src/__tests__/action/channel.spec.ts index 1f76c1b9..42a58390 100644 --- a/app/src/__tests__/action/channel.spec.ts +++ b/app/src/__tests__/action/channel.spec.ts @@ -1,3 +1,4 @@ +import { lndListChannels } from 'util/tests/sampleData'; import ChannelAction from 'action/channel'; import { GrpcClient, LndApi } from 'api'; import { Store } from 'store'; @@ -16,6 +17,6 @@ describe('ChannelAction', () => { it('should fetch list of channels', async () => { expect(store.channels).toEqual([]); await channel.getChannels(); - expect(store.channels).toHaveLength(1); + expect(store.channels).toHaveLength(lndListChannels.channelsList.length); }); }); diff --git a/app/src/__tests__/components/loop/ChannelBalance.spec.tsx b/app/src/__tests__/components/loop/ChannelBalance.spec.tsx new file mode 100644 index 00000000..0817bb3f --- /dev/null +++ b/app/src/__tests__/components/loop/ChannelBalance.spec.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { BalanceLevel, Channel } from 'types/state'; +import { renderWithProviders } from 'util/tests'; +import ChannelBalance from 'components/loop/ChannelBalance'; + +describe('ChannelBalance component', () => { + const channel: Channel = { + active: true, + capacity: 15000000, + chanId: '150633093070848', + localBalance: 9990950, + remoteBalance: 5000000, + remotePubkey: '02ac59099da6d4bd818e6a81098f5d54580b7c3aa8255c707fa0f95ca89b02cb8c', + uptime: 100, + localPercent: 67, + balancePercent: 67, + balanceLevel: BalanceLevel.warn, + }; + + const bgColor = (el: any) => window.getComputedStyle(el).backgroundColor; + const width = (el: any) => window.getComputedStyle(el).width; + + const render = () => { + const result = renderWithProviders(); + const el = result.container.children[0]; + return { + ...result, + el, + remote: el.children[0], + local: el.children[2], + }; + }; + + it('should display a good balance', () => { + channel.localPercent = 55; + channel.balanceLevel = BalanceLevel.good; + const { el, remote, local } = render(); + expect(el.children.length).toBe(3); + expect(width(local)).toBe('55%'); + expect(bgColor(local)).toBe('rgb(70, 232, 14)'); + expect(bgColor(remote)).toBe('rgb(70, 232, 14)'); + }); + + it('should display a warning balance', () => { + channel.localPercent = 72; + channel.balanceLevel = BalanceLevel.warn; + const { el, remote, local } = render(); + expect(el.children.length).toBe(3); + expect(width(local)).toBe('72%'); + expect(bgColor(local)).toBe('rgb(246, 107, 28)'); + expect(bgColor(remote)).toBe('rgb(246, 107, 28)'); + }); + + it('should display a bad balance', () => { + channel.localPercent = 93; + channel.balanceLevel = BalanceLevel.bad; + const { el, remote, local } = render(); + expect(el.children.length).toBe(3); + expect(width(local)).toBe('93%'); + expect(bgColor(local)).toBe('rgb(245, 64, 110)'); + expect(bgColor(remote)).toBe('rgb(245, 64, 110)'); + }); +}); diff --git a/app/src/__tests__/components/loop/LoopPage.spec.tsx b/app/src/__tests__/components/loop/LoopPage.spec.tsx index 4e03746f..abb8246a 100644 --- a/app/src/__tests__/components/loop/LoopPage.spec.tsx +++ b/app/src/__tests__/components/loop/LoopPage.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { SwapStatus } from 'types/generated/loop_pb'; +import { wait } from '@testing-library/react'; import { renderWithProviders } from 'util/tests'; import { loopListSwaps } from 'util/tests/sampleData'; import LoopPage from 'components/loop/LoopPage'; @@ -22,10 +23,11 @@ describe('LoopPage component', () => { }); it('should display the liquidity numbers', async () => { - const { findByText } = render(); - // these values are defined in sampleData.ts - expect(await findByText('4,501,409 SAT')).toBeInTheDocument(); - expect(await findByText('9,988,660 SAT')).toBeInTheDocument(); + const { getByText, store } = render(); + // wait for the channels to be fetched async before checking the UI labels + await wait(() => expect(store.totalInbound).toBeGreaterThan(0)); + expect(getByText(`${store.totalInbound.toLocaleString()} SAT`)).toBeInTheDocument(); + expect(getByText(`${store.totalOutbound.toLocaleString()} SAT`)).toBeInTheDocument(); }); it('should display the loop history records', async () => { diff --git a/app/src/action/channel.ts b/app/src/action/channel.ts index 450759ff..cbbe5b0b 100644 --- a/app/src/action/channel.ts +++ b/app/src/action/channel.ts @@ -1,4 +1,5 @@ import { action, toJS } from 'mobx'; +import { BalanceLevel } from 'types/state'; import { actionLog as log } from 'util/log'; import { LndApi } from 'api'; import { Store } from 'store'; @@ -30,9 +31,40 @@ class ChannelAction { remoteBalance: c.remoteBalance, uptime: Math.floor((c.uptime * 100) / c.lifetime), active: c.active, + localPercent: this._calcLocalPercent(c.localBalance, c.remoteBalance), + balancePercent: this._calcBalancePercent(c.localBalance, c.remoteBalance), + balanceLevel: this._calcBalanceLevel(c.localBalance, c.remoteBalance), })); log.info('updated store.channels', toJS(this._store.channels)); } + + /** + * Determines the local balance percentage of a channel based on the local and + * remote balances + * @param local the local balance of the channel + * @param remote the remote balance of the channel + */ + private _calcLocalPercent(local: number, remote: number): number { + return Math.round((local * 100) / (local + remote)); + } + + private _calcBalancePercent(local: number, remote: number): number { + const pct = this._calcLocalPercent(local, remote); + return pct >= 50 ? pct : 100 - pct; + } + + /** + * Determines the balance level of a channel based on the percentage on each side + * @param local the local balance of the channel + * @param remote the remote balance of the channel + */ + private _calcBalanceLevel(local: number, remote: number): BalanceLevel { + const pct = this._calcBalancePercent(local, remote); + + if (pct > 85) return BalanceLevel.bad; + if (pct > 65) return BalanceLevel.warn; + return BalanceLevel.good; + } } export default ChannelAction; diff --git a/app/src/components/loop/ChannelBalance.tsx b/app/src/components/loop/ChannelBalance.tsx new file mode 100644 index 00000000..8f3632da --- /dev/null +++ b/app/src/components/loop/ChannelBalance.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { BalanceLevel, Channel } from 'types/state'; +import { levelToColor, styled } from 'components/theme'; + +const Styled = { + Wrapper: styled.div<{ pct: number; level: BalanceLevel; active: boolean }>` + display: flex; + width: 100%; + + > div { + min-width: 10px; + + &:first-of-type { + flex-grow: 1; + background-color: ${props => + levelToColor(props.level, props.active, props.theme)}; + } + + &:last-of-type { + width: ${props => props.pct}%; + background-color: ${props => + levelToColor(props.level, props.active, props.theme)}; + } + } + `, + Section: styled.div` + height: 4px; + border-radius: 2px; + `, + Gap: styled.div` + width: 10px; + background-color: transparent; + `, +}; + +interface Props { + channel: Channel; + className?: string; +} + +const ChannelBalance: React.FC = ({ channel, className }) => { + const { active, localPercent, balanceLevel } = channel; + + const { Wrapper, Section, Gap } = Styled; + return ( + +
+ +
+ + ); +}; + +export default ChannelBalance; diff --git a/app/src/components/loop/ChannelRow.tsx b/app/src/components/loop/ChannelRow.tsx index d41202ad..37645b1c 100644 --- a/app/src/components/loop/ChannelRow.tsx +++ b/app/src/components/loop/ChannelRow.tsx @@ -4,6 +4,7 @@ import { Column, Row } from 'components/common/grid'; import { Dot } from 'components/common/icons'; import { Title } from 'components/common/text'; import { styled } from 'components/theme'; +import ChannelBalance from './ChannelBalance'; /** * the virtualized list requires each row to have a specified @@ -30,12 +31,8 @@ const Styled = { margin-left: 15px; color: ${props => props.theme.colors.pink}; `, - Balance: styled.div` + Balance: styled(ChannelBalance)` margin-top: ${ROW_HEIGHT / 2 - 2}px; - height: 4px; - width: 100%; - background-color: ${props => props.theme.colors.pink}; - border-radius: 2px; `, }; @@ -73,12 +70,12 @@ const ChannelRow: React.FC = ({ channel, style }) => { - {channel.localBalance.toLocaleString()} + {channel.remoteBalance.toLocaleString()} - + - {channel.remoteBalance.toLocaleString()} + {channel.localBalance.toLocaleString()} {channel.uptime} {channel.remotePubkey} {channel.capacity.toLocaleString()} diff --git a/app/src/components/theme.tsx b/app/src/components/theme.tsx index 2847de98..4ae7d404 100644 --- a/app/src/components/theme.tsx +++ b/app/src/components/theme.tsx @@ -1,8 +1,37 @@ import React from 'react'; +import { BalanceLevel } from 'types/state'; import emotionStyled, { CreateStyled } from '@emotion/styled/macro'; import { ThemeProvider as EmotionThemeProvider } from 'emotion-theming'; -const theme = { +export interface Theme { + fonts: { + light: string; + regular: string; + semiBold: string; + bold: string; + extraBold: string; + }; + sizes: { + s: string; + m: string; + l: string; + xl: string; + }; + colors: { + blue: string; + darkBlue: string; + gray: string; + darkGray: string; + white: string; + whitish: string; + pink: string; + green: string; + orange: string; + tileBack: string; + }; +} + +const theme: Theme = { fonts: { light: "'OpenSans Light'", regular: "'OpenSans Regular'", @@ -24,11 +53,21 @@ const theme = { white: '#ffffff', whitish: '#f5f5f5', pink: '#f5406e', + green: '#46E80E', + orange: '#f66b1c', tileBack: 'rgba(245,245,245,0.04)', }, }; -export const styled = emotionStyled as CreateStyled; +export const levelToColor = (level: BalanceLevel, active: boolean, theme: Theme) => { + if (!active) return theme.colors.gray; + + if (level === BalanceLevel.bad) return theme.colors.pink; + if (level === BalanceLevel.warn) return theme.colors.orange; + return theme.colors.green; +}; + +export const styled = emotionStyled as CreateStyled; export const ThemeProvider: React.FC = ({ children }) => { return {children}; diff --git a/app/src/types/state.ts b/app/src/types/state.ts index d76f6833..dcb5b8e6 100644 --- a/app/src/types/state.ts +++ b/app/src/types/state.ts @@ -12,6 +12,12 @@ export interface NodeBalances { channelBalance: number; } +export enum BalanceLevel { + good = 'good', + warn = 'warn', + bad = 'bad', +} + export interface Channel { chanId: string; remotePubkey: string; @@ -20,6 +26,9 @@ export interface Channel { remoteBalance: number; uptime: number; active: boolean; + localPercent: number; + balancePercent: number; + balanceLevel: BalanceLevel; } export interface Swap {