diff --git a/i18n/generic.json b/i18n/generic.json index 5872d1f0c..8497de204 100644 --- a/i18n/generic.json +++ b/i18n/generic.json @@ -48,10 +48,16 @@ "one": "1 seconds", "other": "%{count} seconds" }, + "count_selected_nodes_19bbd632": { + "one": "1 selected-nodes", + "other": "%{count} selected-nodes" + }, "create_network_d229d642": "Create network", "create_new_network_28805f92": "Create new network", "create_session_ad54bdb6": "Create Session", "currently_your_node_has_version_1c26984b": "Currently your node has version:", + "delete_a6efa79d": "Delete", + "delete_nodes_f63ec0d5": "Delete Nodes", "device_95d26d94": "Device", "don_t_show_this_message_again_9950c20": "Don't show this message again", "download_c7ffdfb9": "Download", @@ -95,6 +101,7 @@ "most_active_2d5a3cae": "Most Active", "must_select_a_network_and_a_valid_hostname_ea82e72c": "Must select a network and a valid hostname", "network_configuration_ea7f4215": "Network Configuration", + "network_nodes_4368eb67": "Network Nodes", "no_network_found_try_realigning_your_node_and_resc_176a9b3e": "No network found, try realigning your node and rescanning.", "node_configuration_7342e6f5": "Node Configuration", "notes_c42e0fd5": "Notes", @@ -116,6 +123,8 @@ "radio_2573b256": "Radio", "re_enter_password_49757ed": "Re-enter Password", "re_enter_the_shared_password_20f09406": "Re-enter the shared password", + "reachable_howmany_6f891e31": "Reachable (%{howMany})", + "reachable_nodes_748c93f0": "Reachable Nodes", "reload_3e45154f": "Reload", "reload_page_2d381199": "Reload page", "remote_support_9ba7a3a7": "Remote Support", @@ -134,6 +143,7 @@ "select_file_71aa4113": "Select file", "select_new_node_5b2e9165": "Select new node", "select_one_b647b384": "Select one", + "select_the_nodes_which_no_longer_belong_to_the_net_92f853ef": "Select the nodes which no longer belong to the network and delete them from the list of unreachable nodes", "set_network_bcd0ea96": "Set network", "setting_network_21ebac51": "Setting network", "setting_up_new_password_4daf8f1c": "Setting up new password", @@ -146,6 +156,7 @@ "size_b30e1077": "Size", "station_name_7d67417c": "Station name", "status_e7fdbe06": "Status", + "successfully_deleted_23ce0a20": "Successfully deleted", "system_55b0ca91": "System", "the_are_not_mesh_interfaces_available_4055abd7": "The are not mesh interfaces available", "the_download_failed_130e1274": "The download failed", @@ -155,11 +166,15 @@ "the_selected_image_is_not_valid_for_the_target_dev_cea9b494": "The selected image is not valid for the target device", "the_shared_password_has_been_chosen_by_the_communi_f9d30a92": "The shared password has been chosen by the community when the network was created. You can ask other community members for it.", "the_upgrade_should_be_done_d66854": "The upgrade should be done", + "there_are_no_left_unreachable_nodes_c0bec63d": "There are no left unreachable nodes", "there_s_an_active_remote_support_session_4a40a8bb": "There's an active remote support session", "there_s_no_open_session_for_remote_support_click_a_efd0d415": "There's no open session for remote support. Click at Create Session to begin one", "these_are_the_nodes_associated_on_this_radio_3d302167": "These are the nodes associated on this radio", + "these_are_the_nodes_that_can_be_reached_from_your__4c524abe": "These are the nodes that can be reached from your node, i.e. there is a working path from your node to each of them.", + "these_are_the_nodes_that_can_t_be_reached_from_you_dbbf9032": "These are the nodes that can't be reached from your node, it is possible that they are not turned on or a link to reach them is down.", "this_device_does_not_support_secure_rollback_to_pr_1c167a2c": "This device does not support secure rollback to previous version if something goes wrong", "this_device_supports_secure_rollback_to_previous_v_a60ddbcb": "This device supports secure rollback to previous version if something goes wrong", + "this_information_is_synced_periodically_and_can_be_8b74cb8c": "This information is synced periodically and can be outdated by some minutes", "this_node_is_the_gateway_1e20aaff": "This node is the gateway", "this_radio_is_not_associated_with_other_nodes_6722a471": "This radio is not associated with other nodes", "to_internet_494eb85c": "To Internet", @@ -167,6 +182,8 @@ "to_the_previous_configuration_bf087867": "to the previous configuration", "traffic_bfe536d2": "Traffic", "try_reloading_the_app_4e4c3a66": "Try reloading the app", + "unreachable_howmany_e5c8f844": "Unreachable (%{howMany})", + "unreachable_nodes_e6785f10": "Unreachable Nodes", "upgrade_5de364f8": "Upgrade", "upgrade_now_f300d697": "Upgrade Now", "upgrade_to_lastest_firmware_version_9b159910": "Upgrade to lastest firmware version", diff --git a/i18n/translations/en.json b/i18n/translations/en.json index bb1272aef..0eeaca9ad 100644 --- a/i18n/translations/en.json +++ b/i18n/translations/en.json @@ -59,5 +59,9 @@ "zero": "No one has joined yet.", "one": "One person has joined.", "other": "%{count} people have joined." + }, + "count_selected_nodes_19bbd632": { + "one": "node selected", + "other": "nodes selected" } } diff --git a/i18n/translations/es.json b/i18n/translations/es.json index 47267d89f..0d2165325 100644 --- a/i18n/translations/es.json +++ b/i18n/translations/es.json @@ -192,7 +192,24 @@ "visit_a_neighboring_node_4116be4": "Visitar un nodo vecino", "select_new_node_5b2e9165": "Selecciona el nodo", "visit_864b4060": "Visitar", + "go_to_node_view_26ba929d": "Ir a Vista de Nodo", + "network_nodes_4368eb67": "Nodos de la Red", + "delete_a6efa79d": "Eliminar", + "delete_nodes_f63ec0d5": "Baja de Nodos", "go_to_community_view_d12b8d67": "Ir a Vista de Comunidad", - "go_to_node_view_26ba929d": "Ir a Vista de Nodo" + "count_selected_nodes_19bbd632": { + "one": "nodo seleccionado", + "other": "nodos seleccionados" + }, + "successfully_deleted_23ce0a20": "Eliminado/s correctamente", + "select_the_nodes_which_no_longer_belong_to_the_net_92f853ef": "Selecciona los nodos que ya no pertenecen a la red y elimínalos de la lista de nodos no alcanzables", + "there_are_no_left_unreachable_nodes_c0bec63d": "No hay nodos inalcanzables", + "reachable_howmany_6f891e31": "Alcanzables (%{howMany})", + "reachable_nodes_748c93f0": "Nodos Alcanzables", + "these_are_the_nodes_that_can_be_reached_from_your__4c524abe": "Son los nodos con los que no tienes conectividad, es posible que no estén encendidos o que algún enlace para llegar a ellos esté caído.", + "these_are_the_nodes_that_can_t_be_reached_from_you_dbbf9032": "Son los nodos con los que tienes conectividad, es decir que hay un camino funcionando entre tu nodo y cada uno de ellos.", + "this_information_is_synced_periodically_and_can_be_8b74cb8c": "Esta información se sincroniza periódicamente, puede estar desactualizada algunos minutos.", + "unreachable_howmany_e5c8f844": "No Alcanzables (%{howMany})", + "unreachable_nodes_e6785f10": "Nodos No Alcanzables" } diff --git a/jest.config.js b/jest.config.js index e9fb4a11b..c7ce6a8f7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,6 +10,7 @@ module.exports = { moduleNameMapper: { ...preactPreset.moduleNameMapper, '^components/(.*)$': '/src/components/$1', - '^utils/(.*)$': '/src/utils/$1' + '^utils/(.*)$': '/src/utils/$1', + '^plugins/(.*)$': '/plugins/$1' } }; diff --git a/plugins/lime-plugin-delete-nodes/deleteNodes.spec.js b/plugins/lime-plugin-delete-nodes/deleteNodes.spec.js new file mode 100644 index 000000000..9dfe24fcf --- /dev/null +++ b/plugins/lime-plugin-delete-nodes/deleteNodes.spec.js @@ -0,0 +1,78 @@ +import { h } from 'preact'; +import { fireEvent, act, screen } from '@testing-library/preact'; +import '@testing-library/jest-dom'; +import waitForExpect from 'wait-for-expect'; + +import DeleteNodesPage from './src/deleteNodesPage'; +import queryCache from 'utils/queryCache'; +import { getNodes, markNodesAsGone } from 'plugins/lime-plugin-network-nodes/src/networkNodesApi'; +import { render } from 'utils/test_utils'; + +jest.mock('plugins/lime-plugin-network-nodes/src/networkNodesApi'); + +describe('delete nodes page', () => { + beforeEach(() => { + getNodes.mockImplementation(async () => [ + { hostname: 'node1', status: 'recently_reachable' }, + { hostname: 'node2', status: 'recently_reachable' }, + { hostname: 'node3', status: 'recently_reachable' }, + { hostname: 'node4', status: 'unreachable' }, + { hostname: 'node5', status: 'unreachable' }, + { hostname: 'node6', status: 'unreachable' }, + { hostname: 'node7', status: 'unreachable' }, + { hostname: 'node8', status: 'gone' }, + { hostname: 'node9', status: 'gone' }, + ]); + markNodesAsGone.mockImplementation(async () => []); + }); + + afterEach(() => { + act(() => queryCache.clear()); + getNodes.mockClear(); + markNodesAsGone.mockClear(); + }); + + it('shows the list of unreachable nodes only', async () => { + render(); + expect(await screen.findByText('node4')).toBeVisible(); + expect(await screen.findByText('node5')).toBeVisible(); + expect(await screen.findByText('node6')).toBeVisible(); + expect(await screen.findByText('node7')).toBeVisible(); + expect(screen.queryByText('node1')).toBeNull(); + expect(screen.queryByText('node2')).toBeNull(); + expect(screen.queryByText('node3')).toBeNull(); + expect(screen.queryByText('node8')).toBeNull(); + expect(screen.queryByText('node9')).toBeNull(); + }); + + it('calls the markNodesAsGone api when deleting', async () => { + markNodesAsGone.mockImplementation(async () => ['node6']); + render(); + fireEvent.click(await screen.findByText('node6')); + fireEvent.click(await screen.findByRole('button', { name: /delete/i })); + await waitForExpect(() => { + expect(markNodesAsGone).toBeCalledWith(['node6']); + }) + }) + + it('hide nodes from the list after deletion', async () => { + markNodesAsGone.mockImplementation(async () => ['node6', 'node7']); + render(); + fireEvent.click(await screen.findByText('node6')); + fireEvent.click(await screen.findByText('node7')); + fireEvent.click(await screen.findByRole('button', { name: /delete/i })); + expect(await screen.findByText('node4')).toBeVisible(); + expect(await screen.queryByText('node5')).toBeVisible(); + expect(await screen.queryByText('node6')).toBeNull(); + expect(await screen.queryByText('node7')).toBeNull(); + }) + + it('show success message after deletion', async () => { + markNodesAsGone.mockImplementation(async () => ['node6', 'node7']); + render(); + fireEvent.click(await screen.findByText('node6')); + fireEvent.click(await screen.findByText('node7')); + fireEvent.click(await screen.findByRole('button', { name: /delete/i })); + expect(await screen.findByText(/successfully deleted/i)).toBeVisible(); + }) +}) diff --git a/plugins/lime-plugin-delete-nodes/deleteNodes.stories.js b/plugins/lime-plugin-delete-nodes/deleteNodes.stories.js new file mode 100644 index 000000000..a8ae2df8c --- /dev/null +++ b/plugins/lime-plugin-delete-nodes/deleteNodes.stories.js @@ -0,0 +1,21 @@ +import { DeleteNodesPage_ } from './src/deleteNodesPage'; + +export default { + title: 'Containers/Remove Nodes' +}; + +const nodes = [ + { hostname: "ql-refu-bbone", status: "unreachable" }, + { hostname: "si-soniam", status: "unreachable" }, + { hostname: "si-giordano", status: "unreachable" }, + { hostname: "si-mario", status: "unreachable" }, + { hostname: "si-manu", status: "unreachable" }, +]; + +export const deleteNodesPage = (args) => ( + +); + +deleteNodesPage.argTypes = { + onDelete: { action: 'deleted' } +}; \ No newline at end of file diff --git a/plugins/lime-plugin-delete-nodes/index.js b/plugins/lime-plugin-delete-nodes/index.js new file mode 100644 index 000000000..d4a606bb4 --- /dev/null +++ b/plugins/lime-plugin-delete-nodes/index.js @@ -0,0 +1,10 @@ +import Page from './src/deleteNodesPage'; +import Menu from './src/deleteNodesMenu'; + +export default { + name: 'deleteNodes', + page: Page, + menu: Menu, + isCommunityProtected: true, + menuView: 'community' +}; diff --git a/plugins/lime-plugin-delete-nodes/src/deleteNodesMenu.js b/plugins/lime-plugin-delete-nodes/src/deleteNodesMenu.js new file mode 100644 index 000000000..091be2c84 --- /dev/null +++ b/plugins/lime-plugin-delete-nodes/src/deleteNodesMenu.js @@ -0,0 +1,8 @@ +import { h } from 'preact'; +import I18n from 'i18n-js'; + +const Menu = () => ( + {I18n.t('Delete Nodes')} +); + +export default Menu; diff --git a/plugins/lime-plugin-delete-nodes/src/deleteNodesPage.js b/plugins/lime-plugin-delete-nodes/src/deleteNodesPage.js new file mode 100644 index 000000000..f0417d7dd --- /dev/null +++ b/plugins/lime-plugin-delete-nodes/src/deleteNodesPage.js @@ -0,0 +1,86 @@ +import { h } from "preact"; +import { List, ListItem } from 'components/list'; +import Loading from 'components/loading'; +import Toast from 'components/toast'; +import { useEffect, useState } from 'preact/hooks'; +import { useSet } from 'react-use'; +import { useMarkNodesAsGone, useNetworkNodes } from 'plugins/lime-plugin-network-nodes/src/networkNodesQueries' +import style from './deleteNodesStyle.less'; +import I18n from 'i18n-js'; + +export const DeleteNodesPage_ = ({ nodes, onDelete, isSubmitting, isSuccess }) => { + const [selectedNodes, { toggle, has, reset }] = useSet(new Set([])); + const [showSuccess, setshowSuccess] = useState(false); + const unreachableNodes = nodes.filter(n => n.status === "unreachable"); + + useEffect(() => { + if (isSuccess) { + reset(); + setshowSuccess(true); + setTimeout(() => { + setshowSuccess(false); + }, 2000); + } + }, [isSuccess]) + + return ( +
+
+

{I18n.t("Delete Nodes")}

+ {unreachableNodes.length > 0 && +

{I18n.t("Select the nodes which no longer belong to the network and " + + "delete them from the list of unreachable nodes")}

+ } + {unreachableNodes.length === 0 && +

{I18n.t("There are no left unreachable nodes")}

+ } + + {unreachableNodes.map(node => + toggle(node.hostname)} > +
+ + {node.hostname} +
+
+ )} +
+
+
+ + {[selectedNodes.size, + I18n.t('selected-nodes', { count: selectedNodes.size }) + ].join(' ')} + + {!isSubmitting && + + } + {isSubmitting && +
+ +
+ } +
+ {showSuccess && + + } +
+ ) +}; + +const DeleteNodesPage = () => { + const [deleteNodes, { isSubmitting, isSuccess }] = useMarkNodesAsGone(); + const { data: nodes, isLoading } = useNetworkNodes(); + if (isLoading) { + return
+ } + + return +} + +export default DeleteNodesPage; diff --git a/plugins/lime-plugin-delete-nodes/src/deleteNodesStyle.less b/plugins/lime-plugin-delete-nodes/src/deleteNodesStyle.less new file mode 100644 index 000000000..6e844dce3 --- /dev/null +++ b/plugins/lime-plugin-delete-nodes/src/deleteNodesStyle.less @@ -0,0 +1,17 @@ +.nodeItem { + font-size: 2rem; + display: flex; + flex: auto; + input { + margin-right: 1em; + } + cursor: pointer; +} + +.bottomAction { + display: flex; + align-items: baseline; + padding: 0.5em 1em; + font-weight: bold; + border-top: 0.05em solid #bdbdbd; +} \ No newline at end of file diff --git a/plugins/lime-plugin-network-nodes/index.js b/plugins/lime-plugin-network-nodes/index.js new file mode 100644 index 000000000..602ea57b8 --- /dev/null +++ b/plugins/lime-plugin-network-nodes/index.js @@ -0,0 +1,9 @@ +import Page from './src/networkNodesPage'; +import Menu from './src/networkNodesMenu'; + +export default { + name: 'networkNodes', + page: Page, + menu: Menu, + menuView: 'community' +}; diff --git a/plugins/lime-plugin-network-nodes/networkNodes.spec.js b/plugins/lime-plugin-network-nodes/networkNodes.spec.js new file mode 100644 index 000000000..8cfbf48c7 --- /dev/null +++ b/plugins/lime-plugin-network-nodes/networkNodes.spec.js @@ -0,0 +1,69 @@ +// Here you define tests that closely resemble how your component is used +// Using the testing-library: https://testing-library.com + +import { h } from 'preact'; +import { fireEvent, screen, cleanup, act } from '@testing-library/preact'; +import '@testing-library/jest-dom'; +import { render } from 'utils/test_utils'; +import queryCache from 'utils/queryCache'; + +import NetworkNodes from './src/networkNodesPage'; +import { getNodes } from './src/networkNodesApi'; + +jest.mock('./src/networkNodesApi'); + +describe('networkNodes', () => { + beforeEach(() => { + getNodes.mockImplementation(async () => ({ + "ql-berta": { + ipv4: '10.5.0.16', + ipv6: 'fd0d:fe46:8ce8::8bbf:7500', + board: 'LibreRouter v1', + fw_version: 'LibreRouterOS 1.4', + status: 'recently_connected' + }, + "ql-nelson": { + ipv4: '10.5.0.17', + ipv6: 'fd0d:fe46:8ce8::8bbf:75bf', + board: 'LibreRouter v1', + fw_version: 'LibreRouterOS 1.4', + status: 'disconnected' + }, + "ql-gone-node": { + ipv4: '10.5.0.18', + ipv6: 'fd0d:fe46:8ce8::8bbf:75be', + board: 'LibreRouter v1', + fw_version: 'LibreRouterOS 1.4', + status: 'gone' + } + })); + }); + + afterEach(() => { + cleanup(); + act(() => queryCache.clear()); + }); + + it('test that nodes recently_connected and connected nodes are shown', async () => { + render(); + expect(await screen.findByText('ql-nelson')).toBeInTheDocument(); + expect(await screen.findByText('ql-berta')).toBeInTheDocument(); + }); + + it('test that details are shown on click', async () => { + render(); + const element = await screen.findByText('ql-nelson'); + fireEvent.click(element); + expect(await screen.findByRole('link', { name: '10.5.0.17'})).toBeInTheDocument(); + expect(await screen.findByText('IPv6: fd0d:fe46:8ce8::8bbf:75bf')).toBeInTheDocument(); + expect(await screen.findByText('Device: LibreRouter v1')).toBeInTheDocument(); + expect(await screen.findByText('Firmware: LibreRouterOS 1.4')).toBeInTheDocument(); + }); + + it('test that gone nodes are not shown', async () => { + render(); + await screen.findByText('ql-nelson'); + expect(screen.queryByText('ql-gone-node')).toBeNull(); + }) + +}); diff --git a/plugins/lime-plugin-network-nodes/src/components/expandableNode/index.js b/plugins/lime-plugin-network-nodes/src/components/expandableNode/index.js new file mode 100644 index 000000000..8624eb5dd --- /dev/null +++ b/plugins/lime-plugin-network-nodes/src/components/expandableNode/index.js @@ -0,0 +1,25 @@ +import { h } from 'preact'; +import I18n from 'i18n-js'; +import { ListItem } from 'components/list'; +import style from './style.less'; + +export const ExpandableNode = ({ node, showMore, onClick }) => { + const { hostname, ipv4, ipv6, board, fw_version } = node; + return ( + +
+
+
{hostname}
+
+ {showMore && +
e.stopPropagation()}> + {ipv4 &&
IPv4: {ipv4}
} + {ipv6 &&
IPv6: {ipv6}
} + {board &&
{I18n.t('Device')}: {board}
} + {fw_version &&
{I18n.t('Firmware')}: {fw_version}
} +
+ } +
+
+ ) +} \ No newline at end of file diff --git a/plugins/lime-plugin-network-nodes/src/components/expandableNode/stories.js b/plugins/lime-plugin-network-nodes/src/components/expandableNode/stories.js new file mode 100644 index 000000000..2abf1859f --- /dev/null +++ b/plugins/lime-plugin-network-nodes/src/components/expandableNode/stories.js @@ -0,0 +1,20 @@ +import { ExpandableNode } from './index'; + +export default { + title: 'Containers/NetworkNodes/Components/ExpandableNode', + component: ExpandableNode +}; + +const node = { + hostname: 'ql-flor', + ipv4:'10.5.0.16', + ipv6: 'fd0d:fe46:8ce8::8bbf:7500', + board: 'LibreRouter v1', + fw_version: 'LibreRouterOS 1.4' +}; + +export const folded = () => + + +export const unfolded = () => + \ No newline at end of file diff --git a/plugins/lime-plugin-network-nodes/src/components/expandableNode/style.less b/plugins/lime-plugin-network-nodes/src/components/expandableNode/style.less new file mode 100644 index 000000000..5dc98d636 --- /dev/null +++ b/plugins/lime-plugin-network-nodes/src/components/expandableNode/style.less @@ -0,0 +1,14 @@ +.moreData { + padding-left: 2em; + cursor: text; +} + +.hostname { + font-size: 2em; +} + +.threeDots { + font-size: 1.5em; + font-weight: bold; + cursor: pointer; +} \ No newline at end of file diff --git a/plugins/lime-plugin-network-nodes/src/networkNodesApi.js b/plugins/lime-plugin-network-nodes/src/networkNodesApi.js new file mode 100644 index 000000000..38feb6ce6 --- /dev/null +++ b/plugins/lime-plugin-network-nodes/src/networkNodesApi.js @@ -0,0 +1,11 @@ +import api from 'utils/uhttpd.service'; + +export const getNodes = () => + api.call('network-nodes', 'get_nodes', {}).toPromise() + .then(res => { + return res.nodes; + }); + +export const markNodesAsGone = (hostnames) => + api.call('network-nodes', 'mark_nodes_as_gone', { hostnames: hostnames }).toPromise() + .then(() => hostnames); \ No newline at end of file diff --git a/plugins/lime-plugin-network-nodes/src/networkNodesApi.spec.js b/plugins/lime-plugin-network-nodes/src/networkNodesApi.spec.js new file mode 100644 index 000000000..c5ee29040 --- /dev/null +++ b/plugins/lime-plugin-network-nodes/src/networkNodesApi.spec.js @@ -0,0 +1,48 @@ +import { getNodes, markNodesAsGone } from './networkNodesApi' +import api from 'utils/uhttpd.service'; +import { of } from 'rxjs'; +jest.mock('utils/uhttpd.service') + +beforeEach(() => { + api.call.mockImplementation(() => of({ status: 'ok' })) +}) + +describe('getNodes', () => { + it('hits the expected endpoint', async () => { + getNodes(); + expect(api.call).toBeCalledWith('network-nodes', 'get_nodes', {}); + }); + + it('test resolves to nodes data', async () => { + const nodes = { + 'host1': { + ipv4: '10.5.0.16', + ipv6: 'fd0d:fe46:8ce8::8bbf:7500', + board: 'LibreRouter v1', + fw_version: 'LibreRouterOS 1.4' + }, + 'host2': { + ipv4: '10.5.0.17', + ipv6: 'fd0d:fe46:8ce8::8bbf:75bf', + board: 'TL-WDR3500', + fw_version: 'LibreRouterOS 1.4' + } + }; + api.call.mockImplementation(() => of({ status: 'ok', nodes })); + expect(await getNodes()).toEqual(nodes); + }); +}); + +describe('markNodesAsGone', () => { + it('calls the expected endpoint', async () => { + api.call.mockImplementation(() => of({ status: 'ok' })) + await markNodesAsGone(['node1']); + expect(api.call).toBeCalledWith('network-nodes', 'mark_nodes_as_gone', { hostnames: ['node1'] }) + }) + + it('resolve to hostnames passed as parameters on success', async() => { + api.call.mockImplementation(() => of({status: 'ok'})) + const result = await markNodesAsGone(['node1', 'node2']) + expect(result).toEqual(['node1', 'node2']) + }) +}); \ No newline at end of file diff --git a/plugins/lime-plugin-network-nodes/src/networkNodesMenu.js b/plugins/lime-plugin-network-nodes/src/networkNodesMenu.js new file mode 100644 index 000000000..0303aa3c6 --- /dev/null +++ b/plugins/lime-plugin-network-nodes/src/networkNodesMenu.js @@ -0,0 +1,8 @@ +import { h } from 'preact'; +import I18n from 'i18n-js'; + +const Menu = () => ( + {I18n.t('Network Nodes')} +); + +export default Menu; diff --git a/plugins/lime-plugin-network-nodes/src/networkNodesPage.js b/plugins/lime-plugin-network-nodes/src/networkNodesPage.js new file mode 100644 index 000000000..5a72fcdf6 --- /dev/null +++ b/plugins/lime-plugin-network-nodes/src/networkNodesPage.js @@ -0,0 +1,50 @@ +// NetworkNodes will be rendered when navigating to this plugin +import { h } from 'preact'; +import { useNetworkNodes } from './networkNodesQueries'; +import { List } from 'components/list'; +import { Loading } from 'components/loading'; +import { ExpandableNode } from './components/expandableNode'; +import style from './networkNodesStyle.less'; +import { useState } from 'preact/hooks'; +import I18n from 'i18n-js'; + +export const _NetworkNodes = ({ nodes, isLoading, unfoldedNode, onUnfold }) => { + if (isLoading) { + return
+ } + return ( +
+
{I18n.t("Network Nodes")}
+ + {nodes.map((node) => + onUnfold(node.hostname)} /> + )} + +
+ ) +}; + +const NetworkNodes = () => { + const { data: networkNodes, isLoading } = useNetworkNodes(); + const [ unfoldedNode, setunfoldedNode ] = useState(null); + const sortedNodes = (networkNodes && + Object.entries(networkNodes) + .map(([k, v]) => ({ ...v, hostname: k })) + .filter(n => n.status !== 'gone') + .sort((a, b) => a.hostname > b.hostname)); + + function changeUnfolded(hostname) { + if (unfoldedNode == hostname) { + setunfoldedNode(null); + return; + } + setunfoldedNode(hostname); + } + + return <_NetworkNodes nodes={sortedNodes} isLoading={isLoading} + unfoldedNode={unfoldedNode} onUnfold={changeUnfolded}/>; +} + +export default NetworkNodes; diff --git a/plugins/lime-plugin-network-nodes/src/networkNodesPage.stories.js b/plugins/lime-plugin-network-nodes/src/networkNodesPage.stories.js new file mode 100644 index 000000000..45f7c3763 --- /dev/null +++ b/plugins/lime-plugin-network-nodes/src/networkNodesPage.stories.js @@ -0,0 +1,51 @@ +import NetworkNodes, {_NetworkNodes} from './networkNodesPage'; + +export default { + title: 'Containers/networkNodes' +} + +const nodes = [ + { + hostname: 'ql-berta', + ipv4:'10.5.0.16', + ipv6: 'fd0d:fe46:8ce8::8bbf:7500', + board: 'LibreRouter v1', + fw_version: 'LibreRouterOS 1.4' + }, + { + hostname: 'ql-nelson', + ipv4:'10.5.0.17', + ipv6: 'fd0d:fe46:8ce8::8bbf:75bf', + board: 'LibreRouter v1', + fw_version: 'LibreRouterOS 1.4' + } +]; + +export const networkNodesNonUnfolded = () => + <_NetworkNodes nodes={nodes} /> + +export const networkNodesOneUnfolded = () => + <_NetworkNodes nodes={nodes} unfoldedNode={'ql-berta'} /> + +export const networkNodesLoading = () => + <_NetworkNodes isLoading={true} /> + +const manyNodes = []; +for (let i = 0; i < 15; i++) { + const hostname = `host${i}`; + const node = {...nodes[0]}; + node.hostname = hostname; + manyNodes.push(node); +} + +export const networkNodesManyNodes = () => + <_NetworkNodes nodes={manyNodes} /> + +export const networkNodesInteractive = () => + +networkNodesInteractive.args = { + queries: [ + [['network-nodes', 'get_nodes'], + Object.fromEntries(nodes.map(n => [n.hostname, n]))] + ] +} \ No newline at end of file diff --git a/plugins/lime-plugin-network-nodes/src/networkNodesQueries.js b/plugins/lime-plugin-network-nodes/src/networkNodesQueries.js new file mode 100644 index 000000000..0e82bc51b --- /dev/null +++ b/plugins/lime-plugin-network-nodes/src/networkNodesQueries.js @@ -0,0 +1,17 @@ +import { useQuery, useMutation } from 'react-query'; +import { getNodes, markNodesAsGone } from './networkNodesApi'; +import queryCache from 'utils/queryCache'; + +export const useNetworkNodes = () => + useQuery(['network-nodes', 'get_nodes'], getNodes); + +export const useMarkNodesAsGone = () => useMutation(markNodesAsGone, { + onSuccess: hostnames => queryCache.setQueryData(['network-nodes', 'get_nodes'], + old => { + const result = old.map( + node => hostnames.indexOf(node.hostname) != -1 ? { ...node, status: "gone" } : node + ) + return result; + } + ) +}) \ No newline at end of file diff --git a/plugins/lime-plugin-network-nodes/src/networkNodesStyle.less b/plugins/lime-plugin-network-nodes/src/networkNodesStyle.less new file mode 100644 index 000000000..81dacaf66 --- /dev/null +++ b/plugins/lime-plugin-network-nodes/src/networkNodesStyle.less @@ -0,0 +1,5 @@ +.title { + text-align: center; + font-size: 2em; + padding-top: 1rem; +} \ No newline at end of file diff --git a/plugins/lime-plugin-reachable-nodes/index.js b/plugins/lime-plugin-reachable-nodes/index.js new file mode 100644 index 000000000..f378f77b3 --- /dev/null +++ b/plugins/lime-plugin-reachable-nodes/index.js @@ -0,0 +1,9 @@ +import Page from './src/reachableNodesPage'; +import { ReachableNodesMenu } from './src/reachableNodesMenu'; + +export default { + name: 'NetworkNodes', + page: Page, + menu: ReachableNodesMenu, + menuView: 'community', +}; diff --git a/plugins/lime-plugin-reachable-nodes/reachableNodes.spec.js b/plugins/lime-plugin-reachable-nodes/reachableNodes.spec.js new file mode 100644 index 000000000..be0d33712 --- /dev/null +++ b/plugins/lime-plugin-reachable-nodes/reachableNodes.spec.js @@ -0,0 +1,68 @@ +import { h } from 'preact'; +import { fireEvent, act, screen } from '@testing-library/preact'; +import '@testing-library/jest-dom'; + +import ReachableNodesPage from './src/reachableNodesPage'; +import queryCache from 'utils/queryCache'; +import { getNodes, markNodesAsGone } from 'plugins/lime-plugin-network-nodes/src/networkNodesApi'; +import { render } from 'utils/test_utils'; + +jest.mock('plugins/lime-plugin-network-nodes/src/networkNodesApi'); + +beforeEach(() => { + getNodes.mockImplementation(async () => [ + { hostname: 'node1', status: 'recently_reachable' }, + { hostname: 'node2', status: 'recently_reachable' }, + { hostname: 'node3', status: 'recently_reachable' }, + { hostname: 'node4', status: 'unreachable' }, + { hostname: 'node5', status: 'unreachable' }, + { hostname: 'node6', status: 'unreachable' }, + { hostname: 'node7', status: 'unreachable' }, + { hostname: 'node8', status: 'gone' }, + { hostname: 'node9', status: 'gone' }, + ]); + markNodesAsGone.mockImplementation(async () => []); +}); + +afterEach(() => { + act(() => queryCache.clear()); +}); + +describe('network nodes screen', () => { + it('shows one tab for reachable nodes and one for unreachable nodes with length', async () => { + render(); + expect(await screen.findByRole('tab', { name: /^reachable \(3\)/i })).toBeVisible(); + expect(await screen.findByRole('tab', { name: /^unreachable \(4\)/i })).toBeVisible(); + }) + + it('shows one row with the hostname for each connect node', async () => { + render(); + expect(await screen.findByText('node1')).toBeVisible(); + expect(await screen.findByText('node2')).toBeVisible(); + expect(await screen.findByText('node3')).toBeVisible(); + }) + + it('shows one row with the hostname for each disconnect node', async () => { + render(); + const tabDisconnected = await screen.findByRole('tab', { name: /^unreachable \(4\)/i }); + fireEvent.click(tabDisconnected); + expect(await screen.findByText('node4')).toBeVisible(); + expect(await screen.findByText('node5')).toBeVisible(); + expect(await screen.findByText('node6')).toBeVisible(); + expect(await screen.findByText('node7')).toBeVisible(); + }) + + it('shows help message when clicking on help button', async () => { + render(); + const helpButton = await screen.findByLabelText('help'); + fireEvent.click(helpButton); + expect(await screen.findByText("Reachable Nodes")).toBeVisible(); + expect(await screen.findByText("These are the nodes that can be reached from your node, " + + "i.e. there is a working path from your node to each of them." + + "This information is synced periodically " + + "and can be outdated by some minutes")).toBeVisible(); + expect(await screen.findByText("Unreachable Nodes")).toBeVisible(); + expect(await screen.findByText("These are the nodes that can't be reached from your node, " + + "it is possible that they are not turned on or a link to reach them is down.")).toBeVisible(); + }) +}); diff --git a/plugins/lime-plugin-reachable-nodes/reachableNodes.stories.js b/plugins/lime-plugin-reachable-nodes/reachableNodes.stories.js new file mode 100644 index 000000000..195b32265 --- /dev/null +++ b/plugins/lime-plugin-reachable-nodes/reachableNodes.stories.js @@ -0,0 +1,29 @@ +import { ReachableNodesPage_ } from "./src/reachableNodesPage"; + +export default { + title: 'Containers/ReachableNodes', +}; + +const nodes = [ + { hostname: "ql-czuk", status: "recently_reachable", + ipv4:'10.5.0.3', ipv6: 'fd0d:fe46:8ce8::8bbf:7500', + board: 'LibreRouter v1', fw_version: 'LibreRouterOS 1.4' + }, + { hostname: "ql-irene", status: "recently_reachable" }, + { hostname: "ql-ipem", status: "recently_reachable" }, + { hostname: "ql-czuck-bbone", status: "recently_reachable" }, + { hostname: "ql-graciela", status: "recently_reachable" }, + { hostname: "ql-marisa", status: "recently_reachable" }, + { hostname: "ql-anaymarcos", status: "recently_reachable" }, + { hostname: "ql-quinteros", status: "recently_reachable" }, + { hostname: "ql-guada", status: "recently_reachable" }, + { hostname: "ql-refu-bbone", status: "unreachable" }, + { hostname: "si-soniam", status: "unreachable" }, + { hostname: "si-giordano", status: "unreachable" }, + { hostname: "si-mario", status: "unreachable" }, + { hostname: "si-manu", status: "unreachable" }, +]; + +export const reachableNodesPage = () => ( + +); diff --git a/plugins/lime-plugin-reachable-nodes/src/reachableNodesMenu.js b/plugins/lime-plugin-reachable-nodes/src/reachableNodesMenu.js new file mode 100644 index 000000000..55b9138be --- /dev/null +++ b/plugins/lime-plugin-reachable-nodes/src/reachableNodesMenu.js @@ -0,0 +1,7 @@ +import { h } from 'preact'; + +import I18n from 'i18n-js'; + +export const ReachableNodesMenu = () => ( + {I18n.t('Reachable Nodes')} +); \ No newline at end of file diff --git a/plugins/lime-plugin-reachable-nodes/src/reachableNodesPage.js b/plugins/lime-plugin-reachable-nodes/src/reachableNodesPage.js new file mode 100644 index 000000000..92172c6b7 --- /dev/null +++ b/plugins/lime-plugin-reachable-nodes/src/reachableNodesPage.js @@ -0,0 +1,83 @@ +import { h } from "preact"; +import { useState } from "preact/hooks"; +import Tabs from "components/tabs"; +import Loading from "components/loading"; +import { List } from "components/list"; +import { ExpandableNode } from "plugins/lime-plugin-network-nodes/src/components/expandableNode"; +import { useNetworkNodes } from "plugins/lime-plugin-network-nodes/src/networkNodesQueries"; +import Help from "components/help"; +import I18n from 'i18n-js'; + +const PageHelp = () => ( +
+

+

{I18n.t("Reachable Nodes")}
+ {I18n.t("These are the nodes that can be reached from your node, " + + "i.e. there is a working path from your node to each of them.")} +
+ {I18n.t("This information is synced periodically " + + "and can be outdated by some minutes")} +

+

+

{I18n.t("Unreachable Nodes")}
+ {I18n.t("These are the nodes that can't be reached from your node, " + + "it is possible that they are not turned on or a link to reach them is down.")} +

+
+); + +const PageTabs = ({ nodes, ...props }) => { + const nReachable = nodes.filter(n => n.status === "recently_reachable").length; + const nUnreachable = nodes.filter(n => n.status === "unreachable").length; + const tabs = [ + { key: 'recently_reachable', repr: I18n.t('Reachable (%{howMany})', { howMany: nReachable }) }, + { key: 'unreachable', repr: I18n.t('Unreachable (%{howMany})', { howMany: nUnreachable }) }, + ]; + return +} + +export const ReachableNodesPage_ = ({ nodes }) => { + const [ selectedGroup, setselectedGroup ] = useState('recently_reachable'); + const [ unfoldedNode, setunfoldedNode ] = useState(null); + + function changeUnfolded(hostname) { + if (unfoldedNode == hostname) { + setunfoldedNode(null); + return; + } + setunfoldedNode(hostname); + } + return ( +
+
+ +
+ +
+
+ + {nodes + .filter(n => n.status === selectedGroup) + .sort((a, b) => a.hostname > b.hostname) + .map( + node => + changeUnfolded(node.hostname)}/> + )} + +
+ ) +} + +const ReachableNodesPage = () => { + const { data: nodes, isLoading } = useNetworkNodes(); + + if (isLoading) { + return
+ } + + return +} + +export default ReachableNodesPage diff --git a/preact.config.js b/preact.config.js index 261f405a0..eca0a2744 100644 --- a/preact.config.js +++ b/preact.config.js @@ -43,4 +43,5 @@ export default function (config, env, helpers) { // Add common imports aliases config.resolve.alias.components = path.resolve(__dirname, 'src/components'); config.resolve.alias.utils = path.resolve(__dirname, 'src/utils'); + config.resolve.alias.plugins = path.resolve(__dirname, 'plugins'); } diff --git a/src/components/help/style.less b/src/components/help/style.less index fa98012f0..1cb7fd193 100644 --- a/src/components/help/style.less +++ b/src/components/help/style.less @@ -13,7 +13,7 @@ background: #fff; border: 0.1em solid #F39100; border-radius: 1em; - padding: 2em; + padding: 1.5rem; cursor:auto; } diff --git a/src/config.js b/src/config.js index 270a4db2d..c483915cf 100644 --- a/src/config.js +++ b/src/config.js @@ -9,6 +9,9 @@ import Fbw from '../plugins/lime-plugin-fbw'; import NetworkAdmin from '../plugins/lime-plugin-network-admin'; import Firmware from '../plugins/lime-plugin-firmware'; import RemoteSupport from '../plugins/lime-plugin-remotesupport'; +import NetworkNodes from '../plugins/lime-plugin-network-nodes'; +import DeleteNodes from '../plugins/lime-plugin-delete-nodes'; +import ReachableNodes from '../plugins/lime-plugin-reachable-nodes'; // REGISTER PLUGINS export const plugins = [ @@ -22,5 +25,7 @@ export const plugins = [ Firmware, ChangeNode, RemoteSupport, + NetworkNodes, + DeleteNodes, Fbw // fbw does not have menu item ];