Skip to content

Commit aae72cc

Browse files
committed
feat(network nodes): add network nodes page
Also add a screen to mark disconnected nodes as gone
1 parent 7151f38 commit aae72cc

13 files changed

Lines changed: 470 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Page from './src/networkNodesPage';
2+
import { NetworkNodesMenu } from './src/networkNodesMenu';
3+
import DeleteNodesPage from './src/containers/deleteNodesPage';
4+
5+
export default {
6+
name: 'NetworkNodes',
7+
page: Page,
8+
menu: NetworkNodesMenu,
9+
menuView: 'community',
10+
additionalProtectedRoutes: [
11+
['delete-nodes', DeleteNodesPage]
12+
]
13+
};
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { h } from 'preact';
2+
import { fireEvent, act, screen } from '@testing-library/preact';
3+
import '@testing-library/jest-dom';
4+
import waitForExpect from 'wait-for-expect';
5+
6+
import NetworkNodesPage from './src/networkNodesPage';
7+
import DeleteNodesPage from './src/containers/deleteNodesPage';
8+
import queryCache from 'utils/queryCache';
9+
import { getNodes, markNodesAsGone } from './src/networkNodesApi';
10+
import { render } from 'utils/test_utils';
11+
12+
jest.mock('./src/networkNodesApi');
13+
14+
beforeEach(() => {
15+
getNodes.mockImplementation(async () => [
16+
{ hostname: 'node1', status: 'connected' },
17+
{ hostname: 'node2', status: 'connected' },
18+
{ hostname: 'node3', status: 'connected' },
19+
{ hostname: 'node4', status: 'disconnected' },
20+
{ hostname: 'node5', status: 'disconnected' },
21+
{ hostname: 'node6', status: 'disconnected' },
22+
{ hostname: 'node7', status: 'disconnected' },
23+
{ hostname: 'node8', status: 'gone' },
24+
{ hostname: 'node9', status: 'gone' },
25+
]);
26+
markNodesAsGone.mockImplementation(async () => []);
27+
});
28+
29+
afterEach(() => {
30+
act(() => queryCache.clear());
31+
});
32+
33+
describe('network nodes screen', () => {
34+
it('shows one tab for connected nodes and one for discconected nodes with length', async () => {
35+
render(<NetworkNodesPage />);
36+
expect(await screen.findByRole('tab', { name: /^connected \(3\)/i })).toBeVisible();
37+
expect(await screen.findByRole('tab', { name: /^disconnected \(4\)/i })).toBeVisible();
38+
})
39+
40+
it('shows one row with the hostname for each connect node', async () => {
41+
render(<NetworkNodesPage />);
42+
expect(await screen.findByText('node1')).toBeVisible();
43+
expect(await screen.findByText('node2')).toBeVisible();
44+
expect(await screen.findByText('node3')).toBeVisible();
45+
})
46+
47+
it('shows one row with the hostname for each disconnect node', async () => {
48+
render(<NetworkNodesPage />);
49+
const tabDisconnected = await screen.findByRole('tab', { name: /^disconnected \(4\)/i });
50+
fireEvent.click(tabDisconnected);
51+
expect(await screen.findByText('node4')).toBeVisible();
52+
expect(await screen.findByText('node5')).toBeVisible();
53+
expect(await screen.findByText('node6')).toBeVisible();
54+
expect(await screen.findByText('node7')).toBeVisible();
55+
})
56+
57+
it('shows a link to go to delete nodes page', async () => {
58+
render(<NetworkNodesPage />);
59+
const tabDisconnected = await screen.findByRole('tab', { name: /^disconnected \(4\)/i });
60+
fireEvent.click(tabDisconnected);
61+
expect(await screen.findByRole('link', { name: /go to delete nodes/i })).toBeVisible();
62+
})
63+
64+
it('does not show a link to go to delete nodes page if there are no disconnected nodes', async () => {
65+
getNodes.mockImplementation(async () => [
66+
{ hostname: 'node1', status: 'connected' },
67+
{ hostname: 'node2', status: 'connected' },
68+
{ hostname: 'node3', status: 'connected' },
69+
]);
70+
render(<NetworkNodesPage />);
71+
const tabDisconnected = await screen.findByRole('tab', { name: /^disconnected \(0\)/i });
72+
fireEvent.click(tabDisconnected);
73+
expect(screen.queryByRole('link', { name: /go to delete nodes/i })).toBeNull();
74+
})
75+
76+
it('shows help message when clicking on help button', async () => {
77+
render(<NetworkNodesPage />);
78+
const helpButton = await screen.findByLabelText('help');
79+
fireEvent.click(helpButton);
80+
expect(await screen.findByText("Connected Nodes")).toBeVisible();
81+
expect(await screen.findByText("These are the nodes with which you have connectivity, "
82+
+ "i.e. there is a working path from your node to each of them.")).toBeVisible();
83+
expect(await screen.findByText("Disconnected Nodes")).toBeVisible();
84+
expect(await screen.findByText("These are the nodes with which you do not have connectivity, "
85+
+ "it is possible that they are not turned on or a link to reach them is down.")).toBeVisible();
86+
})
87+
});
88+
89+
90+
describe('delete nodes page', () => {
91+
it('shows the list of disconnected nodes only', async () => {
92+
render(<DeleteNodesPage />);
93+
expect(await screen.findByText('node4')).toBeVisible();
94+
expect(await screen.findByText('node5')).toBeVisible();
95+
expect(await screen.findByText('node6')).toBeVisible();
96+
expect(await screen.findByText('node7')).toBeVisible();
97+
expect(screen.queryByText('node1')).toBeNull();
98+
expect(screen.queryByText('node2')).toBeNull();
99+
expect(screen.queryByText('node3')).toBeNull();
100+
expect(screen.queryByText('node8')).toBeNull();
101+
expect(screen.queryByText('node9')).toBeNull();
102+
})
103+
104+
it('calls the markNodesAsGone api when deleting', async () => {
105+
markNodesAsGone.mockImplementation(async () => ['node6']);
106+
render(<DeleteNodesPage />);
107+
fireEvent.click(await screen.findByText('node6'));
108+
fireEvent.click(await screen.findByRole('button', { name: /delete/i }));
109+
await waitForExpect(() => {
110+
expect(markNodesAsGone).toBeCalledWith(['node6']);
111+
})
112+
})
113+
114+
it('hide nodes from the list after deletion', async () => {
115+
markNodesAsGone.mockImplementation(async () => ['node6', 'node7']);
116+
render(<DeleteNodesPage />);
117+
fireEvent.click(await screen.findByText('node6'));
118+
fireEvent.click(await screen.findByText('node7'));
119+
fireEvent.click(await screen.findByRole('button', { name: /delete/i }));
120+
expect(await screen.findByText('node4')).toBeVisible();
121+
expect(await screen.queryByText('node5')).toBeVisible();
122+
expect(await screen.queryByText('node6')).toBeNull();
123+
expect(await screen.queryByText('node7')).toBeNull();
124+
})
125+
126+
it('show success message after deletion', async () => {
127+
markNodesAsGone.mockImplementation(async () => ['node6', 'node7']);
128+
render(<DeleteNodesPage />);
129+
fireEvent.click(await screen.findByText('node6'));
130+
fireEvent.click(await screen.findByText('node7'));
131+
fireEvent.click(await screen.findByRole('button', { name: /delete/i }));
132+
expect(await screen.findByText(/successfully deleted/i)).toBeVisible();
133+
})
134+
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { NetworkNodesPage_ } from "./src/networkNodesPage";
2+
import { DeleteNodesPage_ } from "./src/containers/deleteNodesPage";
3+
4+
export default {
5+
title: 'Containers/Network nodes',
6+
};
7+
8+
const nodes = [
9+
{ hostname: "ql-czuk", status: "connected" },
10+
{ hostname: "ql-irene", status: "connected" },
11+
{ hostname: "ql-ipem", status: "connected" },
12+
{ hostname: "ql-czuck-bbone", status: "connected" },
13+
{ hostname: "ql-graciela", status: "connected" },
14+
{ hostname: "ql-marisa", status: "connected" },
15+
{ hostname: "ql-anaymarcos", status: "connected" },
16+
{ hostname: "ql-quinteros", status: "connected" },
17+
{ hostname: "ql-guada", status: "connected" },
18+
{ hostname: "ql-refu-bbone", status: "disconnected" },
19+
{ hostname: "si-soniam", status: "disconnected" },
20+
{ hostname: "si-giordano", status: "disconnected" },
21+
{ hostname: "si-mario", status: "disconnected" },
22+
{ hostname: "si-manu", status: "disconnected" },
23+
];
24+
25+
export const networkNodesPage = () => (
26+
<NetworkNodesPage_ nodes={nodes} />
27+
)
28+
29+
export const deleteNodesPage = (args) => (
30+
<DeleteNodesPage_ nodes={nodes} {...args} />
31+
)
32+
deleteNodesPage.argTypes = {
33+
onDelete: { action: 'deleted' }
34+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { h } from "preact";
2+
import { List, ListItem } from 'components/list';
3+
import Loading from 'components/loading';
4+
import Toast from 'components/toast';
5+
import { useEffect, useState } from 'preact/hooks';
6+
import { useSet } from 'react-use';
7+
import { useMarkNodesAsGone, useNetworkNodes } from '../../networkNodesQueries'
8+
import style from './style.less';
9+
import I18n from 'i18n-js';
10+
11+
export const DeleteNodesPage_ = ({ nodes, onDelete, isSubmitting, isSuccess }) => {
12+
const [selectedNodes, { toggle, has, reset }] = useSet(new Set([]));
13+
const [showSuccess, setshowSuccess] = useState(false);
14+
const disconnectedNodes = nodes.filter(n => n.status === "disconnected");
15+
16+
useEffect(() => {
17+
if (isSuccess) {
18+
reset();
19+
setshowSuccess(true);
20+
setTimeout(() => {
21+
setshowSuccess(false);
22+
}, 2000);
23+
}
24+
}, [isSuccess])
25+
26+
return (
27+
<div class="d-flex flex-column flex-grow-1 overflow-auto ">
28+
<div class="d-flex flex-column flex-grow-1 overflow-auto container container-padded">
29+
<h4>{I18n.t("Delete Nodes")}</h4>
30+
{disconnectedNodes.length > 0 &&
31+
<p>{I18n.t("Select the nodes which no longer belong to the network and \
32+
delete them from the list of disconnected nodes")}</p>
33+
}
34+
{disconnectedNodes.length === 0 &&
35+
<p>{I18n.t("There are no left discconected nodes")}</p>
36+
}
37+
<List>
38+
{disconnectedNodes.map(node =>
39+
<ListItem key={node.hostname} onClick={() => toggle(node.hostname)} >
40+
<div class={style.nodeItem} >
41+
<input type="checkbox" name="selected-nodes" id={node.hostname}
42+
checked={has(node.hostname)} />
43+
{node.hostname}
44+
</div>
45+
</ListItem>
46+
)}
47+
</List>
48+
</div>
49+
{selectedNodes.size >= 1 &&
50+
<div class={style.bottomAction}>
51+
<span>
52+
{[selectedNodes.size,
53+
I18n.t('Selected Nodes', { count: selectedNodes.size })
54+
].join(' ')}
55+
</span>
56+
{!isSubmitting &&
57+
<button class="ml-auto" onClick={() => onDelete([...selectedNodes])}>
58+
{I18n.t("Delete")}
59+
</button>
60+
}
61+
{isSubmitting &&
62+
<div class="ml-auto">
63+
<Loading />
64+
</div>
65+
}
66+
</div>
67+
}
68+
{showSuccess &&
69+
<Toast type={"success"} text={I18n.t("Successfully deleted")} />
70+
}
71+
</div>
72+
)
73+
};
74+
75+
const DeleteNodesPage = () => {
76+
const [deleteNodes, { isSubmitting, isSuccess }] = useMarkNodesAsGone();
77+
const { data: nodes, isLoading } = useNetworkNodes();
78+
if (isLoading) {
79+
return <div className="container container-center"><Loading /></div>
80+
}
81+
82+
return <DeleteNodesPage_ nodes={nodes} onDelete={deleteNodes}
83+
isSubmitting={isSubmitting} isSuccess={isSuccess} />
84+
}
85+
86+
export default DeleteNodesPage;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './deleteNodesPage';
2+
export { default } from './deleteNodesPage';
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.nodeItem {
2+
font-size: 2rem;
3+
display: flex;
4+
flex: auto;
5+
input {
6+
margin-right: 1em;
7+
}
8+
cursor: pointer;
9+
}
10+
11+
.bottomAction {
12+
display: flex;
13+
align-items: baseline;
14+
padding: 0.5em 1em;
15+
font-weight: bold;
16+
border-top: 0.05em solid #bdbdbd;
17+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import api from 'utils/uhttpd.service';
2+
3+
export const getNodes = () =>
4+
api.call('network-nodes', 'get_nodes', {}).toPromise()
5+
.then(res => res.nodes);
6+
7+
export const markNodesAsGone = (hostnames) =>
8+
api.call('network-nodes', 'mark_nodes_as_gone', { hostnames: hostnames }).toPromise()
9+
.then(() => hostnames);
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { of, throwError } from 'rxjs';
2+
import api from 'utils/uhttpd.service';
3+
import waitForExpect from 'wait-for-expect';
4+
5+
jest.mock('utils/uhttpd.service')
6+
7+
import { getNodes, markNodesAsGone } from './networkNodesApi';
8+
9+
10+
beforeEach(() => {
11+
api.call.mockClear();
12+
})
13+
14+
describe('getNodes', () => {
15+
it('calls the expected endpoint', async () => {
16+
api.call.mockImplementation(() => of({ status: 'ok' }))
17+
await getNodes();
18+
expect(api.call).toBeCalledWith('network-nodes', 'get_nodes', {});
19+
})
20+
21+
it('resolves to network nodes', async () => {
22+
const networkNodes = [
23+
{ hostname: 'node1', status: 'connected' },
24+
{ hostname: 'node2', status: 'disconnected' },
25+
{ hostname: 'node3', status: 'gone' },
26+
];
27+
api.call.mockImplementation(() => of(
28+
{
29+
status: 'ok',
30+
nodes: networkNodes,
31+
}));
32+
let nodes = await getNodes();
33+
expect(nodes).toEqual(networkNodes);
34+
});
35+
});
36+
37+
describe('markNodesAsGone', () => {
38+
it('calls the expected endpoint', async () => {
39+
api.call.mockImplementation(() => of({ status: 'ok' }))
40+
await markNodesAsGone(['node1']);
41+
expect(api.call).toBeCalledWith('network-nodes', 'mark_nodes_as_gone', { hostnames: ['node1'] })
42+
})
43+
44+
it('resolve to hostnames passed as parameters on success', async() => {
45+
api.call.mockImplementation(() => of({status: 'ok'}))
46+
const result = await markNodesAsGone(['node1', 'node2'])
47+
expect(result).toEqual(['node1', 'node2'])
48+
})
49+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { h } from 'preact';
2+
3+
import I18n from 'i18n-js';
4+
5+
export const NetworkNodesMenu = () => (
6+
<a href={'#/networknodes'}>{I18n.t('Network Nodes')}</a>
7+
);

0 commit comments

Comments
 (0)