mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
feat: add colored channel balance component
This commit is contained in:
parent
6ff4b753fb
commit
5bd334a4fe
11 changed files with 306 additions and 49 deletions
54
app/src/__stories__/ChannelBalance.stories.tsx
Normal file
54
app/src/__stories__/ChannelBalance.stories.tsx
Normal file
|
|
@ -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 <ChannelBalance channel={channel} />;
|
||||
};
|
||||
|
||||
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 <ChannelBalance channel={channel} />;
|
||||
};
|
||||
|
||||
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 <ChannelBalance channel={channel} />;
|
||||
};
|
||||
|
||||
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 <ChannelBalance channel={channel} />;
|
||||
};
|
||||
|
|
@ -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 <ChannelList channels={store.channels} />;
|
||||
export const NoChannels = () => {
|
||||
return <ChannelList channels={[]} />;
|
||||
};
|
||||
|
||||
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 <ChannelList channels={store.channels} />;
|
||||
return <ChannelList channels={store.channels.slice(0, 10)} />;
|
||||
};
|
||||
|
||||
export const ManyChannels = (ctx: StoryContext) => {
|
||||
// grab the store from the Storybook parameter defined in preview.tsx
|
||||
const store = ctx.parameters.store as Store;
|
||||
|
||||
return <ChannelList channels={store.channels} />;
|
||||
};
|
||||
|
||||
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 <ChannelList channels={channels} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = () => <LoopPage />;
|
||||
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 <LoopPage />;
|
||||
};
|
||||
|
||||
export const ManyChannels = () => <LoopPage />;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
63
app/src/__tests__/components/loop/ChannelBalance.spec.tsx
Normal file
63
app/src/__tests__/components/loop/ChannelBalance.spec.tsx
Normal file
|
|
@ -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(<ChannelBalance channel={channel} />);
|
||||
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)');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
59
app/src/components/loop/ChannelBalance.tsx
Normal file
59
app/src/components/loop/ChannelBalance.tsx
Normal file
|
|
@ -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<Props> = ({ channel, className }) => {
|
||||
const { active, localPercent, balanceLevel } = channel;
|
||||
|
||||
const { Wrapper, Section, Gap } = Styled;
|
||||
return (
|
||||
<Wrapper
|
||||
pct={localPercent}
|
||||
level={balanceLevel}
|
||||
active={active}
|
||||
className={className}
|
||||
>
|
||||
<Section />
|
||||
<Gap />
|
||||
<Section />
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelBalance;
|
||||
|
|
@ -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<Props> = ({ channel, style }) => {
|
|||
<StatusIcon>
|
||||
<Dot />
|
||||
</StatusIcon>
|
||||
{channel.localBalance.toLocaleString()}
|
||||
{channel.remoteBalance.toLocaleString()}
|
||||
</Column>
|
||||
<Column cols={3}>
|
||||
<Balance />
|
||||
<Balance channel={channel} />
|
||||
</Column>
|
||||
<Column>{channel.remoteBalance.toLocaleString()}</Column>
|
||||
<Column>{channel.localBalance.toLocaleString()}</Column>
|
||||
<Column>{channel.uptime}</Column>
|
||||
<Column>{channel.remotePubkey}</Column>
|
||||
<Column right>{channel.capacity.toLocaleString()}</Column>
|
||||
|
|
|
|||
|
|
@ -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<typeof theme>;
|
||||
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<Theme>;
|
||||
|
||||
export const ThemeProvider: React.FC = ({ children }) => {
|
||||
return <EmotionThemeProvider theme={theme}>{children}</EmotionThemeProvider>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue