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 {