diff --git a/i18n/generic.json b/i18n/generic.json
new file mode 100644
index 00000000..c194666e
--- /dev/null
+++ b/i18n/generic.json
@@ -0,0 +1,215 @@
+{
+ "en": {
+ "a_new_firmware_version_has_been_released_17f89266": "A new firmware version has been released",
+ "align_11050992": "Align",
+ "an_error_occurred_a4e1cda4": "An error occurred",
+ "applying_changes_23ae34f2": "Applying changes.",
+ "ask_for_remote_support_7e7eaab0": "Ask for remote support",
+ "at_least_one_alphanumeric_character_357817ee": "At least one alphanumeric character",
+ "at_least_one_number_bf8434bb": "At least one number",
+ "best_signal_913e3460": "Best signal",
+ "cancel_caeb1e68": "Cancel",
+ "cancel_cd471b5e": "cancel",
+ "cannot_connect_to_the_remote_support_server_6a95528b": "Cannot connect to the remote support server",
+ "cannot_load_map_check_your_internet_connection_d24f5daf": "Cannot load map, check your internet connection",
+ "change_dcaa253a": "Change",
+ "change_shared_password_58dc580c": "Change Shared Password",
+ "checking_connection_863b319e": "Checking connection",
+ "choose_a_name_for_this_node_f491aa8d": "Choose a name for this node",
+ "choose_a_name_for_your_network_69df41e3": "Choose a name for your network",
+ "choose_a_shared_password_for_network_administratio_4c98ee0f": "Choose a shared password for network administration",
+ "click_at_close_session_to_end_the_remote_support_s_1af24bb9": "Click at Close Session to end the remote support session. No one will be able to access your node with this token again",
+ "click_at_show_console_to_follow_the_remote_support_8b39ccbc": "Click at Show Console to follow the remote support session.",
+ "close_session_89aadfa2": "Close Session",
+ "cofirm_upgrade_before_seconds_seconds_or_it_will_b_48b600b6": "Cofirm upgrade before %{seconds} seconds or it will be reverted",
+ "community_name_115a617": "Community name",
+ "configure_your_network_7736471d": "Configure your network",
+ "configure_your_new_community_network_a6daad12": "Configure your new community network",
+ "confirm_6556b3a6": "Confirm",
+ "confirm_location_2fe5ae11": "confirm location",
+ "congratulations_ffe43bf9": "Congratulations",
+ "count_days_de0c6a32": {
+ "one": "1 days",
+ "other": "%{count} days"
+ },
+ "count_hours_1bd03883": {
+ "one": "1 hours",
+ "other": "%{count} hours"
+ },
+ "count_minutes_a6eeeacb": {
+ "one": "1 minutes",
+ "other": "%{count} minutes"
+ },
+ "count_people_join_sessions_c24dac9c": {
+ "one": "1 people-join-session",
+ "other": "%{count} people-join-sessions"
+ },
+ "count_seconds_2953a98e": {
+ "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",
+ "downloading_1e41f805": "Downloading",
+ "edit_location_420eadc4": "edit location",
+ "error_98e81528": "Error",
+ "fetching_name_96831fa4": "Fetching name",
+ "filename_83eeb4ac": "Filename",
+ "firmware_6a098a0d": "Firmware",
+ "from_fdd4956d": "From",
+ "full_path_metrics_2859608f": "Full path metrics",
+ "go_64ecd1fd": "Go!",
+ "go_to_community_view_d12b8d67": "Go to Community View",
+ "go_to_node_view_26ba929d": "Go to Node View",
+ "ground_routing_12ab04c9": "Ground Routing",
+ "ground_routing_configuration_3f4fa9c1": "Ground Routing configuration",
+ "hide_community_773b3f33": "hide community",
+ "hide_console_9bbb309e": "Hide Console",
+ "host_name_d865cef3": "Host name",
+ "i_don_t_know_the_shared_password_336b198": "I don't know the shared password",
+ "interface_177dac54": "Interface",
+ "internet_connection_fda60ffa": "Internet connection",
+ "ip_addresses_440ac240": "IP Addresses",
+ "join_the_mesh_653219c6": "Join the mesh",
+ "last_known_internet_path_45f31c9a": "last_known_internet_path",
+ "last_packet_82ee8e9d": "Last packet",
+ "load_last_known_internet_path_677f6229": "load_last_known_internet_path",
+ "loading_node_status_547ed318": "Loading node status...",
+ "locate_my_node_b91489b": "locate my node",
+ "logging_in_1164a773": "Logging in",
+ "login_6f3d6249": "Login",
+ "map_eb9418c7": "Map",
+ "metrics_c80fba05": "Metrics",
+ "metrics_status_gateway_2a77a113": "metrics_status_gateway",
+ "metrics_status_path_905a8d22": "metrics_status_path",
+ "metrics_status_stations_464641e8": "metrics_status_stations",
+ "more_details_on_the_release_can_be_found_at_dfc8f165": "More details on the release can be found at:",
+ "more_info_at_117c8533": "More info at:",
+ "more_than_10_characters_15a6e3bf": "More than 10 characters",
+ "more_than_a_minute_ago_a2a28531": "more than a minute ago",
+ "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",
+ "not_upgraded_howmany_5ed230c3": "Not Upgraded (%{howMany})",
+ "not_upgraded_nodes_9e67db38": "Not Upgraded Nodes",
+ "notes_c42e0fd5": "Notes",
+ "notes_of_a44a4158": "Notes of",
+ "ok_ff1b646a": "Ok",
+ "on_its_radio_radio_f32d79ce": "On its radio %{radio}",
+ "only_gateway_727b1656": "Only gateway",
+ "or_choose_a_firmware_image_from_your_device_d56be2d8": "Or choose a firmware image from your device",
+ "or_upgrade_to_latest_release_e062ddee": "Or upgrade to latest release",
+ "packet_loss_1afe48a8": "Packet loss",
+ "password_8a271b1c": "Password",
+ "please_configure_your_network_d6eb8b76": "Please configure your network",
+ "please_select_a_file_b49d6bf4": "Please select a file",
+ "please_select_a_sh_or_bin_file_4004723": "Please select a .sh or .bin file",
+ "please_verify_your_internet_connection_92ecd88c": "Please verify your internet connection",
+ "please_wait_62914c7c": "Please wait",
+ "please_wait_patiently_for_seconds_seconds_and_do_n_b98cfb66": "Please wait patiently for %{seconds} seconds and do not disconnect the device.",
+ "please_wait_while_the_device_reboots_and_reload_th_67bd290d": "Please wait while the device reboots, and reload the app",
+ "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",
+ "rescan_dff042fc": "Rescan",
+ "retry_ebd5f8ba": "Retry",
+ "revert_702e7694": "Revert",
+ "reverting_to_previous_version_e6e43529": "Reverting to previous version",
+ "save_notes_616850ea": "Save notes",
+ "scan_for_existing_networks_f7f485c": "Scan for existing networks",
+ "scanning_for_existing_networks_195ddb9b": "Scanning for existing networks",
+ "seconds_aee2098": "seconds",
+ "seconds_seconds_ago_699b6316": "%{seconds} seconds ago",
+ "see_more_b24a4422": "See More",
+ "select_a_network_to_join_b7040672": "Select a network to join",
+ "select_another_node_and_use_the_limeapp_as_you_wer_d189728": "Select another node and use the LimeApp as you were there",
+ "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",
+ "share_the_following_command_with_whoever_you_want__6fd30335": "Share the following command with whoever you want to give them access to your node",
+ "shared_password_changed_successfully_b2820acc": "Shared Password changed successfully",
+ "shared_password_dac7c19d": "Shared Password",
+ "show_community_42f3833": "show community",
+ "show_console_5d6937ac": "Show Console",
+ "signal_lost_690073": "Signal lost",
+ "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",
+ "the_firmware_is_being_upgraded_f3881802": "The firmware is being upgraded...",
+ "the_password_should_have_b9f88155": "The password should have:",
+ "the_passwords_do_not_match_62d77c67": "The passwords do not match!",
+ "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_running_the_last_version_of_th_5165bdfe": "These are the nodes running the last version of the Firmware",
+ "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.",
+ "these_are_the_nodes_that_need_to_be_upgraded_to_th_d09d104": "These are the nodes that need to be upgraded to the last version of the Firmware",
+ "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",
+ "to_keep_the_current_configuration_or_ab76f6d1": "to keep the current configuration. Or ...",
+ "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",
+ "upgrade_to_versionname_621a0b6a": "Upgrade to %{versionName}",
+ "upgraded_howmany_e439d4b1": "Upgraded (%{howMany})",
+ "upgraded_nodes_dfc85207": "Upgraded Nodes",
+ "upload_firmware_image_from_your_device_57327bee": "Upload firmware image from your device",
+ "uptime_c1d2415d": "Uptime",
+ "versionname_is_now_available_a6fbbb63": "%{versionName} is now available",
+ "visit_864b4060": "Visit",
+ "visit_a_neighboring_node_4116be4": "Visit a neighboring node",
+ "when_reloading_the_app_you_will_be_asked_to_confir_f9ecb33e": "When reloading the app you will be asked to confirm the upgrade, otherwise it will be reverted",
+ "with_radio_radio_alignin_with_531510d": "With radio %{radio} alignin with",
+ "wrong_password_try_again_3100aecf": "Wrong password, try again",
+ "you_are_connected_to_another_node_in_the_network_t_a423710a": "You are connected to another node in the network, try connecting to",
+ "you_are_now_part_of_90f2585a": "You are now part of ",
+ "you_can_search_for_mesh_networks_around_you_to_add_e6fbf1c5": "You can search for mesh networks around you to add or to create a new one.",
+ "you_can_upgrade_to_7af1ea19": "You can upgrade to:",
+ "you_don_t_go_through_any_paths_to_get_here_25203ed3": "You don't go through any paths to get here.",
+ "you_have_successfuly_connected_to_ddb8c613": "You have successfuly connected to",
+ "you_need_to_know_the_shared_password_to_enter_this_4b0c4ec1": "You need to know the shared password to enter this page",
+ "you_should_try_to_connect_to_the_network_network_8d7f515e": "You should try to connect to the network %{network}.",
+ "your_router_has_not_yet_been_configured_you_can_us_27c91373": "Your router has not yet been configured, \n\t\t\tyou can use our wizard to incorporate it into an existing network or create a new one.\n\t\t\tIf you ignore this message it will continue to work with the default configuration."
+ }
+}
\ No newline at end of file
diff --git a/i18n/translations/en.json b/i18n/translations/en.json
new file mode 100644
index 00000000..0eeaca9a
--- /dev/null
+++ b/i18n/translations/en.json
@@ -0,0 +1,67 @@
+{
+ "align_11050992" :"Align",
+ "back_to_base_443797cb" :"Back to base",
+ "base_host_a17d45a4" :"Base Host",
+ "change_dcaa253a" :"Change",
+ "connected_host_91e11459": "Connected Host",
+ "config_4877f466" :"Config",
+ "current_status_830c5a75" :"Current status",
+ "from_fdd4956d" :"From",
+ "full_path_metrics_2859608f" :"Full path metrics",
+ "interface_177dac54" :"Interface",
+ "internet_connection_fda60ffa" :"Internet connection",
+ "ip_addresses_440ac240" :"IP Addresses",
+ "load_last_known_internet_path_677f6229" :"Load last known Internet path",
+ "loading_node_status_547ed318" :"Loading node status...",
+ "locate_5f6685db" :"Locate",
+ "metrics_c80fba05" :"Metrics",
+ "metrics_status_gateway_2a77a113" :"Searching gateway",
+ "metrics_status_path_905a8d22" :"Calculating network path",
+ "metrics_status_stations_464641e8" :"Measuring links",
+ "most_active_2d5a3cae" :"Most Active",
+ "move_to_new_position_eb97c4c3" :"MOVE TO NEW POSITION",
+ "notes_c42e0fd5" :"Notes",
+ "notes_of_a44a4158" :"Notes of",
+ "only_gateway_727b1656" :"Only gateway",
+ "packet_loss_1afe48a8": "Packet loss",
+ "save_notes_616850ea" :"Save notes",
+ "select_new_base_station_3652ae73" :"Select new base station",
+ "station_75bce853" :"Station",
+ "stations_18122820" :"Stations",
+ "status_e7fdbe06" :"Status",
+ "system_55b0ca91" :"System",
+ "to_internet_494eb85c" :"To Internet",
+ "traffic_bfe536d2" :"Traffic",
+ "connection_fail_57f84354": "Connection to %{meta_ws} fail",
+ "trying_to_connect_ff82bf9f" :"Trying to connect to %{meta_ws}",
+ "try_thisnode_info_1ee1bfe2" :"Try thisnode.info",
+ "uptime_c1d2415d" :"Uptime",
+ "interfaces_44f8a99c": "Interfaces",
+ "last_known_internet_path_45f31c9a": "This your last working path to the Internet",
+ "you_should_try_to_connect_to_the_network_network_8d7f515e": "You should try to connect to the wifi network %{network}.",
+ "count_days_de0c6a32": {
+ "one": "day",
+ "other": "days"
+ },
+ "count_hours_1bd03883": {
+ "one": "hour",
+ "other": "hours"
+ },
+ "count_minutes_a6eeeacb": {
+ "one": "minute",
+ "other": "minutes"
+ },
+ "count_seconds_2953a98e": {
+ "one": "second",
+ "other": "seconds"
+ },
+ "count_people_join_sessions_c24dac9c": {
+ "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/package.json b/package.json
index d7e9ed58..c455134b 100644
--- a/package.json
+++ b/package.json
@@ -85,7 +85,6 @@
"leaflet.gridlayer.googlemutant": "0.13.4",
"preact": "10.5.7",
"preact-cli": "3.0.0",
- "preact-i18nline": "2.0.0",
"preact-router": "3.2.1",
"react-hook-form": "6.9.2",
"react-query": "2.23.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 00000000..8ec7143d
--- /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 () => ({
+ 'node1': { hostname: 'node1', status: 'recently_reachable' },
+ 'node2': { hostname: 'node2', status: 'recently_reachable' },
+ 'node3': { hostname: 'node3', status: 'recently_reachable' },
+ 'node4': { hostname: 'node4', status: 'unreachable' },
+ 'node5': { hostname: 'node5', status: 'unreachable' },
+ 'node6': { hostname: 'node6', status: 'unreachable' },
+ 'node7': { hostname: 'node7', status: 'unreachable' },
+ 'node8': { hostname: 'node8', status: 'gone' },
+ 'node9': { 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 00000000..a8ae2df8
--- /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 00000000..d4a606bb
--- /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 00000000..5f7be227
--- /dev/null
+++ b/plugins/lime-plugin-delete-nodes/src/deleteNodesMenu.js
@@ -0,0 +1,8 @@
+import { h } from 'preact';
+import { Trans } from '@lingui/macro';
+
+const Menu = () => (
+ 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 00000000..b33a0311
--- /dev/null
+++ b/plugins/lime-plugin-delete-nodes/src/deleteNodesPage.js
@@ -0,0 +1,91 @@
+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 { Trans } from '@lingui/macro';
+
+export const DeleteNodesPage_ = ({ nodes, onDelete, isSubmitting, isSuccess }) => {
+ const [selectedNodes, { toggle, has, reset }] = useSet(new Set([]));
+ const [showSuccess, setshowSuccess] = useState(false);
+ const unreachableNodes = Object.values(nodes).filter(n => n.status === "unreachable");
+
+ useEffect(() => {
+ if (isSuccess) {
+ reset();
+ setshowSuccess(true);
+ setTimeout(() => {
+ setshowSuccess(false);
+ }, 2000);
+ }
+ }, [isSuccess])
+
+ return (
+
+
+
Delete Nodes
+ {unreachableNodes.length > 0 &&
+
+
+ Select the nodes which no longer belong to the network and
+ delete them from the list of unreachable nodes
+
+
+ }
+ {unreachableNodes.length === 0 &&
+
There are no left unreachable nodes
+ }
+
+ {unreachableNodes.map(node =>
+ toggle(node.hostname)} >
+
+
+ {node.hostname}
+
+
+ )}
+
+
+
+
+
+
+ {!isSubmitting &&
+
onDelete([...selectedNodes])}
+ disabled={selectedNodes.size < 1}>
+ Delete
+
+ }
+ {isSubmitting &&
+
+
+
+ }
+
+ {showSuccess &&
+
Successfully deleted} />
+ }
+
+ )
+};
+
+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 00000000..6e844dce
--- /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 00000000..602ea57b
--- /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 00000000..009ce5c6
--- /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.findByRole('link', { name: '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 00000000..05186ad9
--- /dev/null
+++ b/plugins/lime-plugin-network-nodes/src/components/expandableNode/index.js
@@ -0,0 +1,25 @@
+import { h } from 'preact';
+import { Trans } from '@lingui/macro';
+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 (
+
+
+
+ {showMore &&
+
e.stopPropagation()}>
+ {ipv4 &&
}
+ {ipv6 &&
}
+ {board &&
Device : {board}
}
+ {fw_version &&
Firmware : {fw_version}
}
+
+ }
+
+
+ )
+}
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 00000000..2abf1859
--- /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 00000000..5dc98d63
--- /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 00000000..fc588626
--- /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', {})
+ .then(res => {
+ return res.nodes;
+ });
+
+export const markNodesAsGone = (hostnames) =>
+ api.call('network-nodes', 'mark_nodes_as_gone', { hostnames: hostnames })
+ .then(() => hostnames);
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 00000000..c9c83a9d
--- /dev/null
+++ b/plugins/lime-plugin-network-nodes/src/networkNodesApi.spec.js
@@ -0,0 +1,47 @@
+import { getNodes, markNodesAsGone } from './networkNodesApi'
+import api from 'utils/uhttpd.service';
+jest.mock('utils/uhttpd.service')
+
+beforeEach(() => {
+ api.call.mockImplementation(async () => ({ 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(async () => ({ status: 'ok', nodes }));
+ expect(await getNodes()).toEqual(nodes);
+ });
+});
+
+describe('markNodesAsGone', () => {
+ it('calls the expected endpoint', async () => {
+ api.call.mockImplementation(async () => ({ 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(async () => ({status: 'ok'}));
+ const result = await markNodesAsGone(['node1', 'node2'])
+ expect(result).toEqual(['node1', 'node2'])
+ })
+});
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 00000000..9f2019d7
--- /dev/null
+++ b/plugins/lime-plugin-network-nodes/src/networkNodesMenu.js
@@ -0,0 +1,8 @@
+import { h } from 'preact';
+import { Trans } from '@lingui/macro';
+
+const Menu = () => (
+ 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 00000000..f256d8b0
--- /dev/null
+++ b/plugins/lime-plugin-network-nodes/src/networkNodesPage.js
@@ -0,0 +1,49 @@
+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 { Trans } from '@lingui/macro';
+
+export const _NetworkNodes = ({ nodes, isLoading, unfoldedNode, onUnfold }) => {
+ if (isLoading) {
+ return
+ }
+ return (
+
+
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 00000000..45f7c376
--- /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 00000000..ad8a9356
--- /dev/null
+++ b/plugins/lime-plugin-network-nodes/src/networkNodesQueries.js
@@ -0,0 +1,18 @@
+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;
+ hostnames.forEach(hostname => {
+ result[hostname] = {...old[hostname], status: "gone"}
+ });
+ return result;
+ }
+ )
+})
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 00000000..81dacaf6
--- /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 00000000..6d601450
--- /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: 'ReachableNodes',
+ 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 00000000..e490f644
--- /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 () => ({
+ 'node1': { hostname: 'node1', status: 'recently_reachable' },
+ 'node2': { hostname: 'node2', status: 'recently_reachable' },
+ 'node3': { hostname: 'node3', status: 'recently_reachable' },
+ 'node4': { hostname: 'node4', status: 'unreachable' },
+ 'node5': { hostname: 'node5', status: 'unreachable' },
+ 'node6': { hostname: 'node6', status: 'unreachable' },
+ 'node7': { hostname: 'node7', status: 'unreachable' },
+ 'node8': { hostname: 'node8', status: 'gone' },
+ 'node9': { 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 00000000..195b3226
--- /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 00000000..72998454
--- /dev/null
+++ b/plugins/lime-plugin-reachable-nodes/src/reachableNodesMenu.js
@@ -0,0 +1,7 @@
+import { h } from 'preact';
+
+import { Trans } from '@lingui/macro';
+
+export const ReachableNodesMenu = () => (
+ Reachable Nodes
+);
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 00000000..798a80b9
--- /dev/null
+++ b/plugins/lime-plugin-reachable-nodes/src/reachableNodesPage.js
@@ -0,0 +1,88 @@
+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 { Trans } from '@lingui/macro';
+
+const PageHelp = () => (
+
+
+
Reachable Nodes
+
+ 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.
+
+
+
+
Unreachable Nodes
+
+ 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 = Object.values(nodes).filter(n => n.status === "recently_reachable").length;
+ const nUnreachable = Object.values(nodes).filter(n => n.status === "unreachable").length;
+ const tabs = [
+ { key: 'recently_reachable', repr: Reachable ({nReachable}) },
+ { key: 'unreachable', repr: Unreachable ({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 (
+
+
+
+ {Object.values(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/plugins/lime-plugin-upgraded-nodes/index.js b/plugins/lime-plugin-upgraded-nodes/index.js
new file mode 100644
index 00000000..70f42889
--- /dev/null
+++ b/plugins/lime-plugin-upgraded-nodes/index.js
@@ -0,0 +1,10 @@
+import Page from './src/upgradedNodesPage';
+import Menu from './src/upgradedNodesMenu';
+
+export default {
+ name: 'upgradedNodes',
+ page: Page,
+ menu: Menu,
+ menuView: 'community',
+ isCommunityProtected: false
+};
diff --git a/plugins/lime-plugin-upgraded-nodes/src/style.less b/plugins/lime-plugin-upgraded-nodes/src/style.less
new file mode 100644
index 00000000..0b786cbe
--- /dev/null
+++ b/plugins/lime-plugin-upgraded-nodes/src/style.less
@@ -0,0 +1,8 @@
+.stickySubheader {
+ position: sticky;
+ top: 0;
+ background-color: #325850;
+ padding: 0.2rem;
+ text-align: center;
+ color:white;
+}
diff --git a/plugins/lime-plugin-upgraded-nodes/src/upgradedNodesMenu.js b/plugins/lime-plugin-upgraded-nodes/src/upgradedNodesMenu.js
new file mode 100644
index 00000000..38dcd2f4
--- /dev/null
+++ b/plugins/lime-plugin-upgraded-nodes/src/upgradedNodesMenu.js
@@ -0,0 +1,8 @@
+import { h } from 'preact';
+import { Trans } from '@lingui/macro';
+
+const Menu = () => (
+ Upgraded Nodes
+);
+
+export default Menu;
diff --git a/plugins/lime-plugin-upgraded-nodes/src/upgradedNodesPage.js b/plugins/lime-plugin-upgraded-nodes/src/upgradedNodesPage.js
new file mode 100644
index 00000000..b14e582e
--- /dev/null
+++ b/plugins/lime-plugin-upgraded-nodes/src/upgradedNodesPage.js
@@ -0,0 +1,122 @@
+import { h, Fragment } from "preact";
+import style from "./style.less";
+import { useEffect, 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 { useNewVersion } from "plugins/lime-plugin-firmware/src/firmwareQueries";
+import Help from "components/help";
+import { Trans } from '@lingui/macro';
+
+function groupBy(xs, key) {
+ return xs.reduce(function (rv, x) {
+ let v = key instanceof Function ? key(x) : x[key];
+ let el = rv.find((r) => r && r.key === v);
+ if (el) {
+ el.values.push(x);
+ } else {
+ rv.push({ key: v, values: [x] });
+ }
+ return rv;
+ }, []);
+};
+
+const PageHelp = () => (
+
+
+
Upgraded Nodes
+
These are the nodes running the last version of the Firmware
+
+
+
Not Upgraded Nodes
+
These are the nodes that need to be upgraded to the last version of the Firmware
+
+
+);
+
+const PageTabs = ({ nodes, ...props }) => {
+ const nUpgraded = nodes.filter(n => n.group === "upgraded").length;
+ const nNotUpgraded = nodes.filter(n => n.group === "not_upgraded").length;
+ const tabs = [
+ { key: 'upgraded', repr: Upgraded ({nUpgraded}) },
+ { key: 'not_upgraded', repr: Not Upgraded ({nNotUpgraded}) },
+ ];
+ return
+}
+
+export const UpgradedNodesPage_ = ({ nodes }) => {
+ const [selectedGroup, setselectedGroup] = useState('upgraded');
+ const [unfoldedNode, setunfoldedNode] = useState(null);
+
+ function changeUnfolded(hostname) {
+ if (unfoldedNode == hostname) {
+ setunfoldedNode(null);
+ return;
+ }
+ setunfoldedNode(hostname);
+ }
+
+ const groupNodes = nodes
+ .filter(n => n.status !== 'gone')
+ .filter(n => n.group === selectedGroup);
+
+ const grouppedByVersion = groupBy(groupNodes, 'fw_version')
+ .sort((a, b) => a.key > b.key);
+
+ return (
+
+
+
+ {grouppedByVersion
+ .map((versionGroup, index) => (
+
+ {selectedGroup === 'not_upgraded' &&
+
+ }
+ {versionGroup.values
+ .map(node =>
+ changeUnfolded(node.hostname)} />
+ )}
+
+ ))
+ }
+
+
+ )
+}
+
+const UpgradedNodesPage = () => {
+ const { data: nodes, isLoading: isLoadingNodes } = useNetworkNodes();
+ const { data: newVersion, isLoading: isLoadingVersion } = useNewVersion();
+ const [taggedNodes, setTaggedNodes] = useState(undefined);
+
+ useEffect(() => {
+ if (nodes && newVersion) {
+ const taggedNodes = [...Object.values(nodes)].map(
+ n => ({
+ ...n,
+ group: n.fw_version === newVersion.version ? 'upgraded' : 'not_upgraded'
+ }));
+ setTaggedNodes(taggedNodes);
+ }
+ }, [nodes, newVersion])
+
+ if (isLoadingNodes || isLoadingVersion || taggedNodes === undefined) {
+ return
+ }
+
+ return
+}
+
+export default UpgradedNodesPage
diff --git a/plugins/lime-plugin-upgraded-nodes/upgradedNodes.spec.js b/plugins/lime-plugin-upgraded-nodes/upgradedNodes.spec.js
new file mode 100644
index 00000000..aa3d68be
--- /dev/null
+++ b/plugins/lime-plugin-upgraded-nodes/upgradedNodes.spec.js
@@ -0,0 +1,52 @@
+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 UpgradedNodesPage from './src/upgradedNodesPage';
+import { getNodes } from 'plugins/lime-plugin-network-nodes/src/networkNodesApi';
+import { getNewVersion } from 'plugins/lime-plugin-firmware/src/firmwareApi';
+
+jest.mock('plugins/lime-plugin-network-nodes/src/networkNodesApi');
+jest.mock('plugins/lime-plugin-firmware/src/firmwareApi');
+
+
+describe('upgradedNodes', () => {
+ beforeEach(() => {
+ getNodes.mockImplementation(async () => ({
+ 'node1': { hostname: 'node1', status: 'unreachable', fw_version: 'LibreRouterOS 1.4' },
+ 'node2': { hostname: 'node2', status: 'recently_reachable', fw_version: 'LibreRouterOS 1.4' },
+ 'node3': { hostname: 'node3', status: 'unreachable', fw_version: 'LibreRouterOS 1.3' },
+ 'node4': { hostname: 'node4', status: 'recently_reachable', fw_version: 'LibreRouterOS 1.3' },
+ }));
+ getNewVersion.mockImplementation(async () => ({
+ version: 'LibreRouterOS 1.4'
+ }))
+ });
+
+ afterEach(() => {
+ cleanup();
+ act(() => queryCache.clear());
+ });
+
+ it('shows one tab for upgraded nodes and one for not upgradaded nodes with length', async () => {
+ render( );
+ expect(await screen.findByRole('tab', { name: /^Upgraded \(2\)/i })).toBeVisible();
+ expect(await screen.findByRole('tab', { name: /^Not Upgraded \(2\)/i })).toBeVisible();
+ })
+
+ it('shows one row with the hostname for each upgraded node', async () => {
+ render( );
+ expect(await screen.findByText('node1')).toBeVisible();
+ expect(await screen.findByText('node2')).toBeVisible();
+ })
+
+ it('shows one row with the hostname for each not upgraded node', async () => {
+ render( );
+ const tabNotUpgraded = await screen.findByRole('tab', { name: /^Not Upgraded \(2\)/i });
+ fireEvent.click(tabNotUpgraded);
+ expect(await screen.findByText('node3')).toBeVisible();
+ expect(await screen.findByText('node4')).toBeVisible();
+ })
+});
diff --git a/plugins/lime-plugin-upgraded-nodes/upgradedNodes.stories.js b/plugins/lime-plugin-upgraded-nodes/upgradedNodes.stories.js
new file mode 100644
index 00000000..3f3dbe58
--- /dev/null
+++ b/plugins/lime-plugin-upgraded-nodes/upgradedNodes.stories.js
@@ -0,0 +1,55 @@
+import { UpgradedNodesPage_ } from './src/upgradedNodesPage';
+
+export default {
+ title: 'Containers/Upgraded Nodes'
+}
+
+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.3',
+ group: 'not_upgraded'
+ },
+ { hostname: "ql-czuk-bbone", status: "recently_reachable",
+ ipv4:'10.5.0.9', ipv6: 'fd0d:fe46:8ce8::8bbf:7500',
+ board: 'LibreRouter v1', fw_version: 'LibreRouterOS 1.3',
+ group: 'not_upgraded'
+ },
+ { hostname: "si-soniam", status: "recently_reachable",
+ ipv4:'10.5.0.16', ipv6: 'fd0d:fe46:8ce8::8bbf:7500',
+ board: 'LibreRouter v1', fw_version: 'LibreRouterOS 1.4',
+ group: 'upgraded'
+ },
+ { hostname: "ql-berta", status: "recently_reachable",
+ ipv4:'10.5.0.9', ipv6: 'fd0d:fe46:8ce8::8bbf:7500',
+ board: 'LibreRouter v1', fw_version: 'LibreRouterOS 1.4',
+ group: 'upgraded'
+ },
+ { hostname: "ql-nelson", status: "recently_reachable",
+ ipv4:'10.5.0.9', ipv6: 'fd0d:fe46:8ce8::8bbf:7500',
+ board: 'LibreRouter v1', fw_version: 'LibreRouterOS 1.3',
+ group: 'not_upgraded'
+ },
+ { hostname: "ql-irene", status: "recently_reachable",
+ ipv4:'10.5.0.9', ipv6: 'fd0d:fe46:8ce8::8bbf:7500',
+ board: 'LibreRouter v1', fw_version: 'LibreRouterOS 1.3',
+ group: 'not_upgraded'
+ },
+ { hostname: "ql-guillermina", status: "recently_reachable",
+ ipv4:'10.5.0.9', ipv6: 'fd0d:fe46:8ce8::8bbf:7500',
+ board: 'LibreRouter v1', fw_version: 'LibreRouterOS 1.3',
+ group: 'not_upgraded'
+ },
+ { hostname: "ql-silviak", status: "recently_reachable",
+ ipv4:'10.5.0.9', ipv6: 'fd0d:fe46:8ce8::8bbf:7500',
+ board: 'LibreRouter v1', fw_version: 'LibreRouterOS 1.3',
+ group: 'not_upgraded'
+ },
+ { hostname: "ql-oncelotes", status: "recently_reachable",
+ ipv4:'10.5.0.9', ipv6: 'fd0d:fe46:8ce8::8bbf:7500',
+ board: 'LibreRouter v1', fw_version: 'LibreRouterOS 1.2',
+ group: 'not_upgraded'
+ },
+];
+
+export const upgradedNodesPage = () =>
diff --git a/src/components/help/style.less b/src/components/help/style.less
index fa98012f..1cb7fd19 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 99197c6a..f9ab9b77 100644
--- a/src/config.js
+++ b/src/config.js
@@ -10,6 +10,10 @@ import NetworkAdmin from '../plugins/lime-plugin-network-admin';
import Firmware from '../plugins/lime-plugin-firmware';
import RemoteSupport from '../plugins/lime-plugin-remotesupport';
import Pirania from '../plugins/lime-plugin-pirania';
+import NetworkNodes from '../plugins/lime-plugin-network-nodes';
+import DeleteNodes from '../plugins/lime-plugin-delete-nodes';
+import ReachableNodes from '../plugins/lime-plugin-reachable-nodes';
+import UpgradedNodes from '../plugins/lime-plugin-upgraded-nodes';
// REGISTER PLUGINS
export const plugins = [
@@ -24,5 +28,9 @@ export const plugins = [
ChangeNode,
RemoteSupport,
Pirania,
+ NetworkNodes,
+ ReachableNodes,
+ UpgradedNodes,
+ DeleteNodes,
Fbw // fbw does not have menu item
];