Version 2.5.18

This commit is contained in:
Charles
2022-01-12 14:49:09 +01:00
parent 917c31bef4
commit 54b7a27e65
103 changed files with 5811 additions and 2146 deletions

597
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ucentral-client",
"version": "2.4.1",
"version": "2.5.18",
"dependencies": {
"@coreui/coreui": "^3.4.0",
"@coreui/icons": "^2.0.1",
@@ -26,7 +26,7 @@
"react-tooltip": "^4.2.21",
"react-widgets": "^5.1.1",
"sass": "^1.35.1",
"ucentral-libs": "^1.0.31",
"ucentral-libs": "^1.0.57",
"uuid": "^8.3.2"
},
"main": "index.js",
@@ -82,7 +82,6 @@
"husky": "^4.3.8",
"lint-staged": "^11.0.0",
"mini-css-extract-plugin": "^1.6.1",
"node-sass": "^5.0.0",
"path": "^0.12.7",
"prettier": "^2.3.2",
"react-refresh": "^0.9.0",

View File

@@ -17,6 +17,7 @@
"blink": "LEDs Blinken",
"device_leds": "LEDs",
"execute_now": "Möchten Sie dieses Muster jetzt einstellen?",
"explanation": "Welches Muster möchten Sie auf diesem Gerät für 30 Sekunden einstellen?",
"pattern": "Wählen Sie das Muster, das Sie verwenden möchten:",
"set_leds": "LEDs einstellen",
"when_blink_leds": "Wann möchten Sie die LEDs blinken lassen?"
@@ -37,9 +38,11 @@
"add_note": "Notiz hinzufügen",
"add_note_explanation": "Schreiben Sie unten Ihre neue Notiz und klicken Sie auf die Schaltfläche \"+\", wo Sie fertig sind",
"adding_ellipsis": "Hinzufügen ...",
"all": "Alles",
"are_you_sure": "Bist du sicher?",
"back_to_login": "Zurück zur Anmeldung",
"back_to_start": "Zurück zum Start",
"blacklist": "Schwarze Liste",
"by": "Durch",
"cancel": "Abbrechen",
"certificate": "Zertifikat",
@@ -62,12 +65,14 @@
"create": "Erstellen",
"created": "Erstellt",
"created_by": "Erstellt von",
"creator": "Schöpfer",
"current": "Aktuell",
"custom_date": "Benutzerdefiniertes Datum",
"dashboard": "Instrumententafel",
"date": "Datum",
"day": "tag",
"days": "tage",
"default_map": "Standardkarte",
"delete": "Löschen",
"delete_device": "Gerät löschen",
"details": "Einzelheiten",
@@ -85,6 +90,7 @@
"dismiss": "entlassen",
"do_now": "Sofort",
"download": "Herunterladen",
"duplicate": "Duplikat",
"duration": "Dauer",
"edit": "Bearbeiten",
"edit_user": "Bearbeiten",
@@ -94,6 +100,7 @@
"error": "Fehler",
"error_adding_note": "Fehler beim Hinzufügen einer Notiz",
"error_code": "Fehlercode",
"errors": "Fehler",
"execute_now": "Möchten Sie diesen Befehl jetzt ausführen?",
"executed": "Ausgeführt",
"exit": "Ausgang",
@@ -142,14 +149,16 @@
"no_items": "Keine Gegenstände",
"none": "Keiner",
"not_connected": "Nicht verbunden",
"of_connected": "% der Geräte",
"of_connected": "% der verbundenen Geräte",
"off": "Aus",
"on": "An",
"optional": "Wahlweise",
"overall_health": "Allgemeine Gesundheit",
"password_policy": "Kennwortrichtlinie",
"preferences": "Einstellungen",
"preview": "Vorschau",
"program": "Programm",
"reason": "Grund",
"recorded": "Verzeichnet",
"refresh": "Aktualisierung",
"refresh_device": "Gerät aktualisieren",
@@ -170,12 +179,14 @@
"show_all": "Zeige alles",
"socket_connection_closed": "Verbindung geschlossen!",
"start": "Start",
"status": "Status",
"stop_editing": "Stoppen Sie die Bearbeitung",
"submit": "Absenden",
"submitted": "Eingereicht",
"success": "Erfolg",
"system": "System",
"table": "Tabelle",
"time_per_device": "Gerät/Sekunde",
"timestamp": "Zeit",
"to": "zu",
"type": "Art",
@@ -190,6 +201,7 @@
"uuid": "UUID",
"vendors": "Anbieter",
"view_more": "Mehr anzeigen",
"visibility": "Sichtweite",
"yes": "Ja"
},
"configuration": {
@@ -210,6 +222,8 @@
"creation_success": "Konfiguration erfolgreich erstellt!",
"currently_associated": "Aktuell zugeordnete Konfiguration: {{config}}",
"currently_selected_config": "Derzeit ausgewählte Konfiguration: {{config}}",
"default_configs": "Standardkonfigurationen",
"default_configurations": "Standardkonfigurationen",
"delete_config": "Konfiguration löschen",
"details": "Gerätedetails",
"device_password": "Passwort",
@@ -218,6 +232,7 @@
"devices_affected": "Von dieser Konfiguration betroffene Geräte:",
"edit_configuration": "Konfiguration bearbeiten",
"error_delete": "Fehler beim Versuch zu löschen: {{error}}",
"error_delete_blacklist": "Fehler beim Löschen aus der schwarzen Liste: {{error}}",
"error_fetching_config": "Fehler beim Abrufen der Konfiguration",
"error_trying_delete": "Fehler beim Versuch zu löschen: {{error}}",
"error_update": "Fehler: {{error}}",
@@ -261,6 +276,7 @@
"contact": {
"access_pin": "Zugangs-PIN",
"add_contact": "Kontakt hinzufügen",
"contact": "Kontakt",
"create_contact": "Kontakt erstellen",
"currently_selected_contact": "Aktuell ausgewählter Kontakt: {{contact}}",
"delete": "Kontakt löschen?",
@@ -300,12 +316,23 @@
"healthchecks_title": "Healthchecks löschen"
},
"device": {
"add_to_blacklist": "Gerät zur Blacklist hinzufügen",
"all_devices": "Alle Geräte",
"blacklisted_on": "Datum",
"capabilities": "Fähigkeiten",
"certificate_explanation": "Zertifikate der angeschlossenen Geräte",
"edit_blacklist": "Gerät auf der schwarzen Liste bearbeiten",
"error_adding_blacklist": "Fehler beim Hinzufügen des Geräts zur Blacklist: {{error}}",
"error_edit_blacklist": "Fehler beim Bearbeiten der schwarzen Liste: {{error}}",
"error_fetching_device": "Fehler beim Abrufen der Geräteinformationen: {{error}}",
"error_fetching_devices": "Fehler beim Abrufen von Geräten: {{error}}",
"health_explanation": "Zustand der angeschlossenen Geräte",
"memory_explanation": "Von angeschlossenen Geräten belegter Speicher",
"uptimes_explanation": "Zeit, zu der verbundene Geräte aktiv und verbunden waren"
"health_explanation": "Zustand der verbundenen Geräte ((Geräte = 100 % * 100 + Geräte > 90 % * 95 + Geräte > 60 % * 75 + Geräte < 60 % * 35) / Verbundene Geräte)",
"memory_explanation": "Anzahl verbundener Geräte mit entsprechendem belegtem Speicher %",
"remove_from_blacklist": "Von der schwarzen Liste entfernen",
"success_added_blacklist": "Gerät erfolgreich zur Blacklist hinzugefügt!",
"success_edit_blacklist": "Blacklist erfolgreich bearbeitet!",
"success_removed_blacklist": "Gerät erfolgreich von Blacklist entfernt!",
"uptimes_explanation": "Anzahl der verbundenen Geräte basierend auf ihrer Betriebszeit"
},
"device_logs": {
"log": "Protokoll",
@@ -320,23 +347,32 @@
"add_success": "Entität erfolgreich erstellt!",
"assigned_inventory": "Zugewiesenes Inventar",
"cannot_delete": "Entitäten mit untergeordneten Elementen können nicht gelöscht werden. Löschen Sie die untergeordneten Elemente dieser Entität, um sie löschen zu können.",
"confirm_map_delete": "Möchten Sie die Karte {{name}}wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden",
"currently_selected_entity": "Derzeit ausgewähltes Unternehmen: {{config}}",
"currently_selected_venue": "Aktuell ausgewählter Veranstaltungsort: {{config}}",
"delete_success": "Entität erfolgreich gelöscht",
"delete_warning": "Achtung: Dieser Vorgang kann nicht rückgängig gemacht werden",
"duplicate_from_node": "Mit einem bestimmten Root-Knoten duplizieren",
"duplicate_map": "Karte duplizieren",
"duplicate_with_node": "Dupliziere {{mapName}} mit {{rootName}} als Root-Knoten",
"edit_failure": "Aktualisierung fehlgeschlagen : {{error}}",
"enter_here": "Geben Sie hier die IP(s) ein, die Sie hinzufügen möchten",
"entire_tree": "Seitenverzeichnis",
"entire_tree": "Netzwerkkarte",
"entities": "Entitäten",
"entity": "Entität",
"error_deleting_map": "Fehler beim Löschen der Karte: {{error}}",
"error_fetch_entity": "Fehler beim Abrufen von Entitätsinformationen",
"error_fetching": "Fehler beim Abrufen von Entitäten",
"error_fetching_map": "Fehler beim Abrufen der Karte: {{error}}",
"error_fetching_tree": "Fehler beim Abrufen des Baums: {{error}}",
"error_saving": "Fehler beim Speichern der Entität",
"error_saving_map": "Fehler beim Speichern der Karte: {{error}}",
"higher_priority": "Stellen Sie eine höhere Priorität ein",
"ip_detection": "IP-Erkennung",
"ip_formats": "Sie können IPv4- oder IPv6-Adressen in den folgenden Formaten hinzufügen:",
"lower_priority": "Niedrigere Priorität setzen",
"map": "Karte",
"map_delete_success": "Karte erfolgreich gelöscht!",
"need_select_entity": "sSie müssen eine Entität aus der folgenden Tabelle auswählen",
"no_ips": "Keine IPs ausgewählt",
"not_assigned": "Nicht zugeordnet",
@@ -344,6 +380,7 @@
"select_entity": "Wählen Sie diese Entität aus",
"selected_entity": "Ausgewählte Einheit",
"selected_map": "Ausgewählte Karte",
"tree_saved": "Karte erfolgreich gespeichert!",
"update_failure_error": "Fehler beim Versuch, die Entität zu aktualisieren: {{error}}",
"valid_serial": "Muss eine gültige Seriennummer sein (12 HEX-Zeichen)",
"venues": "Veranstaltungsorte"
@@ -541,6 +578,9 @@
"verification_code": "Geben Sie hier Ihre Bestätigung ein",
"wrong_code": "Der eingegebene Bestätigungscode ist ungültig."
},
"preferences": {
"provisioning": "Bereitstellung"
},
"reboot": {
"directions": "Wann möchten Sie dieses Gerät neu starten?",
"now": "Möchten Sie dieses Gerät jetzt neu starten?",
@@ -584,7 +624,7 @@
"mac_prefix": "MAC-Präfix",
"max_associations": "max. Verbände",
"max_clients": "Max. Kunden",
"messages_transmitted": "Gesendete Nachrichten",
"messages_transmitted": "Nachricht TX",
"min_associations": "Mindest. Verbände",
"min_clients": "Mindest. Kunden",
"pause": "Pause",
@@ -592,7 +632,7 @@
"prefix_length": "Erforderlich, muss eine Länge von 6 Zeichen haben",
"previous_runs": "Vorherige Läufe",
"received": "empfangen",
"received_messages": "Erhaltene Nachrichten",
"received_messages": "Nachricht RX",
"reconnect_interval": "Wiederverbindungsintervall",
"resume": "Fortsetzen",
"resume_success": "Lauf wieder aufgenommen!",
@@ -634,6 +674,19 @@
"uptime": "Betriebszeit",
"used_total_memory": "{{used}} verwendet / {{total}} insgesamt"
},
"subscriber": {
"create": "Abonnenten erstellen",
"edit": "Abonnent bearbeiten",
"error_create": "Fehler beim Erstellen des Abonnenten: {{error}}",
"error_delete": "Fehler beim Löschen des Abonnenten: {{error}}",
"error_fetching": "Fehler beim Abrufen von Abonnenten: {{error}}",
"error_fetching_single": "Fehler beim Abrufen des Abonnenten: {{error}}",
"error_update": "Fehler beim Aktualisieren des Abonnenten: {{error}}",
"subscribers": "Abonnenten",
"success_create": "Abonnent erfolgreich erstellt!",
"success_delete": "Abonnent erfolgreich gelöscht!",
"success_update": "Abonnent erfolgreich aktualisiert!"
},
"system": {
"error_fetching": "Fehler beim Abrufen von Systeminformationen",
"error_reloading": "Fehler beim Neuladen: {{error}}",
@@ -723,6 +776,7 @@
"send_code_again": "Code nochmal senden",
"show_hide_password": "Passwort anzeigen/verbergen",
"successful_validation": "Telefonnummer bestätigt! Klicken Sie auf die Schaltfläche Speichern, um es mit Ihrem Profil zu verknüpfen",
"table_title": "Admin-Benutzer",
"update_failure": "Fehler beim Aktualisieren: {{error}}",
"update_failure_title": "Update fehlgeschlagen",
"update_success": "Benutzer erfolgreich aktualisiert",

View File

@@ -17,6 +17,7 @@
"blink": "Blink",
"device_leds": "Device LEDs",
"execute_now": "Would you like to set this pattern now?",
"explanation": "What pattern would you like to set on this device for 30 seconds?",
"pattern": "LEDs pattern: ",
"set_leds": "Set LEDs",
"when_blink_leds": "When would you like to make the device LEDs blink?"
@@ -37,9 +38,11 @@
"add_note": "Add Note",
"add_note_explanation": "Write your new note below and click the '+' button where you are done",
"adding_ellipsis": "Adding...",
"all": "All",
"are_you_sure": "Are you sure?",
"back_to_login": "Back to Login",
"back_to_start": "Back to start",
"blacklist": "Blacklist",
"by": "By",
"cancel": "Cancel",
"certificate": "Certificate",
@@ -62,12 +65,14 @@
"create": "Create",
"created": "Created",
"created_by": "Created By",
"creator": "Creator",
"current": "Current ",
"custom_date": "Custom Date",
"dashboard": "Dashboard",
"date": "Date",
"day": "day",
"days": "days",
"default_map": "Default Map",
"delete": "Delete",
"delete_device": "Delete Device",
"details": "Details",
@@ -85,6 +90,7 @@
"dismiss": "Dismiss",
"do_now": "Do Now!",
"download": "Download",
"duplicate": "Duplicate",
"duration": "Duration",
"edit": "Edit",
"edit_user": "Edit",
@@ -94,6 +100,7 @@
"error": "Error",
"error_adding_note": "Error while adding note",
"error_code": "Error Code",
"errors": "Errors",
"execute_now": "Would you like to execute this command now?",
"executed": "Executed",
"exit": "Exit",
@@ -142,14 +149,16 @@
"no_items": "No Items",
"none": "None",
"not_connected": "Not Connected",
"of_connected": "% of devices",
"of_connected": "% of connected devices",
"off": "Off",
"on": "On",
"optional": "Optional",
"overall_health": "Overall Health",
"password_policy": "Password Policy",
"preferences": "Preferences",
"preview": "Preview",
"program": "Program",
"reason": "Reason",
"recorded": "Recorded",
"refresh": "Refresh",
"refresh_device": "Refresh Device",
@@ -170,12 +179,14 @@
"show_all": "Show All",
"socket_connection_closed": "Connection closed!",
"start": "Start",
"status": "Status",
"stop_editing": "Stop Editing",
"submit": "Submit",
"submitted": "Submitted",
"success": "Success",
"system": "System",
"table": "Table",
"time_per_device": "Devices/Second",
"timestamp": "Time",
"to": "To",
"type": "Type",
@@ -190,6 +201,7 @@
"uuid": "UUID",
"vendors": "Vendors",
"view_more": "View more",
"visibility": "Visibility",
"yes": "Yes"
},
"configuration": {
@@ -210,6 +222,8 @@
"creation_success": "Configuration successfully created!",
"currently_associated": "Currently Associated Configuration: {{config}}",
"currently_selected_config": "Currently Selected Configuration: {{config}}",
"default_configs": "Default Configs",
"default_configurations": "Default Configurations",
"delete_config": "Delete Config",
"details": "Details",
"device_password": "Password",
@@ -218,6 +232,7 @@
"devices_affected": "Devices affected by this configuration: ",
"edit_configuration": "Edit Configuration",
"error_delete": "Error while trying to delete: {{error}}",
"error_delete_blacklist": "Error deleting from blacklist: {{error}}",
"error_fetching_config": "Error while fetching configuration",
"error_trying_delete": "Error while trying to delete: {{error}}",
"error_update": "Error: {{error}}",
@@ -261,6 +276,7 @@
"contact": {
"access_pin": "Access PIN",
"add_contact": "Add Contact",
"contact": "Contact",
"create_contact": "Create Contact",
"currently_selected_contact": "Currently Selected Contact: {{contact}}",
"delete": "Delete Contact?",
@@ -300,12 +316,23 @@
"healthchecks_title": "Delete Healthchecks"
},
"device": {
"add_to_blacklist": "Add Device To Blacklist",
"all_devices": "All Devices",
"blacklisted_on": "Date",
"capabilities": "Capabilities",
"certificate_explanation": "Certificates of connected devices",
"edit_blacklist": "Edit Blacklisted Device",
"error_adding_blacklist": "Error adding device to blacklist: {{error}}",
"error_edit_blacklist": "Error editing blacklist: {{error}}",
"error_fetching_device": "Error fetching device information: {{error}}",
"error_fetching_devices": "Error while fetching devices: {{error}}",
"health_explanation": "Health of connected devices",
"memory_explanation": "Memory used by connected devices",
"uptimes_explanation": "Time connected devices have been up and connected"
"health_explanation": "Health of connected devices ((Devices=100% * 100 + Devices>90% * 95 + Devices>60% * 75 + Devices<60% * 35) / ConnectedDevices)",
"memory_explanation": "Amount of connected devices with corresponding memory used percentage",
"remove_from_blacklist": "Remove from blacklist",
"success_added_blacklist": "Device successfully added to blacklist!",
"success_edit_blacklist": "Successfully edited blacklist!",
"success_removed_blacklist": "Successfully removed device from blacklist!",
"uptimes_explanation": "Amount of devices connected based on their uptime"
},
"device_logs": {
"log": "Log",
@@ -320,23 +347,32 @@
"add_success": "Entity Successfully Created!",
"assigned_inventory": "Assigned Inventory",
"cannot_delete": "You cannot delete entities which have children. Delete this entity's children to be able to delete it.",
"confirm_map_delete": "Are you sure you want to delete the map {{name}}? This action cannot be reverted",
"currently_selected_entity": "Currently Selected Entity: {{config}}",
"currently_selected_venue": "Currently Selected Venue: {{config}}",
"delete_success": "Entity Successfully Deleted",
"delete_warning": "Warning: this operation cannot be reverted",
"duplicate_from_node": "Duplicate with specific Root Node",
"duplicate_map": "Duplicate Map",
"duplicate_with_node": "Duplicate {{mapName}} with {{rootName}} as root node",
"edit_failure": "Update unsuccessful : {{error}}",
"enter_here": "Enter the IP(s) you'd like to add here",
"entire_tree": "Site Map",
"entire_tree": "Network Map",
"entities": "Entities",
"entity": "Entity",
"error_deleting_map": "Error deleting map: {{error}}",
"error_fetch_entity": "Error while fetching entity information",
"error_fetching": "Error while fetching entities",
"error_fetching_map": "Error fetching map: {{error}}",
"error_fetching_tree": "Error while fetching tree: {{error}}",
"error_saving": "Error while saving entity",
"error_saving_map": "Error saving map: {{error}}",
"higher_priority": "Make Higher Priority",
"ip_detection": "IP Detection",
"ip_formats": "You can add IPv4 or IPv6 addresses in the following formats:",
"lower_priority": "Make Lower Priority",
"map": "Map",
"map_delete_success": "Map Successfully Deleted!",
"need_select_entity": "You need to select an entity from the table below",
"no_ips": "No IPs selected",
"not_assigned": "Not Assigned",
@@ -344,6 +380,7 @@
"select_entity": "Select this Entity",
"selected_entity": "Selected Entity",
"selected_map": "Selected Map",
"tree_saved": "Map Successfully Saved!",
"update_failure_error": "Error while trying to update entity: {{error}}",
"valid_serial": "Needs to be a valid serial number (12 HEX characters)",
"venues": "Venues"
@@ -541,6 +578,9 @@
"verification_code": "Enter your verification here",
"wrong_code": "The verification code that was entered is not valid. "
},
"preferences": {
"provisioning": "Provisioning"
},
"reboot": {
"directions": "When would you like to reboot this device?",
"now": "Would you like to reboot this device now?",
@@ -584,7 +624,7 @@
"mac_prefix": "MAC Prefix",
"max_associations": "Max. Associations",
"max_clients": "Max. Clients",
"messages_transmitted": "Messages Transmitted",
"messages_transmitted": "Msgs TX",
"min_associations": "Min. Associations",
"min_clients": "Min. Clients",
"pause": "Pause",
@@ -592,7 +632,7 @@
"prefix_length": "Required, needs to be of a length of 6 characters",
"previous_runs": "Previous Runs",
"received": "Received",
"received_messages": "Messages Received",
"received_messages": "Msgs RX",
"reconnect_interval": "Reconnect Interval",
"resume": "Resume",
"resume_success": "Run Resumed!",
@@ -634,6 +674,19 @@
"uptime": "Uptime",
"used_total_memory": "{{used}} used / {{total}} total "
},
"subscriber": {
"create": "Create Subscriber",
"edit": "Edit Subscriber",
"error_create": "Error creating subscriber: {{error}}",
"error_delete": "Error deleting subscriber: {{error}}",
"error_fetching": "Error fetching subscribers: {{error}}",
"error_fetching_single": "Error fetching subscriber: {{error}}",
"error_update": "Error updating subscriber: {{error}}",
"subscribers": "Subscribers",
"success_create": "Subscriber successfully created!",
"success_delete": "Subscriber successfully deleted!",
"success_update": "Successfully updated subscriber!"
},
"system": {
"error_fetching": "Error while fetching system information",
"error_reloading": "Error while reloading: {{error}}",
@@ -723,6 +776,7 @@
"send_code_again": "Send Code Again",
"show_hide_password": "Show/Hide Password",
"successful_validation": "Phone Number Validated! Click the save button to link it to your profile",
"table_title": "Admin Users",
"update_failure": "Error while trying to update: {{error}}",
"update_failure_title": "Update Failed",
"update_success": "User Updated Successfully",

View File

@@ -17,6 +17,7 @@
"blink": "Parpadeo",
"device_leds": "LED de dispositivo",
"execute_now": "¿Le gustaría establecer este patrón ahora?",
"explanation": "¿Qué patrón le gustaría establecer en este dispositivo durante 30 segundos?",
"pattern": "Elija el patrón que le gustaría usar:",
"set_leds": "Establecer LED",
"when_blink_leds": "¿Cuándo desea que los LED del dispositivo parpadeen?"
@@ -37,9 +38,11 @@
"add_note": "Añadir la nota",
"add_note_explanation": "Escriba su nueva nota a continuación y haga clic en el botón '+' donde haya terminado",
"adding_ellipsis": "Añadiendo ...",
"all": "TODOS",
"are_you_sure": "¿Estás seguro?",
"back_to_login": "Atrás para iniciar sesión",
"back_to_start": "volver a empezar",
"blacklist": "Lista negra",
"by": "Por",
"cancel": "Cancelar",
"certificate": "Certificado",
@@ -62,12 +65,14 @@
"create": "Crear",
"created": "creado",
"created_by": "Creado por",
"creator": "Creador",
"current": "Corriente",
"custom_date": "Fecha personalizada",
"dashboard": "Tablero",
"date": "Fecha",
"day": "día",
"days": "días",
"default_map": "Mapa predeterminado",
"delete": "Borrar",
"delete_device": "Eliminar dispositivo",
"details": "Detalles",
@@ -85,6 +90,7 @@
"dismiss": "Despedir",
"do_now": "¡Hagan ahora!",
"download": "Descargar",
"duplicate": "Duplicar",
"duration": "Duración",
"edit": "Editar",
"edit_user": "Editar",
@@ -94,6 +100,7 @@
"error": "Error",
"error_adding_note": "Error al agregar una nota",
"error_code": "código de error",
"errors": "Los errores",
"execute_now": "¿Le gustaría ejecutar este comando ahora?",
"executed": "ejecutado",
"exit": "salida",
@@ -142,14 +149,16 @@
"no_items": "No hay articulos",
"none": "Ninguna",
"not_connected": "No conectado",
"of_connected": "% de dispositivos",
"of_connected": "% de dispositivos conectados",
"off": "Apagado",
"on": "en",
"optional": "Opcional",
"overall_health": "Salud en general",
"password_policy": "Política de contraseñas",
"preferences": "Preferencias",
"preview": "Avance",
"program": "Programa",
"reason": "Razón",
"recorded": "Grabado",
"refresh": "Refrescar",
"refresh_device": "Actualizar dispositivo",
@@ -170,12 +179,14 @@
"show_all": "Mostrar todo",
"socket_connection_closed": "¡Conexión cerrada!",
"start": "comienzo",
"status": "Estado",
"stop_editing": "Dejar de editar",
"submit": "Enviar",
"submitted": "Presentado",
"success": "Éxito",
"system": "Sistema",
"table": "Mesa",
"time_per_device": "Dispositivo / segundo",
"timestamp": "hora",
"to": "a",
"type": "Tipo",
@@ -190,6 +201,7 @@
"uuid": "UUID",
"vendors": "Vendedores",
"view_more": "Ver más",
"visibility": "Visibilidad",
"yes": "Sí"
},
"configuration": {
@@ -210,6 +222,8 @@
"creation_success": "¡Configuración creada con éxito!",
"currently_associated": "Configuración asociada actual: {{config}}",
"currently_selected_config": "Configuración seleccionada actualmente: {{config}}",
"default_configs": "Configuraciones predeterminadas",
"default_configurations": "Configuraciones predeterminadas",
"delete_config": "Eliminar Configuración",
"details": "Detalles",
"device_password": "Contraseña",
@@ -218,6 +232,7 @@
"devices_affected": "Dispositivos afectados por esta configuración:",
"edit_configuration": "Editar configuración",
"error_delete": "Error al intentar eliminar: {{error}}",
"error_delete_blacklist": "Error al eliminar de la lista negra: {{error}}",
"error_fetching_config": "Error al obtener la configuración",
"error_trying_delete": "Error al intentar eliminar: {{error}}",
"error_update": "Error: {{error}}",
@@ -261,6 +276,7 @@
"contact": {
"access_pin": "PIN de acceso",
"add_contact": "Agregar contacto",
"contact": "Contacto",
"create_contact": "Crear contacto",
"currently_selected_contact": "Contacto seleccionado actualmente: {{contact}}",
"delete": "¿Borrar contacto?",
@@ -300,12 +316,23 @@
"healthchecks_title": "Eliminar comprobaciones de estado"
},
"device": {
"add_to_blacklist": "Agregar dispositivo a la lista negra",
"all_devices": "Todos los dispositivos",
"blacklisted_on": "Fecha",
"capabilities": "capacidades",
"certificate_explanation": "Certificados de dispositivos conectados",
"edit_blacklist": "Editar dispositivo incluido en la lista negra",
"error_adding_blacklist": "Error al agregar el dispositivo a la lista negra: {{error}}",
"error_edit_blacklist": "Error al editar la lista negra: {{error}}",
"error_fetching_device": "Error al obtener la información del dispositivo: {{error}}",
"error_fetching_devices": "Error al recuperar dispositivos: {{error}}",
"health_explanation": "Salud de los dispositivos conectados",
"memory_explanation": "Memoria utilizada por dispositivos conectados",
"uptimes_explanation": "Tiempo que los dispositivos conectados han estado en funcionamiento y conectados"
"health_explanation": "Estado de los dispositivos conectados ((Dispositivos = 100% * 100 + Dispositivos> 90% * 95 + Dispositivos> 60% * 75 + Dispositivos <60% * 35) / Dispositivos conectados)",
"memory_explanation": "Cantidad de dispositivos conectados con la memoria correspondiente utilizada%",
"remove_from_blacklist": "ELIMINAR DE LA LISTA NEGRA",
"success_added_blacklist": "¡Dispositivo agregado exitosamente a la lista negra!",
"success_edit_blacklist": "Lista negra editada con éxito!",
"success_removed_blacklist": "¡Dispositivo eliminado con éxito de la lista negra!",
"uptimes_explanation": "Cantidad de dispositivos conectados según su tiempo de actividad"
},
"device_logs": {
"log": "Iniciar sesión",
@@ -320,23 +347,32 @@
"add_success": "¡Entidad creada con éxito!",
"assigned_inventory": "Inventario asignado",
"cannot_delete": "No puede eliminar entidades que tienen hijos. Elimina los hijos de esta entidad para poder eliminarla.",
"confirm_map_delete": "¿Está seguro de que desea eliminar el mapa {{name}}? Esta acción no se puede revertir",
"currently_selected_entity": "Entidad seleccionada actualmente: {{config}}",
"currently_selected_venue": "Lugar seleccionado actualmente: {{config}}",
"delete_success": "Entidad eliminada correctamente",
"delete_warning": "Advertencia: esta operación no se puede revertir",
"duplicate_from_node": "Duplicar con un nodo raíz específico",
"duplicate_map": "Mapa duplicado",
"duplicate_with_node": "Duplicar {{mapName}} con {{rootName}} como nodo raíz",
"edit_failure": "Actualización fallida: {{error}}",
"enter_here": "Ingrese las IP que desea agregar aquí",
"entire_tree": "Site MAp",
"entire_tree": "Mapa de red",
"entities": "entidades",
"entity": "Entidad",
"error_deleting_map": "Error al eliminar el mapa: {{error}}",
"error_fetch_entity": "Error al obtener la información de la entidad",
"error_fetching": "Error al recuperar entidades",
"error_fetching_map": "Error al obtener el mapa: {{error}}",
"error_fetching_tree": "Error al obtener el árbol: {{error}}",
"error_saving": "Error al guardar la entidad",
"error_saving_map": "Error al guardar el mapa: {{error}}",
"higher_priority": "Dar mayor prioridad",
"ip_detection": "Detección de IP",
"ip_formats": "Puede agregar direcciones IPv4 o IPv6 en los siguientes formatos:",
"lower_priority": "Hacer una prioridad más baja",
"map": "Mapa",
"map_delete_success": "¡Mapa eliminado correctamente!",
"need_select_entity": "Debe seleccionar una entidad de la siguiente tabla",
"no_ips": "No se seleccionaron direcciones IP",
"not_assigned": "No asignado",
@@ -344,6 +380,7 @@
"select_entity": "Seleccione esta entidad",
"selected_entity": "Entidad seleccionada",
"selected_map": "Mapa seleccionado",
"tree_saved": "¡Mapa guardado con éxito!",
"update_failure_error": "Error al intentar actualizar la entidad: {{error}}",
"valid_serial": "Debe ser un número de serie válido (12 caracteres HEX)",
"venues": "Sedes"
@@ -541,6 +578,9 @@
"verification_code": "Ingrese su verificación aquí",
"wrong_code": "El código de verificación que se ingresó no es válido."
},
"preferences": {
"provisioning": "Aprovisionamiento"
},
"reboot": {
"directions": "¿Cuándo le gustaría reiniciar este dispositivo?",
"now": "¿Le gustaría reiniciar este dispositivo ahora?",
@@ -584,7 +624,7 @@
"mac_prefix": "Prefijo MAC",
"max_associations": "Max. Asociaciones",
"max_clients": "Max. Clientela",
"messages_transmitted": "Mensajes transmitidos",
"messages_transmitted": "Mensajes TX",
"min_associations": "Min. Asociaciones",
"min_clients": "Min. Clientela",
"pause": "pausa",
@@ -592,7 +632,7 @@
"prefix_length": "Obligatorio, debe tener una longitud de 6 caracteres",
"previous_runs": "Ejecuciones anteriores",
"received": "recibido",
"received_messages": "Mensajes recibidos",
"received_messages": "Msgs RX",
"reconnect_interval": "Intervalo de reconexión",
"resume": "Currículum",
"resume_success": "¡Ejecutar reanudado!",
@@ -634,6 +674,19 @@
"uptime": "Tiempo de actividad",
"used_total_memory": "{{used}} usado / {{total}} total"
},
"subscriber": {
"create": "Crear suscriptor",
"edit": "Editar suscriptor",
"error_create": "Error al crear el suscriptor: {{error}}",
"error_delete": "Error al eliminar el suscriptor: {{error}}",
"error_fetching": "Error al obtener suscriptores: {{error}}",
"error_fetching_single": "Error al obtener el suscriptor: {{error}}",
"error_update": "Error al actualizar el suscriptor: {{error}}",
"subscribers": "Suscriptores",
"success_create": "¡Suscriptor creado correctamente!",
"success_delete": "¡Suscriptor eliminado correctamente!",
"success_update": "Suscriptor actualizado con éxito!"
},
"system": {
"error_fetching": "Error al obtener información del sistema",
"error_reloading": "Error al recargar: {{error}}",
@@ -723,6 +776,7 @@
"send_code_again": "Enviar Código De nuevo",
"show_hide_password": "Mostrar / Ocultar contraseña",
"successful_validation": "¡Número de teléfono validado! Haga clic en el botón guardar para vincularlo a su perfil",
"table_title": "Usuarios administrativos",
"update_failure": "Error al intentar actualizar: {{error}}",
"update_failure_title": "Actualización fallida",
"update_success": "Usuario actualizado con éxito",

View File

@@ -17,6 +17,7 @@
"blink": "Cligner",
"device_leds": "LED de l'appareil",
"execute_now": "Souhaitez-vous définir ce modèle maintenant ?",
"explanation": "Quel modèle souhaitez-vous définir sur cet appareil pendant 30 secondes ?",
"pattern": "Choisissez le modèle que vous souhaitez utiliser :",
"set_leds": "Définir les LED",
"when_blink_leds": "Quand souhaitez-vous faire clignoter les LED de l'appareil ?"
@@ -37,9 +38,11 @@
"add_note": "Ajouter une note",
"add_note_explanation": "Écrivez votre nouvelle note ci-dessous et cliquez sur le bouton '+' où vous avez terminé",
"adding_ellipsis": "Ajouter...",
"all": "Tout",
"are_you_sure": "Êtes-vous sûr?",
"back_to_login": "Retour connexion",
"back_to_start": "Retour au début",
"blacklist": "Liste noire",
"by": "Par",
"cancel": "annuler",
"certificate": "Certificat",
@@ -62,12 +65,14 @@
"create": "Créer",
"created": "Créé",
"created_by": "Créé par",
"creator": "Créateur",
"current": "Actuel",
"custom_date": "Date personnalisée",
"dashboard": "Tableau de bord",
"date": "Rendez-vous amoureux",
"day": "journée",
"days": "journées",
"default_map": "Carte par défaut",
"delete": "Effacer",
"delete_device": "Supprimer le périphérique",
"details": "Détails",
@@ -85,6 +90,7 @@
"dismiss": "Rejeter",
"do_now": "Faire maintenant!",
"download": "Télécharger",
"duplicate": "Dupliquer",
"duration": "Durée",
"edit": "modifier",
"edit_user": "Modifier",
@@ -94,6 +100,7 @@
"error": "Erreur",
"error_adding_note": "Erreur lors de l'ajout de la note",
"error_code": "Code d'erreur",
"errors": "les erreurs",
"execute_now": "Souhaitez-vous exécuter cette commande maintenant ?",
"executed": "réalisé",
"exit": "Sortie",
@@ -142,14 +149,16 @@
"no_items": "Pas d'objet",
"none": "Aucun",
"not_connected": "Pas connecté",
"of_connected": "% d'appareils",
"of_connected": "% d'appareils connectés",
"off": "De",
"on": "sur",
"optional": "Optionnel",
"overall_health": "Santé globale",
"password_policy": "Politique de mot de passe",
"preferences": "Préférences",
"preview": "Aperçu",
"program": "Programme",
"reason": "raison",
"recorded": "Enregistré",
"refresh": "Rafraîchir",
"refresh_device": "Actualiser l'appareil",
@@ -170,12 +179,14 @@
"show_all": "Montre tout",
"socket_connection_closed": "Connexion fermée !",
"start": "Début",
"status": "Statut",
"stop_editing": "Arrêter la modification",
"submit": "Soumettre",
"submitted": "Soumis",
"success": "Succès",
"system": "Système",
"table": "Table",
"time_per_device": "Appareils/Seconde",
"timestamp": "Temps",
"to": "à",
"type": "Type",
@@ -190,6 +201,7 @@
"uuid": "UUID",
"vendors": "Vendeurs",
"view_more": "Afficher plus",
"visibility": "Visibilité",
"yes": "Oui"
},
"configuration": {
@@ -210,6 +222,8 @@
"creation_success": "Configuration créée avec succès !",
"currently_associated": "Configuration associée actuelle : {{config}}",
"currently_selected_config": "Configuration actuellement sélectionnée : {{config}}",
"default_configs": "Configurations par défaut",
"default_configurations": "Configurations par défaut",
"delete_config": "Supprimer la configuration",
"details": "Détails",
"device_password": "Mot de passe",
@@ -218,6 +232,7 @@
"devices_affected": "Appareils concernés par cette configuration :",
"edit_configuration": "Modifier la configuration",
"error_delete": "Erreur lors de la tentative de suppression : {{error}}",
"error_delete_blacklist": "Erreur lors de la suppression de la liste noire : {{error}}",
"error_fetching_config": "Erreur lors de la récupération de la configuration",
"error_trying_delete": "Erreur lors de la tentative de suppression : {{error}}",
"error_update": "Erreur: {{error}}",
@@ -261,6 +276,7 @@
"contact": {
"access_pin": "NIP d'accès",
"add_contact": "Ajouter le contact",
"contact": "Contact",
"create_contact": "Créer un contact",
"currently_selected_contact": "Contact actuellement sélectionné : {{contact}}",
"delete": "Effacer le contact?",
@@ -300,12 +316,23 @@
"healthchecks_title": "Supprimer les vérifications d'état"
},
"device": {
"add_to_blacklist": "Ajouter un appareil à la liste noire",
"all_devices": "Tous les dispositifs",
"blacklisted_on": "Rendez-vous amoureux",
"capabilities": "Capacités",
"certificate_explanation": "Certificats des appareils connectés",
"edit_blacklist": "Modifier l'appareil sur liste noire",
"error_adding_blacklist": "Erreur lors de l'ajout de l'appareil à la liste noire : {{error}}",
"error_edit_blacklist": "Erreur lors de la modification de la liste noire : {{error}}",
"error_fetching_device": "Erreur lors de la récupération des informations sur l'appareil : {{error}}",
"error_fetching_devices": "Erreur lors de la récupération des appareils : {{error}}",
"health_explanation": "Santé des appareils connectés",
"memory_explanation": "Mémoire utilisée par les appareils connectés",
"uptimes_explanation": "Heure à laquelle les appareils connectés ont été activés et connectés"
"health_explanation": "Santé des appareils connectés ((Appareils = 100 % * 100 + Appareils> 90 % * 95 + Appareils> 60 % * 75 + Appareils < 60 % * 35) / Appareils connectés)",
"memory_explanation": "Nombre d'appareils connectés avec la mémoire correspondante utilisée %",
"remove_from_blacklist": "Supprimer de la liste noire",
"success_added_blacklist": "Appareil ajouté avec succès à la liste noire !",
"success_edit_blacklist": "Liste noire modifiée avec succès !",
"success_removed_blacklist": "Appareil supprimé de la liste noire !",
"uptimes_explanation": "Nombre d'appareils connectés en fonction de leur disponibilité"
},
"device_logs": {
"log": "Bûche",
@@ -320,23 +347,32 @@
"add_success": "Entité créée avec succès !",
"assigned_inventory": "Inventaire assigné",
"cannot_delete": "Vous ne pouvez pas supprimer des entités qui ont des enfants. Supprimez les enfants de cette entité pour pouvoir la supprimer.",
"confirm_map_delete": "Êtes-vous sûr de vouloir supprimer la carte {{name}}? Cette action ne peut pas être annulée",
"currently_selected_entity": "Entité actuellement sélectionnée : {{config}}",
"currently_selected_venue": "Lieu actuellement sélectionné : {{config}}",
"delete_success": "Entité supprimée avec succès",
"delete_warning": "Attention : cette opération ne peut pas être annulée",
"duplicate_from_node": "Dupliquer avec un nœud racine spécifique",
"duplicate_map": "Carte en double",
"duplicate_with_node": "Dupliquer {{mapName}} avec {{rootName}} comme nœud racine",
"edit_failure": "Échec de la mise à jour : {{error}}",
"enter_here": "Entrez les IP que vous souhaitez ajouter ici",
"entire_tree": "Site MAp",
"entire_tree": "Carte du réseau",
"entities": "Entités",
"entity": "Entité",
"error_deleting_map": "Erreur lors de la suppression de la carte : {{error}}",
"error_fetch_entity": "Erreur lors de la récupération des informations sur l'entité",
"error_fetching": "Erreur lors de la récupération des entités",
"error_fetching_map": "Erreur lors de la récupération de la carte : {{error}}",
"error_fetching_tree": "Erreur lors de la récupération de l'arborescence : {{error}}",
"error_saving": "Erreur lors de l'enregistrement de l'entité",
"error_saving_map": "Erreur lors de l'enregistrement de la carte : {{error}}",
"higher_priority": "Faire une priorité plus élevée",
"ip_detection": "Détection IP",
"ip_formats": "Vous pouvez ajouter des adresses IPv4 ou IPv6 aux formats suivants :",
"lower_priority": "Faire une priorité inférieure",
"map": "Carte",
"map_delete_success": "Carte supprimée avec succès !",
"need_select_entity": "Vous devez sélectionner une entité dans le tableau ci-dessous",
"no_ips": "Aucune adresse IP sélectionnée",
"not_assigned": "Non attribué",
@@ -344,6 +380,7 @@
"select_entity": "Sélectionnez cette entité",
"selected_entity": "Entité sélectionnée",
"selected_map": "Carte sélectionnée",
"tree_saved": "Carte enregistrée avec succès !",
"update_failure_error": "Erreur lors de la tentative de mise à jour de l'entité : {{error}}",
"valid_serial": "Doit être un numéro de série valide (12 caractères HEX)",
"venues": "Les lieux"
@@ -541,6 +578,9 @@
"verification_code": "Entrez votre vérification ici",
"wrong_code": "Le code de vérification saisi n'est pas valide."
},
"preferences": {
"provisioning": "Provisioning"
},
"reboot": {
"directions": "Quand souhaitez-vous redémarrer cet appareil ?",
"now": "Souhaitez-vous redémarrer cet appareil maintenant ?",
@@ -584,7 +624,7 @@
"mac_prefix": "Préfixe MAC",
"max_associations": "Max. Les associations",
"max_clients": "Max. Clients",
"messages_transmitted": "Messages transmis",
"messages_transmitted": "Émission de messages",
"min_associations": "Min. Les associations",
"min_clients": "Min. Clients",
"pause": "Pause",
@@ -592,7 +632,7 @@
"prefix_length": "Obligatoire, doit être d'une longueur de 6 caractères",
"previous_runs": "Courses précédentes",
"received": "reçu",
"received_messages": "Messages reçus",
"received_messages": "Réception des messages",
"reconnect_interval": "Intervalle de reconnexion",
"resume": "CV",
"resume_success": "Exécution reprise !",
@@ -634,6 +674,19 @@
"uptime": "La disponibilité",
"used_total_memory": "{{used}} utilisé / {{total}} total"
},
"subscriber": {
"create": "Créer un abonné",
"edit": "Modifier l'abonné",
"error_create": "Erreur lors de la création de l'abonné : {{error}}",
"error_delete": "Erreur lors de la suppression de l'abonné : {{error}}",
"error_fetching": "Erreur lors de la récupération des abonnés : {{error}}",
"error_fetching_single": "Erreur lors de la récupération de l'abonné : {{error}}",
"error_update": "Erreur lors de la mise à jour de l'abonné : {{error}}",
"subscribers": "Les abonnés",
"success_create": "Abonné créé avec succès !",
"success_delete": "Abonné supprimé avec succès !",
"success_update": "Abonné mis à jour avec succès !"
},
"system": {
"error_fetching": "Erreur lors de la récupération des informations système",
"error_reloading": "Erreur lors du rechargement : {{error}}",
@@ -723,6 +776,7 @@
"send_code_again": "Envoyer code à nouveau",
"show_hide_password": "Afficher/Masquer le mot de passe",
"successful_validation": "Numéro de téléphone validé ! Cliquez sur le bouton Enregistrer pour le lier à votre profil",
"table_title": "Utilisateurs administrateurs",
"update_failure": "Erreur lors de la tentative de mise à jour : {{error}}",
"update_failure_title": "mise à jour a échoué",
"update_success": "L'utilisateur a bien été mis à jour",

View File

@@ -17,6 +17,7 @@
"blink": "Piscar",
"device_leds": "LEDs do dispositivo",
"execute_now": "Você gostaria de definir este padrão agora?",
"explanation": "Que padrão você gostaria de definir neste dispositivo por 30 segundos?",
"pattern": "Escolha o padrão que deseja usar:",
"set_leds": "Definir LEDs",
"when_blink_leds": "Quando você gostaria de fazer os LEDs do dispositivo piscarem?"
@@ -37,9 +38,11 @@
"add_note": "Adicionar nota",
"add_note_explanation": "Escreva sua nova nota abaixo e clique no botão '+' quando terminar",
"adding_ellipsis": "Adicionando ...",
"all": "Todos",
"are_you_sure": "Você tem certeza?",
"back_to_login": "Volte ao login",
"back_to_start": "Voltar ao Início",
"blacklist": "Lista negra",
"by": "Por",
"cancel": "Cancelar",
"certificate": "Certificado",
@@ -62,12 +65,14 @@
"create": "Crio",
"created": "Criado",
"created_by": "Criado Por",
"creator": "O Criador",
"current": "Atual",
"custom_date": "Data personalizada",
"dashboard": "painel de controle",
"date": "Encontro",
"day": "dia",
"days": "dias",
"default_map": "Mapa Padrão",
"delete": "Excluir",
"delete_device": "Apagar dispositivo",
"details": "Detalhes",
@@ -85,6 +90,7 @@
"dismiss": "Dispensar",
"do_now": "Faça agora!",
"download": "Baixar",
"duplicate": "Duplicado",
"duration": "Duração",
"edit": "Editar",
"edit_user": "Editar",
@@ -94,6 +100,7 @@
"error": "Erro",
"error_adding_note": "Erro ao adicionar nota",
"error_code": "Erro de código",
"errors": "Erros",
"execute_now": "Você gostaria de executar este comando agora?",
"executed": "Executado",
"exit": "Saída",
@@ -142,14 +149,16 @@
"no_items": "Nenhum item",
"none": "Nenhum",
"not_connected": "Não conectado",
"of_connected": "% de dispositivos",
"of_connected": "% de dispositivos conectados",
"off": "Fora",
"on": "em",
"optional": "Opcional",
"overall_health": "Saúde geral",
"password_policy": "Política de Senha",
"preferences": "Preferências",
"preview": "Visualizar",
"program": "Programa",
"reason": "RAZÃO",
"recorded": "Gravado",
"refresh": "REFRESH",
"refresh_device": "Atualizar dispositivo",
@@ -170,12 +179,14 @@
"show_all": "mostre tudo",
"socket_connection_closed": "Conexão fechada!",
"start": "Começar",
"status": "Status",
"stop_editing": "Pare de editar",
"submit": "Enviar",
"submitted": "Submetido",
"success": "Sucesso",
"system": "Sistema",
"table": "Mesa",
"time_per_device": "Dispositivo / segundo",
"timestamp": "tempo",
"to": "Para",
"type": "Tipo",
@@ -190,6 +201,7 @@
"uuid": "UUID",
"vendors": "Vendedores",
"view_more": "Veja mais",
"visibility": "visibilidade",
"yes": "sim"
},
"configuration": {
@@ -210,6 +222,8 @@
"creation_success": "Configuração criada com sucesso!",
"currently_associated": "Configuração atual associada: {{config}}",
"currently_selected_config": "Configuração atualmente selecionada: {{config}}",
"default_configs": "Configurações padrão",
"default_configurations": "Configurações padrão",
"delete_config": "Excluir configuração",
"details": "Detalhes",
"device_password": "Senha",
@@ -218,6 +232,7 @@
"devices_affected": "Dispositivos afetados por esta configuração:",
"edit_configuration": "Editar configuração",
"error_delete": "Erro ao tentar excluir: {{error}}",
"error_delete_blacklist": "Erro ao excluir da lista negra: {{error}}",
"error_fetching_config": "Erro ao buscar configuração",
"error_trying_delete": "Erro ao tentar excluir: {{error}}",
"error_update": "Erro: {{error}}",
@@ -261,6 +276,7 @@
"contact": {
"access_pin": "PIN de acesso",
"add_contact": "Adicionar contato",
"contact": "Contato",
"create_contact": "Criar Contato",
"currently_selected_contact": "Contato atualmente selecionado: {{contact}}",
"delete": "Excluir contato?",
@@ -300,12 +316,23 @@
"healthchecks_title": "Excluir verificações de saúde"
},
"device": {
"add_to_blacklist": "Adicionar dispositivo à lista negra",
"all_devices": "Todos os dispositivos",
"blacklisted_on": "Encontro",
"capabilities": "Recursos",
"certificate_explanation": "Certificados de dispositivos conectados",
"edit_blacklist": "Editar dispositivo na lista negra",
"error_adding_blacklist": "Erro ao adicionar dispositivo à lista negra: {{error}}",
"error_edit_blacklist": "Erro ao editar a lista negra: {{error}}",
"error_fetching_device": "Erro ao buscar informações do dispositivo: {{error}}",
"error_fetching_devices": "Erro ao buscar dispositivos: {{error}}",
"health_explanation": "Saúde de dispositivos conectados",
"memory_explanation": "Memória usada por dispositivos conectados",
"uptimes_explanation": "Há tempo em que os dispositivos conectados estão ativados e conectados"
"health_explanation": "Integridade dos dispositivos conectados ((Dispositivos = 100% * 100 + Dispositivos> 90% * 95 + Dispositivos> 60% * 75 + Dispositivos <60% * 35) / Dispositivos Conectados)",
"memory_explanation": "Quantidade de dispositivos conectados com a memória correspondente usada%",
"remove_from_blacklist": "Remover da lista negra",
"success_added_blacklist": "Dispositivo adicionado à lista negra com sucesso!",
"success_edit_blacklist": "Lista negra editada com sucesso!",
"success_removed_blacklist": "Dispositivo removido com sucesso da lista negra!",
"uptimes_explanation": "Quantidade de dispositivos conectados com base em seu tempo de atividade"
},
"device_logs": {
"log": "Registro",
@@ -320,23 +347,32 @@
"add_success": "Entidade criada com sucesso!",
"assigned_inventory": "Estoque Atribuído",
"cannot_delete": "Você não pode excluir entidades que têm filhos. Exclua os filhos desta entidade para poder excluí-la.",
"confirm_map_delete": "Tem certeza que deseja excluir o mapa {{name}}? Esta ação não pode ser revertida",
"currently_selected_entity": "Entidade atualmente selecionada: {{config}}",
"currently_selected_venue": "Local selecionado atualmente: {{config}}",
"delete_success": "Entidade excluída com sucesso",
"delete_warning": "Aviso: esta operação não pode ser revertida",
"duplicate_from_node": "Duplicar com nó raiz específico",
"duplicate_map": "Mapa duplicado",
"duplicate_with_node": "Duplicar {{mapName}} com {{rootName}} como nó raiz",
"edit_failure": "Atualização malsucedida: {{error}}",
"enter_here": "Digite o (s) IP (s) que deseja adicionar aqui",
"entire_tree": "Mapa do Site",
"entire_tree": "Mapa de Rede",
"entities": "Entidades",
"entity": "Entidade",
"error_deleting_map": "Erro ao excluir mapa: {{error}}",
"error_fetch_entity": "Erro ao buscar informações da entidade",
"error_fetching": "Erro ao buscar entidades",
"error_fetching_map": "Erro ao buscar mapa: {{error}}",
"error_fetching_tree": "Erro ao buscar árvore: {{error}}",
"error_saving": "Erro ao salvar entidade",
"error_saving_map": "Erro ao salvar o mapa: {{error}}",
"higher_priority": "Dê maior prioridade",
"ip_detection": "Detecção de IP",
"ip_formats": "Você pode adicionar endereços IPv4 ou IPv6 nos seguintes formatos:",
"lower_priority": "Faça menor prioridade",
"map": "Mapa",
"map_delete_success": "Mapa excluído com sucesso!",
"need_select_entity": "Você precisa selecionar uma entidade da tabela abaixo",
"no_ips": "Nenhum IP selecionado",
"not_assigned": "Não atribuído",
@@ -344,6 +380,7 @@
"select_entity": "Selecione esta Entidade",
"selected_entity": "Entidade Selecionada",
"selected_map": "Mapa Selecionado",
"tree_saved": "Mapa salvo com sucesso!",
"update_failure_error": "Erro ao tentar atualizar a entidade: {{error}}",
"valid_serial": "Precisa ser um número de série válido (12 caracteres HEX)",
"venues": "Locais"
@@ -541,6 +578,9 @@
"verification_code": "Insira sua verificação aqui",
"wrong_code": "O código de verificação inserido não é válido."
},
"preferences": {
"provisioning": "Provisioning"
},
"reboot": {
"directions": "Quando você gostaria de reinicializar este dispositivo?",
"now": "Você gostaria de reiniciar este dispositivo agora?",
@@ -584,7 +624,7 @@
"mac_prefix": "Prefixo MAC",
"max_associations": "Máx. Associações",
"max_clients": "Máx. Clientes",
"messages_transmitted": "Mensagens Transmitidas",
"messages_transmitted": "Msgs TX",
"min_associations": "Min. Associações",
"min_clients": "Min. Clientes",
"pause": "pausa",
@@ -592,7 +632,7 @@
"prefix_length": "Obrigatório, deve ter 6 caracteres",
"previous_runs": "Execuções anteriores",
"received": "recebido",
"received_messages": "Mensagens recebidas",
"received_messages": "Msgs RX",
"reconnect_interval": "Intervalo de reconexão",
"resume": "Currículo",
"resume_success": "Executar retomado!",
@@ -634,6 +674,19 @@
"uptime": "Tempo de atividade",
"used_total_memory": "{{used}} usado / {{total}} total"
},
"subscriber": {
"create": "Criar assinante",
"edit": "Editar Assinante",
"error_create": "Erro ao criar assinante: {{error}}",
"error_delete": "Erro ao excluir assinante: {{error}}",
"error_fetching": "Erro ao buscar assinantes: {{error}}",
"error_fetching_single": "Erro ao buscar assinante: {{error}}",
"error_update": "Erro ao atualizar assinante: {{error}}",
"subscribers": "Inscritos",
"success_create": "Assinante criado com sucesso!",
"success_delete": "Assinante excluído com sucesso!",
"success_update": "Assinante atualizado com sucesso!"
},
"system": {
"error_fetching": "Erro ao buscar informações do sistema",
"error_reloading": "Erro ao recarregar: {{error}}",
@@ -723,6 +776,7 @@
"send_code_again": "Envie o Código Novamente",
"show_hide_password": "Mostrar / ocultar senha",
"successful_validation": "Número de telefone validado! Clique no botão Salvar para vinculá-lo ao seu perfil",
"table_title": "Usuários administrativos",
"update_failure": "Erro ao tentar atualizar: {{error}}",
"update_failure_title": "Atualização falhou",
"update_success": "Usuário atualizado com sucesso",

BIN
src/assets/NotFound.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -13,6 +13,7 @@ import {
cilArrowTop,
cilAsterisk,
cilBan,
cilBarcode,
cilBasket,
cilBell,
cilBold,
@@ -108,6 +109,7 @@ export const icons = {
cilArrowTop,
cilAsterisk,
cilBan,
cilBarcode,
cilBasket,
cilBell,
cilBold,

View File

@@ -0,0 +1,163 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import Select from 'react-select';
import {
CForm,
CInput,
CLabel,
CCol,
CFormGroup,
CInvalidFeedback,
CFormText,
CRow,
CTextarea,
} from '@coreui/react';
import { CopyToClipboardButton } from 'ucentral-libs';
const AddDefaultConfigurationForm = ({
t,
disable,
fields,
updateField,
updateFieldWithKey,
deviceTypes,
}) => {
const [typeOptions, setTypeOptions] = useState([]);
const [chosenTypes, setChosenTypes] = useState([]);
const parseOptions = () => {
const options = [{ value: '*', label: 'All' }];
const newOptions = deviceTypes.map((option) => ({
value: option,
label: option,
}));
options.push(...newOptions);
setTypeOptions(options);
setChosenTypes([]);
};
const typeOnChange = (chosenArray) => {
const allIndex = chosenArray.findIndex((el) => el.value === '*');
// If the All option was chosen before, we take it out of the array
if (allIndex === 0 && chosenTypes.length > 0) {
const newResults = chosenArray.slice(1);
setChosenTypes(newResults);
updateFieldWithKey('deviceTypes', {
value: newResults.map((el) => el.value),
error: false,
notEmpty: true,
});
} else if (allIndex > 0) {
setChosenTypes([{ value: '*', label: 'All' }]);
updateFieldWithKey('deviceTypes', { value: ['*'], error: false, notEmpty: true });
} else if (chosenArray.length > 0) {
setChosenTypes(chosenArray);
updateFieldWithKey('deviceTypes', {
value: chosenArray.map((el) => el.value),
error: false,
notEmpty: true,
});
} else {
setChosenTypes([]);
updateFieldWithKey('deviceTypes', { value: [], error: false, notEmpty: true });
}
};
useEffect(() => {
parseOptions();
}, [deviceTypes]);
return (
<CForm>
<CFormGroup row className="pb-3">
<CLabel col htmlFor="name">
{t('user.name')}
</CLabel>
<CCol sm="7">
<CInput
id="name"
type="text"
required
value={fields.name.value}
onChange={updateField}
invalid={fields.name.error}
disabled={disable}
maxLength="50"
/>
<CInvalidFeedback>{t('common.required')}</CInvalidFeedback>
</CCol>
</CFormGroup>
<CFormGroup row className="pb-3">
<CLabel col htmlFor="description">
{t('user.description')}
</CLabel>
<CCol sm="7">
<CInput
id="description"
type="text"
required
value={fields.description.value}
onChange={updateField}
invalid={fields.description.error}
disabled={disable}
maxLength="50"
/>
<CInvalidFeedback>{t('common.required')}</CInvalidFeedback>
</CCol>
</CFormGroup>
<CRow className="pb-3">
<CLabel col htmlFor="deviceTypes">
<div>{t('configuration.supported_device_types')}:</div>
</CLabel>
<CCol sm="7">
<Select
isMulti
closeMenuOnSelect={false}
id="deviceTypes"
options={typeOptions}
onChange={typeOnChange}
value={chosenTypes}
className={`basic-multi-select ${fields.deviceTypes.error ? 'border-danger' : ''}`}
classNamePrefix="select"
/>
<CFormText hidden={!fields.deviceTypes.error} color="danger">
{t('configuration.need_device_type')}
</CFormText>
</CCol>
</CRow>
<div className="pb-3">
{t('configure.enter_new')}
<CopyToClipboardButton t={t} size="sm" content={fields.configuration.value} />
</div>
<CRow className="pb-3">
<CCol>
<CTextarea
style={{ overflowY: 'scroll', height: '500px' }}
id="configuration"
type="text"
required
value={fields.configuration.value}
onChange={updateField}
invalid={fields.configuration.error}
disabled={disable}
/>
<CFormText hidden={!fields.configuration.error} color="danger">
{t('configure.valid_json')}
</CFormText>
</CCol>
</CRow>
</CForm>
);
};
AddDefaultConfigurationForm.propTypes = {
t: PropTypes.func.isRequired,
disable: PropTypes.bool.isRequired,
fields: PropTypes.instanceOf(Object).isRequired,
updateField: PropTypes.func.isRequired,
updateFieldWithKey: PropTypes.func.isRequired,
deviceTypes: PropTypes.instanceOf(Array).isRequired,
};
export default AddDefaultConfigurationForm;

View File

@@ -0,0 +1,183 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { CModal, CModalHeader, CModalTitle, CModalBody, CButton, CPopover } from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilX, cilSave } from '@coreui/icons';
import { useToast, useFormFields, useAuth } from 'ucentral-libs';
import axiosInstance from 'utils/axiosInstance';
import { useTranslation } from 'react-i18next';
import { checkIfJson } from 'utils/helper';
import Form from './Form';
const initialForm = {
name: {
value: '',
error: false,
required: true,
},
description: {
value: '',
error: false,
},
deviceTypes: {
value: [],
error: false,
notEmpty: true,
},
configuration: {
value: '',
error: false,
required: true,
},
};
const AddConfigurationModal = ({ show, toggle, refresh }) => {
const { t } = useTranslation();
const { addToast } = useToast();
const { currentToken, endpoints } = useAuth();
const [fields, updateFieldWithId, updateField, setFormFields] = useFormFields(initialForm);
const [loading, setLoading] = useState(false);
const [deviceTypes, setDeviceTypes] = useState([]);
const getDeviceTypes = () => {
setLoading(true);
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
};
axiosInstance
.get(`${endpoints.owfms}/api/v1/firmwares?deviceSet=true`, {
headers,
})
.then((response) => {
setDeviceTypes([...response.data.deviceTypes]);
})
.catch(() => {})
.finally(() => {
setLoading(false);
});
};
const validation = () => {
let success = true;
for (const [key, field] of Object.entries(fields)) {
if (field.required && field.value === '') {
updateField(key, { error: true });
success = false;
break;
}
if (field.notEmpty && field.value.length === 0) {
updateField(key, { error: true, notEmpty: true });
success = false;
break;
}
}
if (!checkIfJson(fields.configuration.value)) {
updateField('configuration', { error: true });
success = false;
}
return success;
};
const addConfiguration = () => {
if (validation()) {
setLoading(true);
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
const parameters = {
name: fields.name.value,
description: fields.description.value,
modelIds: fields.deviceTypes.value,
configuration: fields.configuration.value,
};
axiosInstance
.post(
`${endpoints.owgw}/api/v1/default_configuration/${fields.name.value}`,
parameters,
options,
)
.then(() => {
if (refresh !== null) refresh();
toggle();
addToast({
title: t('common.success'),
body: t('configuration.creation_success'),
color: 'success',
autohide: true,
});
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('entity.add_failure', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
})
.finally(() => {
setLoading(false);
});
}
};
useEffect(() => {
if (show) {
getDeviceTypes();
setFormFields(initialForm);
}
}, [show]);
return (
<CModal className="text-dark" size="lg" show={show} onClose={toggle}>
<CModalHeader className="p-1">
<CModalTitle className="pl-1 pt-1">{t('configuration.create')}</CModalTitle>
<div className="text-right">
<CPopover content={t('common.add')}>
<CButton color="primary" variant="outline" className="ml-2" onClick={addConfiguration}>
<CIcon content={cilSave} />
</CButton>
</CPopover>
<CPopover content={t('common.close')}>
<CButton color="primary" variant="outline" className="ml-2" onClick={toggle}>
<CIcon content={cilX} />
</CButton>
</CPopover>
</div>
</CModalHeader>
<CModalBody className="px-5">
<Form
t={t}
disable={loading}
fields={fields}
updateField={updateFieldWithId}
updateFieldWithKey={updateField}
deviceTypes={deviceTypes}
show={show}
/>
</CModalBody>
</CModal>
);
};
AddConfigurationModal.propTypes = {
show: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
refresh: PropTypes.func,
};
AddConfigurationModal.defaultProps = {
refresh: null,
};
export default AddConfigurationModal;

View File

@@ -0,0 +1,158 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import {
CButton,
CModal,
CModalHeader,
CModalTitle,
CModalBody,
CPopover,
CRow,
CCol,
CLabel,
CTextarea,
CInput,
CInvalidFeedback,
} from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { useAuth, useToast } from 'ucentral-libs';
import { cilPlus, cilX } from '@coreui/icons';
import axiosInstance from 'utils/axiosInstance';
const AddToBlacklistModal = ({ show, toggle, serialNumber, refresh }) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const { addToast } = useToast();
const { endpoints, currentToken } = useAuth();
const [chosenSerialNumber, setChosenSerialNumber] = useState('');
const [reason, setReason] = useState('');
const addToBlacklist = () => {
setLoading(true);
const parameters = {
serialNumber: chosenSerialNumber,
reason,
};
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
};
axiosInstance
.post(`${endpoints.owgw}/api/v1/blacklist/${chosenSerialNumber}`, parameters, { headers })
.then(() => {
addToast({
title: t('common.success'),
body: t('device.success_added_blacklist'),
color: 'success',
autohide: true,
});
toggle();
if (refresh) refresh();
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('device.error_adding_blacklist', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
if (show) {
if (serialNumber) setChosenSerialNumber(serialNumber);
else setChosenSerialNumber('');
}
}, [show, serialNumber]);
return (
<CModal className="text-dark" size="lg" show={show} onClose={toggle}>
<CModalHeader className="p-1">
<CModalTitle className="pl-1 pt-1">{t('device.add_to_blacklist')}</CModalTitle>
<div className="text-right">
<CPopover content={t('common.add')}>
<CButton
color="primary"
variant="outline"
className="ml-2"
onClick={addToBlacklist}
disabled={
chosenSerialNumber.length !== 12 ||
!chosenSerialNumber.match('^[a-fA-F0-9]+$') ||
reason === '' ||
loading
}
>
<CIcon content={cilPlus} />
</CButton>
</CPopover>
<CPopover content={t('common.close')}>
<CButton color="primary" variant="outline" className="ml-2" onClick={toggle}>
<CIcon content={cilX} />
</CButton>
</CPopover>
</div>
</CModalHeader>
<CModalBody>
<CRow>
<CLabel col sm="3">
{t('common.serial_number')}
</CLabel>
<CCol sm="9" className="pt-1">
<CInput
id="description"
type="text"
required
value={chosenSerialNumber}
onChange={(e) => setChosenSerialNumber(e.target.value)}
invalid={
chosenSerialNumber.length !== 12 && chosenSerialNumber.match('^[a-fA-F0-9]+$')
}
disabled={loading}
maxLength="50"
/>
<CInvalidFeedback>{t('entity.valid_serial')}</CInvalidFeedback>
</CCol>
</CRow>
<CRow>
<CLabel col sm="3">
{t('common.reason')}
</CLabel>
<CCol sm="9" className="pt-2">
<CTextarea
name="reason"
id="reason"
rows="3"
type="text"
required
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</CCol>
</CRow>
</CModalBody>
</CModal>
);
};
AddToBlacklistModal.propTypes = {
show: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
serialNumber: PropTypes.string,
refresh: PropTypes.func,
};
AddToBlacklistModal.defaultProps = {
serialNumber: '',
refresh: null,
};
export default AddToBlacklistModal;

View File

@@ -0,0 +1,210 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import ReactPaginate from 'react-paginate';
import {
CCardBody,
CDataTable,
CButton,
CLink,
CCard,
CCardHeader,
CPopover,
CSelect,
CButtonToolbar,
} from '@coreui/react';
import { cilSearch, cilPencil, cilPlus, cilTrash } from '@coreui/icons';
import CIcon from '@coreui/icons-react';
import ReactTooltip from 'react-tooltip';
import { FormattedDate } from 'ucentral-libs';
const BlacklistTable = ({
currentPage,
devices,
toggleAddBlacklist,
toggleEditModal,
devicesPerPage,
loading,
removeFromBlacklist,
updateDevicesPerPage,
pageCount,
updatePage,
t,
}) => {
const columns = [
{ key: 'serialNumber', label: t('common.serial_number'), _style: { width: '6%' } },
{ key: 'created', label: t('device.blacklisted_on'), _style: { width: '1%' } },
{ key: 'author', label: t('common.by'), filter: false, _style: { width: '15%' } },
{ key: 'reason', label: t('common.reason'), filter: false },
{ key: 'actions', label: t('actions.actions'), _style: { width: '1%' } },
];
const hideTooltips = () => ReactTooltip.hide();
const escFunction = (event) => {
if (event.keyCode === 27) {
hideTooltips();
}
};
useEffect(() => {
document.addEventListener('keydown', escFunction, false);
return () => {
document.removeEventListener('keydown', escFunction, false);
};
}, []);
return (
<>
<CCard className="m-0 p-0">
<CCardHeader className="p-0 text-right">
<CPopover content={t('device.add_to_blacklist')}>
<CButton size="sm" color="primary" onClick={toggleAddBlacklist}>
<CIcon content={cilPlus} />
</CButton>
</CPopover>
</CCardHeader>
<CCardBody className="p-0">
<CDataTable
addTableClasses="ignore-overflow table-sm"
items={devices ?? []}
fields={columns}
hover
border
loading={loading}
scopedSlots={{
serialNumber: (item) => (
<td className="text-center align-middle">
<CLink
className="c-subheader-nav-link"
aria-current="page"
to={() => `/devices/${item.serialNumber}`}
>
{item.serialNumber}
</CLink>
</td>
),
created: (item) => (
<td className="text-left align-middle">
<div style={{ width: '130px' }}>
<FormattedDate date={item.created} />
</div>
</td>
),
author: (item) => <td className="align-middle">{item.author}</td>,
reason: (item) => <td className="align-middle">{item.reason}</td>,
actions: (item) => (
<td className="text-center align-middle">
<CButtonToolbar
role="group"
className="justify-content-center"
style={{ width: '130px' }}
>
<CPopover content={t('configuration.details')}>
<CLink
className="c-subheader-nav-link"
aria-current="page"
to={() => `/devices/${item.serialNumber}`}
>
<CButton
color="primary"
variant="outline"
shape="square"
size="sm"
className="mx-1"
style={{ width: '33px', height: '30px' }}
>
<CIcon name="cil-search" content={cilSearch} size="sm" />
</CButton>
</CLink>
</CPopover>
<CPopover content={t('device.remove_from_blacklist')}>
<CButton
onClick={() => removeFromBlacklist(item.serialNumber)}
color="primary"
variant="outline"
shape="square"
size="sm"
className="mx-1"
style={{ width: '33px', height: '30px' }}
>
<CIcon content={cilTrash} size="sm" />
</CButton>
</CPopover>
<CPopover content={t('common.edit')}>
<CButton
onClick={() => toggleEditModal(item.serialNumber)}
color="primary"
variant="outline"
shape="square"
size="sm"
className="mx-1"
style={{ width: '33px', height: '30px' }}
>
<CIcon content={cilPencil} size="sm" />
</CButton>
</CPopover>
</CButtonToolbar>
</td>
),
}}
/>
<div className="d-flex flex-row pl-3">
<div className="pr-3">
<ReactPaginate
previousLabel="← Previous"
nextLabel="Next →"
pageCount={pageCount}
onPageChange={updatePage}
forcePage={Number(currentPage)}
breakClassName="page-item"
breakLinkClassName="page-link"
containerClassName="pagination"
pageClassName="page-item"
pageLinkClassName="page-link"
previousClassName="page-item"
previousLinkClassName="page-link"
nextClassName="page-item"
nextLinkClassName="page-link"
activeClassName="active"
/>
</div>
<p className="pr-2 mt-1">{t('common.items_per_page')}</p>
<div style={{ width: '100px' }} className="px-2">
<CSelect
custom
defaultValue={devicesPerPage}
onChange={(e) => updateDevicesPerPage(e.target.value)}
disabled={loading}
>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</CSelect>
</div>
</div>
</CCardBody>
</CCard>
</>
);
};
BlacklistTable.propTypes = {
currentPage: PropTypes.string,
devices: PropTypes.instanceOf(Array).isRequired,
toggleAddBlacklist: PropTypes.func.isRequired,
toggleEditModal: PropTypes.func.isRequired,
updateDevicesPerPage: PropTypes.func.isRequired,
pageCount: PropTypes.number.isRequired,
updatePage: PropTypes.func.isRequired,
devicesPerPage: PropTypes.string.isRequired,
removeFromBlacklist: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
};
BlacklistTable.defaultProps = {
currentPage: '0',
};
export default React.memo(BlacklistTable);

View File

@@ -0,0 +1,30 @@
.firmwareTooltip {
opacity: 1 !important;
padding: 0px 0px 0px 0px !important;
border-radius: 1rem !important;
background-color: #fff !important;
border-color: #321fdb !important;
font-size: 0.875rem !important;
font-weight: 400 !important;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2) !important;
width: 400px;
}
.deleteTooltip {
opacity: 1 !important;
padding: 0px 0px 0px 0px !important;
border-radius: 1rem !important;
background-color: #fff !important;
border-color: #321fdb !important;
font-size: 0.875rem !important;
font-weight: 400 !important;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2) !important;
width: 200px;
}
.tooltipHeader {
padding-left: 5px;
padding-right: 10px;
border-top-left-radius: 1rem !important;
border-top-right-radius: 1rem !important;
}

View File

@@ -0,0 +1,244 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import axiosInstance from 'utils/axiosInstance';
import { getItem, setItem } from 'utils/localStorageHelper';
import { useAuth, useToast, useToggle } from 'ucentral-libs';
import AddToBlacklistModal from 'components/AddToBlacklistModal';
import EditBlacklistModal from 'components/EditBlacklistModal';
import Table from './Table';
const BlacklistTable = () => {
const { t } = useTranslation();
const { addToast } = useToast();
const history = useHistory();
const [page, setPage] = useState(parseInt(sessionStorage.getItem('deviceTable') ?? 0, 10));
const { currentToken, endpoints } = useAuth();
const [deviceCount, setDeviceCount] = useState(0);
const [pageCount, setPageCount] = useState(0);
const [devicesPerPage, setDevicesPerPage] = useState(getItem('devicesPerPage') || '10');
const [devices, setDevices] = useState([]);
const [loading, setLoading] = useState(true);
const [editSerial, setEditSerial] = useState('');
const [showEditModal, setShowEditModal] = useState(false);
const [showAddModal, toggleAddModal] = useToggle(false);
const toggleEditModal = (serialNumber) => {
if (serialNumber) setEditSerial(serialNumber);
setShowEditModal(!showEditModal);
};
const getDeviceInformation = (selectedPage = page, devicePerPage = devicesPerPage) => {
setLoading(true);
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
axiosInstance
.get(
`${endpoints.owgw}/api/v1/blacklist?limit=${devicePerPage}&offset=${
devicePerPage * selectedPage
}`,
options,
)
.then((response) => {
setDevices(response.data.devices);
setLoading(false);
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('device.error_fetching_devices', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
setLoading(false);
});
};
const getCount = () => {
setLoading(true);
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
};
axiosInstance
.get(`${endpoints.owgw}/api/v1/blacklist?countOnly=true`, {
headers,
})
.then((response) => {
const devicesCount = response.data.count;
const pagesCount = Math.ceil(devicesCount / devicesPerPage);
setPageCount(pagesCount);
setDeviceCount(devicesCount);
let selectedPage = page;
if (page >= pagesCount) {
history.push(`/devices?page=${pagesCount - 1}`);
selectedPage = pagesCount - 1;
}
getDeviceInformation(selectedPage);
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('device.error_fetching_devices', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
setLoading(false);
});
};
const refreshDevice = (serialNumber) => {
setLoading(true);
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
let newDevice;
axiosInstance
.get(
`${endpoints.owgw}/api/v1/blacklist?deviceWithStatus=true&select=${encodeURIComponent(
serialNumber,
)}`,
options,
)
.then(
({
data: {
devicesWithStatus: [device],
},
}) => {
newDevice = device;
return axiosInstance.get(
`${endpoints.owfms}/api/v1/firmwareAge?select=${serialNumber}`,
options,
);
},
)
.then((response) => {
newDevice.firmwareInfo = {
age: response.data.ages[0].age,
latest: response.data.ages[0].latest,
};
const foundIndex = devices.findIndex((obj) => obj.serialNumber === serialNumber);
const newList = devices;
newList[foundIndex] = newDevice;
setDevices(newList);
setLoading(false);
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('device.error_fetching_devices', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
setLoading(false);
});
};
const updateDevicesPerPage = (value) => {
setItem('devicesPerPage', value);
setDevicesPerPage(value);
const newPageCount = Math.ceil(deviceCount / value);
setPageCount(newPageCount);
let selectedPage = page;
if (page >= newPageCount) {
history.push(`/blacklist?page=${newPageCount - 1}`);
selectedPage = newPageCount - 1;
}
getDeviceInformation(selectedPage, value);
};
const updatePageCount = ({ selected: selectedPage }) => {
sessionStorage.setItem('deviceTable', selectedPage);
setPage(selectedPage);
getDeviceInformation(selectedPage);
};
const removeFromBlacklist = (serialNumber) => {
setLoading(true);
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
};
axiosInstance
.delete(`${endpoints.owgw}/api/v1/blacklist/${serialNumber}`, { headers })
.then(() => {
addToast({
title: t('common.success'),
body: t('device.success_removed_blacklist'),
color: 'success',
autohide: true,
});
getCount();
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('device.error_adding_blacklist', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
getCount();
}, []);
return (
<div>
<Table
currentPage={page}
t={t}
devices={devices}
loading={loading}
toggleAddBlacklist={toggleAddModal}
toggleEditModal={toggleEditModal}
updateDevicesPerPage={updateDevicesPerPage}
devicesPerPage={devicesPerPage}
pageCount={pageCount}
updatePage={updatePageCount}
pageRangeDisplayed={5}
refreshDevice={refreshDevice}
removeFromBlacklist={removeFromBlacklist}
/>
{showAddModal ? (
<AddToBlacklistModal show={showAddModal} toggle={toggleAddModal} refresh={getCount} />
) : null}
<EditBlacklistModal
show={showEditModal}
toggle={toggleEditModal}
refresh={getCount}
serialNumber={editSerial}
/>
</div>
);
};
export default BlacklistTable;

View File

@@ -5,21 +5,18 @@ import {
CModalTitle,
CModalBody,
CModalFooter,
CSwitch,
CCol,
CRow,
CFormGroup,
CInputRadio,
CLabel,
CPopover,
CRow,
} from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilX } from '@coreui/icons';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import DatePicker from 'react-widgets/DatePicker';
import PropTypes from 'prop-types';
import { dateToUnix } from 'utils/helper';
import 'react-widgets/styles.css';
import axiosInstance from 'utils/axiosInstance';
import eventBus from 'utils/eventBus';
@@ -31,38 +28,24 @@ const BlinkModal = ({ show, toggleModal }) => {
const { currentToken, endpoints } = useAuth();
const { deviceSerialNumber } = useDevice();
const { addToast } = useToast();
const [isNow, setIsNow] = useState(false);
const [waiting, setWaiting] = useState(false);
const [chosenDate, setChosenDate] = useState(new Date().toString());
const [chosenPattern, setPattern] = useState('on');
const [chosenPattern, setPattern] = useState('blink');
const [result, setResult] = useState(null);
const toggleNow = () => {
setIsNow(!isNow);
};
const setDate = (date) => {
if (date) {
setChosenDate(date.toString());
}
};
useEffect(() => {
if (show) {
setWaiting(false);
setChosenDate(new Date().toString());
setPattern('on');
setPattern('blink');
setResult(null);
}
}, [show]);
const doAction = () => {
setWaiting(true);
const utcDate = new Date(chosenDate);
const parameters = {
serialNumber: deviceSerialNumber,
when: isNow ? 0 : dateToUnix(utcDate),
when: 0,
pattern: chosenPattern,
duration: 30,
};
@@ -113,11 +96,26 @@ const BlinkModal = ({ show, toggleModal }) => {
) : (
<div>
<CModalBody>
<CFormGroup row>
<CRow className="mb-3">
<CCol>{t('blink.explanation')}</CCol>
</CRow>
<CFormGroup row className="mb-0">
<CCol md="3">
<CLabel>{t('blink.pattern')}</CLabel>
</CCol>
<CCol>
<CFormGroup variant="custom-radio" onClick={() => setPattern('blink')} inline>
<CInputRadio
custom
defaultChecked={chosenPattern === 'blink'}
id="radio3"
name="radios"
value="option3"
/>
<CLabel variant="custom-checkbox" htmlFor="radio3">
{t('blink.blink')}
</CLabel>
</CFormGroup>
<CFormGroup variant="custom-radio" onClick={() => setPattern('on')} inline>
<CInputRadio
custom
@@ -142,55 +140,12 @@ const BlinkModal = ({ show, toggleModal }) => {
{t('common.off')}
</CLabel>
</CFormGroup>
<CFormGroup variant="custom-radio" onClick={() => setPattern('blink')} inline>
<CInputRadio
custom
defaultChecked={chosenPattern === 'blink'}
id="radio3"
name="radios"
value="option3"
/>
<CLabel variant="custom-checkbox" htmlFor="radio3">
{t('blink.blink')}
</CLabel>
</CFormGroup>
</CCol>
</CFormGroup>
<CRow className="pt-1">
<CCol md="8">
<p>{t('blink.execute_now')}</p>
</CCol>
<CCol>
<CSwitch
disabled={waiting}
color="primary"
defaultChecked={isNow}
onClick={toggleNow}
labelOn={t('common.yes')}
labelOff={t('common.no')}
/>
</CCol>
</CRow>
<CRow hidden={isNow} className="pt-3">
<CCol md="4" className="pt-2">
<p>{t('common.custom_date')}</p>
</CCol>
<CCol xs="12" md="8">
<DatePicker
selected={new Date(chosenDate)}
includeTime
value={new Date(chosenDate)}
placeholder="Select custom date"
disabled={waiting}
onChange={(date) => setDate(date)}
min={new Date()}
/>
</CCol>
</CRow>
</CModalBody>
<CModalFooter>
<LoadingButton
label={isNow ? t('blink.set_leds') : t('common.schedule')}
label={t('blink.set_leds')}
isLoadingLabel={t('common.loading_ellipsis')}
isLoading={waiting}
action={doAction}

View File

@@ -0,0 +1,100 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
CRow,
CCol,
CCard,
CCardBody,
CCardHeader,
CLabel,
CPopover,
CSpinner,
CButton,
} from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilSync } from '@coreui/icons';
import { useTranslation } from 'react-i18next';
import { CopyToClipboardButton, useAuth, useToast, FormattedDate } from 'ucentral-libs';
import axiosInstance from 'utils/axiosInstance';
const CapabilitiesDisplay = ({ serialNumber }) => {
const { t } = useTranslation();
const { currentToken, endpoints } = useAuth();
const [capabilities, setCapabilities] = useState({});
const { addToast } = useToast();
const [loading, setLoading] = useState(false);
const getCapabilities = () => {
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
axiosInstance
.get(
`${endpoints.owgw}/api/v1/device/${encodeURIComponent(serialNumber)}/capabilities`,
options,
)
.then((response) => {
setCapabilities(response.data);
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('device.error_fetching_device', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
getCapabilities();
}, []);
return (
<CCard className="m-0">
<CCardHeader className="dark-header">
<div className="d-flex flex-row-reverse align-items-center">
<div className="text-right">
<CPopover content={t('common.refresh')}>
<CButton size="sm" color="info" onClick={getCapabilities}>
<CIcon content={cilSync} />
</CButton>
</CPopover>
</div>
</div>
</CCardHeader>
<CCardBody>
<h5>
{t('device.capabilities')}
<CopyToClipboardButton
t={t}
size="sm"
content={JSON.stringify(capabilities?.capabilities ?? {})}
/>
</h5>
<CRow>
<CCol>
<CLabel>
{t('inventory.last_modification')}: <FormattedDate date={capabilities?.lastUpdate} />
</CLabel>
</CCol>
</CRow>
{loading ? <CSpinner /> : null}
<pre className="ignore">{JSON.stringify(capabilities?.capabilities ?? {}, null, 4)}</pre>
</CCardBody>
</CCard>
);
};
CapabilitiesDisplay.propTypes = {
serialNumber: PropTypes.string.isRequired,
};
export default CapabilitiesDisplay;

View File

@@ -325,17 +325,9 @@ const DeviceCommands = () => {
}}
>
{item.command === 'trace' ? (
<CIcon
name="cil-cloud-download"
content={cilCloudDownload}
size="md"
/>
<CIcon name="cil-cloud-download" content={cilCloudDownload} />
) : (
<CIcon
name="cil-calendar-check"
content={cilCalendarCheck}
size="md"
/>
<CIcon name="cil-calendar-check" content={cilCalendarCheck} />
)}
</CButton>
</CPopover>
@@ -350,7 +342,7 @@ const DeviceCommands = () => {
toggleResponse(item);
}}
>
<CIcon name="cilList" size="md" />
<CIcon name="cilList" />
</CButton>
</CPopover>
<CPopover content={t('common.delete')}>
@@ -364,7 +356,7 @@ const DeviceCommands = () => {
toggleConfirmModal(item.UUID, index);
}}
>
<CIcon name="cilTrash" size="mdå" />
<CIcon name="cilTrash" />
</CButton>
</CPopover>
</CButtonToolbar>

View File

@@ -41,10 +41,12 @@ const ConfigurationDisplay = ({ getData, deviceConfig }) => {
/>
</h5>
<CRow>
<CCol md="2" xl="2" xxl="1">
<CLabel>{t('configuration.last_configuration_change')}: </CLabel>
<CCol>
<CLabel>
{t('configuration.last_configuration_change')}:{' '}
{prettyDate(deviceConfig?.lastConfigurationChange)}
</CLabel>
</CCol>
<CCol>{prettyDate(deviceConfig?.lastConfigurationChange)}</CCol>
</CRow>
<pre className="ignore">{JSON.stringify(deviceConfig?.configuration ?? {}, null, 4)}</pre>
</CCardBody>

View File

@@ -1,161 +0,0 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { CModal, CModalHeader, CModalBody, CModalTitle, CPopover, CButton } from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilSave, cilX } from '@coreui/icons';
import { CreateUserForm, useFormFields, useAuth, useToast } from 'ucentral-libs';
import axiosInstance from 'utils/axiosInstance';
import { testRegex, validateEmail } from 'utils/helper';
const initialState = {
name: {
value: '',
error: false,
optional: true,
},
email: {
value: '',
error: false,
},
currentPassword: {
value: '',
error: false,
},
changePassword: {
value: 'on',
error: false,
},
userRole: {
value: 'accounting',
error: false,
},
notes: {
value: '',
error: false,
optional: true,
},
description: {
value: '',
error: false,
optional: true,
},
};
const CreateUserModal = ({ show, toggle, getUsers, policies }) => {
const { t } = useTranslation();
const { currentToken, endpoints } = useAuth();
const { addToast } = useToast();
const [loading, setLoading] = useState(false);
const [formFields, updateFieldWithId, updateField, setFormFields] = useFormFields(initialState);
const toggleChange = () => {
updateField('changePassword', { value: !formFields.changePassword.value });
};
const createUser = () => {
setLoading(true);
const parameters = {
id: 0,
};
let validationSuccess = true;
for (const [key, value] of Object.entries(formFields)) {
if (!value.optional && value.value === '') {
validationSuccess = false;
updateField(key, { value: value.value, error: true });
} else if (key === 'currentPassword' && !testRegex(value.value, policies.passwordPattern)) {
validationSuccess = false;
updateField(key, { value: value.value, error: true });
} else if (key === 'email' && !validateEmail(value.value)) {
validationSuccess = false;
updateField(key, { value: value.value, error: true });
} else if (key === 'notes') {
parameters[key] = [{ note: value.value }];
} else if (key === 'changePassword') {
parameters[key] = value.value === 'on';
} else {
parameters[key] = value.value;
}
}
if (validationSuccess) {
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
};
axiosInstance
.post(`${endpoints.owsec}/api/v1/user/0`, parameters, {
headers,
})
.then(() => {
getUsers();
setFormFields(initialState);
addToast({
title: t('common.success'),
body: t('user.create_success'),
color: 'success',
autohide: true,
});
toggle();
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('user.create_failure', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
})
.finally(() => {
setLoading(false);
});
} else {
setLoading(false);
}
};
useEffect(() => {
setFormFields(initialState);
}, [show]);
return (
<CModal show={show} onClose={toggle} size="xl">
<CModalHeader className="p-1">
<CModalTitle className="pl-1 pt-1">{t('user.create')}</CModalTitle>
<div className="text-right">
<CPopover content={t('user.create')}>
<CButton color="primary" variant="outline" onClick={createUser} disabled={loading}>
<CIcon content={cilSave} />
</CButton>
</CPopover>
<CPopover content={t('common.close')}>
<CButton color="primary" variant="outline" className="ml-2" onClick={toggle}>
<CIcon content={cilX} />
</CButton>
</CPopover>
</div>
</CModalHeader>
<CModalBody>
<CreateUserForm
t={t}
fields={formFields}
updateField={updateFieldWithId}
policies={policies}
toggleChange={toggleChange}
/>
</CModalBody>
</CModal>
);
};
CreateUserModal.propTypes = {
show: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
getUsers: PropTypes.func.isRequired,
policies: PropTypes.instanceOf(Object).isRequired,
};
export default React.memo(CreateUserModal);

View File

@@ -0,0 +1,88 @@
import React, { useState } from 'react';
import ReactTooltip from 'react-tooltip';
import { v4 as createUuid } from 'uuid';
import { CButton, CCardBody, CCardHeader, CRow, CCol, CPopover, CButtonClose } from '@coreui/react';
import { cilTrash } from '@coreui/icons';
import CIcon from '@coreui/icons-react';
import { LoadingButton } from 'ucentral-libs';
import PropTypes from 'prop-types';
import styles from './index.module.scss';
const DeleteButton = ({ t, config, deleteConfig, hideTooltips }) => {
const [tooltipId] = useState(createUuid());
return (
<CPopover content={t('common.delete')}>
<div className="d-inline">
<CButton
color="primary"
variant="outline"
shape="square"
size="sm"
className="mx-2"
data-tip
data-for={tooltipId}
data-event="click"
style={{ width: '33px', height: '30px' }}
>
<CIcon name="cil-trash" content={cilTrash} size="sm" />
</CButton>
<ReactTooltip
id={tooltipId}
place="top"
effect="solid"
globalEventOff="click"
clickable
className={[styles.deleteTooltip, 'tooltipRight'].join(' ')}
border
borderColor="#321fdb"
arrowColor="white"
overridePosition={({ left, top }) => {
const element = document.getElementById(tooltipId);
const tooltipWidth = element ? element.offsetWidth : 0;
const newLeft = left - tooltipWidth * 0.25;
return { top, left: newLeft };
}}
>
<CCardHeader color="primary" className={styles.tooltipHeader}>
{t('configuration.delete_config')}
<CButtonClose
style={{ color: 'white' }}
onClick={(e) => {
e.target.parentNode.parentNode.classList.remove('show');
hideTooltips();
}}
/>
</CCardHeader>
<CCardBody className="py-1 px-4">
<CRow>
<CCol>
<LoadingButton
data-toggle="dropdown"
variant="outline"
color="danger"
label={t('common.confirm')}
isLoadingLabel={t('user.deleting')}
isLoading={false}
action={() => deleteConfig(config.name)}
block
disabled={false}
/>
</CCol>
</CRow>
</CCardBody>
</ReactTooltip>
</div>
</CPopover>
);
};
DeleteButton.propTypes = {
t: PropTypes.func.isRequired,
config: PropTypes.instanceOf(Object).isRequired,
deleteConfig: PropTypes.func.isRequired,
hideTooltips: PropTypes.func.isRequired,
};
export default DeleteButton;

View File

@@ -0,0 +1,184 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import ReactPaginate from 'react-paginate';
import {
CCardBody,
CDataTable,
CButton,
CCard,
CCardHeader,
CPopover,
CSelect,
CButtonToolbar,
} from '@coreui/react';
import { cilPencil, cilPlus } from '@coreui/icons';
import ReactTooltip from 'react-tooltip';
import CIcon from '@coreui/icons-react';
import { FormattedDate } from 'ucentral-libs';
import DeleteButton from './DeleteButton';
const DefaultConfigurationTable = ({
currentPage,
configurations,
toggleAddBlacklist,
toggleEditModal,
configurationsPerPage,
loading,
deleteConfig,
updateDevicesPerPage,
pageCount,
updatePage,
t,
}) => {
const columns = [
{ key: 'name', label: t('user.name'), _style: { width: '20%' } },
{ key: 'description', label: t('user.description'), _style: { width: '20%' } },
{ key: 'created', label: t('common.created'), _style: { width: '10%' } },
{ key: 'modified', label: t('common.modified'), _style: { width: '10%' } },
{ key: 'deviceTypes', label: t('firmware.device_types'), _style: { width: '20%' } },
{ key: 'actions', label: t('actions.actions'), _style: { width: '1%' } },
];
const hideTooltips = () => ReactTooltip.hide();
const escFunction = (event) => {
if (event.keyCode === 27) {
hideTooltips();
}
};
useEffect(() => {
document.addEventListener('keydown', escFunction, false);
return () => {
document.removeEventListener('keydown', escFunction, false);
};
}, []);
return (
<>
<CCard className="m-0 p-0">
<CCardHeader className="dark-header text-right">
<div className="text-value-lg float-left">
{t('configuration.default_configurations')}
</div>
<div className="text-right float-right">
<CPopover content={t('configuration.create_config')}>
<CButton size="sm" color="info" onClick={toggleAddBlacklist}>
<CIcon content={cilPlus} />
</CButton>
</CPopover>
</div>
</CCardHeader>
<CCardBody className="p-0">
<CDataTable
addTableClasses="ignore-overflow table-sm"
items={configurations ?? []}
fields={columns}
hover
border
loading={loading}
scopedSlots={{
name: (item) => <td className="align-middle">{item.name}</td>,
description: (item) => <td className="align-middle">{item.description}</td>,
deviceTypes: (item) => <td className="align-middle">{item.modelIds.join(', ')}</td>,
created: (item) => (
<td className="align-middle">
<FormattedDate date={item.created} />
</td>
),
modified: (item) => (
<td className="align-middle">
<FormattedDate date={item.lastModified} />
</td>
),
actions: (item) => (
<td className="text-center align-middle">
<CButtonToolbar
role="group"
className="justify-content-center"
style={{ width: '90px' }}
>
<DeleteButton
t={t}
config={item}
deleteConfig={deleteConfig}
hideTooltips={hideTooltips}
/>
<CPopover content={t('common.edit')}>
<CButton
onClick={() => toggleEditModal(item.name)}
color="primary"
variant="outline"
shape="square"
size="sm"
className="mx-1"
style={{ width: '33px', height: '30px' }}
>
<CIcon content={cilPencil} size="sm" />
</CButton>
</CPopover>
</CButtonToolbar>
</td>
),
}}
/>
<div className="d-flex flex-row pl-3">
<div className="pr-3">
<ReactPaginate
previousLabel="← Previous"
nextLabel="Next →"
pageCount={pageCount}
onPageChange={updatePage}
forcePage={Number(currentPage)}
breakClassName="page-item"
breakLinkClassName="page-link"
containerClassName="pagination"
pageClassName="page-item"
pageLinkClassName="page-link"
previousClassName="page-item"
previousLinkClassName="page-link"
nextClassName="page-item"
nextLinkClassName="page-link"
activeClassName="active"
/>
</div>
<p className="pr-2 mt-1">{t('common.items_per_page')}</p>
<div style={{ width: '100px' }} className="px-2">
<CSelect
custom
defaultValue={configurationsPerPage}
onChange={(e) => updateDevicesPerPage(e.target.value)}
disabled={loading}
>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</CSelect>
</div>
</div>
</CCardBody>
</CCard>
</>
);
};
DefaultConfigurationTable.propTypes = {
currentPage: PropTypes.string,
configurations: PropTypes.instanceOf(Array).isRequired,
toggleAddBlacklist: PropTypes.func.isRequired,
toggleEditModal: PropTypes.func.isRequired,
updateDevicesPerPage: PropTypes.func.isRequired,
pageCount: PropTypes.number.isRequired,
updatePage: PropTypes.func.isRequired,
configurationsPerPage: PropTypes.string.isRequired,
deleteConfig: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
};
DefaultConfigurationTable.defaultProps = {
currentPage: '0',
};
export default React.memo(DefaultConfigurationTable);

View File

@@ -0,0 +1,32 @@
.firmwareTooltip {
opacity: 1 !important;
padding: 0px 0px 0px 0px !important;
border-radius: 1rem !important;
background-color: #fff !important;
border-color: #321fdb !important;
font-size: 0.875rem !important;
font-weight: 400 !important;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2) !important;
width: 400px;
}
.deleteTooltip {
opacity: 1 !important;
padding: 0px 0px 0px 0px !important;
border-radius: 1rem !important;
background-color: #fff !important;
border-color: #321fdb !important;
font-size: 0.875rem !important;
font-weight: 400 !important;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2) !important;
width: 150px;
}
.tooltipHeader {
padding-left: 5px;
padding-right: 10px;
padding-top: 5px;
padding-bottom: 5px;
border-top-left-radius: 1rem !important;
border-top-right-radius: 1rem !important;
}

View File

@@ -0,0 +1,199 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import axiosInstance from 'utils/axiosInstance';
import { getItem, setItem } from 'utils/localStorageHelper';
import { useAuth, useToast, useToggle } from 'ucentral-libs';
import AddConfigurationModal from 'components/AddConfigurationModal';
import EditConfigurationModal from 'components/EditConfigurationModal';
import Table from './Table';
const DefaultConfigurationTable = () => {
const { t } = useTranslation();
const { addToast } = useToast();
const history = useHistory();
const [page, setPage] = useState(parseInt(sessionStorage.getItem('configurationTable') ?? 0, 10));
const { currentToken, endpoints } = useAuth();
const [configurationCount, setConfigurationCount] = useState(0);
const [pageCount, setPageCount] = useState(0);
const [configurationsPerPage, setConfigurationsPerPage] = useState(
getItem('configurationsPerPage') || '10',
);
const [configurations, setConfigurations] = useState([]);
const [loading, setLoading] = useState(true);
const [editId, setEditId] = useState('');
const [showEditModal, setShowEditModal] = useState(false);
const [showAddModal, toggleAddModal] = useToggle(false);
const toggleEditModal = (serialNumber) => {
if (serialNumber) setEditId(serialNumber);
setShowEditModal(!showEditModal);
};
const getConfigurationInformation = (
selectedPage = page,
configurationPerPage = configurationsPerPage,
) => {
setLoading(true);
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
axiosInstance
.get(
`${endpoints.owgw}/api/v1/default_configurations?limit=${configurationPerPage}&offset=${
configurationPerPage * selectedPage
}`,
options,
)
.then((response) => {
setConfigurations(response.data.configurations);
setLoading(false);
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('configuration.error_fetching_configurations', {
error: e.response?.data?.ErrorDescription,
}),
color: 'danger',
autohide: true,
});
setLoading(false);
});
};
const getCount = () => {
setLoading(true);
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
};
axiosInstance
.get(`${endpoints.owgw}/api/v1/default_configurations?countOnly=true`, {
headers,
})
.then((response) => {
const configurationsCount = response.data.count;
const pagesCount = Math.ceil(configurationsCount / configurationsPerPage);
setPageCount(pagesCount);
setConfigurationCount(configurationsCount);
let selectedPage = page;
if (page >= pagesCount) {
history.push(`/defaultconfigurations?page=${pagesCount - 1}`);
selectedPage = pagesCount - 1;
}
getConfigurationInformation(selectedPage);
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('configuration.error_fetching_configurations', {
error: e.response?.data?.ErrorDescription,
}),
color: 'danger',
autohide: true,
});
setLoading(false);
});
};
const updateConfigurationsPerPage = (value) => {
setItem('configurationsPerPage', value);
setConfigurationsPerPage(value);
const newPageCount = Math.ceil(configurationCount / value);
setPageCount(newPageCount);
let selectedPage = page;
if (page >= newPageCount) {
history.push(`/default_configurations?page=${newPageCount - 1}`);
selectedPage = newPageCount - 1;
}
getConfigurationInformation(selectedPage, value);
};
const updatePageCount = ({ selected: selectedPage }) => {
sessionStorage.setItem('configurationTable', selectedPage);
setPage(selectedPage);
getConfigurationInformation(selectedPage);
};
const deleteConfig = (name) => {
setLoading(true);
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
};
axiosInstance
.delete(`${endpoints.owgw}/api/v1/default_configuration/${name}`, { headers })
.then(() => {
addToast({
title: t('common.success'),
body: t('configuration.successful_delete'),
color: 'success',
autohide: true,
});
getCount();
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('configuration.error_adding_blacklist', {
error: e.response?.data?.ErrorDescription,
}),
color: 'danger',
autohide: true,
});
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
getCount();
}, []);
return (
<div>
<Table
currentPage={page}
t={t}
configurations={configurations}
loading={loading}
toggleAddBlacklist={toggleAddModal}
toggleEditModal={toggleEditModal}
updateConfigurationsPerPage={updateConfigurationsPerPage}
configurationsPerPage={configurationsPerPage}
pageCount={pageCount}
updatePage={updatePageCount}
pageRangeDisplayed={5}
deleteConfig={deleteConfig}
/>
{showAddModal ? (
<AddConfigurationModal show={showAddModal} toggle={toggleAddModal} refresh={getCount} />
) : null}
<EditConfigurationModal
show={showEditModal}
toggle={toggleEditModal}
refresh={getCount}
configId={editId}
/>
</div>
);
};
export default DefaultConfigurationTable;

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { CButton, CCard, CCardHeader, CCardBody, CRow, CCol } from '@coreui/react';
import axiosInstance from 'utils/axiosInstance';
import { LoadingButton, useAuth, useDevice, useToast } from 'ucentral-libs';
import { LoadingButton, useAuth, useDevice, useToast, useToggle } from 'ucentral-libs';
import RebootModal from 'components/RebootModal';
import DeviceFirmwareModal from 'components/DeviceFirmwareModal';
import ConfigureModal from 'components/ConfigureModal';
@@ -13,7 +14,7 @@ import FactoryResetModal from 'components/FactoryResetModal';
import EventQueueModal from 'components/EventQueueModal';
import TelemetryModal from 'components/TelemetryModal';
const DeviceActions = () => {
const DeviceActions = ({ device }) => {
const { t } = useTranslation();
const { currentToken, endpoints } = useAuth();
const { addToast } = useToast();
@@ -21,35 +22,16 @@ const DeviceActions = () => {
const [upgradeStatus, setUpgradeStatus] = useState({
loading: false,
});
const [device, setDevice] = useState({});
const [showRebootModal, setShowRebootModal] = useState(false);
const [showBlinkModal, setShowBlinkModal] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showTraceModal, setShowTraceModal] = useState(false);
const [showScanModal, setShowScanModal] = useState(false);
const [connectLoading, setConnectLoading] = useState(false);
const [showConfigModal, setConfigModal] = useState(false);
const [showFactoryModal, setShowFactoryModal] = useState(false);
const [showQueueModal, setShowQueueModal] = useState(false);
const [showTelemetryModal, setShowTelemetryModal] = useState(false);
const toggleRebootModal = () => setShowRebootModal(!showRebootModal);
const toggleBlinkModal = () => setShowBlinkModal(!showBlinkModal);
const toggleUpgradeModal = () => setShowUpgradeModal(!showUpgradeModal);
const toggleTraceModal = () => setShowTraceModal(!showTraceModal);
const toggleScanModal = () => setShowScanModal(!showScanModal);
const toggleConfigModal = () => setConfigModal(!showConfigModal);
const toggleFactoryResetModal = () => setShowFactoryModal(!showFactoryModal);
const toggleQueueModal = () => setShowQueueModal(!showQueueModal);
const toggleTelemetryModal = () => setShowTelemetryModal(!showTelemetryModal);
const [showRebootModal, toggleRebootModal] = useToggle(false);
const [showBlinkModal, toggleBlinkModal] = useToggle(false);
const [showUpgradeModal, toggleUpgradeModal, setShowUpgradeModal] = useToggle(false);
const [showTraceModal, toggleTraceModal] = useToggle(false);
const [showScanModal, toggleScanModal] = useToggle(false);
const [showConfigModal, toggleConfigModal] = useToggle(false);
const [showFactoryModal, toggleFactoryResetModal] = useToggle(false);
const [showQueueModal, toggleQueueModal] = useToggle(false);
const [showTelemetryModal, toggleTelemetryModal] = useToggle(false);
const getRttysInfo = () => {
setConnectLoading(true);
@@ -67,6 +49,7 @@ const DeviceActions = () => {
)
.then((response) => {
const url = `https://${response.data.server}:${response.data.viewport}/connect/${response.data.connectionId}`;
const newWindow = window.open(url, '_blank', 'noopener,noreferrer');
if (newWindow) newWindow.opener = null;
})
@@ -83,22 +66,6 @@ const DeviceActions = () => {
});
};
const getDeviceInformation = () => {
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
axiosInstance
.get(`${endpoints.owgw}/api/v1/device/${deviceSerialNumber}`, options)
.then((response) => {
setDevice(response.data);
})
.catch(() => {});
};
useEffect(() => {
if (upgradeStatus.result !== undefined) {
addToast({
@@ -116,10 +83,6 @@ const DeviceActions = () => {
}
}, [upgradeStatus]);
useEffect(() => {
getDeviceInformation();
}, [deviceSerialNumber]);
return (
<CCard>
<CCardHeader className="dark-header">
@@ -128,36 +91,41 @@ const DeviceActions = () => {
<CCardBody>
<CRow>
<CCol>
<CButton block onClick={toggleRebootModal} color="primary">
<CButton block disabled={device === null} onClick={toggleRebootModal} color="primary">
{t('actions.reboot')}
</CButton>
</CCol>
<CCol>
<CButton block onClick={toggleBlinkModal} color="primary">
<CButton block disabled={device === null} onClick={toggleBlinkModal} color="primary">
{t('actions.blink')}
</CButton>
</CCol>
</CRow>
<CRow className="my-1">
<CCol>
<CButton block color="primary" onClick={toggleUpgradeModal}>
<CButton block disabled={device === null} color="primary" onClick={toggleUpgradeModal}>
{t('actions.firmware_upgrade')}
</CButton>
</CCol>
<CCol>
<CButton block color="primary" onClick={toggleTraceModal}>
<CButton block disabled={device === null} color="primary" onClick={toggleTraceModal}>
{t('actions.trace')}
</CButton>
</CCol>
</CRow>
<CRow className="my-1">
<CCol>
<CButton block color="primary" onClick={toggleScanModal}>
<CButton block disabled={device === null} color="primary" onClick={toggleScanModal}>
{t('actions.wifi_scan')}
</CButton>
</CCol>
<CCol>
<CButton block color="primary" onClick={toggleFactoryResetModal}>
<CButton
block
disabled={device === null}
color="primary"
onClick={toggleFactoryResetModal}
>
{t('actions.factory_reset')}
</CButton>
</CCol>
@@ -165,6 +133,7 @@ const DeviceActions = () => {
<CRow className="my-1">
<CCol>
<LoadingButton
disabled={device === null}
isLoading={connectLoading}
label={t('actions.connect')}
isLoadingLabel={t('actions.connecting')}
@@ -172,19 +141,24 @@ const DeviceActions = () => {
/>
</CCol>
<CCol>
<CButton block color="primary" onClick={toggleConfigModal}>
<CButton block disabled={device === null} color="primary" onClick={toggleConfigModal}>
{t('actions.configure')}
</CButton>
</CCol>
</CRow>
<CRow className="my-1">
<CCol>
<CButton block color="primary" onClick={toggleQueueModal}>
<CButton block disabled={device === null} color="primary" onClick={toggleQueueModal}>
{t('commands.event_queue')}
</CButton>
</CCol>
<CCol>
<CButton block color="primary" onClick={toggleTelemetryModal}>
<CButton
block
disabled={device === null}
color="primary"
onClick={toggleTelemetryModal}
>
{t('actions.telemetry')}
</CButton>
</CCol>
@@ -212,4 +186,12 @@ const DeviceActions = () => {
);
};
DeviceActions.propTypes = {
device: PropTypes.instanceOf(Object),
};
DeviceActions.defaultProps = {
device: null,
};
export default DeviceActions;

View File

@@ -0,0 +1,418 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
CCard,
CCardBody,
CCardHeader,
CCol,
CPopover,
CRow,
CSpinner,
CWidgetIcon,
} from '@coreui/react';
import { CChartBar, CChartHorizontalBar, CChartPie } from '@coreui/react-chartjs';
import { cilClock, cilInfo, cilMedicalCross, cilThumbUp, cilWarning } from '@coreui/icons';
import CIcon from '@coreui/icons-react';
import { FormattedDate } from 'ucentral-libs';
import styles from './index.module.scss';
const getColor = (health) => {
const numberHealth = health ? Number(health.replace('%', '')) : 0;
if (numberHealth >= 90) return 'success';
if (numberHealth >= 60) return 'warning';
return 'danger';
};
const getIcon = (health) => {
const numberHealth = health ? Number(health.replace('%', '')) : 0;
if (numberHealth >= 90) return <CIcon width={36} name="cil-thumbs-up" content={cilThumbUp} />;
if (numberHealth >= 60) return <CIcon width={36} name="cil-warning" content={cilWarning} />;
return <CIcon width={36} name="cil-medical-cross" content={cilMedicalCross} />;
};
const DeviceDashboard = ({ t, data, loading }) => (
<div style={{ position: 'relative' }}>
<div style={{ opacity: loading ? '20%' : '100%' }}>
<CRow className="mt-3">
<CCol>
<CWidgetIcon
text={t('common.last_dashboard_refresh')}
header={data.snapshot ? <FormattedDate date={data.snapshot} size="lg" /> : <h2>-</h2>}
color="info"
iconPadding={false}
>
<CIcon width={36} name="cil-clock" content={cilClock} />
</CWidgetIcon>
</CCol>
<CCol>
<CWidgetIcon
text={
<div>
<div className="float-left">{t('common.overall_health')}</div>
<div className="float-left ml-2">
<CPopover content={t('device.health_explanation')}>
<CIcon content={cilInfo} />
</CPopover>
</div>
</div>
}
header={<h2>{data.overallHealth}</h2>}
color={getColor(data.overallHealth)}
iconPadding={false}
>
{getIcon(data.overallHealth)}
</CWidgetIcon>
</CCol>
<CCol>
<CWidgetIcon
text={t('common.devices')}
header={<h2>{data.numberOfDevices}</h2>}
color="primary"
iconPadding={false}
>
<CIcon width={36} name="cil-router" />
</CWidgetIcon>
</CCol>
</CRow>
<CRow>
<CCol lg="6" xl="4">
<CCard>
<CCardHeader className="dark-header">{t('common.device_status')}</CCardHeader>
<CCardBody className="p-1">
<CChartPie
datasets={data.status.datasets}
labels={data.status.labels}
options={{
tooltips: {
callbacks: {
title: (item, ds) => ds.labels[item[0].index],
label: (item, ds) => `${ds.datasets[0].data[item.index]}%`,
},
},
legend: {
display: true,
position: 'right',
},
}}
/>
</CCardBody>
</CCard>
</CCol>
<CCol lg="6" xl="4">
<CCard>
<CCardHeader className="dark-header">
<div>
<div className="float-left">{t('common.device_health')}</div>
<div className="float-left ml-2">
<CPopover content={t('device.health_explanation')}>
<CIcon content={cilInfo} />
</CPopover>
</div>
</div>
</CCardHeader>
<CCardBody className="p-1">
<CChartPie
datasets={data.healths.datasets}
labels={data.healths.labels}
options={{
tooltips: {
callbacks: {
title: (item, ds) => ds.labels[item[0].index],
label: (item, ds) =>
`${ds.datasets[0].data[item.index]}${t('common.of_connected')}`,
},
},
legend: {
display: true,
position: 'right',
},
}}
/>
</CCardBody>
</CCard>
</CCol>
<CCol lg="6" xl="4">
<CCard>
<CCardHeader className="dark-header">
{data.totalAssociations}{' '}
{data.totalAssociations === 1
? t('wifi_analysis.association')
: t('wifi_analysis.associations')}
</CCardHeader>
<CCardBody className="p-1">
<CChartPie
datasets={data.associations.datasets}
labels={data.associations.labels}
options={{
tooltips: {
callbacks: {
title: (item, ds) => ds.labels[item[0].index],
label: (item, ds) =>
`${ds.datasets[0].data[item.index]}% of ${
data.totalAssociations
} associations`,
},
},
legend: {
display: true,
position: 'right',
},
}}
/>
</CCardBody>
</CCard>
</CCol>
<CCol lg="6" xl="4">
<CCard>
<CCardHeader className="dark-header">{t('common.vendors')}</CCardHeader>
<CCardBody className="p-1">
<CChartHorizontalBar
datasets={data.vendors.datasets}
labels={data.vendors.labels}
options={{
tooltips: {
mode: 'index',
intersect: false,
callbacks: {
title: (item, ds) => ds.labels[item[0].index],
label: (item, ds) =>
`${ds.datasets[0].data[item.index]} ${t('common.devices')}`,
},
},
hover: {
mode: 'index',
intersect: false,
},
legend: {
display: false,
position: 'right',
},
scales: {
xAxes: [
{
ticks: {
maxTicksLimit: 5,
beginAtZero: true,
stepSize: 1,
},
},
],
yAxes: [
{
ticks: {
callback: (value) => value.split(' ')[0],
},
},
],
},
}}
/>
</CCardBody>
</CCard>
</CCol>
<CCol lg="6" xl="4">
<CCard>
<CCardHeader className="dark-header">{t('firmware.device_types')}</CCardHeader>
<CCardBody className="p-1">
<CChartPie
datasets={data.deviceType.datasets}
labels={data.deviceType.labels}
options={{
tooltips: {
callbacks: {
title: (item, ds) => ds.labels[item[0].index],
label: (item, ds) =>
`${ds.datasets[0].data[item.index]} ${t('common.devices')}`,
},
},
legend: {
display: true,
position: 'right',
},
}}
/>
</CCardBody>
</CCard>
</CCol>
<CCol lg="6" xl="4">
<CCard>
<CCardHeader className="dark-header">
<div>
<div className="float-left">{t('common.uptimes')}</div>
<div className="float-left ml-2">
<CPopover content={t('device.uptimes_explanation')}>
<CIcon content={cilInfo} />
</CPopover>
</div>
</div>
</CCardHeader>
<CCardBody className="p-1">
<CChartBar
datasets={data.upTimes.datasets}
labels={data.upTimes.labels}
options={{
tooltips: {
mode: 'index',
intersect: false,
callbacks: {
title: (item, ds) => ds.labels[item[0].index],
label: (item, ds) =>
`${ds.datasets[0].data[item.index]} ${t('common.devices')}`,
},
},
hover: {
mode: 'index',
intersect: false,
},
legend: {
display: false,
position: 'right',
},
scales: {
yAxes: [
{
ticks: {
maxTicksLimit: 5,
beginAtZero: true,
stepSize: 1,
},
},
],
},
}}
/>
</CCardBody>
</CCard>
</CCol>
<CCol lg="6" xl="4">
<CCard>
<CCardHeader className="dark-header">
<div>
<div className="float-left">{t('common.certificates')}</div>
<div className="float-left ml-2">
<CPopover content={t('device.certificate_explanation')}>
<CIcon content={cilInfo} />
</CPopover>
</div>
</div>
</CCardHeader>
<CCardBody className="p-1">
<CChartPie
datasets={data.certificates.datasets}
labels={data.certificates.labels}
options={{
tooltips: {
callbacks: {
title: (item, ds) => ds.labels[item[0].index],
label: (item, ds) =>
`${ds.datasets[0].data[item.index]}${t('common.of_connected')}`,
},
},
legend: {
display: true,
position: 'right',
},
}}
/>
</CCardBody>
</CCard>
</CCol>
<CCol lg="6" xl="4">
<CCard>
<CCardHeader className="dark-header">{t('common.commands')}</CCardHeader>
<CCardBody className="p-1">
<CChartBar
datasets={data.commands.datasets}
labels={data.commands.labels}
options={{
tooltips: {
mode: 'index',
intersect: false,
},
hover: {
mode: 'index',
intersect: false,
},
legend: {
display: false,
position: 'right',
},
scales: {
yAxes: [
{
ticks: {
maxTicksLimit: 5,
beginAtZero: true,
stepSize: 1,
},
},
],
},
}}
/>
</CCardBody>
</CCard>
</CCol>
<CCol lg="6" xl="4">
<CCard>
<CCardHeader className="dark-header">
<div>
<div className="float-left">{t('common.memory_used')}</div>
<div className="float-left ml-2">
<CPopover content={t('device.memory_explanation')}>
<CIcon content={cilInfo} />
</CPopover>
</div>
</div>
</CCardHeader>
<CCardBody className="p-1">
<CChartBar
datasets={data.memoryUsed.datasets}
labels={data.memoryUsed.labels}
options={{
tooltips: {
mode: 'index',
intersect: false,
},
hover: {
mode: 'index',
intersect: false,
},
legend: {
display: false,
position: 'right',
},
scales: {
yAxes: [
{
ticks: {
maxTicksLimit: 10,
beginAtZero: true,
stepSize: 1,
},
},
],
},
}}
/>
</CCardBody>
</CCard>
</CCol>
</CRow>
</div>
{loading ? (
<div className={styles.centerContainer}>
<CSpinner className={styles.spinner} />
</div>
) : null}
</div>
);
DeviceDashboard.propTypes = {
t: PropTypes.func.isRequired,
data: PropTypes.instanceOf(Object).isRequired,
loading: PropTypes.bool.isRequired,
};
export default React.memo(DeviceDashboard);

View File

@@ -0,0 +1,10 @@
.centerContainer {
position: absolute;
top: 5%;
right: 50%;
}
.spinner {
height: 50px;
width: 50px;
}

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { DeviceDashboard as Dashboard, useAuth, COLOR_LIST } from 'ucentral-libs';
import { useAuth, COLOR_LIST } from 'ucentral-libs';
import axiosInstance from 'utils/axiosInstance';
import Dashboard from './Dashboard';
const DeviceDashboard = () => {
const { t } = useTranslation();

View File

@@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
CButton,
CDataTable,
CModal,
CModalHeader,
CModalTitle,
CModalBody,
CRow,
CCol,
CInput,
CPopover,
CSwitch,
} from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilX } from '@coreui/icons';
import { LoadingButton } from 'ucentral-libs';
import { cleanBytesString, prettyDate } from 'utils/helper';
const DeviceFirmwareModal = ({
t,
device,
show,
toggle,
firmwareVersions,
upgradeToVersion,
loading,
upgradeStatus,
keepRedirector,
toggleRedirector,
}) => {
const [filter, setFilter] = useState('');
const fields = [
{ key: 'imageDate', label: t('firmware.image_date'), _style: { width: '17%' }, filter: false },
{ key: 'size', label: t('firmware.size'), _style: { width: '8%' }, filter: false },
{ key: 'revision', label: t('firmware.revision'), _style: { width: '60%' } },
{ key: 'show_details', label: '', _style: { width: '15%' }, filter: false },
];
useEffect(() => {
setFilter('');
}, [show]);
return (
<CModal show={show} onClose={toggle} size="xl">
<CModalHeader className="p-1">
<CModalTitle className="pl-1 pt-1">#{device?.serialNumber}</CModalTitle>
<div className="text-right">
<CPopover content={t('common.close')}>
<CButton color="primary" variant="outline" className="ml-2" onClick={toggle}>
<CIcon content={cilX} />
</CButton>
</CPopover>
</div>
</CModalHeader>
<CModalBody>
{show ? (
<div>
<CRow>
<CCol sm="2" className="pt-2">
{t('firmware.installed_firmware')}
</CCol>
<CCol className="pt-2">{device.firmware}</CCol>
</CRow>
<CRow className="mt-3">
<CCol sm="2" className="pt-2">
{t('factory_reset.redirector')}
</CCol>
<CCol className="pt-2">
<CSwitch
color="primary"
defaultChecked={keepRedirector}
onClick={toggleRedirector}
labelOn="Yes"
labelOff="No"
/>
</CCol>
</CRow>
<CRow className="my-4">
<CCol sm="5">
<CInput
type="text"
placeholder="Search"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</CCol>
<CCol />
</CRow>
<CRow className="mb-4">
<CCol>
<div className="overflow-auto" style={{ height: '600px' }}>
<CDataTable
addTableClasses="table-sm"
items={firmwareVersions}
fields={fields}
loading={loading}
hover
tableFilterValue={filter}
border
scopedSlots={{
imageDate: (item) => <td>{prettyDate(item.imageDate)}</td>,
size: (item) => <td>{cleanBytesString(item.size)}</td>,
show_details: (item) => (
<td className="text-center">
<LoadingButton
label={t('firmware.upgrade')}
isLoadingLabel={t('firmware.upgrading')}
isLoading={false}
action={() => upgradeToVersion(item.uri)}
block={false}
disabled={upgradeStatus.loading}
/>
</td>
),
}}
/>
</div>
</CCol>
</CRow>
</div>
) : (
<div />
)}
</CModalBody>
</CModal>
);
};
DeviceFirmwareModal.propTypes = {
t: PropTypes.func.isRequired,
device: PropTypes.instanceOf(Object).isRequired,
show: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
firmwareVersions: PropTypes.instanceOf(Array).isRequired,
upgradeToVersion: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
upgradeStatus: PropTypes.instanceOf(Object).isRequired,
keepRedirector: PropTypes.bool.isRequired,
toggleRedirector: PropTypes.func.isRequired,
};
export default React.memo(DeviceFirmwareModal);

View File

@@ -1,9 +1,10 @@
/* eslint-disable no-await-in-loop */
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { DeviceFirmwareModal as Modal, useAuth, useToast } from 'ucentral-libs';
import { useAuth, useToast, useToggle } from 'ucentral-libs';
import axiosInstance from 'utils/axiosInstance';
import { useTranslation } from 'react-i18next';
import Modal from './Modal';
const DeviceFirmwareModal = ({
device,
@@ -17,6 +18,7 @@ const DeviceFirmwareModal = ({
const { currentToken, endpoints } = useAuth();
const [loading, setLoading] = useState(false);
const [firmwareVersions, setFirmwareVersions] = useState([]);
const [keepRedirector, toggleKeepRedirector, setKeepRedirector] = useToggle(true);
const getPartialFirmware = async (offset) => {
const headers = {
@@ -48,7 +50,7 @@ const DeviceFirmwareModal = ({
const allFirmwares = [];
let continueFirmware = true;
let i = 1;
let i = 0;
while (continueFirmware) {
const newFirmwares = await getPartialFirmware(i);
if (newFirmwares === null || newFirmwares.length === 0) continueFirmware = false;
@@ -78,6 +80,7 @@ const DeviceFirmwareModal = ({
const parameters = {
serialNumber: device.serialNumber,
keepRedirector,
when: 0,
uri,
};
@@ -108,6 +111,7 @@ const DeviceFirmwareModal = ({
useEffect(() => {
if (show && device.compatible) getFirmwareList();
if (show) setKeepRedirector(true);
}, [device, show]);
return (
@@ -120,6 +124,8 @@ const DeviceFirmwareModal = ({
upgradeToVersion={upgradeToVersion}
loading={loading}
upgradeStatus={upgradeStatus}
keepRedirector={keepRedirector}
toggleRedirector={toggleKeepRedirector}
/>
);
};

View File

@@ -0,0 +1,456 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import ReactPaginate from 'react-paginate';
import {
CCardBody,
CDataTable,
CButton,
CLink,
CCard,
CCardHeader,
CRow,
CCol,
CPopover,
CSelect,
CButtonClose,
} from '@coreui/react';
import {
cilSync,
cilArrowCircleTop,
cilCheckCircle,
cilTerminal,
cilTrash,
cilSearch,
} from '@coreui/icons';
import CIcon from '@coreui/icons-react';
import ReactTooltip from 'react-tooltip';
import { v4 as createUuid } from 'uuid';
import { cleanBytesString } from 'utils/helper';
import { DeviceBadge, LoadingButton } from 'ucentral-libs';
import styles from './index.module.scss';
const DeviceListTable = ({
currentPage,
devices,
searchBar,
devicesPerPage,
loading,
updateDevicesPerPage,
pageCount,
updatePage,
refreshDevice,
t,
toggleFirmwareModal,
toggleHistoryModal,
upgradeToLatest,
upgradeStatus,
deviceIcons,
connectRtty,
deleteDevice,
deleteStatus,
}) => {
const columns = [
{ key: 'deviceType', label: '', filter: false, sorter: false, _style: { width: '1%' } },
{ key: 'serialNumber', label: t('common.serial_number'), _style: { width: '6%' } },
{ key: 'firmware', label: t('firmware.revision') },
{ key: 'firmware_button', label: '', filter: false, _style: { width: '1%' } },
{ key: 'compatible', label: t('common.type'), filter: false, _style: { width: '13%' } },
{ key: 'txBytes', label: 'Tx', filter: false, _style: { width: '14%' } },
{ key: 'rxBytes', label: 'Rx', filter: false, _style: { width: '14%' } },
{ key: 'ipAddress', label: t('IP'), _style: { width: '10%' } },
{ key: 'twoG', label: t('2G'), _style: { width: '10%' } },
{ key: 'fiveG', label: t('5G'), _style: { width: '10%' } },
{ key: 'actions', label: t('actions.actions'), _style: { width: '10%' } },
];
const hideTooltips = () => ReactTooltip.hide();
const escFunction = (event) => {
if (event.keyCode === 27) {
hideTooltips();
}
};
const getShortRevision = (revision) => {
if (revision.includes(' / ')) {
return revision.split(' / ')[1];
}
return revision;
};
useEffect(() => {
document.addEventListener('keydown', escFunction, false);
return () => {
document.removeEventListener('keydown', escFunction, false);
};
}, []);
const getFirmwareButton = (latest, device) => {
const tooltipId = createUuid();
let text = t('firmware.unknown_firmware_status');
let upgradeText = t('firmware.upgrade_to_latest');
let icon = <CIcon name="cil-arrow-circle-top" content={cilArrowCircleTop} />;
let color = 'secondary';
if (latest !== undefined) {
text = t('firmware.newer_firmware_available');
color = 'warning';
if (latest) {
icon = <CIcon name="cil-check-circle" content={cilCheckCircle} />;
text = t('firmware.latest_version_installed');
upgradeText = t('firmware.reinstall_latest');
color = 'success';
}
}
return (
<div>
<CButton size="sm" color={color} data-tip data-for={tooltipId} data-event="click">
{icon}
</CButton>
<ReactTooltip
id={tooltipId}
place="top"
effect="solid"
globalEventOff="click"
clickable
className={[styles.firmwareTooltip, 'tooltipLeft'].join(' ')}
border
borderColor="#321fdb"
arrowColor="white"
overridePosition={({ left, top }) => {
const element = document.getElementById(tooltipId);
const tooltipWidth = element ? element.offsetWidth : 0;
const newLeft = left + tooltipWidth * 0.25;
return { top, left: newLeft };
}}
>
<CCardHeader color="primary" className={styles.tooltipHeader}>
{text}
<CButtonClose
style={{ color: 'white' }}
onClick={(e) => {
e.target.parentNode.parentNode.classList.remove('show');
hideTooltips();
}}
/>
</CCardHeader>
<CCardBody>
<CRow>
<CCol>
<LoadingButton
variant="outline"
label={upgradeText}
isLoadingLabel={t('firmware.upgrading')}
isLoading={upgradeStatus.loading}
action={() => upgradeToLatest(device)}
block
disabled={
upgradeStatus.loading && upgradeStatus.serialNumber === device.serialNumber
}
/>
</CCol>
<CCol>
<CButton
block
variant="outline"
color="primary"
onClick={() => {
toggleFirmwareModal(device);
}}
>
{t('firmware.choose_custom')}
</CButton>
</CCol>
<CCol>
<CButton
block
variant="outline"
color="primary"
onClick={() => {
toggleHistoryModal(device);
}}
>
{t('firmware.history_title')}
</CButton>
</CCol>
</CRow>
</CCardBody>
</ReactTooltip>
</div>
);
};
const deleteButton = (serialNumber) => {
const tooltipId = createUuid();
return (
<>
<CPopover content={t('common.delete_device')}>
<CButton
color="primary"
variant="outline"
shape="square"
size="sm"
className="mx-1 d-inline"
data-tip
data-for={tooltipId}
data-event="click"
style={{ width: '33px', height: '30px' }}
>
<CIcon name="cil-trash" content={cilTrash} size="sm" />
</CButton>
</CPopover>
<ReactTooltip
id={tooltipId}
place="top"
effect="solid"
globalEventOff="click"
clickable
className={[styles.deleteTooltip, 'tooltipRight'].join(' ')}
border
borderColor="#321fdb"
arrowColor="white"
overridePosition={({ left, top }) => {
const element = document.getElementById(tooltipId);
const tooltipWidth = element ? element.offsetWidth : 0;
const newLeft = left - tooltipWidth * 0.25;
return { top, left: newLeft };
}}
>
<CCardHeader color="primary" className={styles.tooltipHeader}>
{t('common.device_delete', { serialNumber })}
<CButtonClose
className="p-0 mb-1"
style={{ color: 'white' }}
onClick={(e) => {
e.target.parentNode.parentNode.classList.remove('show');
hideTooltips();
}}
/>
</CCardHeader>
<CCardBody>
<CRow>
<CCol>
<LoadingButton
data-toggle="dropdown"
variant="outline"
color="danger"
label={t('common.confirm')}
isLoadingLabel={t('user.deleting')}
isLoading={deleteStatus.loading}
action={(e) => {
e.target.parentNode.parentNode.parentNode.parentNode.classList.remove('show');
hideTooltips();
deleteDevice(serialNumber);
}}
block
disabled={deleteStatus.loading}
/>
</CCol>
</CRow>
</CCardBody>
</ReactTooltip>
</>
);
};
return (
<>
<CCard className="m-0 p-0">
<CCardHeader className="p-0">
<div className="float-left" style={{ width: '400px' }}>
{searchBar}
</div>
</CCardHeader>
<CCardBody className="p-0">
<CDataTable
addTableClasses="ignore-overflow table-sm"
items={devices ?? []}
fields={columns}
hover
border
loading={loading}
scopedSlots={{
deviceType: (item) => (
<td className="align-middle text-center">
<DeviceBadge t={t} device={item} deviceIcons={deviceIcons} />
</td>
),
serialNumber: (item) => (
<td className="text-center align-middle">
<CLink
className="c-subheader-nav-link"
aria-current="page"
to={() => `/devices/${item.serialNumber}`}
>
{item.serialNumber}
</CLink>
</td>
),
firmware: (item) => (
<td className="align-middle">
<CPopover
content={item.firmware ? item.firmware : t('common.na')}
placement="top"
>
<div style={{ width: 'calc(10vw)' }} className="text-truncate align-middle">
{getShortRevision(item.firmware)}
</div>
</CPopover>
</td>
),
firmware_button: (item) => (
<td className="text-center align-middle">
{item.firmwareInfo
? getFirmwareButton(item.firmwareInfo.latest, item)
: getFirmwareButton(undefined, item)}
</td>
),
compatible: (item) => (
<td className="align-middle">
<CPopover
content={item.compatible ? item.compatible : t('common.na')}
placement="top"
>
<div style={{ width: 'calc(10vw)' }} className="text-truncate align-middle">
{item.compatible}
</div>
</CPopover>
</td>
),
txBytes: (item) => <td className="align-middle">{cleanBytesString(item.txBytes)}</td>,
rxBytes: (item) => <td className="align-middle">{cleanBytesString(item.rxBytes)}</td>,
ipAddress: (item) => (
<td className="align-middle">
<CPopover
content={item.ipAddress ? item.ipAddress : t('common.na')}
placement="top"
>
<div style={{ width: 'calc(8vw)' }} className="text-truncate align-middle">
{item.ipAddress}
</div>
</CPopover>
</td>
),
twoG: (item) => <td className="align-middle">{item.associations_2G ?? 0}</td>,
fiveG: (item) => <td className="align-middle">{item.associations_5G ?? 0}</td>,
actions: (item) => (
<td className="text-center align-middle">
<div role="group" className="justify-content-center" style={{ width: '190px' }}>
<CPopover content={t('actions.connect')}>
<CButton
className="mx-1 d-inline"
color="primary"
variant="outline"
shape="square"
size="sm"
onClick={() => connectRtty(item.serialNumber)}
style={{ width: '33px', height: '30px' }}
>
<CIcon name="cil-terminal" content={cilTerminal} size="sm" />
</CButton>
</CPopover>
{deleteButton(item.serialNumber)}
<CPopover content={t('configuration.details')}>
<CLink
className="c-subheader-nav-link"
aria-current="page"
to={() => `/devices/${item.serialNumber}`}
>
<CButton
color="primary"
variant="outline"
shape="square"
size="sm"
className="mx-1 d-inline"
style={{ width: '33px', height: '30px' }}
>
<CIcon name="cil-search" content={cilSearch} size="sm" />
</CButton>
</CLink>
</CPopover>
<CPopover content={t('common.refresh_device')}>
<CButton
onClick={() => refreshDevice(item.serialNumber)}
color="primary"
variant="outline"
shape="square"
size="sm"
className="mx-1 d-inline"
style={{ width: '33px', height: '30px' }}
>
<CIcon name="cil-sync" content={cilSync} size="sm" />
</CButton>
</CPopover>
</div>
</td>
),
}}
/>
<div className="d-flex flex-row pl-3">
<div className="pr-3">
<ReactPaginate
previousLabel="← Previous"
nextLabel="Next →"
pageCount={pageCount}
onPageChange={updatePage}
forcePage={Number(currentPage)}
breakClassName="page-item"
breakLinkClassName="page-link"
containerClassName="pagination"
pageClassName="page-item"
pageLinkClassName="page-link"
previousClassName="page-item"
previousLinkClassName="page-link"
nextClassName="page-item"
nextLinkClassName="page-link"
activeClassName="active"
/>
</div>
<p className="pr-2 mt-1">{t('common.items_per_page')}</p>
<div style={{ width: '100px' }} className="px-2">
<CSelect
custom
defaultValue={devicesPerPage}
onChange={(e) => updateDevicesPerPage(e.target.value)}
disabled={loading}
>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</CSelect>
</div>
</div>
</CCardBody>
</CCard>
</>
);
};
DeviceListTable.propTypes = {
currentPage: PropTypes.oneOf(['string', 'number']),
devices: PropTypes.instanceOf(Array).isRequired,
searchBar: PropTypes.node.isRequired,
updateDevicesPerPage: PropTypes.func.isRequired,
pageCount: PropTypes.number.isRequired,
updatePage: PropTypes.func.isRequired,
devicesPerPage: PropTypes.string.isRequired,
refreshDevice: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
toggleFirmwareModal: PropTypes.func.isRequired,
toggleHistoryModal: PropTypes.func.isRequired,
upgradeToLatest: PropTypes.func.isRequired,
upgradeStatus: PropTypes.instanceOf(Object).isRequired,
deviceIcons: PropTypes.instanceOf(Object).isRequired,
connectRtty: PropTypes.func.isRequired,
deleteDevice: PropTypes.func.isRequired,
deleteStatus: PropTypes.instanceOf(Object).isRequired,
};
DeviceListTable.defaultProps = {
currentPage: '0',
};
export default React.memo(DeviceListTable);

View File

@@ -0,0 +1,30 @@
.firmwareTooltip {
opacity: 1 !important;
padding: 0px 0px 0px 0px !important;
border-radius: 1rem !important;
background-color: #fff !important;
border-color: #321fdb !important;
font-size: 0.875rem !important;
font-weight: 400 !important;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2) !important;
width: 400px;
}
.deleteTooltip {
opacity: 1 !important;
padding: 0px 0px 0px 0px !important;
border-radius: 1rem !important;
background-color: #fff !important;
border-color: #321fdb !important;
font-size: 0.875rem !important;
font-weight: 400 !important;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2) !important;
width: 200px;
}
.tooltipHeader {
padding-left: 5px;
padding-right: 10px;
border-top-left-radius: 1rem !important;
border-top-right-radius: 1rem !important;
}

View File

@@ -6,7 +6,8 @@ import { getItem, setItem } from 'utils/localStorageHelper';
import DeviceSearchBar from 'components/DeviceSearchBar';
import DeviceFirmwareModal from 'components/DeviceFirmwareModal';
import FirmwareHistoryModal from 'components/FirmwareHistoryModal';
import { DeviceListTable, useAuth, useToast } from 'ucentral-libs';
import { useAuth, useToast } from 'ucentral-libs';
import Table from './Table';
import meshIcon from '../../assets/icons/Mesh.png';
import apIcon from '../../assets/icons/AP.png';
import internetSwitch from '../../assets/icons/Switch.png';
@@ -68,7 +69,7 @@ const DeviceList = () => {
axiosInstance
.get(
`${endpoints.owgw}/api/v1/devices?deviceWithStatus=true&limit=${devicePerPage}&offset=${
devicePerPage * selectedPage + 1
devicePerPage * selectedPage
}`,
options,
)
@@ -377,7 +378,7 @@ const DeviceList = () => {
return (
<div>
<DeviceListTable
<Table
currentPage={page}
t={t}
searchBar={<DeviceSearchBar />}

View File

@@ -214,7 +214,7 @@ const DeviceLogs = () => {
toggleDetails(index);
}}
>
<CIcon name="cilList" size="md" />
<CIcon name="cilList" />
</CButton>
</td>
),

View File

@@ -1,10 +1,11 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useAuth, DeviceSearchBar as SearchBar } from 'ucentral-libs';
import { checkIfJson } from 'utils/helper';
const DeviceSearchBar = () => {
const DeviceSearchBar = ({ action }) => {
const { t } = useTranslation();
const history = useHistory();
const { currentToken, endpoints } = useAuth();
@@ -65,7 +66,15 @@ const DeviceSearchBar = () => {
}
}, []);
return <SearchBar t={t} search={search} results={results} history={history} />;
return <SearchBar t={t} search={search} results={results} history={history} action={action} />;
};
DeviceSearchBar.propTypes = {
action: PropTypes.func,
};
DeviceSearchBar.defaultProps = {
action: null,
};
export default DeviceSearchBar;

View File

@@ -1,100 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import axiosInstance from 'utils/axiosInstance';
import { DeviceStatusCard as Card, useDevice, useAuth, useToast } from 'ucentral-libs';
const DeviceStatusCard = () => {
const { t } = useTranslation();
const { currentToken, endpoints } = useAuth();
const { deviceSerialNumber } = useDevice();
const { addToast } = useToast();
const [lastStats, setLastStats] = useState(null);
const [status, setStatus] = useState(null);
const [deviceConfig, setDeviceConfig] = useState(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const getDevice = () => {
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
axiosInstance
.get(`${endpoints.owgw}/api/v1/device/${encodeURIComponent(deviceSerialNumber)}`, options)
.then((response) => {
setDeviceConfig(response.data);
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('device.error_fetching_device', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
});
};
const getData = () => {
setLoading(true);
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
const lastStatsRequest = axiosInstance.get(
`${endpoints.owgw}/api/v1/device/${encodeURIComponent(
deviceSerialNumber,
)}/statistics?lastOnly=true`,
options,
);
const statusRequest = axiosInstance.get(
`${endpoints.owgw}/api/v1/device/${encodeURIComponent(deviceSerialNumber)}/status`,
options,
);
Promise.all([lastStatsRequest, statusRequest])
.then(([newStats, newStatus]) => {
setLastStats(newStats.data);
setStatus(newStatus.data);
})
.catch(() => {
setError(true);
})
.finally(() => {
setLoading(false);
});
};
const refresh = () => {
getData();
getDevice();
};
useEffect(() => {
setError(false);
if (deviceSerialNumber) {
getDevice();
getData();
}
}, [deviceSerialNumber]);
return (
<Card
t={t}
loading={loading}
error={error}
deviceSerialNumber={deviceSerialNumber}
getData={refresh}
deviceConfig={deviceConfig}
status={status}
lastStats={lastStats}
/>
);
};
export default DeviceStatusCard;

View File

@@ -0,0 +1,154 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import {
CButton,
CModal,
CModalHeader,
CModalTitle,
CModalBody,
CPopover,
CRow,
CCol,
CLabel,
CTextarea,
} from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { useAuth, useToast } from 'ucentral-libs';
import { cilSave, cilX } from '@coreui/icons';
import axiosInstance from 'utils/axiosInstance';
const EditBlacklistModal = ({ show, toggle, serialNumber, refresh }) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const { addToast } = useToast();
const { endpoints, currentToken } = useAuth();
const [reason, setReason] = useState('');
const getBlacklistInfo = () => {
setLoading(true);
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
};
axiosInstance
.get(`${endpoints.owgw}/api/v1/blacklist/${serialNumber}`, {
headers,
})
.then((response) => {
setReason(response.data.reason);
setLoading(false);
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('device.error_fetching_devices', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
setLoading(false);
toggle();
});
};
const save = () => {
setLoading(true);
const parameters = {
reason,
};
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
};
axiosInstance
.put(`${endpoints.owgw}/api/v1/blacklist/${serialNumber}`, parameters, { headers })
.then(() => {
addToast({
title: t('common.success'),
body: t('device.success_edit_blacklist'),
color: 'success',
autohide: true,
});
toggle();
if (refresh) refresh();
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('device.error_edit_blacklist', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
if (show) getBlacklistInfo();
}, [show, serialNumber]);
return (
<CModal className="text-dark" size="lg" show={show} onClose={toggle}>
<CModalHeader className="p-1">
<CModalTitle className="pl-1 pt-1">{t('device.edit_blacklist')}</CModalTitle>
<div className="text-right">
<CPopover content={t('common.add')}>
<CButton
color="primary"
variant="outline"
className="ml-2"
onClick={save}
disabled={loading || reason === ''}
>
<CIcon content={cilSave} />
</CButton>
</CPopover>
<CPopover content={t('common.close')}>
<CButton color="primary" variant="outline" className="ml-2" onClick={toggle}>
<CIcon content={cilX} />
</CButton>
</CPopover>
</div>
</CModalHeader>
<CModalBody>
<CRow>
<CLabel col sm="3">
{t('common.reason')}
</CLabel>
<CCol sm="9" className="pt-2">
<CTextarea
name="reason"
id="reason"
rows="3"
type="text"
required
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</CCol>
</CRow>
</CModalBody>
</CModal>
);
};
EditBlacklistModal.propTypes = {
show: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
serialNumber: PropTypes.string,
refresh: PropTypes.func,
};
EditBlacklistModal.defaultProps = {
serialNumber: '',
refresh: null,
};
export default EditBlacklistModal;

View File

@@ -0,0 +1,163 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import Select from 'react-select';
import {
CForm,
CInput,
CLabel,
CCol,
CFormGroup,
CInvalidFeedback,
CFormText,
CRow,
CTextarea,
} from '@coreui/react';
import { CopyToClipboardButton } from 'ucentral-libs';
const EditDefaultConfigurationForm = ({
t,
disable,
fields,
updateField,
updateFieldWithKey,
deviceTypes,
editing,
}) => {
const [typeOptions, setTypeOptions] = useState([]);
const [chosenTypes, setChosenTypes] = useState([]);
const parseOptions = () => {
const options = [{ value: '*', label: 'All' }];
const newOptions = deviceTypes.map((option) => ({
value: option,
label: option,
}));
options.push(...newOptions);
setTypeOptions(options);
setChosenTypes([]);
const newChosenTypes = fields.modelIds.value.map((dType) => ({
value: dType,
label: dType === '*' ? 'All' : dType,
}));
setChosenTypes(newChosenTypes);
};
const typeOnChange = (chosenArray) => {
const allIndex = chosenArray.findIndex((el) => el.value === '*');
// If the All option was chosen before, we take it out of the array
if (allIndex === 0 && chosenTypes.length > 0) {
const newResults = chosenArray.slice(1);
setChosenTypes(newResults);
updateFieldWithKey('modelIds', {
value: newResults.map((el) => el.value),
error: false,
notEmpty: true,
});
} else if (allIndex > 0) {
setChosenTypes([{ value: '*', label: 'All' }]);
updateFieldWithKey('modelIds', { value: ['*'], error: false, notEmpty: true });
} else if (chosenArray.length > 0) {
setChosenTypes(chosenArray);
updateFieldWithKey('modelIds', {
value: chosenArray.map((el) => el.value),
error: false,
notEmpty: true,
});
} else {
setChosenTypes([]);
updateFieldWithKey('modelIds', { value: [], error: false, notEmpty: true });
}
};
useEffect(() => {
parseOptions();
}, [deviceTypes, fields.name.value]);
return (
<CForm>
<CFormGroup row className="pb-3">
<CLabel col htmlFor="name">
{t('user.name')}
</CLabel>
<CCol sm="7" className="pt-2">
{fields.name.value}
</CCol>
</CFormGroup>
<CFormGroup row className="pb-3">
<CLabel col htmlFor="description">
{t('user.description')}
</CLabel>
<CCol sm="7">
<CInput
id="description"
type="text"
required
value={fields.description.value}
onChange={updateField}
invalid={fields.description.error}
disabled={disable || !editing}
maxLength="50"
/>
<CInvalidFeedback>{t('common.required')}</CInvalidFeedback>
</CCol>
</CFormGroup>
<CRow className="pb-3">
<CLabel col htmlFor="deviceTypes">
<div>{t('configuration.supported_device_types')}:</div>
</CLabel>
<CCol sm="7">
<Select
isMulti
closeMenuOnSelect={false}
id="deviceTypes"
options={typeOptions}
onChange={typeOnChange}
value={chosenTypes}
className={`basic-multi-select ${fields.modelIds.error ? 'border-danger' : ''}`}
classNamePrefix="select"
isDisabled={disable || !editing}
/>
<CFormText hidden={!fields.modelIds.error} color="danger">
{t('configuration.need_device_type')}
</CFormText>
</CCol>
</CRow>
<div className="pb-3">
{t('configuration.title')}
<CopyToClipboardButton t={t} size="sm" content={fields.configuration.value} />
</div>
<CRow className="pb-3">
<CCol>
<CTextarea
style={{ overflowY: 'scroll', height: '500px' }}
id="configuration"
type="text"
required
value={fields.configuration.value}
onChange={updateField}
invalid={fields.configuration.error}
disabled={disable || !editing}
/>
<CFormText hidden={!fields.configuration.error} color="danger">
{t('common.required')}
</CFormText>
</CCol>
</CRow>
</CForm>
);
};
EditDefaultConfigurationForm.propTypes = {
t: PropTypes.func.isRequired,
disable: PropTypes.bool.isRequired,
fields: PropTypes.instanceOf(Object).isRequired,
updateField: PropTypes.func.isRequired,
updateFieldWithKey: PropTypes.func.isRequired,
deviceTypes: PropTypes.instanceOf(Array).isRequired,
editing: PropTypes.bool.isRequired,
};
export default EditDefaultConfigurationForm;

View File

@@ -0,0 +1,243 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { CModal, CModalHeader, CModalTitle, CModalBody, CButton, CPopover } from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilX, cilSave, cilPencil } from '@coreui/icons';
import { useToast, useFormFields, useAuth } from 'ucentral-libs';
import axiosInstance from 'utils/axiosInstance';
import { useTranslation } from 'react-i18next';
import { checkIfJson } from 'utils/helper';
import Form from './Form';
const initialForm = {
name: {
value: '',
error: false,
required: true,
},
description: {
value: '',
error: false,
},
modelIds: {
value: [],
error: false,
notEmpty: true,
},
configuration: {
value: '',
error: false,
required: true,
},
};
const EditConfigurationModal = ({ show, toggle, refresh, configId }) => {
const { t } = useTranslation();
const { addToast } = useToast();
const { currentToken, endpoints } = useAuth();
const [fields, updateFieldWithId, updateField, setFormFields] = useFormFields(initialForm);
const [loading, setLoading] = useState(false);
const [deviceTypes, setDeviceTypes] = useState([]);
const [editing, setEditing] = useState(false);
const getConfig = () => {
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
axiosInstance
.get(`${endpoints.owgw}/api/v1/default_configuration/${configId}`, options)
.then((response) => {
const newConfig = {};
for (const key of Object.keys(response.data)) {
if (key in initialForm) {
newConfig[key] = {
...initialForm[key],
value: response.data[key],
};
}
}
newConfig.configuration.value = JSON.stringify(response.data.configuration, null, 2);
setFormFields(newConfig);
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('configuration.error_fetching_config', {
error: e.response?.data?.ErrorDescription,
}),
color: 'danger',
autohide: true,
});
toggle();
});
};
const getDeviceTypes = () => {
setLoading(true);
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
};
axiosInstance
.get(`${endpoints.owfms}/api/v1/firmwares?deviceSet=true`, {
headers,
})
.then((response) => {
setDeviceTypes([...response.data.deviceTypes]);
})
.catch(() => {})
.finally(() => {
setLoading(false);
});
};
const validation = () => {
let success = true;
for (const [key, field] of Object.entries(fields)) {
if (field.required && field.value === '') {
updateField(key, { error: true });
success = false;
break;
}
if (field.notEmpty && field.value.length === 0) {
updateField(key, { error: true, notEmpty: true });
success = false;
break;
}
}
if (!checkIfJson(fields.configuration.value)) {
updateField('configuration', { error: true });
success = false;
}
return success;
};
const save = () => {
if (validation()) {
setLoading(true);
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
const parameters = {
name: fields.name.value,
description: fields.description.value,
modelIds: fields.modelIds.value,
configuration: fields.configuration.value,
};
axiosInstance
.put(`${endpoints.owgw}/api/v1/default_configuration/${configId}`, parameters, options)
.then(() => {
if (refresh !== null) refresh();
toggle();
addToast({
title: t('common.success'),
body: t('configuration.success_update'),
color: 'success',
autohide: true,
});
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('configuration.error_update', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
})
.finally(() => {
setLoading(false);
});
}
};
const toggleEditing = () => {
if (editing) getConfig();
setEditing(!editing);
};
useEffect(() => {
if (show) {
setEditing(false);
getConfig();
getDeviceTypes();
}
}, [show]);
return (
<CModal className="text-dark" size="lg" show={show} onClose={toggle}>
<CModalHeader className="p-1">
<CModalTitle className="pl-1 pt-1">{t('configuration.edit_configuration')}</CModalTitle>
<div className="text-right">
<CPopover content={t('common.save')}>
<CButton
color="primary"
variant="outline"
className="ml-2"
onClick={save}
disabled={!editing}
>
<CIcon content={cilSave} />
</CButton>
</CPopover>
<CPopover content={t('common.edit')}>
<CButton
color="primary"
variant="outline"
className="ml-2"
onClick={toggleEditing}
disabled={editing}
>
<CIcon content={cilPencil} />
</CButton>
</CPopover>
<CPopover content={t('common.close')}>
<CButton color="primary" variant="outline" className="ml-2" onClick={toggle}>
<CIcon content={cilX} />
</CButton>
</CPopover>
</div>
</CModalHeader>
<CModalBody className="px-5">
<Form
t={t}
disable={loading}
fields={fields}
editing={editing}
updateField={updateFieldWithId}
updateFieldWithKey={updateField}
deviceTypes={deviceTypes}
show={show}
/>
</CModalBody>
</CModal>
);
};
EditConfigurationModal.propTypes = {
show: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
refresh: PropTypes.func,
configId: PropTypes.string,
};
EditConfigurationModal.defaultProps = {
refresh: null,
configId: '',
};
export default EditConfigurationModal;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import PropTypes from 'prop-types';
import { CCardBody, CCol, CInput, CRow } from '@coreui/react';
import { prettyDate, cleanBytesString } from 'utils/helper';
const FirmwareDetailsForm = ({ t, fields, updateFieldsWithId, editing }) => (
<CCardBody className="p-1">
<CRow>
<CCol sm="2">{t('firmware.release')}</CCol>
<CCol sm="4">{fields.release.value}</CCol>
<CCol sm="2">{t('common.created')}</CCol>
<CCol sm="4">{prettyDate(fields.created.value)}</CCol>
</CRow>
<CRow className="my-3">
<CCol sm="2">{t('firmware.image_date')}</CCol>
<CCol sm="4">{prettyDate(fields.imageDate.value)}</CCol>
<CCol sm="2">{t('firmware.size')}</CCol>
<CCol sm="4">{cleanBytesString(fields.size.value)}</CCol>
</CRow>
<CRow className="my-3">
<CCol sm="2">{t('firmware.image')}</CCol>
<CCol sm="4">{fields.image.value}</CCol>
<CCol sm="2">{t('firmware.revision')}</CCol>
<CCol sm="4">{fields.revision.value}</CCol>
</CRow>
<CRow className="my-3">
<CCol sm="2">URI</CCol>
<CCol sm="4">{fields.uri.value}</CCol>
<CCol sm="2" className="mt-2">
{t('user.description')}
</CCol>
<CCol sm="4">
{editing ? (
<CInput
id="description"
value={fields.description.value}
onChange={updateFieldsWithId}
maxLength="50"
/>
) : (
<p className="mt-2 mb-0">{fields.description.value}</p>
)}
</CCol>
</CRow>
</CCardBody>
);
FirmwareDetailsForm.propTypes = {
t: PropTypes.func.isRequired,
fields: PropTypes.instanceOf(Object).isRequired,
updateFieldsWithId: PropTypes.func.isRequired,
editing: PropTypes.bool.isRequired,
};
export default FirmwareDetailsForm;

View File

@@ -16,13 +16,8 @@ import {
import CIcon from '@coreui/icons-react';
import { cilPencil, cilSave, cilX } from '@coreui/icons';
import axiosInstance from 'utils/axiosInstance';
import {
useFormFields,
useAuth,
useToast,
FirmwareDetailsForm,
DetailedNotesTable,
} from 'ucentral-libs';
import { useFormFields, useAuth, useToast, DetailedNotesTable } from 'ucentral-libs';
import Form from './Form';
const initialState = {
created: {
@@ -237,12 +232,7 @@ const EditFirmwareModal = ({ show, toggle, firmwareId, refreshTable }) => {
<CTabContent>
<CTabPane active={index === 0} className="pt-2">
{index === 0 ? (
<FirmwareDetailsForm
t={t}
fields={firmware}
updateFieldsWithId={updateWithId}
editing={editing}
/>
<Form t={t} fields={firmware} updateFieldsWithId={updateWithId} editing={editing} />
) : null}
</CTabPane>
<CTabPane active={index === 1}>

View File

@@ -1,229 +0,0 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import axiosInstance from 'utils/axiosInstance';
import { useUser, EditUserModal as Modal, useAuth, useToast } from 'ucentral-libs';
const initialState = {
Id: {
value: '',
error: false,
editable: false,
},
changePassword: {
value: false,
error: false,
editable: true,
},
currentPassword: {
value: '',
error: false,
editable: true,
},
email: {
value: '',
error: false,
editable: false,
},
description: {
value: '',
error: false,
editable: true,
},
name: {
value: '',
error: false,
editable: true,
},
userRole: {
value: 'accounting',
error: false,
editable: true,
},
notes: {
value: [],
editable: false,
},
};
const EditUserModal = ({ show, toggle, userId, getUsers, policies }) => {
const { t } = useTranslation();
const { currentToken, endpoints } = useAuth();
const { addToast } = useToast();
const [loading, setLoading] = useState(false);
const [initialUser, setInitialUser] = useState({});
const [editing, setEditing] = useState(false);
const [user, updateWithId, updateWithKey, setUser] = useUser(initialState);
const getUser = () => {
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
axiosInstance
.get(`${endpoints.owsec}/api/v1/user/${userId}`, options)
.then((response) => {
const newUser = {};
for (const key of Object.keys(response.data)) {
if (key in initialState && key !== 'currentPassword') {
newUser[key] = {
...initialState[key],
value: response.data[key],
};
}
}
setInitialUser({ ...initialState, ...newUser });
setUser({ ...initialState, ...newUser });
})
.catch(() => {
addToast({
title: t('common.error'),
body: t('user.error_retrieving'),
color: 'danger',
autohide: true,
});
toggle();
});
};
const toggleEditing = () => {
if (editing) {
getUser();
}
setEditing(!editing);
};
const updateUser = () => {
setLoading(true);
const parameters = {
id: userId,
};
let newData = false;
for (const key of Object.keys(user)) {
if (user[key].editable && user[key].value !== initialUser[key].value) {
if (key === 'currentPassword' && user[key].length < 8) {
updateWithKey('currentPassword', {
error: true,
});
newData = false;
break;
} else if (key === 'changePassword') {
parameters[key] = user[key].value === 'on';
newData = true;
} else {
parameters[key] = user[key].value;
newData = true;
}
}
}
const newNotes = [];
for (let i = 0; i < user.notes.value.length; i += 1) {
if (user.notes.value[i].new) newNotes.push({ note: user.notes.value[i].note });
}
parameters.notes = newNotes;
if (newData || newNotes.length > 0) {
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
axiosInstance
.put(`${endpoints.owsec}/api/v1/user/${userId}`, parameters, options)
.then(() => {
addToast({
title: t('user.update_success_title'),
body: t('user.update_success'),
color: 'success',
autohide: true,
});
getUsers();
toggle();
})
.catch((e) => {
addToast({
title: t('user.update_failure_title'),
body: t('user.update_failure', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
getUser();
})
.finally(() => {
setLoading(false);
});
} else {
setLoading(false);
addToast({
title: t('user.update_success_title'),
body: t('user.update_success'),
color: 'success',
autohide: true,
});
getUsers();
toggle();
}
};
const addNote = (currentNote) => {
const newNotes = [...user.notes.value];
newNotes.unshift({
note: currentNote,
new: true,
created: new Date().getTime() / 1000,
createdBy: '',
});
updateWithKey('notes', { value: newNotes });
};
useEffect(() => {
if (userId) {
getUser();
}
}, [userId]);
useEffect(() => {
if (show) {
getUser();
setEditing(false);
}
}, [show]);
return (
<Modal
t={t}
user={user}
updateUserWithId={updateWithId}
saveUser={updateUser}
loading={loading}
policies={policies}
show={show}
toggle={toggle}
editing={editing}
toggleEditing={toggleEditing}
addNote={addNote}
/>
);
};
EditUserModal.propTypes = {
userId: PropTypes.string.isRequired,
show: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
getUsers: PropTypes.func.isRequired,
policies: PropTypes.instanceOf(Object).isRequired,
};
export default React.memo(EditUserModal);

View File

@@ -0,0 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
CModal,
CModalBody,
CModalHeader,
CModalTitle,
CSpinner,
CPopover,
CButton,
} from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilX } from '@coreui/icons';
const EventQueueModal = ({ t, show, toggle, loading, result }) => (
<CModal size="lg" show={show} onClose={toggle}>
<CModalHeader className="p-1">
<CModalTitle className="pl-1 pt-1">{t('commands.event_queue')}</CModalTitle>
<div className="text-right">
<CPopover content={t('common.close')}>
<CButton color="primary" variant="outline" className="ml-2" onClick={toggle}>
<CIcon content={cilX} />
</CButton>
</CPopover>
</div>
</CModalHeader>
<CModalBody className="text-center">
{loading ? (
<CSpinner color="primary" size="lg" />
) : (
<pre className="ignore text-left">{JSON.stringify(result, null, 4)}</pre>
)}
</CModalBody>
</CModal>
);
EventQueueModal.propTypes = {
t: PropTypes.func.isRequired,
show: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
result: PropTypes.instanceOf(Object).isRequired,
};
export default EventQueueModal;

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { EventQueueModal as Modal, useAuth, useDevice, useToast } from 'ucentral-libs';
import { useAuth, useDevice, useToast } from 'ucentral-libs';
import { useTranslation } from 'react-i18next';
import axiosInstance from 'utils/axiosInstance';
import Modal from './Modal';
const EventQueueModal = ({ show, toggle }) => {
const { t } = useTranslation();

View File

@@ -0,0 +1,329 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
CCard,
CCardBody,
CCardHeader,
CCol,
CDataTable,
CPopover,
CRow,
CSpinner,
CWidgetIcon,
} from '@coreui/react';
import { CChartBar, CChartHorizontalBar, CChartPie } from '@coreui/react-chartjs';
import { cilClock, cilHappy, cilMeh, cilFrown, cilBirthdayCake, cilInfo } from '@coreui/icons';
import CIcon from '@coreui/icons-react';
import { FormattedDate } from 'ucentral-libs';
import styles from './index.module.scss';
const getLatestColor = (percent = 0) => {
const numberPercent = percent ? Number(percent.replace('%', '')) : 0;
if (numberPercent >= 90) return 'success';
if (numberPercent > 60) return 'warning';
return 'danger';
};
const getLatestIcon = (percent = 0) => {
const numberPercent = percent ? Number(percent.replace('%', '')) : 0;
if (numberPercent >= 90) return <CIcon width={36} name="cil-happy" content={cilHappy} />;
if (numberPercent > 60) return <CIcon width={36} name="cil-meh" content={cilMeh} />;
return <CIcon width={36} name="cil-frown" content={cilFrown} />;
};
const FirmwareDashboard = ({ t, data, loading }) => {
const columns = [
{ key: 'endpoint', label: t('common.endpoint'), filter: false, sorter: false },
{ key: 'devices', label: t('common.devices') },
{ key: 'percent', label: '' },
];
return (
<div style={{ position: 'relative' }}>
<div style={{ opacity: loading ? '20%' : '100%' }}>
<CRow className="mt-3">
<CCol>
<CWidgetIcon
text={t('common.last_dashboard_refresh')}
header={data.snapshot ? <FormattedDate date={data.snapshot} size="lg" /> : <h2>-</h2>}
color="info"
iconPadding={false}
>
<CIcon width={36} name="cil-clock" content={cilClock} />
</CWidgetIcon>
</CCol>
<CCol>
<CWidgetIcon
text={t('common.up_to_date')}
header={<h2>{data.latestSoftwareRate}</h2>}
color={getLatestColor(data.latestSoftwareRate)}
iconPadding={false}
>
{getLatestIcon(data.latestSoftwareRate)}
</CWidgetIcon>
</CCol>
<CCol>
<CWidgetIcon
text={t('common.devices')}
header={<h2>{data.numberOfDevices}</h2>}
color="primary"
iconPadding={false}
>
<CIcon width={36} name="cil-router" />
</CWidgetIcon>
</CCol>
<CCol>
<CWidgetIcon
text={
<div>
<div className="float-left">{t('firmware.average_age')}</div>
<div className="float-left ml-2">
<CPopover content={t('firmware.age_explanation')}>
<CIcon content={cilInfo} />
</CPopover>
</div>
</div>
}
header={<h2>{data.averageFirmwareAge}</h2>}
color="dark"
iconPadding={false}
>
<CIcon width={36} content={cilBirthdayCake} />
</CWidgetIcon>
</CCol>
</CRow>
<CRow>
<CCol>
<CCard>
<CCardHeader className="dark-header">{t('common.firmware_installed')}</CCardHeader>
<CCardBody>
<CChartPie
datasets={data.firmwareDistribution.datasets}
labels={data.firmwareDistribution.labels}
options={{
legend: {
display: true,
position: 'right',
},
}}
/>
</CCardBody>
</CCard>
</CCol>
<CCol>
<CCard>
<CCardHeader className="dark-header">
<div>
<div className="float-left">{t('common.devices_using_latest')}</div>
<div className="float-left ml-2">
<CPopover content={t('firmware.latest_explanation')}>
<CIcon content={cilInfo} />
</CPopover>
</div>
</div>
</CCardHeader>
<CCardBody>
<CChartBar
datasets={data.latest.datasets}
labels={data.latest.labels}
options={{
tooltips: {
mode: 'index',
intersect: false,
},
hover: {
mode: 'index',
intersect: false,
},
legend: {
display: false,
position: 'right',
},
scales: {
yAxes: [
{
ticks: {
maxTicksLimit: 5,
beginAtZero: true,
stepSize: 1,
},
},
],
},
}}
/>
</CCardBody>
</CCard>
</CCol>
<CCol>
<CCard>
<CCardHeader className="dark-header">Unknown Firmware</CCardHeader>
<CCardBody>
<CChartHorizontalBar
datasets={data.unknownFirmwares.datasets}
labels={data.unknownFirmwares.labels}
options={{
tooltips: {
mode: 'index',
intersect: false,
},
hover: {
mode: 'index',
intersect: false,
},
legend: {
display: false,
position: 'right',
},
scales: {
xAxes: [
{
ticks: {
maxTicksLimit: 5,
beginAtZero: true,
stepSize: 1,
},
},
],
yAxes: [
{
ticks: {
callback: (value) => value.split(' ')[0],
},
},
],
},
}}
/>
</CCardBody>
</CCard>
</CCol>
</CRow>
<CRow>
<CCol>
<CCard>
<CCardHeader className="dark-header">{t('common.device_status')}</CCardHeader>
<CCardBody>
<CChartPie
datasets={data.status.datasets}
labels={data.status.labels}
options={{
tooltips: {
callbacks: {
title: (item, ds) => ds.labels[item[0].index],
label: (item, ds) => `${ds.datasets[0].data[item.index]}%`,
},
},
legend: {
display: true,
position: 'right',
},
}}
/>
</CCardBody>
</CCard>
</CCol>
<CCol>
<CCard>
<CCardHeader className="dark-header">{t('firmware.device_types')}</CCardHeader>
<CCardBody>
<CChartPie
datasets={data.deviceType.datasets}
labels={data.deviceType.labels}
options={{
tooltips: {
callbacks: {
title: (item, ds) => ds.labels[item[0].index],
label: (item, ds) =>
`${ds.datasets[0].data[item.index]} ${t('common.devices')}`,
},
},
legend: {
display: true,
position: 'right',
},
}}
/>
</CCardBody>
</CCard>
</CCol>
<CCol>
<CCard>
<CCardHeader className="dark-header">OUIs</CCardHeader>
<CCardBody>
<CChartHorizontalBar
datasets={data.ouis.datasets}
labels={data.ouis.labels}
options={{
tooltips: {
mode: 'index',
intersect: false,
},
hover: {
mode: 'index',
intersect: false,
},
legend: {
display: false,
position: 'right',
},
scales: {
xAxes: [
{
ticks: {
maxTicksLimit: 5,
beginAtZero: true,
stepSize: 1,
},
},
],
yAxes: [
{
ticks: {
callback: (value) => value.split(' ')[0],
},
},
],
},
}}
/>
</CCardBody>
</CCard>
</CCol>
</CRow>
<CRow>
<CCol>
<CCard>
<CCardHeader className="dark-header">{t('common.endpoints')}</CCardHeader>
<CCardBody>
<CDataTable
addTableClasses="table-sm"
items={data.endpoints ?? []}
fields={columns}
hover
border
/>
</CCardBody>
</CCard>
</CCol>
<CCol />
<CCol />
</CRow>
</div>
{loading ? (
<div className={styles.centerContainer}>
<CSpinner className={styles.spinner} />
</div>
) : null}
</div>
);
};
FirmwareDashboard.propTypes = {
t: PropTypes.func.isRequired,
data: PropTypes.instanceOf(Object).isRequired,
loading: PropTypes.bool.isRequired,
};
export default React.memo(FirmwareDashboard);

View File

@@ -0,0 +1,10 @@
.centerContainer {
position: absolute;
top: 5%;
right: 50%;
}
.spinner {
height: 50px;
width: 50px;
}

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { FirmwareDashboard as Dashboard, useAuth, COLOR_LIST } from 'ucentral-libs';
import { useAuth, COLOR_LIST } from 'ucentral-libs';
import axiosInstance from 'utils/axiosInstance';
import Dashboard from './Dashboard';
const FirmwareDashboard = () => {
const { t } = useTranslation();

View File

@@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { CDataTable } from '@coreui/react';
import { prettyDate } from 'utils/helper';
const FirmwareHistoryModal = ({ t, loading, data }) => {
const columns = [
{ key: 'date', label: '#', _style: { width: '20%' } },
{ key: 'fromRelease', label: t('firmware.from_release'), sorter: false },
{ key: 'toRelease', label: t('firmware.to_release'), sorter: false },
];
return (
<CDataTable
addTableClasses="ignore-overflow table-sm"
fields={columns}
items={data}
hover
border
loading={loading}
sorter
sorterValue={{ column: 'radio', asc: true }}
scopedSlots={{
date: (item) => <td>{prettyDate(item.upgraded)}</td>,
}}
/>
);
};
FirmwareHistoryModal.propTypes = {
t: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
data: PropTypes.instanceOf(Array).isRequired,
};
export default React.memo(FirmwareHistoryModal);

View File

@@ -10,7 +10,8 @@ import {
CModalFooter,
CModalTitle,
} from '@coreui/react';
import { FirmwareHistoryTable, useAuth } from 'ucentral-libs';
import { useAuth } from 'ucentral-libs';
import Modal from './Modal';
const FirmwareHistoryModal = ({ serialNumber, show, toggle }) => {
const { t } = useTranslation();
@@ -51,7 +52,7 @@ const FirmwareHistoryModal = ({ serialNumber, show, toggle }) => {
</CModalTitle>
</CModalHeader>
<CModalBody>
<FirmwareHistoryTable t={t} loading={loading} data={data} />
<Modal t={t} loading={loading} data={data} />
</CModalBody>
<CModalFooter>
<CButton color="secondary" onClick={toggle}>

View File

@@ -0,0 +1,55 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import ReactFlow, { removeElements, MiniMap, Controls, Background } from 'react-flow-renderer';
const NetworkDiagram = ({ show, elements, setElements }) => {
const [reactFlowInstance, setReactFlowInstance] = useState(null);
const onElementsRemove = (elementsToRemove) => {
setElements((els) => removeElements(elementsToRemove, els));
};
const onLoad = (instance) => {
setReactFlowInstance(instance);
};
useEffect(() => {
if (show && reactFlowInstance !== null && elements.length > 0) {
setTimeout(() => reactFlowInstance.fitView(), 100);
}
}, [show, reactFlowInstance, elements]);
return (
<div style={{ height: '80vh', width: '100%' }}>
<ReactFlow
elements={elements}
onElementsRemove={onElementsRemove}
onLoad={onLoad}
snapToGrid
snapGrid={[20, 20]}
>
<MiniMap
nodeColor={(n) => {
if (n.style?.background) return n.style.background;
return '#fff';
}}
nodeBorderRadius={5}
/>
<Controls />
<Background color="#aaa" gap={20} />
</ReactFlow>
</div>
);
};
NetworkDiagram.propTypes = {
show: PropTypes.bool,
elements: PropTypes.instanceOf(Array).isRequired,
setElements: PropTypes.func.isRequired,
};
NetworkDiagram.defaultProps = {
show: true,
};
export default React.memo(NetworkDiagram);

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { CRow, CCol } from '@coreui/react';
import { NetworkDiagram as Graph } from 'ucentral-libs';
import { useTranslation } from 'react-i18next';
import createLayoutedElements from './dagreAdapter';
import Graph from './Graph';
const associationStyle = {
background: '#3399ff',

View File

@@ -0,0 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import { CDataTable } from '@coreui/react';
const RadioAnalysisTable = ({ data, loading }) => {
const columns = [
{ key: 'radio', label: '#', _style: { width: '5%' } },
{ key: 'channel', label: 'Ch', _style: { width: '5%' } },
{ key: 'channelWidth', label: 'C Width', _style: { width: '7%' }, sorter: false },
{ key: 'noise', label: 'Noise', _style: { width: '4%' }, sorter: false },
{ key: 'txPower', label: 'Tx Power', _style: { width: '9%' }, sorter: false },
{ key: 'activeMs', label: 'Active MS', _style: { width: '23%' }, sorter: false },
{ key: 'busyMs', label: 'Busy MS', _style: { width: '23%' }, sorter: false },
{ key: 'receiveMs', label: 'Receive MS', _style: { width: '23%' }, sorter: false },
];
const centerIfEmpty = (value) => (
<td className={!value || value === '' || value === '-' ? 'text-center' : ''}>{value}</td>
);
return (
<CDataTable
addTableClasses="table-sm"
fields={columns}
items={data}
hover
border
loading={loading}
sorter
sorterValue={{ column: 'radio', asc: true }}
scopedSlots={{
noise: (item) => centerIfEmpty(item.noise),
}}
/>
);
};
RadioAnalysisTable.propTypes = {
data: PropTypes.instanceOf(Object).isRequired,
loading: PropTypes.bool.isRequired,
};
export default RadioAnalysisTable;

View File

@@ -0,0 +1,117 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import {
CButton,
CDataTable,
CPopover,
CModal,
CModalHeader,
CModalTitle,
CModalBody,
} from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilX } from '@coreui/icons';
import { v4 as createUuid } from 'uuid';
const WifiAnalysisTable = ({ t, data, loading }) => {
const [show, setShow] = useState(false);
const [ips, setIps] = useState(null);
const toggle = (ssid, v4, v6) => {
if (v4 && v6) setIps({ ssid, v4, v6 });
setShow(!show);
};
const columns = [
{ key: 'radio', label: '#', _style: { width: '5%' } },
{ key: 'bssid', label: 'BSSID', _style: { width: '14%' } },
{ key: 'mode', label: t('wifi_analysis.mode'), _style: { width: '9%' }, sorter: false },
{ key: 'ssid', label: 'SSID', _style: { width: '17%' } },
{ key: 'rssi', label: 'RSSI', _style: { width: '5%' }, sorter: false },
{ key: 'rxRate', label: 'Rx Rate', _style: { width: '7%' }, sorter: false },
{ key: 'rxBytes', label: 'Rx', _style: { width: '7%' }, sorter: false },
{ key: 'rxMcs', label: 'Rx MCS', _style: { width: '6%' }, sorter: false },
{ key: 'rxNss', label: 'Rx NSS', _style: { width: '6%' }, sorter: false },
{ key: 'txRate', label: 'Tx Rate', _style: { width: '7%' }, sorter: false },
{ key: 'txBytes', label: 'Tx', _style: { width: '7%' }, sorter: false },
{ key: 'ips', label: 'IP', _style: { width: '6%' }, sorter: false },
];
const centerIfEmpty = (value) => (
<td className={!value || value === '' || value === '-' ? 'text-center' : ''}>{value}</td>
);
const displayIp = (ssid, v4, v6) => {
const count = v4.length + v6.length;
return (
<td className="ignore-overflow text-center">
{count > 0 ? (
<CPopover content="View">
<CButton color="primary" size="sm" onClick={() => toggle(ssid, v4, v6)}>
{count}
</CButton>
</CPopover>
) : (
<p>{count}</p>
)}
</td>
);
};
return (
<div>
<CDataTable
addTableClasses="ignore-overflow mb-5 table-sm"
fields={columns}
items={data}
hover
border
loading={loading}
sorter
sorterValue={{ column: 'radio', asc: true }}
scopedSlots={{
radio: (item) => <td className="text-center">{item.radio.radio}</td>,
rxMcs: (item) => centerIfEmpty(item.rxMcs),
rxNss: (item) => centerIfEmpty(item.rxNss),
rssi: (item) => centerIfEmpty(item.rssi),
ips: (item) => displayIp(item.ssid, item.ipV4, item.ipV6),
}}
/>
<CModal size="lg" show={show} onClose={toggle}>
<CModalHeader className="p-1">
<CModalTitle className="pl-1 pt-1">{ips?.ssid}</CModalTitle>
<div className="text-right">
<CPopover content={t('common.close')}>
<CButton color="primary" variant="outline" className="ml-2" onClick={toggle}>
<CIcon content={cilX} />
</CButton>
</CPopover>
</div>
</CModalHeader>
<CModalBody>
<div>
IpV4
<ul>
{ips?.v4?.map((ip) => (
<li key={createUuid()}>{ip}</li>
))}
</ul>
IpV6
<ul>
{ips?.v6?.map((ip) => (
<li key={createUuid()}>{ip}</li>
))}
</ul>
</div>
</CModalBody>
</CModal>
</div>
);
};
WifiAnalysisTable.propTypes = {
t: PropTypes.func.isRequired,
data: PropTypes.instanceOf(Object).isRequired,
loading: PropTypes.bool.isRequired,
};
export default WifiAnalysisTable;

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { WifiAnalysisTable, RadioAnalysisTable, useAuth } from 'ucentral-libs';
import { useAuth } from 'ucentral-libs';
import axiosInstance from 'utils/axiosInstance';
import NetworkDiagram from 'components/NetworkDiagram';
import { cleanBytesString, prettyDate, compactSecondsToDetailed } from 'utils/helper';
@@ -20,6 +20,8 @@ import {
} from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilX } from '@coreui/icons';
import RadioAnalysisTable from './RadioAnalysis';
import WifiAnalysisTable from './WifiAnalysis';
const parseDbm = (value) => {
if (!value) return '-';

View File

@@ -1,6 +1,9 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import routes from 'routes';
import { CSidebarNavItem } from '@coreui/react';
import { cilBarcode, cilRouter, cilSave, cilSettings, cilPeople } from '@coreui/icons';
import CIcon from '@coreui/icons-react';
import { Header, Sidebar, Footer, PageContainer, ToastProvider, useAuth } from 'ucentral-libs';
const TheLayout = () => {
@@ -8,40 +11,46 @@ const TheLayout = () => {
const { endpoints, currentToken, user, avatar, logout } = useAuth();
const { t, i18n } = useTranslation();
const navigation = [
{
_tag: 'CSidebarNavItem',
name: t('common.devices'),
icon: 'cilRouter',
to: '/devices',
},
{
_tag: 'CSidebarNavItem',
name: t('firmware.title'),
icon: 'cilSave',
to: '/firmware',
},
{
_tag: 'CSidebarNavItem',
name: t('user.users'),
to: '/users',
icon: 'cilPeople',
},
{
_tag: 'CSidebarNavItem',
name: t('common.system'),
to: '/system',
icon: 'cilSettings',
},
];
return (
<div className="c-app c-default-layout">
<Sidebar
showSidebar={showSidebar}
setShowSidebar={setShowSidebar}
logo="assets/OpenWiFi_LogoLockup_WhiteColour.svg"
options={navigation}
options={
<>
<CSidebarNavItem
className="font-weight-bold"
name={t('common.devices')}
to="/devices"
icon={<CIcon content={cilRouter} size="xl" className="mr-3" />}
/>
<CSidebarNavItem
className="font-weight-bold"
name={t('firmware.title')}
to="/firmware"
icon={<CIcon content={cilSave} size="xl" className="mr-3" />}
/>
<CSidebarNavItem
className="font-weight-bold"
name={t('configuration.default_configs')}
to="/defaultconfigurations"
icon={<CIcon content={cilBarcode} size="xl" className="mr-3" />}
/>
<CSidebarNavItem
className="font-weight-bold"
name={t('user.users')}
to="/users"
icon={<CIcon content={cilPeople} size="xl" className="mr-3" />}
/>
<CSidebarNavItem
className="font-weight-bold"
name={t('common.system')}
to="/system"
icon={<CIcon content={cilSettings} size="xl" className="mr-3" />}
/>
</>
}
redirectTo="/devices"
/>
<div className="c-wrapper">

View File

@@ -0,0 +1,6 @@
import React from 'react';
import DefaultConfigurationTable from 'components/DefaultConfigurationTable';
const DefaultConfigurationsPage = () => <DefaultConfigurationTable />;
export default DefaultConfigurationsPage;

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import DeviceList from 'components/DeviceListTable';
import DeviceDashboard from 'components/DeviceDashboard';
import BlacklistTable from 'components/BlacklistTable';
import {
CCard,
CCardBody,
@@ -48,16 +49,21 @@ const DeviceListPage = () => {
active={index === 1}
onClick={() => updateNav(1)}
>
{t('common.table')}
{t('common.all')}
</CNavLink>
<CNavLink
className="font-weight-bold"
href="#"
active={index === 2}
onClick={() => updateNav(2)}
>
{t('common.blacklist')}
</CNavLink>
</CNav>
<CTabContent>
<CTabPane active={index === 0}>
<DeviceDashboard />
</CTabPane>
<CTabPane active={index === 1}>
<DeviceList />
</CTabPane>
<CTabPane active={index === 0}>{index === 0 ? <DeviceDashboard /> : null}</CTabPane>
<CTabPane active={index === 1}>{index === 1 ? <DeviceList /> : null}</CTabPane>
<CTabPane active={index === 2}>{index === 2 ? <BlacklistTable /> : null}</CTabPane>
</CTabContent>
</CCardBody>
</CCard>

View File

@@ -0,0 +1,181 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import {
CCard,
CCardHeader,
CRow,
CCol,
CCardBody,
CPopover,
CButton,
CSpinner,
CLabel,
CLink,
} from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilSync } from '@coreui/icons';
import { prettyDate } from 'utils/helper';
import { CopyToClipboardButton, HideTextButton } from 'ucentral-libs';
import styles from './index.module.scss';
const DeviceDetails = ({ t, loading, getData, status, deviceConfig, lastStats }) => {
const [showPassword, setShowPassword] = useState(false);
const toggleShowPassword = () => {
setShowPassword(!showPassword);
};
const getPassword = () => {
const password =
deviceConfig?.devicePassword === '' ? 'openwifi' : deviceConfig?.devicePassword;
return showPassword ? password : '******';
};
const displayExtra = (key, value, extraData) => {
if (!extraData || !extraData[key]) return value;
if (!localStorage.getItem('owprov-ui') || key === 'owner') return extraData[key].name;
return (
<CLink
className="c-subheader-nav-link align-self-center"
aria-current="page"
href={`${localStorage.getItem('owprov-ui')}/#/${key === 'entity' ? 'entity' : 'venue'}/${
extraData[key].id
}`}
target="_blank"
>
{extraData[key].name}
</CLink>
);
};
return (
<CCard className="m-0">
<CCardHeader className="dark-header">
<div className="d-flex flex-row-reverse align-items-center">
<div className="text-right">
<CPopover content={t('common.refresh')}>
<CButton size="sm" color="info" onClick={getData}>
<CIcon content={cilSync} />
</CButton>
</CPopover>
</div>
</div>
</CCardHeader>
<CCardBody>
{(!lastStats || !status) && loading ? (
<div className={styles.centerContainer}>
<CSpinner className={styles.spinner} />
</div>
) : (
<div style={{ position: 'relative' }}>
<div className={styles.overlayContainer} hidden={!loading}>
<CSpinner className={styles.spinner} />
</div>
<CRow>
<CCol lg="2" xl="1" xxl="1">
<CLabel>{t('common.serial_num')}: </CLabel>
</CCol>
<CCol className="border-right" lg="2" xl="3" xxl="3">
{deviceConfig?.serialNumber}
{' '}
<CopyToClipboardButton t={t} size="sm" content={deviceConfig?.serialNumber} />
</CCol>
<CCol lg="2" xl="1" xxl="1">
<CLabel className="align-middle">{t('configuration.device_password')}: </CLabel>
</CCol>
<CCol lg="2" xl="3" xxl="3">
{getPassword()}
{' '}
<HideTextButton t={t} toggle={toggleShowPassword} show={showPassword} />
<CopyToClipboardButton
t={t}
size="sm"
content={
deviceConfig?.devicePassword === '' ? 'openwifi' : deviceConfig?.devicePassword
}
/>
</CCol>
<CCol className="border-left" lg="2" xl="1" xxl="1">
<CLabel>{t('configuration.owner')}:</CLabel>
</CCol>
<CCol lg="2" xl="3" xxl="3">
{deviceConfig?.owner}
</CCol>
<CCol lg="2" xl="1" xxl="1">
<CLabel>{t('common.mac')}:</CLabel>
</CCol>
<CCol className="border-right" lg="2" xl="3" xxl="3">
{deviceConfig?.macAddress}
</CCol>
<CCol lg="2" xl="1" xxl="1">
<CLabel>{t('configuration.type')}: </CLabel>
</CCol>
<CCol lg="2" xl="3" xxl="3">
{deviceConfig?.deviceType}
</CCol>
<CCol className="border-left" lg="2" xl="1" xxl="1">
<CLabel>
{deviceConfig?.venue?.substring(0, 3) === 'ent'
? t('entity.entity')
: t('inventory.venue')}
:
</CLabel>
</CCol>
<CCol lg="2" xl="3" xxl="3">
{deviceConfig?.venue?.substring(0, 3) === 'ent'
? displayExtra(
'entity',
deviceConfig?.venue?.slice(4),
deviceConfig?.extendedInfo,
)
: displayExtra(
'venue',
deviceConfig?.venue?.slice(4),
deviceConfig?.extendedInfo,
)}
</CCol>
<CCol lg="2" xl="1" xxl="1">
<CLabel>{t('common.manufacturer')}:</CLabel>
</CCol>
<CCol className="border-right" lg="2" xl="3" xxl="3">
{deviceConfig?.manufacturer}
</CCol>
<CCol lg="2" xl="1" xxl="1">
<CLabel>{t('configuration.created')}: </CLabel>
</CCol>
<CCol lg="2" xl="3" xxl="3">
{prettyDate(deviceConfig?.createdTimestamp)}
</CCol>
<CCol className="border-left" lg="2" xl="1" xxl="1">
<CLabel>{t('configuration.location')}:</CLabel>
</CCol>
<CCol lg="2" xl="3" xxl="3">
{deviceConfig?.location}
</CCol>
</CRow>
</div>
)}
</CCardBody>
</CCard>
);
};
DeviceDetails.propTypes = {
t: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
getData: PropTypes.func.isRequired,
status: PropTypes.instanceOf(Object),
deviceConfig: PropTypes.instanceOf(Object),
lastStats: PropTypes.instanceOf(Object),
};
DeviceDetails.defaultProps = {
status: null,
lastStats: null,
deviceConfig: null,
};
export default React.memo(DeviceDetails);

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { CPopover, CProgress, CProgressBar } from '@coreui/react';
import PropTypes from 'prop-types';
import { cleanBytesString } from 'utils/helper';
const MemoryBar = ({ t, usedBytes, totalBytes }) => {
const used = cleanBytesString(usedBytes);
const total = cleanBytesString(totalBytes);
const percentage = Math.floor((usedBytes / totalBytes) * 100);
return (
<CPopover content={t('status.used_total_memory', { used, total })}>
<CProgress>
<CProgressBar value={percentage}>
{percentage >= 25 ? t('status.percentage_used', { percentage, total }) : ''}
</CProgressBar>
<CProgressBar value={100 - percentage} color="transparent">
<div style={{ color: 'black' }}>
{percentage < 25
? t('status.percentage_free', { percentage: 100 - percentage, total })
: ''}
</div>
</CProgressBar>
</CProgress>
</CPopover>
);
};
MemoryBar.propTypes = {
t: PropTypes.func.isRequired,
usedBytes: PropTypes.number.isRequired,
totalBytes: PropTypes.number.isRequired,
};
export default React.memo(MemoryBar);

View File

@@ -0,0 +1,203 @@
/* eslint-disable jsx-a11y/img-redundant-alt */
import React from 'react';
import PropTypes from 'prop-types';
import {
CCard,
CCardHeader,
CRow,
CCol,
CCardBody,
CBadge,
CAlert,
CPopover,
CButton,
CSpinner,
CLabel,
} from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilSync } from '@coreui/icons';
import { prettyDate, compactSecondsToDetailed } from 'utils/helper';
import MemoryBar from './MemoryBar';
import styles from './index.module.scss';
const errorField = (t) => (
<CAlert className="py-0" color="danger">
{t('status.error')}
</CAlert>
);
const DeviceStatusCard = ({
t,
loading,
error,
deviceSerialNumber,
getData,
status,
deviceConfig,
lastStats,
}) => (
<CCard>
<CCardHeader className="dark-header">
<div className="d-flex flex-row-reverse align-items-center">
<div className="text-right">
<CPopover content={t('common.refresh')}>
<CButton size="sm" color="info" onClick={getData}>
<CIcon content={cilSync} />
</CButton>
</CPopover>
</div>
<div className="text-value-lg mr-auto">
{deviceSerialNumber}, {deviceConfig?.compatible}
</div>
</div>
</CCardHeader>
<CCardBody>
{(!lastStats || !status) && loading ? (
<div className={styles.centerContainer}>
<CSpinner className={styles.spinner} />
</div>
) : (
<div style={{ position: 'relative' }}>
<div className={styles.overlayContainer} hidden={!loading}>
<CSpinner className={styles.spinner} />
</div>
<CRow>
<CCol md="5" lg="5" xl="4" className="text-center align-middle bg-light">
<img
style={{
maxHeight: '250px',
maxWidth: '100%',
position: 'relative',
top: '50%',
transform: 'translateY(-50%)',
}}
src={`assets/devices/${deviceConfig?.compatible}.png`}
alt="Image not found"
onError={(e) => {
e.target.onerror = null;
e.target.src = 'assets/NotFound.png';
}}
height="auto"
width="auto"
/>
</CCol>
<CCol md="7" lg="7" xl="8" className="border-left">
<CRow>
<CCol className="mb-1" md="4" xl="4">
{t('status.connection_status')}:
</CCol>
<CCol className="mb-1" md="8" xl="8">
{status?.connected ? (
<CBadge color="success">{t('common.connected')}</CBadge>
) : (
<CBadge color="danger">{t('common.not_connected')}</CBadge>
)}
</CCol>
<CCol className="my-1" md="4" xl="4">
{t('status.uptime')}:
</CCol>
<CCol className="my-1" md="8" xl="8">
{error
? errorField(t)
: compactSecondsToDetailed(
lastStats?.unit?.uptime,
t('common.day'),
t('common.days'),
t('common.seconds'),
)}
</CCol>
<CCol className="my-1" md="4" xl="4">
{t('status.last_contact')}:
</CCol>
<CCol className="my-1" md="8" xl="8">
{error ? errorField(t) : prettyDate(status?.lastContact)}
</CCol>
<CCol className="my-1" md="4" xl="4">
{t('status.localtime')}:
</CCol>
<CCol className="my-1" md="8" xl="8">
{error ? errorField(t) : prettyDate(lastStats?.unit?.localtime)}
</CCol>
<CCol className="mt-1" md="4" xl="4">
<CLabel>{t('firmware.revision')}: </CLabel>
</CCol>
<CCol className="mt-1" md="8" xl="8">
<CPopover content={deviceConfig?.firmware}>
<CLabel>
{deviceConfig?.firmware?.split(' / ').length > 1
? deviceConfig.firmware.split(' / ')[1]
: deviceConfig?.firmware}
</CLabel>
</CPopover>
</CCol>
</CRow>
<CRow>
<CCol className="mb-1" md="4" xl="4">
{t('status.load_averages')}:
</CCol>
<CCol className="mb-1" md="8" xl="8">
{error ? (
errorField(t)
) : (
<div>
{lastStats?.unit?.load[0] !== undefined
? (lastStats?.unit?.load[0] * 100).toFixed(2)
: '-'}
%{' / '}
{lastStats?.unit?.load[1] !== undefined
? (lastStats?.unit?.load[1] * 100).toFixed(2)
: '-'}
%{' / '}
{lastStats?.unit?.load[2] !== undefined
? (lastStats?.unit?.load[2] * 100).toFixed(2)
: '-'}
%
</div>
)}
</CCol>
<CCol className="mb-1" md="4" xl="4">
{t('status.memory')}:
</CCol>
<CCol className="mb-1" md="8" xl="8" style={{ paddingTop: '5px' }}>
{error ? (
errorField(t)
) : (
<MemoryBar
t={t}
usedBytes={
lastStats?.unit?.memory?.total && lastStats?.unit?.memory?.free
? lastStats?.unit?.memory?.total - lastStats?.unit?.memory?.free
: 0
}
totalBytes={lastStats?.unit?.memory?.total ?? 0}
/>
)}
</CCol>
</CRow>
</CCol>
</CRow>
</div>
)}
</CCardBody>
</CCard>
);
DeviceStatusCard.propTypes = {
t: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
error: PropTypes.bool.isRequired,
deviceSerialNumber: PropTypes.string.isRequired,
getData: PropTypes.func.isRequired,
status: PropTypes.instanceOf(Object),
deviceConfig: PropTypes.instanceOf(Object),
lastStats: PropTypes.instanceOf(Object),
};
DeviceStatusCard.defaultProps = {
status: null,
lastStats: null,
deviceConfig: null,
};
export default React.memo(DeviceStatusCard);

View File

@@ -0,0 +1,20 @@
.centerContainer {
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.overlayContainer {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
width: 100%;
height: 100%;
}
.spinner {
height: 50px;
width: 50px;
}

View File

@@ -0,0 +1,138 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { CCard, CCardHeader, CCardBody, CPopover, CButton } from '@coreui/react';
import { cilPencil, cilX, cilSave } from '@coreui/icons';
import CIcon from '@coreui/icons-react';
import { useTranslation } from 'react-i18next';
import { DetailedNotesTable, useAuth, useToast, useToggle } from 'ucentral-libs';
import axiosInstance from 'utils/axiosInstance';
const NotesTab = ({ deviceConfig, refresh }) => {
const { t } = useTranslation();
const { currentToken, endpoints, user } = useAuth();
const { addToast } = useToast();
const [editing, toggleEditing, setEditing] = useToggle(false);
const [loading, setLoading] = useState(false);
const [currentNotes, setCurrentNotes] = useState(deviceConfig.notes);
const stopEditing = () => {
setEditing(false);
refresh();
};
const addNote = (currentNote) => {
const newNotes = currentNotes;
newNotes.unshift({
note: currentNote,
new: true,
created: new Date().getTime() / 1000,
createdBy: user?.email ?? '',
});
setCurrentNotes([...newNotes]);
};
const save = () => {
setLoading(true);
const newNotes = [];
for (let i = 0; i < currentNotes.length; i += 1) {
if (currentNotes[i].new) newNotes.push({ note: currentNotes[i].note });
}
const parameters = {
id: deviceConfig.serialNumber,
notes: newNotes,
};
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
axiosInstance
.put(`${endpoints.owgw}/api/v1/device/${deviceConfig.serialNumber}`, parameters, options)
.then(() => {
addToast({
title: t('firmware.update_success_title'),
body: t('firmware.update_success'),
color: 'success',
autohide: true,
});
refresh();
toggleEditing();
})
.catch((e) => {
addToast({
title: t('firmware.update_failure_title'),
body: t('firmware.update_failure', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
setCurrentNotes(deviceConfig.notes);
}, [deviceConfig.notes]);
return (
<div>
<CCard className="m-0">
<CCardHeader className="dark-header">
<div className="d-flex flex-row-reverse align-items-center">
<div className="pl-2">
<CPopover content={t('common.save')}>
<CButton className="ml-2" size="sm" color="info" onClick={save} disabled={!editing}>
<CIcon content={cilSave} />
</CButton>
</CPopover>
<CPopover content={t('common.edit')}>
<CButton
className="ml-2"
size="sm"
color="dark"
onClick={toggleEditing}
disabled={editing}
>
<CIcon content={cilPencil} />
</CButton>
</CPopover>
<CPopover content={t('common.stop_editing')}>
<CButton
className="ml-2"
size="sm"
color="dark"
onClick={stopEditing}
disabled={!editing}
>
<CIcon content={cilX} />
</CButton>
</CPopover>
</div>
</div>
</CCardHeader>
<CCardBody className="p-1">
<DetailedNotesTable
t={t}
notes={currentNotes}
addNote={addNote}
loading={loading}
editable={editing}
/>
</CCardBody>
</CCard>
</div>
);
};
NotesTab.propTypes = {
deviceConfig: PropTypes.instanceOf(Object).isRequired,
refresh: PropTypes.func.isRequired,
};
export default NotesTab;

View File

@@ -7,10 +7,14 @@ import DeviceLogs from 'components/DeviceLogs';
import DeviceStatisticsCard from 'components/InterfaceStatistics';
import DeviceActionCard from 'components/DeviceActionCard';
import axiosInstance from 'utils/axiosInstance';
import { DeviceProvider, DeviceStatusCard, DeviceDetails, useAuth, useToast } from 'ucentral-libs';
import { DeviceProvider, useAuth, useToast } from 'ucentral-libs';
import { useTranslation } from 'react-i18next';
import ConfigurationDisplay from 'components/ConfigurationDisplay';
import WifiAnalysis from 'components/WifiAnalysis';
import CapabilitiesDisplay from 'components/CapabilitiesDisplay';
import NotesTab from './NotesTab';
import DeviceDetails from './Details';
import DeviceStatusCard from './DeviceStatusCard';
const DevicePage = () => {
const { t } = useTranslation();
@@ -24,6 +28,11 @@ const DevicePage = () => {
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const updateNav = (target) => {
sessionStorage.setItem('devicePageIndex', target);
setIndex(target);
};
const getDevice = () => {
const options = {
headers: {
@@ -48,13 +57,14 @@ const DevicePage = () => {
);
}
setDeviceConfig(deviceInfo);
setDeviceConfig({ ...deviceInfo });
return null;
})
.then((response) => {
if (response) setDeviceConfig({ ...deviceInfo, extendedInfo: response.data.extendedInfo });
})
.catch((e) => {
setDeviceConfig(null);
addToast({
title: t('common.error'),
body: t('device.error_fetching_device', { error: e.response?.data?.ErrorDescription }),
@@ -84,10 +94,13 @@ const DevicePage = () => {
Promise.all([lastStatsRequest, statusRequest])
.then(([newStats, newStatus]) => {
setLastStats(newStats.data);
setStatus(newStatus.data);
setLastStats({ ...newStats.data });
setStatus({ ...newStatus.data });
setError(false);
})
.catch(() => {
setLastStats(null);
setStatus(null);
setError(true);
})
.finally(() => {
@@ -100,6 +113,12 @@ const DevicePage = () => {
getDevice();
};
useEffect(() => {
const target = sessionStorage.getItem('devicePageIndex');
if (target !== null) setIndex(parseInt(target, 10));
}, []);
useEffect(() => {
setError(false);
if (deviceId) {
@@ -125,7 +144,7 @@ const DevicePage = () => {
/>
</CCol>
<CCol lg="12" xl="6">
<DeviceActionCard />
<DeviceActionCard device={deviceConfig} />
</CCol>
</CRow>
<CRow>
@@ -137,7 +156,7 @@ const DevicePage = () => {
className="font-weight-bold"
href="#"
active={index === 0}
onClick={() => setIndex(0)}
onClick={() => updateNav(0)}
>
{t('statistics.title')}
</CNavLink>
@@ -145,7 +164,7 @@ const DevicePage = () => {
className="font-weight-bold"
href="#"
active={index === 1}
onClick={() => setIndex(1)}
onClick={() => updateNav(1)}
>
{t('common.details')}
</CNavLink>
@@ -153,15 +172,31 @@ const DevicePage = () => {
className="font-weight-bold"
href="#"
active={index === 5}
onClick={() => setIndex(5)}
onClick={() => updateNav(5)}
>
{t('configuration.title')}
</CNavLink>
<CNavLink
className="font-weight-bold"
href="#"
active={index === 8}
onClick={() => updateNav(8)}
>
{t('device.capabilities')}
</CNavLink>
<CNavLink
className="font-weight-bold"
href="#"
active={index === 7}
onClick={() => updateNav(7)}
>
{t('configuration.notes')}
</CNavLink>
<CNavLink
className="font-weight-bold"
href="#"
active={index === 6}
onClick={() => setIndex(6)}
onClick={() => updateNav(6)}
>
{t('wifi_analysis.title')}
</CNavLink>
@@ -169,7 +204,7 @@ const DevicePage = () => {
className="font-weight-bold"
href="#"
active={index === 2}
onClick={() => setIndex(2)}
onClick={() => updateNav(2)}
>
{t('commands.title')}
</CNavLink>
@@ -177,7 +212,7 @@ const DevicePage = () => {
className="font-weight-bold"
href="#"
active={index === 3}
onClick={() => setIndex(3)}
onClick={() => updateNav(3)}
>
{t('health.title')}
</CNavLink>
@@ -185,39 +220,53 @@ const DevicePage = () => {
className="font-weight-bold"
href="#"
active={index === 4}
onClick={() => setIndex(4)}
onClick={() => updateNav(4)}
>
{t('device_logs.title')}
</CNavLink>
</CNav>
<CTabContent>
<CTabPane active={index === 0}>
{index === 0 ? <DeviceStatisticsCard /> : null}
</CTabPane>
<CTabPane active={index === 1}>
{index === 1 ? (
<DeviceDetails
t={t}
loading={loading}
getData={refresh}
deviceConfig={deviceConfig}
status={status}
lastStats={lastStats}
/>
) : null}
</CTabPane>
<CTabPane active={index === 5}>
{index === 5 ? (
<ConfigurationDisplay deviceConfig={deviceConfig} getData={refresh} />
) : null}
</CTabPane>
<CTabPane active={index === 6}>{index === 6 ? <WifiAnalysis /> : null}</CTabPane>
<CTabPane active={index === 2}>
{index === 2 ? <CommandHistory /> : null}
</CTabPane>
<CTabPane active={index === 3}>{index === 3 ? <DeviceHealth /> : null}</CTabPane>
<CTabPane active={index === 4}>{index === 4 ? <DeviceLogs /> : null}</CTabPane>
</CTabContent>
{deviceConfig ? (
<CTabContent>
<CTabPane active={index === 0}>
{index === 0 ? <DeviceStatisticsCard /> : null}
</CTabPane>
<CTabPane active={index === 1}>
{index === 1 ? (
<DeviceDetails
t={t}
loading={loading}
getData={refresh}
deviceConfig={deviceConfig}
status={status}
lastStats={lastStats}
/>
) : null}
</CTabPane>
<CTabPane active={index === 5}>
{index === 5 ? (
<ConfigurationDisplay deviceConfig={deviceConfig} getData={refresh} />
) : null}
</CTabPane>
<CTabPane active={index === 8}>
{index === 8 ? <CapabilitiesDisplay serialNumber={deviceId} /> : null}
</CTabPane>
<CTabPane active={index === 6}>
{index === 6 ? <WifiAnalysis /> : null}
</CTabPane>
<CTabPane active={index === 7}>
{index === 7 ? (
<NotesTab deviceConfig={deviceConfig} refresh={refresh} />
) : null}
</CTabPane>
<CTabPane active={index === 2}>
{index === 2 ? <CommandHistory /> : null}
</CTabPane>
<CTabPane active={index === 3}>
{index === 3 ? <DeviceHealth /> : null}
</CTabPane>
<CTabPane active={index === 4}>{index === 4 ? <DeviceLogs /> : null}</CTabPane>
</CTabContent>
) : null}
</CCardBody>
</CCard>
</CCol>

View File

@@ -0,0 +1,20 @@
.centerContainer {
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.overlayContainer {
display: flex;
top: 0%;
left: 50%;
position: absolute;
width: 100%;
height: 100%;
}
.spinner {
height: 50px;
width: 50px;
}

View File

@@ -0,0 +1,198 @@
import React from 'react';
import PropTypes from 'prop-types';
import ReactPaginate from 'react-paginate';
import { v4 as createUuid } from 'uuid';
import {
CButton,
CCard,
CCardBody,
CCardHeader,
CDataTable,
CPopover,
CSelect,
CSwitch,
} from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilSearch } from '@coreui/icons';
import { CopyToClipboardButton } from 'ucentral-libs';
import { prettyDate, cleanBytesString } from 'utils/helper';
const FirmwareList = ({
t,
loading,
page,
pageCount,
setPage,
data,
toggleEditModal,
firmwarePerPage,
setFirmwarePerPage,
selectedDeviceType,
deviceTypes,
setSelectedDeviceType,
displayDev,
toggleDevDisplay,
}) => {
const fields = [
{ key: 'imageDate', label: t('firmware.image_date'), _style: { width: '1%' } },
{ key: 'size', label: t('firmware.size'), _style: { width: '1%' } },
{ key: 'revision', label: t('firmware.revision'), _style: { width: '1%' } },
{ key: 'uri', label: 'URI' },
{ key: 'show_details', label: '', _style: { width: '1%' } },
];
const getShortRevision = (revision) => {
if (revision.includes(' / ')) {
return revision.split(' / ')[1];
}
return revision;
};
const changePage = (newValue) => {
setPage(newValue);
};
return (
<CCard className="m-0">
<CCardHeader className="p-1">
<div className="d-flex flex-row-reverse">
<div className="px-3">
<CSwitch
id="showDev"
color="primary"
defaultChecked={displayDev}
onClick={toggleDevDisplay}
size="lg"
/>
</div>
<div className="pr-2 pt-1">{t('firmware.show_dev')}</div>
<div className="px-3">
<CSelect
custom
value={selectedDeviceType}
onChange={(e) => setSelectedDeviceType(e.target.value)}
disabled={loading}
>
{deviceTypes.map((deviceType) => (
<option key={createUuid()} value={deviceType}>
{deviceType}
</option>
))}
</CSelect>
</div>
<div className="pr-2 pt-1">{t('firmware.device_type')}</div>
</div>
</CCardHeader>
<CCardBody className="p-0">
<CDataTable
addTableClasses="table-sm"
items={data}
fields={fields}
loading={loading}
hover
border
scopedSlots={{
imageDate: (item) => (
<td className="text-center align-middle">
<div style={{ width: '150px' }}>{prettyDate(item.imageDate)}</div>
</td>
),
size: (item) => (
<td className="align-middle">
<div style={{ width: '100px' }}>{cleanBytesString(item.size)}</div>
</td>
),
revision: (item) => (
<td className="align-middle">
<CPopover content={item.revision}>
<div style={{ width: 'calc(10vw)' }} className="text-truncate align-middle">
{item.revision ? getShortRevision(item.revision) : 'N/A'}
</div>
</CPopover>
</td>
),
uri: (item) => (
<td className="align-middle">
<div style={{ width: 'calc(50vw)' }}>
<div className="text-truncate align-middle">
<CopyToClipboardButton key={item.uri} t={t} size="sm" content={item.uri} />
<CPopover content={item.uri}>
<span>{item.uri}</span>
</CPopover>
</div>
</div>
</td>
),
show_details: (item) => (
<td className="text-center align-middle">
<CPopover content={t('common.details')}>
<CButton
size="sm"
color="primary"
variant="outline"
onClick={() => toggleEditModal(item.id)}
>
<CIcon content={cilSearch} />
</CButton>
</CPopover>
</td>
),
}}
/>
<div className="d-flex flex-row pl-3">
<div className="pr-3">
<ReactPaginate
previousLabel="← Previous"
nextLabel="Next →"
pageCount={pageCount}
onPageChange={changePage}
forcePage={page.selected}
breakClassName="page-item"
breakLinkClassName="page-link"
containerClassName="pagination"
pageClassName="page-item"
pageLinkClassName="page-link"
previousClassName="page-item"
previousLinkClassName="page-link"
nextClassName="page-item"
nextLinkClassName="page-link"
activeClassName="active"
/>
</div>
<p className="pr-2 mt-1">{t('common.items_per_page')}</p>
<div style={{ width: '100px' }} className="px-2">
<CSelect
custom
defaultValue={firmwarePerPage}
onChange={(e) => setFirmwarePerPage(e.target.value)}
disabled={loading}
>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</CSelect>
</div>
</div>
</CCardBody>
</CCard>
);
};
FirmwareList.propTypes = {
t: PropTypes.func.isRequired,
loading: PropTypes.bool.isRequired,
pageCount: PropTypes.number.isRequired,
page: PropTypes.instanceOf(Object).isRequired,
setPage: PropTypes.func.isRequired,
data: PropTypes.instanceOf(Array).isRequired,
firmwarePerPage: PropTypes.string.isRequired,
setFirmwarePerPage: PropTypes.func.isRequired,
selectedDeviceType: PropTypes.string.isRequired,
deviceTypes: PropTypes.instanceOf(Array).isRequired,
setSelectedDeviceType: PropTypes.func.isRequired,
displayDev: PropTypes.bool.isRequired,
toggleDevDisplay: PropTypes.func.isRequired,
toggleEditModal: PropTypes.func.isRequired,
};
export default React.memo(FirmwareList);

View File

@@ -11,9 +11,10 @@ import {
CTabContent,
CCardHeader,
} from '@coreui/react';
import { FirmwareList, useAuth, useToast } from 'ucentral-libs';
import { useAuth, useToast } from 'ucentral-libs';
import FirmwareDashboard from 'components/FirmwareDashboard';
import EditFirmwareModal from 'components/EditFirmwareModal';
import Table from './Table';
const FirmwareListPage = () => {
const { t } = useTranslation();
@@ -96,7 +97,7 @@ const FirmwareListPage = () => {
const allFirmwares = [];
let continueFirmware = true;
let i = 1;
let i = 0;
while (continueFirmware) {
const newFirmwares = await getPartialFirmware(deviceType ?? selectedDeviceType, i);
if (newFirmwares === null || newFirmwares.length === 0) continueFirmware = false;
@@ -196,7 +197,7 @@ const FirmwareListPage = () => {
<FirmwareDashboard />
</CTabPane>
<CTabPane active={index === 1}>
<FirmwareList
<Table
t={t}
loading={loading}
page={page}

View File

@@ -1,559 +1,22 @@
import React, { useState, useEffect } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import * as axios from 'axios';
import { LoginPage, useFormFields, useAuth, useToast } from 'ucentral-libs';
import { setItem } from 'utils/localStorageHelper';
const initialFormState = {
username: {
value: '',
error: false,
placeholder: 'login.username',
},
password: {
value: '',
error: false,
placeholder: 'login.password',
},
ucentralsecurl: {
value: '',
error: false,
hidden: true,
placeholder: 'login.url',
},
forgotusername: {
value: '',
error: false,
placeholder: 'login.username',
},
newpassword: {
value: '',
error: false,
placeholder: 'login.new_password',
},
confirmpassword: {
value: '',
error: false,
placeholder: 'login.confirm_new_password',
},
};
const initialResponseState = {
text: '',
error: false,
tried: false,
};
import { LoginPage, useAuth, useToast } from 'ucentral-libs';
const Login = () => {
const { t, i18n } = useTranslation();
const { setCurrentToken, setEndpoints } = useAuth();
const { addToast } = useToast();
const [defaultConfig, setDefaultConfig] = useState({
value: '',
error: false,
hidden: true,
placeholder: 'login.url',
});
const [loading, setLoading] = useState(false);
const [loginResponse, setLoginResponse] = useState(initialResponseState);
const [forgotResponse, setForgotResponse] = useState(initialResponseState);
const [changePasswordResponse, setChangeResponse] = useState(initialResponseState);
const [policies, setPolicies] = useState({
passwordPolicy: '',
passwordPattern: '',
accessPolicy: '',
});
const [formType, setFormType] = useState('login');
const [fields, updateFieldWithId, updateField, setFormFields] = useFormFields(initialFormState);
const axiosInstance = axios.create();
axiosInstance.defaults.timeout = 5000;
const toggleForgotPassword = () => {
setFormFields({
...initialFormState,
...{
ucentralsecurl: defaultConfig,
},
});
setLoginResponse(initialResponseState);
setForgotResponse(initialResponseState);
if (formType === 'login') setFormType('forgot-password');
else setFormType('login');
};
const cancelPasswordChange = () => {
setFormFields({
...initialFormState,
...{
ucentralsecurl: defaultConfig,
},
});
setLoginResponse(initialResponseState);
setForgotResponse(initialResponseState);
setFormType('login');
};
const signInValidation = () => {
let valid = true;
if (fields.ucentralsecurl.value === '') {
updateField('ucentralsecurl', { error: true });
valid = false;
}
if (fields.password.value === '') {
updateField('password', { error: true });
valid = false;
}
if (fields.username.value === '') {
updateField('username', { error: true });
valid = false;
}
if (
formType === 'change-password' &&
fields.newpassword.value !== fields.confirmpassword.value
) {
updateField('confirmpassword', { error: true });
valid = false;
}
return valid;
};
const forgotValidation = () => {
let valid = true;
if (fields.ucentralsecurl.value === '') {
updateField('ucentralsecurl', { error: true });
valid = false;
}
if (fields.forgotusername.value === '') {
updateField('forgotusername', { error: true });
valid = false;
}
return valid;
};
const onKeyDown = (event, action) => {
if (event.code === 'Enter') {
action(event);
}
};
const getDefaultConfig = async () => {
let uCentralSecUrl = '';
fetch('./config.json', {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
})
.then((response) => response.json())
.then((json) => {
const newUcentralSecConfig = {
value: json.DEFAULT_UCENTRALSEC_URL,
error: false,
hidden: !json.ALLOW_UCENTRALSEC_CHANGE,
placeholder: json.DEFAULT_UCENTRALSEC_URL,
};
uCentralSecUrl = newUcentralSecConfig.value;
setDefaultConfig(newUcentralSecConfig);
setFormFields({
...fields,
...{
ucentralsecurl: newUcentralSecConfig,
},
});
return axiosInstance.post(
`${newUcentralSecConfig.value}/api/v1/oauth2?requirements=true`,
{},
);
})
.then((response) => {
const newPolicies = response.data;
newPolicies.accessPolicy = `${uCentralSecUrl}${newPolicies.accessPolicy}`;
newPolicies.passwordPolicy = `${uCentralSecUrl}${newPolicies.passwordPolicy}`;
setPolicies(newPolicies);
})
.catch();
};
const getGatewayUIUrl = (token, gwUrl) => {
axiosInstance
.get(`${gwUrl}/api/v1/system?command=info`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
})
.then((response) => {
if (response.data.UI) setItem('owgw-ui', response.data.UI);
})
.catch(() => {});
};
const getProvUIUrl = (token, provUrl) => {
axiosInstance
.get(`${provUrl}/api/v1/system?command=info`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
})
.then((response) => {
if (response.data.UI) setItem('owprov-ui', response.data.UI);
})
.catch(() => {});
};
const SignIn = () => {
setLoginResponse(initialResponseState);
if (signInValidation()) {
setLoading(true);
let token = '';
const parameters = {
userId: fields.username.value,
password: fields.password.value,
};
if (formType === 'change-password') {
parameters.newPassword = fields.newpassword.value;
}
axiosInstance
.post(`${fields.ucentralsecurl.value}/api/v1/oauth2`, parameters)
.then((response) => {
// If there's MFA to do
if (response.data.method && response.data.created) {
setFormType(`validation-${response.data.method}-${response.data.uuid}`);
return null;
}
if (response.data.userMustChangePassword) {
setFormType('change-password');
return null;
}
setItem('access_token', response.data.access_token);
token = response.data.access_token;
return axiosInstance.get(`${fields.ucentralsecurl.value}/api/v1/systemEndpoints`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${response.data.access_token}`,
},
});
})
.then((response) => {
if (response) {
const endpoints = {
owsec: fields.ucentralsecurl.value,
};
for (const endpoint of response.data.endpoints) {
endpoints[endpoint.type] = endpoint.uri;
}
if (endpoints.owgw) getGatewayUIUrl(token, endpoints.owgw);
if (endpoints.owprov) getProvUIUrl(token, endpoints.owprov);
setItem('gateway_endpoints', JSON.stringify(endpoints));
setEndpoints(endpoints);
setCurrentToken(token);
}
})
.catch((error) => {
if (formType === 'change-password') {
if (error.response?.data?.ErrorCode === 3) {
setChangeResponse({
text: t('login.previously_used'),
error: true,
tried: true,
});
} else if (error.response?.data?.ErrorCode === 5) {
setChangeResponse({
text: t('common.invalid_password'),
error: true,
tried: true,
});
} else {
setChangeResponse({
text: t('login.change_password_error'),
error: true,
tried: true,
});
}
} else if (error.response.status === 403) {
if (error.response?.data?.ErrorCode === 1) setFormType('change-password');
else if (error.response?.data?.ErrorCode === 2) {
setLoginResponse({
text: t('common.invalid_credentials'),
error: true,
tried: true,
});
} else {
setLoginResponse({
text: t('login.login_error'),
error: true,
tried: true,
});
}
} else {
setLoginResponse({
text: t('login.login_error'),
error: true,
tried: true,
});
}
})
.finally(() => {
setLoading(false);
});
}
};
const submitForm = (event) => {
event.preventDefault();
setLoginResponse(initialResponseState);
setLoading(true);
let token = '';
const parameters = {
userId: event.target?.username?.value,
password: event.target?.password?.value,
};
if (formType === 'change-password') {
parameters.newPassword = fields.newpassword.value;
}
axiosInstance
.post(`${fields.ucentralsecurl.value}/api/v1/oauth2`, parameters)
.then((response) => {
// If there's MFA to do
if (response.data.method && response.data.created) {
setFormType(`validation-${response.data.method}-${response.data.uuid}`);
return null;
}
if (response.data.userMustChangePassword) {
setFormType('change-password');
return null;
}
setItem('access_token', response.data.access_token);
token = response.data.access_token;
return axiosInstance.get(`${fields.ucentralsecurl.value}/api/v1/systemEndpoints`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${response.data.access_token}`,
},
});
})
.then((response) => {
if (response) {
const endpoints = {
owsec: fields.ucentralsecurl.value,
};
for (const endpoint of response.data.endpoints) {
endpoints[endpoint.type] = endpoint.uri;
}
if (endpoints.owgw) getGatewayUIUrl(token, endpoints.owgw);
if (endpoints.owprov) getProvUIUrl(token, endpoints.owprov);
setItem('gateway_endpoints', JSON.stringify(endpoints));
setEndpoints(endpoints);
setCurrentToken(token);
}
})
.catch((error) => {
if (formType === 'change-password') {
if (error.response?.data?.ErrorCode === 3) {
setChangeResponse({
text: t('login.previously_used'),
error: true,
tried: true,
});
} else if (error.response?.data?.ErrorCode === 5) {
setChangeResponse({
text: t('common.invalid_password'),
error: true,
tried: true,
});
} else {
setChangeResponse({
text: t('login.change_password_error'),
error: true,
tried: true,
});
}
} else if (error.response.status === 403) {
if (error.response?.data?.ErrorCode === 1) setFormType('change-password');
else if (error.response?.data?.ErrorCode === 2) {
setLoginResponse({
text: t('common.invalid_credentials'),
error: true,
tried: true,
});
} else {
setLoginResponse({
text: t('login.login_error'),
error: true,
tried: true,
});
}
} else {
setLoginResponse({
text: t('login.login_error'),
error: true,
tried: true,
});
}
})
.finally(() => {
setLoading(false);
});
};
const sendForgotPasswordEmail = () => {
setForgotResponse(initialResponseState);
if (forgotValidation()) {
setLoading(true);
axiosInstance
.post(`${fields.ucentralsecurl.value}/api/v1/oauth2?forgotPassword=true`, {
userId: fields.forgotusername.value,
})
.then(() => {
updateField('forgotusername', {
value: '',
});
setForgotResponse({
text: t('login.forgot_password_success'),
error: false,
tried: true,
});
})
.catch(() => {
setForgotResponse({
text: t('login.forgot_password_error'),
error: true,
tried: true,
});
})
.finally(() => {
setLoading(false);
});
}
};
const validateCode = (code) => {
const options = {
headers: {
Accept: 'application/json',
},
};
const parameters = {
uuid: formType.split('-').slice(2).join('-'),
answer: code,
};
let token = '';
return axiosInstance
.post(
`${fields.ucentralsecurl.value}/api/v1/oauth2?completeMFAChallenge=true`,
parameters,
options,
)
.then((response) => {
if (response.data.userMustChangePassword) {
setFormType('change-password');
return null;
}
setItem('access_token', response.data.access_token);
token = response.data.access_token;
return axiosInstance.get(`${fields.ucentralsecurl.value}/api/v1/systemEndpoints`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${response.data.access_token}`,
},
});
})
.then((response) => {
if (response) {
const endpoints = {
owsec: fields.ucentralsecurl.value,
};
for (const endpoint of response.data.endpoints) {
endpoints[endpoint.type] = endpoint.uri;
}
if (endpoints.owgw) getGatewayUIUrl(token, endpoints.owgw);
if (endpoints.owprov) getProvUIUrl(token, endpoints.owprov);
setItem('gateway_endpoints', JSON.stringify(endpoints));
setEndpoints(endpoints);
setCurrentToken(token);
}
})
.catch(() => false)
.finally(() => {
setLoading(false);
return true;
});
};
const resendValidationCode = () => {
const options = {
headers: {
Accept: 'application/json',
},
};
const parameters = {
uuid: formType.split('-').slice(2).join('-'),
};
return axiosInstance
.post(`${fields.ucentralsecurl.value}/api/v1/oauth2?resendMFACode=true`, parameters, options)
.then(() => {
addToast({
title: t('common.success'),
body: t('user.new_code_sent'),
color: 'success',
autohide: true,
});
return true;
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('login.authentication_expired'),
color: 'danger',
autohide: true,
});
if (e.response?.data?.ErrorCode === 403) setFormType('login');
return false;
});
};
useEffect(() => {
getDefaultConfig();
}, []);
return (
<LoginPage
t={t}
i18n={i18n}
signIn={SignIn}
loading={loading}
setCurrentToken={setCurrentToken}
setEndpoints={setEndpoints}
addToast={addToast}
axios={axios}
logo="assets/OpenWiFi_LogoLockup_DarkGreyColour.svg"
loginResponse={loginResponse}
forgotResponse={forgotResponse}
fields={fields}
updateField={updateFieldWithId}
toggleForgotPassword={toggleForgotPassword}
formType={formType}
onKeyDown={onKeyDown}
submitForm={submitForm}
sendForgotPasswordEmail={sendForgotPasswordEmail}
changePasswordResponse={changePasswordResponse}
cancelPasswordChange={cancelPasswordChange}
policies={policies}
validateCode={validateCode}
resendValidationCode={resendValidationCode}
/>
);
};

View File

@@ -1,9 +0,0 @@
.logo {
padding-left: 17%;
width: 85%;
}
.languageSwitcher {
float: right;
width: 150px;
}

View File

@@ -1,392 +1,11 @@
import React, { useState, useEffect } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CCard, CCardBody, CCardHeader, CButton, CPopover, CButtonToolbar } from '@coreui/react';
import { cilPencil, cilSave, cilSync, cilX } from '@coreui/icons';
import CIcon from '@coreui/icons-react';
import axiosInstance from 'utils/axiosInstance';
import { testRegex } from 'utils/helper';
import { useUser, EditMyProfile, useAuth, useToast } from 'ucentral-libs';
const initialState = {
Id: {
value: '',
error: false,
editable: false,
},
newPassword: {
value: '',
error: false,
editable: true,
ignore: true,
},
confirmNewPassword: {
value: '',
error: false,
editable: true,
ignore: true,
},
email: {
value: '',
error: false,
editable: false,
},
description: {
value: '',
error: false,
editable: true,
},
name: {
value: '',
error: false,
editable: true,
},
notes: {
value: [],
editable: false,
},
userTypeProprietaryInfo: {
value: {},
error: false,
},
mfaMethod: {
value: '',
error: false,
},
};
import { ProfilePage as Page } from 'ucentral-libs';
const ProfilePage = () => {
const { t } = useTranslation();
const { currentToken, endpoints, user, getAvatar, avatar } = useAuth();
const { addToast } = useToast();
const [editing, setEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [userForm, updateWithId, updateWithKey, setUser] = useUser(initialState);
const [newAvatar, setNewAvatar] = useState('');
const [newAvatarFile, setNewAvatarFile] = useState(null);
const [avatarDeleted, setAvatarDeleted] = useState(false);
const [fileInputKey, setFileInputKey] = useState(0);
const [policies, setPolicies] = useState({
passwordPolicy: '',
passwordPattern: '',
accessPolicy: '',
});
const getPasswordPolicy = () => {
axiosInstance
.post(`${endpoints.owsec}/api/v1/oauth2?requirements=true`, {})
.then((response) => {
const newPolicies = response.data;
newPolicies.accessPolicy = `${endpoints.owsec}${newPolicies.accessPolicy}`;
newPolicies.passwordPolicy = `${endpoints.owsec}${newPolicies.passwordPolicy}`;
setPolicies(response.data);
})
.catch(() => {});
};
const getUser = () => {
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
axiosInstance
.get(`${endpoints.owsec}/api/v1/user/${user.Id}`, options)
.then((response) => {
const newUser = {};
for (const key of Object.keys(response.data)) {
if (key in initialState && key !== 'currentPassword') {
newUser[key] = {
...initialState[key],
value: response.data[key],
};
}
}
newUser.mfaMethod = {
value: response.data.userTypeProprietaryInfo.mfa.enabled
? response.data.userTypeProprietaryInfo.mfa.method
: '',
error: false,
};
setUser({ ...initialState, ...newUser });
})
.catch((e) => {
addToast({
title: t('common.error'),
body: t('user.error_fetching_users', { error: e }),
color: 'danger',
autohide: true,
});
});
};
const uploadAvatar = () => {
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
const data = new FormData();
data.append('file', newAvatarFile);
axiosInstance
.post(`${endpoints.owsec}/api/v1/avatar/${user.Id}`, data, options)
.then(() => {
addToast({
title: t('user.update_success_title'),
body: t('user.update_success'),
color: 'success',
autohide: true,
});
getAvatar();
setNewAvatar('');
setNewAvatarFile(null);
setFileInputKey(fileInputKey + 1);
})
.catch(() => {
addToast({
title: t('user.update_failure_title'),
body: t('user.update_failure'),
color: 'danger',
autohide: true,
});
});
};
const updateUser = () => {
setLoading(true);
if (newAvatar !== '' && newAvatarFile !== null) {
uploadAvatar();
} else if (avatarDeleted) {
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
axiosInstance
.delete(`${endpoints.owsec}/api/v1/avatar/${user.Id}`, options)
.then(() => {
getAvatar();
})
.catch(() => {});
}
if (
userForm.newPassword.value !== '' &&
(!testRegex(userForm.newPassword.value, policies.passwordPattern) ||
userForm.newPassword.value !== userForm.confirmNewPassword.value)
) {
updateWithKey('newPassword', {
error: true,
});
setLoading(false);
} else {
const newNotes = [];
for (let i = 0; i < userForm.notes.value.length; i += 1) {
if (userForm.notes.value[i].new) newNotes.push({ note: userForm.notes.value[i].note });
}
const propInfo = { ...userForm.userTypeProprietaryInfo.value };
propInfo.mfa.method = userForm.mfaMethod.value === '' ? undefined : userForm.mfaMethod.value;
propInfo.mfa.enabled = userForm.mfaMethod.value !== '';
const parameters = {
id: user.Id,
description: userForm.description.value,
name: userForm.name.value,
notes: newNotes,
userTypeProprietaryInfo: propInfo,
currentPassword: userForm.newPassword.value !== '' ? userForm.newPassword.value : undefined,
};
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
axiosInstance
.put(`${endpoints.owsec}/api/v1/user/${user.Id}`, parameters, options)
.then(() => {
addToast({
title: t('user.update_success_title'),
body: t('user.update_success'),
color: 'success',
autohide: true,
});
// eslint-disable-next-line no-use-before-define
toggleEditing();
})
.catch((e) => {
addToast({
title: t('user.update_failure_title'),
body: t('user.update_failure', { error: e.response?.data?.ErrorDescription }),
color: 'danger',
autohide: true,
});
})
.finally(() => {
getUser();
setLoading(false);
});
}
};
const addNote = (currentNote) => {
const newNotes = [...userForm.notes.value];
newNotes.unshift({
note: currentNote,
new: true,
created: new Date().getTime() / 1000,
createdBy: '',
});
updateWithKey('notes', { value: newNotes });
};
const showPreview = (e) => {
setAvatarDeleted(false);
const imageFile = e.target.files[0];
setNewAvatar(URL.createObjectURL(imageFile));
setNewAvatarFile(imageFile);
};
const deleteAvatar = () => {
setNewAvatar('');
setAvatarDeleted(true);
};
const sendPhoneNumberTest = async (phoneNumber) => {
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
return axiosInstance
.post(`${endpoints.owsec}/api/v1/sms?validateNumber=true`, { to: phoneNumber }, options)
.then(() => true)
.catch(() => {
addToast({
title: t('common.error'),
body: t('user.error_sending_code'),
color: 'danger',
autohide: true,
});
return false;
});
};
const testVerificationCode = async (phoneNumber, code) => {
const options = {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${currentToken}`,
},
};
return axiosInstance
.post(
`${endpoints.owsec}/api/v1/sms?completeValidation=true&validationCode=${code}`,
{ to: phoneNumber },
options,
)
.then(() => true)
.catch(() => {
addToast({
title: t('common.error'),
body: t('user.wrong_validation_code'),
color: 'danger',
autohide: true,
});
return false;
});
};
const toggleEditing = () => {
if (editing) {
setAvatarDeleted(false);
setNewAvatar('');
getUser();
getAvatar();
}
setEditing(!editing);
};
useEffect(() => {
if (user.Id) {
getAvatar();
getUser();
}
if (policies.passwordPattern.length === 0) {
getPasswordPolicy();
}
}, [user.Id]);
return (
<CCard className="my-0 py-0">
<CCardHeader className="dark-header">
<div style={{ fontWeight: '600' }} className=" text-value-lg float-left">
{t('user.my_profile')}
</div>
<div className="text-right float-right">
<CButtonToolbar role="group" className="justify-content-end">
<CPopover content={t('common.save')}>
<CButton disabled={!editing} color="info" onClick={updateUser} className="mx-1">
<CIcon name="cil-save" content={cilSave} />
</CButton>
</CPopover>
<CPopover content={t('common.edit')}>
<CButton disabled={editing} color="dark" onClick={toggleEditing} className="mx-1">
<CIcon name="cil-pencil" content={cilPencil} />
</CButton>
</CPopover>
<CPopover content={t('common.stop_editing')}>
<CButton disabled={!editing} color="dark" onClick={toggleEditing} className="mx-1">
<CIcon name="cil-x" content={cilX} />
</CButton>
</CPopover>
<CPopover content={t('common.refresh')}>
<CButton disabled={editing} color="info" onClick={getUser} className="mx-1">
<CIcon content={cilSync} />
</CButton>
</CPopover>
</CButtonToolbar>
</div>
</CCardHeader>
<CCardBody>
<EditMyProfile
t={t}
user={userForm}
updateUserWithId={updateWithId}
updateWithKey={updateWithKey}
loading={loading}
policies={policies}
addNote={addNote}
avatar={avatar}
newAvatar={newAvatar}
showPreview={showPreview}
deleteAvatar={deleteAvatar}
fileInputKey={fileInputKey}
sendPhoneNumberTest={sendPhoneNumberTest}
testVerificationCode={testVerificationCode}
editing={editing}
avatarDeleted={avatarDeleted}
/>
</CCardBody>
</CCard>
);
return <Page t={t} axiosInstance={axiosInstance} />;
};
export default ProfilePage;

Some files were not shown because too many files have changed in this diff Show More