mirror of
https://github.com/optim-enterprises-bv/OptimCloud-gw-ui.git
synced 2025-10-28 17:02:21 +00:00
[WIFI-11542] AP Scripts
Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
@@ -15,7 +15,6 @@
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"ignorePatterns": ["build/", "dist/"],
|
||||
"plugins": ["import", "react", "@typescript-eslint", "prettier"],
|
||||
"extends": [
|
||||
"plugin:react/recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
@@ -27,6 +26,7 @@
|
||||
"plugin:import/warnings",
|
||||
"plugin:import/typescript"
|
||||
],
|
||||
"plugins": ["import", "react", "@typescript-eslint", "prettier"],
|
||||
"rules": {
|
||||
"import/extensions": [
|
||||
"error",
|
||||
@@ -69,6 +69,7 @@
|
||||
],
|
||||
"max-len": ["error", { "code": 150 }],
|
||||
"@typescript-eslint/ban-ts-comment": ["off"],
|
||||
"import/prefer-default-export": ["off"],
|
||||
"react/prop-types": ["warn"],
|
||||
"react/require-default-props": "off",
|
||||
"react/jsx-props-no-spreading": ["off"],
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ucentral-client",
|
||||
"version": "2.8.0(28)",
|
||||
"version": "2.8.0(31)",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ucentral-client",
|
||||
"version": "2.8.0(28)",
|
||||
"version": "2.8.0(31)",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@chakra-ui/icons": "^2.0.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ucentral-client",
|
||||
"version": "2.8.0(30)",
|
||||
"version": "2.8.0(31)",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"main": "index.tsx",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"live_view_help": "Hilfe zur Live-Ansicht",
|
||||
"memory": "Erinnerung",
|
||||
"memory_used": "Verwendeter Speicher",
|
||||
"missing_board": "Die Analytics-Überwachung an diesem Veranstaltungsort ist nicht mehr aktiv. Bitte starten Sie die Überwachung über das obere Menü neu",
|
||||
"mode": "Modus",
|
||||
"noise": "Lärm",
|
||||
"packets": "Pakete",
|
||||
@@ -370,7 +371,7 @@
|
||||
"push_configuration": "Push-Konfiguration",
|
||||
"push_configuration_error": "Fehler beim Versuch, die Konfiguration auf das Gerät zu übertragen: {{e}}",
|
||||
"push_configuration_explanation": "Konfiguration nicht übertragen, Fehlercode {{code}}",
|
||||
"push_success": "Konfiguration erfolgreich übertragen!",
|
||||
"push_success": "Die Konfiguration wurde verifiziert und ein \"Konfigurieren\"-Befehl wurde jetzt von der Steuerung initiiert!",
|
||||
"radio_limit": "Sie haben die maximale Anzahl an Funkgeräten (5) erreicht. Sie müssen eines der aktivierten Bänder löschen, um ein neues hinzuzufügen",
|
||||
"radios": "Radios",
|
||||
"rc_only": "Nur für Release-Kandidaten",
|
||||
@@ -613,6 +614,7 @@
|
||||
"import_explanation": "Für den Massenimport von Geräten müssen Sie eine CSV-Datei mit den folgenden Spalten verwenden: SerialNumber, DeviceType, Name, Description, Note",
|
||||
"invalid_serial_number": "Ungültige Seriennummer (muss 12 HEX-Zeichen lang sein)",
|
||||
"new_devices": "Neue Geräte",
|
||||
"no_model_image": "Kein Modellbild gefunden",
|
||||
"not_connected": "Nicht verbunden",
|
||||
"not_found_gateway": "Fehler: Gerät hat sich noch nicht mit dem Gateway verbunden",
|
||||
"notifications": "Gerätebenachrichtigungen",
|
||||
@@ -700,6 +702,8 @@
|
||||
"invalid_proto_6g": "Dieses Verschlüsselungsprotokoll kann nicht auf einer SSID verwendet werden, die 6G verwendet",
|
||||
"invalid_proto_passpoint": "Dieses Verschlüsselungsprotokoll kann nicht mit einer Passpoint-SSID verwendet werden. Bitte wählen Sie ein Protokoll aus, das Radius verwenden kann",
|
||||
"invalid_select_ports": "Inkompatible Werte zwischen Schnittstellen! Bitte stellen Sie sicher, dass es keine doppelte PORT/VLAN-ID-Kombination zwischen Ihren Schnittstellen gibt",
|
||||
"invalid_static_ipv4_d": "Ungültige Adresse, dieser Bereich ist für Multicasting reserviert (Klasse D). Das erste Oktett sollte 223 oder niedriger sein",
|
||||
"invalid_static_ipv4_e": "Ungültige Adresse, dieser Bereich ist für Experimente reserviert (Klasse E). Das erste Oktett sollte 223 oder niedriger sein",
|
||||
"invalid_third_party": "Ungültige Drittanbieter-JSON-Zeichenfolge. Bitte bestätigen Sie, dass Ihr Wert ein gültiges JSON ist",
|
||||
"key_file_explanation": "Bitte verwenden Sie eine .pem-Datei, die mit „-----BEGIN PRIVATE KEY-----“ beginnt und mit „-----END PRIVATE KEY-----“ endet.",
|
||||
"min_max_string": "Der Wert muss eine Länge zwischen {{min}} (einschließlich) und {{max}} (einschließlich) haben.",
|
||||
@@ -811,7 +815,7 @@
|
||||
"level": "Niveau",
|
||||
"message": "Botschaft",
|
||||
"one": "Log",
|
||||
"receiving_types": "Typen empfangen",
|
||||
"receiving_types": "Benachrichtigungsfilter",
|
||||
"security": "Sicherheit",
|
||||
"source": "Quelle",
|
||||
"thread": "Faden",
|
||||
@@ -874,6 +878,10 @@
|
||||
"activate": "",
|
||||
"add_new_note": "Notiz hinzufügen",
|
||||
"deactivate": "Deaktivieren",
|
||||
"delete_account": "Mein Profil löschen",
|
||||
"delete_account_confirm": "Löschen Sie alle meine Informationen",
|
||||
"delete_warning": "Diese Aktion ist nicht umkehrbar. Alle Ihre Profilinformationen und Ihre API-Schlüssel werden entfernt",
|
||||
"deleted_success": "Ihr Profil ist jetzt gelöscht, wir melden Sie jetzt ab...",
|
||||
"disabled": "Deaktiviert",
|
||||
"enabled": "aktiviert",
|
||||
"manage_avatar": "Avatar verwalten",
|
||||
@@ -901,18 +909,30 @@
|
||||
"version": "Ausführung"
|
||||
},
|
||||
"script": {
|
||||
"author": "Schöpfer",
|
||||
"automatic": "Automatik",
|
||||
"create_success": "Das Skript ist jetzt erstellt und einsatzbereit!",
|
||||
"custom_domain": "Benutzerdefinierten Domain",
|
||||
"deferred": "Aufgeschoben",
|
||||
"device_title": "Führen Sie das Geräteskript aus",
|
||||
"device_title": "Skript ausführen",
|
||||
"diagnostics": "Diagnose",
|
||||
"explanation": "Führen Sie ein benutzerdefiniertes Skript auf diesem Gerät aus und laden Sie die Ergebnisse herunter",
|
||||
"file_not_ready": "Das Ergebnis wurde noch nicht hochgeladen, bitte kommen Sie später wieder",
|
||||
"file_too_large": "Bitte wählen Sie eine Datei aus, die kleiner als 500 KB ist",
|
||||
"helper": "Dokumentation",
|
||||
"no_script_available": "Kein Skript für Ihre Benutzerrolle verfügbar",
|
||||
"now": "Jetzt",
|
||||
"one": "Skript",
|
||||
"other": "Skripte",
|
||||
"restricted": "Benutzer, die dieses Skript ausführen dürfen",
|
||||
"schedule_success": "Geplante Skriptausführung!",
|
||||
"signature": "Unterschrift",
|
||||
"started_execution": "Ausführung des Skripts gestartet, kommen Sie später für die Ergebnisse!",
|
||||
"timeout": "Auszeit",
|
||||
"upload_destination": "Ziel hochladen",
|
||||
"update_success": "Skript aktualisiert!",
|
||||
"upload_destination": "Ergebnis-Upload-Ziel",
|
||||
"upload_file": "Datei hochladen",
|
||||
"visit_external_website": "Dokumentation ansehen",
|
||||
"when": "Ausführung planen"
|
||||
},
|
||||
"service": {
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"live_view_help": "Live View Help",
|
||||
"memory": "Memory",
|
||||
"memory_used": "Memory Used",
|
||||
"missing_board": "Analytics monitoring on this venue is no longer active, please restart monitoring using the top menu",
|
||||
"mode": "Mode",
|
||||
"noise": "Noise",
|
||||
"packets": "Packets",
|
||||
@@ -370,7 +371,7 @@
|
||||
"push_configuration": "Push Configuration",
|
||||
"push_configuration_error": "Error while trying to push configuration to device: {{e}}",
|
||||
"push_configuration_explanation": "Configuration not pushed, error code {{code}}",
|
||||
"push_success": "Configuration Successfully Pushed!",
|
||||
"push_success": "Configuration was verified and a \"Configure\" command was now initiated by the controller!",
|
||||
"radio_limit": "You have reached the maximum amount of radios (5). You need to delete one of the activated bands to add a new one",
|
||||
"radios": "Radios",
|
||||
"rc_only": "Release Candidates Only",
|
||||
@@ -613,6 +614,7 @@
|
||||
"import_explanation": "To bulk import devices, you need to use a CSV file with the following columns: SerialNumber, DeviceType, Name, Description, Note",
|
||||
"invalid_serial_number": "Invalid Serial Number (needs to be 12 HEX chars)",
|
||||
"new_devices": "new devices",
|
||||
"no_model_image": "No Model Image Found",
|
||||
"not_connected": "Not Connected",
|
||||
"not_found_gateway": "Error: device has not yet connected to the controller",
|
||||
"notifications": "Device Notifications",
|
||||
@@ -700,6 +702,8 @@
|
||||
"invalid_proto_6g": "This encryption protocol cannot be used on an SSID which uses 6G",
|
||||
"invalid_proto_passpoint": "",
|
||||
"invalid_select_ports": "Incompatible values between interfaces! Please make sure that there is no duplicate PORT/VLAN ID combination between your interfaces",
|
||||
"invalid_static_ipv4_d": "Invalid address, this range reserved for multicasting (class D). The first octet should be 223 or lower",
|
||||
"invalid_static_ipv4_e": "Invalid address, this range reserved for experiments (class E). The first octet should be 223 or lower",
|
||||
"invalid_third_party": "Invalid Third Party JSON string. Please confirm that your value is a valid JSON",
|
||||
"key_file_explanation": "Please use a .pem file that starts with \"-----BEGIN PRIVATE KEY-----\" and ends with \"-----END PRIVATE KEY-----\"",
|
||||
"min_max_string": "Value needs to be of a length between {{min}} (inclusive) and {{max}} (inclusive)",
|
||||
@@ -811,7 +815,7 @@
|
||||
"level": "Level",
|
||||
"message": "Message",
|
||||
"one": "Log",
|
||||
"receiving_types": "Receiving Types",
|
||||
"receiving_types": "Notifications Filter",
|
||||
"security": "Security",
|
||||
"source": "Source",
|
||||
"thread": "Thread",
|
||||
@@ -874,6 +878,10 @@
|
||||
"activate": "Activate",
|
||||
"add_new_note": "Add Note",
|
||||
"deactivate": "Deactivate",
|
||||
"delete_account": "Delete my Profile",
|
||||
"delete_account_confirm": "Delete all of my information",
|
||||
"delete_warning": "This action is non-reversible. All of your profile information and your API keys will be removed",
|
||||
"deleted_success": "Your profile is now deleted, we will now log you out...",
|
||||
"disabled": "Disabled",
|
||||
"enabled": "Enabled",
|
||||
"manage_avatar": "Manage Avatar",
|
||||
@@ -901,18 +909,30 @@
|
||||
"version": "Version"
|
||||
},
|
||||
"script": {
|
||||
"author": "Creator",
|
||||
"automatic": "Automatic",
|
||||
"create_success": "Script is now created and ready to use!",
|
||||
"custom_domain": "Custom Domain",
|
||||
"deferred": "Deferred",
|
||||
"device_title": "Run Device Script",
|
||||
"device_title": "Run Script",
|
||||
"diagnostics": "Diagnostics",
|
||||
"explanation": "Run a custom script on this device and download its results",
|
||||
"file_not_ready": "Result is not uploaded yet, please come back later",
|
||||
"file_too_large": "Please select a file that is less than 500KB",
|
||||
"helper": "Documentation",
|
||||
"no_script_available": "No script available for your user role",
|
||||
"now": "Now",
|
||||
"one": "Script",
|
||||
"other": "Scripts",
|
||||
"restricted": "Users allowed to run this script",
|
||||
"schedule_success": "Scheduled script execution!",
|
||||
"signature": "Signature",
|
||||
"started_execution": "Started script execution, come later for the results!",
|
||||
"timeout": "Timeout",
|
||||
"upload_destination": "Upload Destination",
|
||||
"update_success": "Script updated!",
|
||||
"upload_destination": "Results Upload Destination",
|
||||
"upload_file": "Upload File",
|
||||
"visit_external_website": "View Documentation",
|
||||
"when": "Schedule Execution"
|
||||
},
|
||||
"service": {
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"live_view_help": "Ayuda de visualización en vivo",
|
||||
"memory": "Memoria",
|
||||
"memory_used": "Memoria usada",
|
||||
"missing_board": "El monitoreo analítico en este lugar ya no está activo, reinicie el monitoreo usando el menú superior",
|
||||
"mode": "Modo",
|
||||
"noise": "Ruido",
|
||||
"packets": "Paquetes",
|
||||
@@ -370,7 +371,7 @@
|
||||
"push_configuration": "Configuración de inserción",
|
||||
"push_configuration_error": "Error al intentar enviar la configuración al dispositivo: {{e}}",
|
||||
"push_configuration_explanation": "Configuración no enviada, código de error {{code}}",
|
||||
"push_success": "¡Configuración presionada con éxito!",
|
||||
"push_success": "¡Se verificó la configuración y ahora el controlador inició un comando \"Configurar\"!",
|
||||
"radio_limit": "Has alcanzado la cantidad máxima de radios (5). Necesita eliminar una de las bandas activadas para agregar una nueva",
|
||||
"radios": "Radios",
|
||||
"rc_only": "Solo candidatos de lanzamiento",
|
||||
@@ -613,6 +614,7 @@
|
||||
"import_explanation": "Para importar dispositivos de forma masiva, debe usar un archivo CSV con las siguientes columnas: Número de serie, Tipo de dispositivo, Nombre, Descripción, Nota",
|
||||
"invalid_serial_number": "Número de serie no válido (debe tener 12 caracteres HEX)",
|
||||
"new_devices": "Nuevos dispositivos",
|
||||
"no_model_image": "No se encontró ninguna imagen de modelo",
|
||||
"not_connected": "No conectado",
|
||||
"not_found_gateway": "Error: el dispositivo aún no se ha conectado a la puerta de enlace",
|
||||
"notifications": "notificaciones de dispositivos",
|
||||
@@ -700,6 +702,8 @@
|
||||
"invalid_proto_6g": "Este protocolo de encriptación no se puede usar en un SSID que usa 6G",
|
||||
"invalid_proto_passpoint": "Este protocolo de cifrado no se puede utilizar con un SSID de punto de acceso. Seleccione un protocolo que pueda usar Radius",
|
||||
"invalid_select_ports": "¡Valores incompatibles entre interfaces! Asegúrese de que no haya una combinación de ID de VLAN/PUERTO duplicada entre sus interfaces",
|
||||
"invalid_static_ipv4_d": "Dirección no válida, este rango está reservado para multidifusión (clase D). El primer octeto debe ser 223 o inferior",
|
||||
"invalid_static_ipv4_e": "Dirección no válida, este rango reservado para experimentos (clase E). El primer octeto debe ser 223 o inferior",
|
||||
"invalid_third_party": "Cadena JSON de terceros no válida. Confirme que su valor es un JSON válido",
|
||||
"key_file_explanation": "Utilice un archivo .pem que comience con \"-----BEGIN PRIVATE KEY-----\" y termine con \"-----END PRIVATE KEY-----\"",
|
||||
"min_max_string": "El valor debe tener una longitud entre {{min}} (inclusive) y {{max}} (inclusive)",
|
||||
@@ -811,7 +815,7 @@
|
||||
"level": "Nivel",
|
||||
"message": "Mensaje",
|
||||
"one": "Iniciar sesión",
|
||||
"receiving_types": "Tipos de recepción",
|
||||
"receiving_types": "Filtro de notificaciones",
|
||||
"security": "SEGURIDAD",
|
||||
"source": "Fuente",
|
||||
"thread": "Hilo",
|
||||
@@ -874,6 +878,10 @@
|
||||
"activate": "",
|
||||
"add_new_note": "Añadir la nota",
|
||||
"deactivate": "Desactivar",
|
||||
"delete_account": "Eliminar mi perfil",
|
||||
"delete_account_confirm": "Eliminar toda mi información",
|
||||
"delete_warning": "Esta acción no es reversible. Toda la información de su perfil y sus claves API serán eliminadas",
|
||||
"deleted_success": "Su perfil ahora está eliminado, ahora cerraremos su sesión...",
|
||||
"disabled": "Discapacitado",
|
||||
"enabled": "Habilitado",
|
||||
"manage_avatar": "Administrar avatar",
|
||||
@@ -901,18 +909,30 @@
|
||||
"version": "Versión"
|
||||
},
|
||||
"script": {
|
||||
"author": "Creador",
|
||||
"automatic": "Automático",
|
||||
"create_success": "¡El script ahora está creado y listo para usar!",
|
||||
"custom_domain": "Dominio personalizado",
|
||||
"deferred": "Diferido",
|
||||
"device_title": "Ejecutar secuencia de comandos del dispositivo",
|
||||
"device_title": "Ejecutar guión",
|
||||
"diagnostics": "Diagnósticos",
|
||||
"explanation": "Ejecute un script personalizado en este dispositivo y descargue sus resultados",
|
||||
"file_not_ready": "El resultado aún no se ha subido, vuelva más tarde",
|
||||
"file_too_large": "Seleccione un archivo que tenga menos de 500 KB",
|
||||
"helper": "Documentación",
|
||||
"no_script_available": "No hay script disponible para su rol de usuario",
|
||||
"now": "ahora",
|
||||
"one": "Guión",
|
||||
"other": "Guiones",
|
||||
"restricted": "Usuarios autorizados a ejecutar este script",
|
||||
"schedule_success": "¡Ejecución de script programada!",
|
||||
"signature": "Firma",
|
||||
"started_execution": "Comenzó la ejecución del script, ¡venga más tarde para conocer los resultados!",
|
||||
"timeout": "Se acabó el tiempo",
|
||||
"upload_destination": "Cargar destino",
|
||||
"update_success": "Guión actualizado!",
|
||||
"upload_destination": "Destino de carga de resultados",
|
||||
"upload_file": "Subir archivo",
|
||||
"visit_external_website": "VER DOCUMENTACIÓN",
|
||||
"when": "Programar Ejecucion"
|
||||
},
|
||||
"service": {
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"live_view_help": "Aide sur l'affichage en direct",
|
||||
"memory": "mémoire",
|
||||
"memory_used": "Mémoire utilisée",
|
||||
"missing_board": "La surveillance analytique sur ce lieu n'est plus active, veuillez redémarrer la surveillance en utilisant le menu du haut",
|
||||
"mode": "Mode",
|
||||
"noise": "Bruit",
|
||||
"packets": "Paquets",
|
||||
@@ -370,7 +371,7 @@
|
||||
"push_configuration": "Pousser la configuration",
|
||||
"push_configuration_error": "Erreur lors de la tentative d'envoi de la configuration sur l'appareil : {{e}}",
|
||||
"push_configuration_explanation": "Configuration non poussée, code d'erreur {{code}}",
|
||||
"push_success": "Configuration poussée avec succès !",
|
||||
"push_success": "La configuration a été vérifiée et une commande \"Configurer\" a maintenant été lancée par le contrôleur !",
|
||||
"radio_limit": "Vous avez atteint le nombre maximum de radios (5). Vous devez supprimer une des bandes activées pour en ajouter une nouvelle",
|
||||
"radios": "Radios",
|
||||
"rc_only": "Libérer les candidats uniquement",
|
||||
@@ -613,6 +614,7 @@
|
||||
"import_explanation": "Pour importer en masse des appareils, vous devez utiliser un fichier CSV avec les colonnes suivantes : SerialNumber, DeviceType, Name, Description, Note",
|
||||
"invalid_serial_number": "Numéro de série non valide (doit être composé de 12 caractères HEX)",
|
||||
"new_devices": "nouveaux appareils",
|
||||
"no_model_image": "Aucune image de modèle trouvée",
|
||||
"not_connected": "Pas connecté",
|
||||
"not_found_gateway": "Erreur : l'appareil n'est pas encore connecté à la passerelle",
|
||||
"notifications": "notifications de l'appareil",
|
||||
@@ -700,6 +702,8 @@
|
||||
"invalid_proto_6g": "Ce protocole de cryptage ne peut pas être utilisé sur un SSID qui utilise la 6G",
|
||||
"invalid_proto_passpoint": "Ce protocole de cryptage ne peut pas être utilisé avec un SSID de point de passe. Veuillez sélectionner un protocole qui peut utiliser Radius",
|
||||
"invalid_select_ports": "Valeurs incompatibles entre les interfaces ! Veuillez vous assurer qu'il n'y a pas de combinaison PORT/VLAN ID en double entre vos interfaces",
|
||||
"invalid_static_ipv4_d": "Adresse invalide, cette plage est réservée à la multidiffusion (classe D). Le premier octet doit être 223 ou moins",
|
||||
"invalid_static_ipv4_e": "Adresse invalide, cette plage est réservée aux expérimentations (classe E). Le premier octet doit être 223 ou moins",
|
||||
"invalid_third_party": "Chaîne JSON tierce non valide. Veuillez confirmer que votre valeur est un JSON valide",
|
||||
"key_file_explanation": "Veuillez utiliser un fichier .pem qui commence par \"-----BEGIN PRIVATE KEY-----\" et se termine par \"-----END PRIVATE KEY-----\"",
|
||||
"min_max_string": "La valeur doit être d'une longueur comprise entre {{min}} (inclus) et {{max}} (inclus)",
|
||||
@@ -811,7 +815,7 @@
|
||||
"level": "Niveau",
|
||||
"message": "Message",
|
||||
"one": "Bûche",
|
||||
"receiving_types": "Types de réception",
|
||||
"receiving_types": "Filtre de notification",
|
||||
"security": "SÉCURITÉ",
|
||||
"source": "La source",
|
||||
"thread": "Fil de discussion",
|
||||
@@ -874,6 +878,10 @@
|
||||
"activate": "",
|
||||
"add_new_note": "Ajouter une note",
|
||||
"deactivate": "Désactiver",
|
||||
"delete_account": "Supprimer mon profil",
|
||||
"delete_account_confirm": "Supprimer toutes mes informations",
|
||||
"delete_warning": "Cette action est irréversible. Toutes les informations de votre profil et vos clés API seront supprimées",
|
||||
"deleted_success": "Votre profil est maintenant supprimé, nous allons maintenant vous déconnecter...",
|
||||
"disabled": "Désactivé",
|
||||
"enabled": "Activée",
|
||||
"manage_avatar": "Gérer l'avatar",
|
||||
@@ -901,18 +909,30 @@
|
||||
"version": "Version"
|
||||
},
|
||||
"script": {
|
||||
"author": "Créateur",
|
||||
"automatic": "Automatique",
|
||||
"create_success": "Le script est maintenant créé et prêt à être utilisé !",
|
||||
"custom_domain": "Domaine personnalisé",
|
||||
"deferred": "Différé",
|
||||
"device_title": "Exécuter le script de périphérique",
|
||||
"device_title": "Script de lancement",
|
||||
"diagnostics": "Diagnostics",
|
||||
"explanation": "Exécutez un script personnalisé sur cet appareil et téléchargez ses résultats",
|
||||
"file_not_ready": "Le résultat n'est pas encore téléchargé, veuillez revenir plus tard",
|
||||
"file_too_large": "Veuillez sélectionner un fichier de moins de 500 Ko",
|
||||
"helper": "Documentation",
|
||||
"no_script_available": "Aucun script disponible pour votre rôle d'utilisateur",
|
||||
"now": "À présent",
|
||||
"one": "Scénario",
|
||||
"other": "scripts",
|
||||
"restricted": "Utilisateurs autorisés à exécuter ce script",
|
||||
"schedule_success": "Exécution du script planifié !",
|
||||
"signature": "signature",
|
||||
"started_execution": "Lancement de l'exécution du script, venez plus tard pour les résultats !",
|
||||
"timeout": "Temps libre",
|
||||
"upload_destination": "Destination de téléchargement",
|
||||
"update_success": "Scénario mis à jour !",
|
||||
"upload_destination": "Destination de téléchargement des résultats",
|
||||
"upload_file": "Téléverser un fichier",
|
||||
"visit_external_website": "Afficher la documentation",
|
||||
"when": "Planifier l'exécution"
|
||||
},
|
||||
"service": {
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"live_view_help": "Ajuda da visualização ao vivo",
|
||||
"memory": "Memória",
|
||||
"memory_used": "Memória Usada",
|
||||
"missing_board": "O monitoramento analítico neste local não está mais ativo, reinicie o monitoramento usando o menu superior",
|
||||
"mode": "Modo",
|
||||
"noise": "Barulho",
|
||||
"packets": "Pacotes",
|
||||
@@ -370,7 +371,7 @@
|
||||
"push_configuration": "Configuração de envio",
|
||||
"push_configuration_error": "Erro ao tentar enviar a configuração para o dispositivo: {{e}}",
|
||||
"push_configuration_explanation": "Configuração não enviada, código de erro {{code}}",
|
||||
"push_success": "Configuração enviada com sucesso!",
|
||||
"push_success": "A configuração foi verificada e um comando \"Configure\" foi iniciado pelo controlador!",
|
||||
"radio_limit": "Você atingiu a quantidade máxima de rádios (5). Você precisa excluir uma das bandas ativadas para adicionar uma nova",
|
||||
"radios": "Rádios",
|
||||
"rc_only": "Liberar apenas candidatos",
|
||||
@@ -613,6 +614,7 @@
|
||||
"import_explanation": "Para importar dispositivos em massa, você precisa usar um arquivo CSV com as seguintes colunas: SerialNumber, DeviceType, Name, Description, Note",
|
||||
"invalid_serial_number": "Número de série inválido (precisa ter 12 caracteres HEX)",
|
||||
"new_devices": "novos dispositivos",
|
||||
"no_model_image": "Nenhuma imagem de modelo encontrada",
|
||||
"not_connected": "Não conectado",
|
||||
"not_found_gateway": "Erro: o dispositivo ainda não se conectou ao gateway",
|
||||
"notifications": "Notificações do dispositivo",
|
||||
@@ -700,6 +702,8 @@
|
||||
"invalid_proto_6g": "Este protocolo de criptografia não pode ser usado em um SSID que usa 6G",
|
||||
"invalid_proto_passpoint": "Este protocolo de criptografia não pode ser usado com um SSID de ponto de acesso. Por favor, selecione um protocolo que pode usar Radius",
|
||||
"invalid_select_ports": "Valores incompatíveis entre interfaces! Certifique-se de que não há combinação duplicada de PORT/VLAN ID entre suas interfaces",
|
||||
"invalid_static_ipv4_d": "Endereço inválido, este intervalo está reservado para multicasting (classe D). O primeiro octeto deve ser 223 ou inferior",
|
||||
"invalid_static_ipv4_e": "Endereço inválido, este intervalo é reservado para experimentos (classe E). O primeiro octeto deve ser 223 ou inferior",
|
||||
"invalid_third_party": "String JSON de terceiros inválida. Confirme se seu valor é um JSON válido",
|
||||
"key_file_explanation": "Use um arquivo .pem que comece com \"-----BEGIN PRIVATE KEY-----\" e termine com \"-----END PRIVATE KEY-----\"",
|
||||
"min_max_string": "O valor precisa ter um comprimento entre {{min}} (inclusive) e {{max}} (inclusive)",
|
||||
@@ -811,7 +815,7 @@
|
||||
"level": "Nível",
|
||||
"message": "mensagem",
|
||||
"one": "Registro",
|
||||
"receiving_types": "Tipos de recebimento",
|
||||
"receiving_types": "Filtro de notificações",
|
||||
"security": "SEGURANÇA",
|
||||
"source": "Fonte",
|
||||
"thread": "FIO",
|
||||
@@ -874,6 +878,10 @@
|
||||
"activate": "",
|
||||
"add_new_note": "Adicionar nota",
|
||||
"deactivate": "Desativar",
|
||||
"delete_account": "Excluir meu perfil",
|
||||
"delete_account_confirm": "Excluir todas as minhas informações",
|
||||
"delete_warning": "Esta ação é irreversível. Todas as suas informações de perfil e suas chaves de API serão removidas",
|
||||
"deleted_success": "Seu perfil agora foi excluído, agora vamos desconectar você...",
|
||||
"disabled": "Desativado",
|
||||
"enabled": "ativado",
|
||||
"manage_avatar": "Gerenciar Avatar",
|
||||
@@ -901,18 +909,30 @@
|
||||
"version": "Versão"
|
||||
},
|
||||
"script": {
|
||||
"author": "O Criador",
|
||||
"automatic": "Automático",
|
||||
"create_success": "O script agora está criado e pronto para uso!",
|
||||
"custom_domain": "Domínio personalizado",
|
||||
"deferred": "Diferido",
|
||||
"device_title": "Executar script de dispositivo",
|
||||
"device_title": "Executar script",
|
||||
"diagnostics": "Diagnóstico",
|
||||
"explanation": "Execute um script personalizado neste dispositivo e baixe seus resultados",
|
||||
"file_not_ready": "O resultado ainda não foi carregado, volte mais tarde",
|
||||
"file_too_large": "Selecione um arquivo com menos de 500 KB",
|
||||
"helper": "Documentação",
|
||||
"no_script_available": "Nenhum script disponível para sua função de usuário",
|
||||
"now": "agora",
|
||||
"one": "Roteiro",
|
||||
"other": "Scripts",
|
||||
"restricted": "Usuários autorizados a executar este script",
|
||||
"schedule_success": "Execução de script agendada!",
|
||||
"signature": "Assinatura",
|
||||
"started_execution": "Execução do script iniciada, venha mais tarde para os resultados!",
|
||||
"timeout": "Tempo esgotado",
|
||||
"upload_destination": "Carregar destino",
|
||||
"update_success": "Roteiro atualizado!",
|
||||
"upload_destination": "Destino de upload de resultados",
|
||||
"upload_file": "Subir arquivo",
|
||||
"visit_external_website": "VER DOCUMENTAÇÃO",
|
||||
"when": "Agendar Execução"
|
||||
},
|
||||
"service": {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Button, IconButton, Menu, MenuButton, MenuItem, MenuList, Spinner, Tooltip, useToast } from '@chakra-ui/react';
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Portal,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import axios from 'axios';
|
||||
import { Wrench } from 'phosphor-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -21,6 +32,7 @@ interface Props {
|
||||
onOpenEventQueue: (serialNumber: string) => void;
|
||||
onOpenConfigureModal: (serialNumber: string) => void;
|
||||
onOpenTelemetryModal: (serialNumber: string) => void;
|
||||
onOpenScriptModal: (device: GatewayDevice) => void;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
@@ -35,6 +47,7 @@ const DeviceActionDropdown = ({
|
||||
onOpenEventQueue,
|
||||
onOpenTelemetryModal,
|
||||
onOpenConfigureModal,
|
||||
onOpenScriptModal,
|
||||
size,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -67,6 +80,7 @@ const DeviceActionDropdown = ({
|
||||
const handleOpenQueue = () => onOpenEventQueue(device.serialNumber);
|
||||
const handleOpenConfigure = () => onOpenConfigureModal(device.serialNumber);
|
||||
const handleOpenTelemetry = () => onOpenTelemetryModal(device.serialNumber);
|
||||
const handleOpenScript = () => onOpenScriptModal(device);
|
||||
const handleUpdateToLatest = () => {
|
||||
updateToLatest.mutate(
|
||||
{ keepRedirector: true },
|
||||
@@ -124,7 +138,7 @@ const DeviceActionDropdown = ({
|
||||
},
|
||||
]);
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
onError: (e) => {
|
||||
if (axios.isAxiosError(e)) {
|
||||
toast({
|
||||
id: `upgrade-to-latest-error-${device.serialNumber}`,
|
||||
@@ -143,7 +157,7 @@ const DeviceActionDropdown = ({
|
||||
const handleConnectClick = () => getRtty();
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<Menu preventOverflow>
|
||||
<Tooltip label={t('commands.other')}>
|
||||
{size === undefined ? (
|
||||
<MenuButton
|
||||
@@ -167,21 +181,24 @@ const DeviceActionDropdown = ({
|
||||
</MenuButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
<MenuList>
|
||||
<MenuItem onClick={handleBlinkClick}>{t('commands.blink')}</MenuItem>
|
||||
<MenuItem onClick={handleOpenConfigure}>{t('controller.configure.title')}</MenuItem>
|
||||
<MenuItem onClick={handleConnectClick}>{t('commands.connect')}</MenuItem>
|
||||
<MenuItem onClick={handleOpenQueue}>{t('controller.queue.title')}</MenuItem>
|
||||
<MenuItem onClick={handleOpenFactoryReset}>{t('commands.factory_reset')}</MenuItem>
|
||||
<MenuItem onClick={handleOpenUpgrade}>{t('commands.firmware_upgrade')}</MenuItem>
|
||||
<RebootMenuItem device={device} refresh={refresh} />
|
||||
<MenuItem onClick={handleOpenTelemetry}>{t('controller.telemetry.title')}</MenuItem>
|
||||
<MenuItem onClick={handleOpenTrace}>{t('controller.devices.trace')}</MenuItem>
|
||||
<MenuItem onClick={handleUpdateToLatest} hidden>
|
||||
{t('premium.toolbox.upgrade_to_latest')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleOpenScan}>{t('commands.wifiscan')}</MenuItem>
|
||||
</MenuList>
|
||||
<Portal>
|
||||
<MenuList>
|
||||
<MenuItem onClick={handleBlinkClick}>{t('commands.blink')}</MenuItem>
|
||||
<MenuItem onClick={handleOpenConfigure}>{t('controller.configure.title')}</MenuItem>
|
||||
<MenuItem onClick={handleConnectClick}>{t('commands.connect')}</MenuItem>
|
||||
<MenuItem onClick={handleOpenQueue}>{t('controller.queue.title')}</MenuItem>
|
||||
<MenuItem onClick={handleOpenFactoryReset}>{t('commands.factory_reset')}</MenuItem>
|
||||
<MenuItem onClick={handleOpenUpgrade}>{t('commands.firmware_upgrade')}</MenuItem>
|
||||
<RebootMenuItem device={device} refresh={refresh} />
|
||||
<MenuItem onClick={handleOpenTelemetry}>{t('controller.telemetry.title')}</MenuItem>
|
||||
<MenuItem onClick={handleOpenScript}>{t('script.one')}</MenuItem>
|
||||
<MenuItem onClick={handleOpenTrace}>{t('controller.devices.trace')}</MenuItem>
|
||||
<MenuItem onClick={handleUpdateToLatest} hidden>
|
||||
{t('premium.toolbox.upgrade_to_latest')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleOpenScan}>{t('commands.wifiscan')}</MenuItem>
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Button, IconButton, Tooltip, useBreakpoint } from '@chakra-ui/react';
|
||||
import { Button, IconButton, ThemeTypings, Tooltip, useBreakpoint } from '@chakra-ui/react';
|
||||
import { ArrowsClockwise } from 'phosphor-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface RefreshButtonProps {
|
||||
isCompact?: boolean;
|
||||
ml?: string | number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
colorScheme?: ThemeTypings['colorSchemes'];
|
||||
}
|
||||
|
||||
const _RefreshButton: React.FC<RefreshButtonProps> = ({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { Flex, FlexProps } from '@chakra-ui/react';
|
||||
|
||||
type Props = {
|
||||
interface Props extends FlexProps {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
const IconBox = ({ children, ...rest }: Props) => (
|
||||
<Flex alignItems="center" justifyContent="center" borderRadius="12px" {...rest}>
|
||||
|
||||
@@ -12,9 +12,17 @@ export type ColumnPickerProps = {
|
||||
hiddenColumns: string[];
|
||||
setHiddenColumns: (str: string[]) => void;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
isCompact?: boolean;
|
||||
};
|
||||
|
||||
export const ColumnPicker = ({ preference, columns, hiddenColumns, setHiddenColumns, size }: ColumnPickerProps) => {
|
||||
export const ColumnPicker = ({
|
||||
preference,
|
||||
columns,
|
||||
hiddenColumns,
|
||||
setHiddenColumns,
|
||||
size,
|
||||
isCompact,
|
||||
}: ColumnPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { getPref, setPref } = useAuth();
|
||||
const breakpoint = useBreakpoint();
|
||||
@@ -32,7 +40,7 @@ export const ColumnPicker = ({ preference, columns, hiddenColumns, setHiddenColu
|
||||
setHiddenColumns(savedPrefs ? savedPrefs.split(',') : []);
|
||||
}, []);
|
||||
|
||||
if (breakpoint === 'base' || breakpoint === 'sm') {
|
||||
if (isCompact || breakpoint === 'base' || breakpoint === 'sm') {
|
||||
return (
|
||||
<Menu closeOnSelect={false}>
|
||||
<MenuButton as={IconButton} size={size} icon={<FunnelSimple />} />
|
||||
|
||||
71
src/components/Form/Fields/SignatureField/index.tsx
Normal file
71
src/components/Form/Fields/SignatureField/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
InputGroup,
|
||||
FormErrorMessage,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Text,
|
||||
Radio,
|
||||
Input,
|
||||
} from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFastField } from 'hooks/useFastField';
|
||||
|
||||
export type SignatureFieldProps = {
|
||||
name: string;
|
||||
isDisabled?: boolean;
|
||||
controlProps?: React.ComponentProps<'div'>;
|
||||
};
|
||||
|
||||
const _SignatureField = ({ name, isDisabled, controlProps }: SignatureFieldProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { value, error, isError, onChange } = useFastField<string | undefined>({ name });
|
||||
|
||||
const onRadioChange = (v: string) => {
|
||||
if (v === '0') onChange(undefined);
|
||||
else onChange('');
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl {...controlProps} isInvalid={isError} isRequired isDisabled={isDisabled}>
|
||||
<FormLabel ms={0} mb={0} fontSize="md" fontWeight="normal" _disabled={{ opacity: 0.8 }}>
|
||||
{t('script.signature')}
|
||||
</FormLabel>
|
||||
<Flex h="40px">
|
||||
<RadioGroup onChange={onRadioChange} defaultValue={value === undefined ? '0' : '1'}>
|
||||
<Stack spacing={5} direction="row">
|
||||
<Radio colorScheme="blue" value="0">
|
||||
{t('script.automatic')}
|
||||
</Radio>
|
||||
<Radio colorScheme="green" value="1">
|
||||
<Flex>
|
||||
<Text my="auto" mr={2}>
|
||||
{t('common.custom')}
|
||||
</Text>
|
||||
<InputGroup>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
isDisabled={isDisabled || value === undefined}
|
||||
onClick={(e) => {
|
||||
(e.target as HTMLInputElement).select();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
w="100%"
|
||||
_disabled={{ opacity: 0.8 }}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Flex>
|
||||
</Radio>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</Flex>
|
||||
<FormErrorMessage mt={0}>{error}</FormErrorMessage>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export const SignatureField = React.memo(_SignatureField);
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { Ref, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@@ -12,11 +12,14 @@ import {
|
||||
Switch,
|
||||
Heading,
|
||||
} from '@chakra-ui/react';
|
||||
import { Formik, FormikProps } from 'formik';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import ConfirmIgnoreCommand from '../ConfirmIgnoreCommand';
|
||||
import FirmwareList from './FirmwareList';
|
||||
import { CloseButton } from 'components/Buttons/CloseButton';
|
||||
import { ModalHeader } from 'components/Containers/Modal/ModalHeader';
|
||||
import { SignatureField } from 'components/Form/Fields/SignatureField';
|
||||
import { useGetDevice } from 'hooks/Network/Devices';
|
||||
import { useGetAvailableFirmware, useUpdateDeviceFirmware } from 'hooks/Network/Firmware';
|
||||
import useCommandModal from 'hooks/useCommandModal';
|
||||
@@ -29,6 +32,13 @@ export type FirmwareUpgradeModalProps = {
|
||||
|
||||
export const FirmwareUpgradeModal = ({ modalProps: { isOpen, onClose }, serialNumber }: FirmwareUpgradeModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [formKey, setFormKey] = React.useState(uuid());
|
||||
const ref = useRef<
|
||||
| FormikProps<{
|
||||
signature?: string | undefined;
|
||||
}>
|
||||
| undefined
|
||||
>();
|
||||
const [isRedirector, { toggle }] = useBoolean(false);
|
||||
const { data: device, isFetching: isFetchingDevice } = useGetDevice({ serialNumber, onClose });
|
||||
const { data: firmware, isFetching: isFetchingFirmware } = useGetAvailableFirmware({
|
||||
@@ -44,11 +54,19 @@ export const FirmwareUpgradeModal = ({ modalProps: { isOpen, onClose }, serialNu
|
||||
});
|
||||
|
||||
const submit = (uri: string) => {
|
||||
upgrade({ keepRedirector: isRedirector, uri });
|
||||
upgrade({
|
||||
keepRedirector: isRedirector,
|
||||
uri,
|
||||
signature: device?.restrictedDevice ? ref.current?.values?.signature : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setFormKey(uuid());
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal onClose={closeModal} isOpen={isOpen} size="xl" scrollBehavior="inside">
|
||||
<Modal onClose={closeModal} isOpen={isOpen} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxWidth={{ sm: '90%', md: '900px', lg: '1000px', xl: '80%' }}>
|
||||
<ModalHeader
|
||||
@@ -71,6 +89,19 @@ export const FirmwareUpgradeModal = ({ modalProps: { isOpen, onClose }, serialNu
|
||||
</FormLabel>
|
||||
<Switch isChecked={isRedirector} onChange={toggle} borderRadius="15px" size="lg" />
|
||||
</FormControl>
|
||||
{device?.restrictedDevice && (
|
||||
<Formik<{ signature?: string }>
|
||||
innerRef={ref as Ref<FormikProps<{ signature?: string | undefined }>> | undefined}
|
||||
key={formKey}
|
||||
enableReinitialize
|
||||
initialValues={{
|
||||
signature: undefined,
|
||||
}}
|
||||
onSubmit={() => {}}
|
||||
>
|
||||
<SignatureField name="signature" />
|
||||
</Formik>
|
||||
)}
|
||||
{firmware?.firmwares && (
|
||||
<FirmwareList firmware={firmware.firmwares} upgrade={submit} isLoading={isUpgrading} />
|
||||
)}
|
||||
|
||||
229
src/components/Modals/ScriptModal/Form.tsx
Normal file
229
src/components/Modals/ScriptModal/Form.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import * as React from 'react';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
Divider,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Spacer,
|
||||
Switch,
|
||||
Tag,
|
||||
Text,
|
||||
useClipboard,
|
||||
} from '@chakra-ui/react';
|
||||
import { Form, Formik, FormikProps } from 'formik';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Yup from 'yup';
|
||||
import { NumberField } from '../../Form/Fields/NumberField';
|
||||
import { SelectField } from '../../Form/Fields/SelectField';
|
||||
import { ToggleField } from '../../Form/Fields/ToggleField';
|
||||
import ScriptFileInput from './ScripFile';
|
||||
import ScriptUploadField from './UploadField';
|
||||
import { SignatureField } from 'components/Form/Fields/SignatureField';
|
||||
import { DeviceScriptCommand } from 'hooks/Network/Commands';
|
||||
import { Script } from 'hooks/Network/Scripts';
|
||||
import { GatewayDevice } from 'models/Device';
|
||||
|
||||
const FormSchema = (t: (str: string) => string) =>
|
||||
Yup.object().shape({
|
||||
serialNumber: Yup.string().required(t('form.required')),
|
||||
deferred: Yup.boolean().required(t('form.required')),
|
||||
type: Yup.string().required(t('form.required')),
|
||||
timeout: Yup.number().when('deferred', {
|
||||
is: false,
|
||||
then: Yup.number()
|
||||
.required(t('form.required'))
|
||||
.moreThan(10)
|
||||
.lessThan(5 * 60), // 5 mins
|
||||
}),
|
||||
script: Yup.string().required(t('form.required')),
|
||||
when: Yup.number().required(t('form.required')),
|
||||
uri: Yup.string().min(1, t('form.required')),
|
||||
signature: Yup.string(),
|
||||
});
|
||||
|
||||
const DEFAULT_VALUES = (serialNumber: string) =>
|
||||
({
|
||||
serialNumber,
|
||||
deferred: true,
|
||||
type: 'shell',
|
||||
script: '',
|
||||
when: 0,
|
||||
} as DeviceScriptCommand);
|
||||
|
||||
type Props = {
|
||||
onStart: (data: DeviceScriptCommand) => Promise<void>;
|
||||
formRef: React.Ref<FormikProps<DeviceScriptCommand>>;
|
||||
formKey: string;
|
||||
areFieldsDisabled: boolean;
|
||||
waitForResponse: boolean;
|
||||
onToggleWaitForResponse: () => void;
|
||||
script?: Script;
|
||||
device?: GatewayDevice;
|
||||
isDiagnostics?: boolean;
|
||||
};
|
||||
|
||||
const CustomScriptForm = ({
|
||||
onStart,
|
||||
formRef,
|
||||
formKey,
|
||||
areFieldsDisabled,
|
||||
waitForResponse,
|
||||
onToggleWaitForResponse,
|
||||
device,
|
||||
script,
|
||||
isDiagnostics,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { hasCopied, onCopy, setValue } = useClipboard(script?.content ?? '');
|
||||
|
||||
React.useEffect(() => {
|
||||
setValue(script?.content ?? '');
|
||||
}, [script?.content]);
|
||||
|
||||
return (
|
||||
<Formik
|
||||
innerRef={formRef}
|
||||
enableReinitialize
|
||||
key={formKey}
|
||||
initialValues={
|
||||
script
|
||||
? // @ts-ignore
|
||||
({
|
||||
...script,
|
||||
serialNumber: device?.serialNumber ?? '',
|
||||
script: script.content,
|
||||
uri: script.defaultUploadURI.length === 0 ? undefined : script.defaultUploadURI,
|
||||
when: 0,
|
||||
} as DeviceScriptCommand)
|
||||
: DEFAULT_VALUES(device?.serialNumber ?? '')
|
||||
}
|
||||
validationSchema={isDiagnostics ? undefined : FormSchema(t)}
|
||||
validateOnMount
|
||||
onSubmit={async (data) => onStart(data)}
|
||||
>
|
||||
{(props: { setFieldValue: (k: string, v: unknown) => void; values: DeviceScriptCommand }) => (
|
||||
<Form>
|
||||
<Flex mt={2}>
|
||||
<FormControl w="180px">
|
||||
<FormLabel mb="12px">{t('controller.trace.wait')}</FormLabel>
|
||||
<Switch size="lg" isChecked={waitForResponse} onChange={onToggleWaitForResponse} />
|
||||
</FormControl>
|
||||
<Box w="120px" mr={2} mb={4}>
|
||||
<ToggleField
|
||||
name="deferred"
|
||||
label={t('script.deferred')}
|
||||
onChangeCallback={(v) => {
|
||||
if (v) {
|
||||
setTimeout(() => props.setFieldValue('timeout', undefined), 100);
|
||||
} else {
|
||||
setTimeout(() => props.setFieldValue('timeout', 120), 100);
|
||||
}
|
||||
}}
|
||||
isDisabled={areFieldsDisabled || isDiagnostics}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
{!props.values.deferred && (
|
||||
<NumberField
|
||||
name="timeout"
|
||||
label={t('script.timeout')}
|
||||
isDisabled={areFieldsDisabled}
|
||||
unit="s"
|
||||
isRequired
|
||||
w="100px"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Divider mt={2} mb={4} border="1px" borderColor="gray" />
|
||||
{!isDiagnostics && script && (
|
||||
<>
|
||||
<Flex mt={2}>
|
||||
<Heading size="md" my="auto">
|
||||
{script.name}
|
||||
</Heading>
|
||||
<Tag colorScheme="teal" size="lg" my="auto" mx={2}>
|
||||
{script.type}
|
||||
</Tag>
|
||||
<Tag colorScheme="blue" size="lg" my="auto">
|
||||
{script.author}
|
||||
</Tag>
|
||||
{script.uri.length > 0 && (
|
||||
<Button
|
||||
onClick={() => window.open(script.uri, '_blank')?.focus()}
|
||||
colorScheme="blue"
|
||||
variant="link"
|
||||
rightIcon={<ExternalLinkIcon />}
|
||||
ml={2}
|
||||
>
|
||||
{t('script.helper')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
<Text fontStyle="italic" mt={2}>
|
||||
{script.description}
|
||||
</Text>
|
||||
<Text mt={2}>
|
||||
{t('script.upload_destination')}:{' '}
|
||||
<b>{script.defaultUploadURI === '' ? t('script.automatic') : script.defaultUploadURI}</b>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{!script && (
|
||||
<Box mt={2}>
|
||||
<ScriptUploadField isDisabled={areFieldsDisabled || script !== undefined} />
|
||||
</Box>
|
||||
)}
|
||||
{!isDiagnostics && (
|
||||
<>
|
||||
<Flex>
|
||||
<Box>
|
||||
{device?.restrictedDevice && <SignatureField name="signature" isDisabled={areFieldsDisabled} />}
|
||||
</Box>
|
||||
</Flex>
|
||||
<SelectField
|
||||
name="type"
|
||||
label={t('common.type')}
|
||||
options={[
|
||||
{ value: 'shell', label: 'Shell' },
|
||||
{ value: 'bundle', label: 'Bundle' },
|
||||
]}
|
||||
isRequired
|
||||
isDisabled={areFieldsDisabled || script !== undefined}
|
||||
isHidden={script !== undefined}
|
||||
w="120px"
|
||||
/>
|
||||
{script && (
|
||||
<Flex>
|
||||
<Heading my="auto" size="sm">
|
||||
{t('script.one')}
|
||||
</Heading>
|
||||
<Spacer />
|
||||
<Button onClick={onCopy} size="sm" colorScheme="teal">
|
||||
{hasCopied ? t('common.copied') : t('common.copy')}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
<Box>
|
||||
{script ? (
|
||||
<Code whiteSpace="pre-line" w="100%" mt={2}>
|
||||
{script.content.replace(/^\n/, '')}
|
||||
</Code>
|
||||
) : (
|
||||
<ScriptFileInput isDisabled={areFieldsDisabled || script !== undefined} />
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomScriptForm;
|
||||
41
src/components/Modals/ScriptModal/LockedView.tsx
Normal file
41
src/components/Modals/ScriptModal/LockedView.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Code, Flex, Heading, Tag, Text } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetAllDeviceScripts } from 'hooks/Network/Scripts';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const ScriptLockedView = ({ id }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const getScripts = useGetAllDeviceScripts();
|
||||
|
||||
const script = getScripts?.data?.find((curr) => curr.id === id);
|
||||
|
||||
if (!script) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex>
|
||||
<Heading size="md" my="auto">
|
||||
{script.name}
|
||||
</Heading>
|
||||
<Tag colorScheme="teal" size="lg" my="auto" mx={2}>
|
||||
{script.type}
|
||||
</Tag>
|
||||
</Flex>
|
||||
<Text fontStyle="italic" mt={2}>
|
||||
{script.description}
|
||||
</Text>
|
||||
<Text mt={2}>
|
||||
{t('common.by')}: <b>{script.author}</b>
|
||||
</Text>
|
||||
<Code whiteSpace="pre-line" w="100%" mt={2}>
|
||||
{script.content.replace(/^\n/, '')}
|
||||
</Code>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptLockedView;
|
||||
41
src/components/Modals/ScriptModal/ResultDisplay.tsx
Normal file
41
src/components/Modals/ScriptModal/ResultDisplay.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Button, Center, Code } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DeviceCommandHistory, useDownloadScriptResult } from 'hooks/Network/Commands';
|
||||
|
||||
type Props = {
|
||||
result: DeviceCommandHistory;
|
||||
};
|
||||
|
||||
const ScriptResultDisplay = ({ result }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const download = useDownloadScriptResult({ serialNumber: result.serialNumber, commandId: result.UUID });
|
||||
|
||||
const onDownload = () => {
|
||||
download.refetch();
|
||||
};
|
||||
|
||||
if (result.details?.uri !== undefined) {
|
||||
return (
|
||||
<Center my="100px">
|
||||
<Button
|
||||
onClick={onDownload}
|
||||
colorScheme="blue"
|
||||
isLoading={download.isFetching}
|
||||
isDisabled={result.waitingForFile === 1}
|
||||
>
|
||||
{result.waitingForFile === 0 ? t('common.download') : t('script.file_not_ready')}
|
||||
</Button>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Box maxH="500px" overflowY="auto" mt={2}>
|
||||
<Code whiteSpace="pre-line">{result.results?.status?.result ?? JSON.stringify(result.results, null, 2)}</Code>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptResultDisplay;
|
||||
107
src/components/Modals/ScriptModal/ScripFile.tsx
Normal file
107
src/components/Modals/ScriptModal/ScripFile.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
InputGroup,
|
||||
Text,
|
||||
Textarea,
|
||||
useBoolean,
|
||||
} from '@chakra-ui/react';
|
||||
import { UploadSimple } from 'phosphor-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { useFastField } from 'hooks/useFastField';
|
||||
|
||||
type Props = {
|
||||
isDisabled: boolean;
|
||||
};
|
||||
const ScriptFileInput = ({ isDisabled }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [fileKey, setFileKey] = React.useState(uuid());
|
||||
const fileInputRef = React.useRef<HTMLInputElement>();
|
||||
const { onChange, error, isError, value, onBlur } = useFastField<string | undefined>({ name: 'script' });
|
||||
const [isTooLarge, { on, off }] = useBoolean();
|
||||
|
||||
let fileReader: FileReader | undefined;
|
||||
|
||||
const handleStringFileRead = () => {
|
||||
if (fileReader) {
|
||||
const content = fileReader.result;
|
||||
if (content) {
|
||||
setFileKey(uuid());
|
||||
onChange(content as string);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
if (fileInputRef.current) fileInputRef?.current?.click();
|
||||
};
|
||||
|
||||
const changeFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files ? e.target.files[0] : undefined;
|
||||
off();
|
||||
|
||||
// File has to be under 2MB
|
||||
if (file && file.size < 500 * 1024) {
|
||||
fileReader = new FileReader();
|
||||
fileReader.onloadend = handleStringFileRead;
|
||||
fileReader.readAsText(file);
|
||||
} else {
|
||||
on();
|
||||
}
|
||||
};
|
||||
|
||||
const onInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={isError} isDisabled={isDisabled} mt={2}>
|
||||
<FormLabel ms="4px" fontSize="md" fontWeight="normal" _disabled={{ opacity: 0.8 }} display="flex">
|
||||
<Text my="auto">{t('script.one')}</Text>
|
||||
<Flex>
|
||||
<Input
|
||||
borderRadius="15px"
|
||||
pt={1}
|
||||
fontSize="sm"
|
||||
type="file"
|
||||
onChange={changeFile}
|
||||
key={fileKey}
|
||||
isDisabled={isDisabled}
|
||||
w="300px"
|
||||
mb={2}
|
||||
ref={fileInputRef as React.LegacyRef<HTMLInputElement> | undefined}
|
||||
hidden
|
||||
/>
|
||||
<Button onClick={handleUploadClick} rightIcon={<UploadSimple />} size="sm" my="auto" ml={2}>
|
||||
{t('script.upload_file')}
|
||||
</Button>
|
||||
{isTooLarge && (
|
||||
<Text ml={2} fontWeight="bold" textColor="red" my="auto">
|
||||
{t('script.file_too_large')}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</FormLabel>
|
||||
<InputGroup size="md">
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={onInputChange}
|
||||
onBlur={onBlur}
|
||||
borderRadius="15px"
|
||||
fontSize="sm"
|
||||
minH="200px"
|
||||
_disabled={{ opacity: 0.8, cursor: 'not-allowed' }}
|
||||
/>
|
||||
</InputGroup>
|
||||
<FormErrorMessage>{error}</FormErrorMessage>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptFileInput;
|
||||
72
src/components/Modals/ScriptModal/UploadField.tsx
Normal file
72
src/components/Modals/ScriptModal/UploadField.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
InputGroup,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFastField } from 'hooks/useFastField';
|
||||
|
||||
type Props = {
|
||||
isDisabled: boolean;
|
||||
};
|
||||
const ScriptUploadField = ({ isDisabled }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { value, onChange, isError, error } = useFastField<string | undefined>({ name: 'uri' });
|
||||
|
||||
const onRadioChange = (v: string) => {
|
||||
if (v === '0') onChange(undefined);
|
||||
else onChange('');
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={isError} isRequired isDisabled={isDisabled}>
|
||||
<FormLabel ms={0} mb={0} fontSize="md" fontWeight="normal" _disabled={{ opacity: 0.8 }}>
|
||||
{t('script.upload_destination')}
|
||||
</FormLabel>
|
||||
<Flex h="40px">
|
||||
<RadioGroup
|
||||
onChange={onRadioChange}
|
||||
defaultValue={value === undefined ? '0' : '1'}
|
||||
_disabled={{ opacity: 0.8 }}
|
||||
>
|
||||
<Stack spacing={5} direction="row">
|
||||
<Radio colorScheme="blue" value="0">
|
||||
{t('script.automatic')}
|
||||
</Radio>
|
||||
<Radio colorScheme="green" value="1">
|
||||
<Flex>
|
||||
<Text my="auto" mr={2} w="180px">
|
||||
{t('script.custom_domain')}
|
||||
</Text>
|
||||
<InputGroup>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
isDisabled={isDisabled || value === undefined}
|
||||
onClick={(e) => {
|
||||
e.target.select();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
w="100%"
|
||||
_disabled={{ opacity: 0.8 }}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Flex>
|
||||
</Radio>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</Flex>
|
||||
<FormErrorMessage>{error}</FormErrorMessage>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptUploadField;
|
||||
73
src/components/Modals/ScriptModal/WhenField.tsx
Normal file
73
src/components/Modals/ScriptModal/WhenField.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
InputGroup,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DateTimePicker from '../../DatePickers/DateTimePicker';
|
||||
import { useFastField } from 'hooks/useFastField';
|
||||
|
||||
type Props = {
|
||||
isDisabled: boolean;
|
||||
};
|
||||
const ScriptWhenField = ({ isDisabled }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { value, onChange, isError, error } = useFastField<number>({ name: 'when' });
|
||||
|
||||
const onRadioChange = (v: string) => {
|
||||
if (v === '0') onChange(0);
|
||||
else onChange(Math.floor(new Date().getTime() / 1000));
|
||||
};
|
||||
|
||||
const onDateChange = (v: Date | null) => {
|
||||
if (v) onChange(Math.floor(v.getTime() / 1000));
|
||||
};
|
||||
|
||||
const tempDate = () => {
|
||||
if (!value || value === 0) return new Date();
|
||||
|
||||
return new Date(value * 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={isError} isRequired isDisabled={isDisabled}>
|
||||
<FormLabel ms={0} mb={0} fontSize="md" fontWeight="normal" _disabled={{ opacity: 0.8 }}>
|
||||
{t('script.when')}
|
||||
</FormLabel>
|
||||
<Flex h="40px">
|
||||
<RadioGroup onChange={onRadioChange} defaultValue={value === 0 ? '0' : '1'}>
|
||||
<Stack spacing={5} direction="row">
|
||||
<Radio colorScheme="blue" value="0">
|
||||
{t('script.now')}
|
||||
</Radio>
|
||||
<Radio colorScheme="green" value="1">
|
||||
<Flex>
|
||||
<Text my="auto" mr={2}>
|
||||
{t('common.custom')}
|
||||
</Text>
|
||||
<InputGroup>
|
||||
<DateTimePicker
|
||||
date={tempDate()}
|
||||
isStart
|
||||
onChange={onDateChange}
|
||||
isDisabled={!value || value === 0 || isDisabled}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Flex>
|
||||
</Radio>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</Flex>
|
||||
<FormErrorMessage>{error}</FormErrorMessage>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptWhenField;
|
||||
227
src/components/Modals/ScriptModal/index.tsx
Normal file
227
src/components/Modals/ScriptModal/index.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Select,
|
||||
Spinner,
|
||||
useBoolean,
|
||||
useClipboard,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { FormikProps } from 'formik';
|
||||
import { ArrowLeft } from 'phosphor-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import ConfirmIgnoreCommand from '../ConfirmIgnoreCommand';
|
||||
import { Modal } from '../Modal';
|
||||
import CustomScriptForm from './Form';
|
||||
import ScriptResultDisplay from './ResultDisplay';
|
||||
import { useAuth } from 'contexts/AuthProvider';
|
||||
import { DeviceScriptCommand, useDeviceScript } from 'hooks/Network/Commands';
|
||||
import { useGetAllDeviceScripts } from 'hooks/Network/Scripts';
|
||||
import useCommandModal from 'hooks/useCommandModal';
|
||||
import { useFormRef } from 'hooks/useFormRef';
|
||||
import { GatewayDevice } from 'models/Device';
|
||||
|
||||
export type ScriptModalProps = {
|
||||
device?: GatewayDevice;
|
||||
modalProps: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
export const ScriptModal = ({ device, modalProps }: ScriptModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { user } = useAuth();
|
||||
const getScripts = useGetAllDeviceScripts();
|
||||
const [selectedScript, setSelectedScript] = React.useState('');
|
||||
const queryClient = useQueryClient();
|
||||
const [formKey, setFormKey] = React.useState(uuid());
|
||||
const doScript = useDeviceScript({ serialNumber: device?.serialNumber ?? '' });
|
||||
const { onCopy, hasCopied, setValue } = useClipboard('');
|
||||
const { form, formRef } = useFormRef();
|
||||
const { isConfirmOpen, closeConfirm, closeModal, closeCancelAndForm } = useCommandModal({
|
||||
isLoading: doScript.isLoading,
|
||||
onModalClose: modalProps.onClose,
|
||||
});
|
||||
const [waitForResponse, { toggle: onToggleWaitForResponse }] = useBoolean();
|
||||
const role = user?.userRole;
|
||||
|
||||
const onStart = async (data: DeviceScriptCommand & { defaultUploadURI?: string }) => {
|
||||
let requestData: {
|
||||
[k: string]: unknown;
|
||||
serialNumber: string;
|
||||
timeout?: number | undefined;
|
||||
} = data;
|
||||
if (selectedScript === 'diagnostics') {
|
||||
requestData = {
|
||||
serialNumber: device?.serialNumber ?? '',
|
||||
when: 0,
|
||||
deferred: true,
|
||||
uri: data.defaultUploadURI && data.defaultUploadURI?.length > 0 ? data.defaultUploadURI : undefined,
|
||||
type: 'diagnostic',
|
||||
};
|
||||
} else if (selectedScript !== '') {
|
||||
requestData = {
|
||||
serialNumber: device?.serialNumber ?? '',
|
||||
when: 0,
|
||||
deferred: data.deferred,
|
||||
timeout: data.timeout,
|
||||
signature: device?.restrictedDevice ? data.signature : undefined,
|
||||
uri: data.defaultUploadURI && data.defaultUploadURI?.length > 0 ? data.defaultUploadURI : undefined,
|
||||
scriptId: selectedScript,
|
||||
type: data.type,
|
||||
};
|
||||
}
|
||||
|
||||
doScript.mutate(requestData, {
|
||||
onSuccess: (response) => {
|
||||
setValue(response.results?.status?.result ?? JSON.stringify(response.results ?? {}, null, 2));
|
||||
queryClient.invalidateQueries(['commands', device?.serialNumber ?? '']);
|
||||
},
|
||||
});
|
||||
if (!waitForResponse) {
|
||||
toast({
|
||||
id: 'script-update-success',
|
||||
title: t('common.success'),
|
||||
description: t('script.started_execution'),
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
closeCancelAndForm();
|
||||
}
|
||||
};
|
||||
|
||||
const options = React.useMemo(() => {
|
||||
if (!role) return [];
|
||||
return getScripts.data?.filter((curr) => curr.restricted.includes(role ?? '')) ?? [];
|
||||
}, [role, getScripts.data]);
|
||||
|
||||
const areFieldsDisabled = doScript.isLoading || !device;
|
||||
|
||||
const display = () => {
|
||||
if (doScript.isLoading)
|
||||
return (
|
||||
<Center my="100px">
|
||||
<Spinner size="xl" />
|
||||
</Center>
|
||||
);
|
||||
if (doScript.data) return <ScriptResultDisplay result={doScript.data} />;
|
||||
if (doScript.error)
|
||||
return (
|
||||
<Alert mb={6} status="error">
|
||||
<AlertIcon />
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{axios.isAxiosError(doScript.error) && doScript.error?.response?.data?.ErrorDescription}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
if (!doScript.isLoading && !doScript.data)
|
||||
return (
|
||||
<>
|
||||
{role === 'root' || options.length > 0 ? (
|
||||
<Select
|
||||
value={selectedScript}
|
||||
onChange={(e) => {
|
||||
setSelectedScript(e.target.value);
|
||||
setFormKey(uuid());
|
||||
}}
|
||||
w="max-content"
|
||||
>
|
||||
<option value="">
|
||||
{role === 'root' ? `${t('common.custom')} ${t('script.one')}` : t('common.none')}
|
||||
</option>
|
||||
<option value="diagnostics">{t('script.diagnostics')}</option>
|
||||
{options
|
||||
?.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((curr) => (
|
||||
<option value={curr.id} key={curr.id}>
|
||||
{curr.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Center mt={2}>
|
||||
<Alert status="error" w="max-content">
|
||||
<AlertIcon />
|
||||
<AlertDescription>{t('script.no_script_available')}</AlertDescription>
|
||||
</Alert>
|
||||
</Center>
|
||||
)}
|
||||
{device && (role === 'root' || selectedScript !== '') && (
|
||||
<CustomScriptForm
|
||||
onStart={onStart}
|
||||
formKey={formKey}
|
||||
formRef={formRef as React.Ref<FormikProps<DeviceScriptCommand>>}
|
||||
waitForResponse={waitForResponse}
|
||||
onToggleWaitForResponse={onToggleWaitForResponse}
|
||||
areFieldsDisabled={areFieldsDisabled}
|
||||
device={device}
|
||||
isDiagnostics={selectedScript === 'diagnostics'}
|
||||
script={selectedScript !== '' ? getScripts.data?.find((curr) => curr.id === selectedScript) : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setFormKey(uuid());
|
||||
doScript.reset();
|
||||
setSelectedScript('');
|
||||
}, [device, modalProps.isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
{...modalProps}
|
||||
onClose={closeModal}
|
||||
title={t('script.device_title')}
|
||||
topRightButtons={
|
||||
doScript.data ? (
|
||||
<>
|
||||
<Button onClick={onCopy} size="md" colorScheme="teal">
|
||||
{hasCopied ? `${t('common.copied')}!` : t('common.copy')}
|
||||
</Button>
|
||||
<Button rightIcon={<ArrowLeft />} onClick={doScript.reset}>
|
||||
{t('common.go_back')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={form.submitForm}
|
||||
isDisabled={!form.isValid || (role !== 'root' && selectedScript === '')}
|
||||
isLoading={doScript.isLoading || form.isSubmitting}
|
||||
>
|
||||
{t('common.start')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{
|
||||
// @ts-ignore
|
||||
<Box>{display()}</Box>
|
||||
}
|
||||
</Modal>
|
||||
<ConfirmIgnoreCommand
|
||||
modalProps={{ isOpen: isConfirmOpen, onOpen: () => {}, onClose: closeConfirm }}
|
||||
confirm={closeCancelAndForm}
|
||||
cancel={closeConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
30
src/components/Modals/ScriptModal/useScriptModal.tsx
Normal file
30
src/components/Modals/ScriptModal/useScriptModal.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import { useDisclosure, UseDisclosureProps } from '@chakra-ui/react';
|
||||
import { ScriptModal } from '.';
|
||||
import { GatewayDevice } from 'models/Device';
|
||||
|
||||
export type UseScriptModalReturn = {
|
||||
modalProps: UseDisclosureProps;
|
||||
device: GatewayDevice;
|
||||
openModal: (device: GatewayDevice) => void;
|
||||
};
|
||||
|
||||
export const useScriptModal = () => {
|
||||
const [device, setDevice] = React.useState<GatewayDevice>();
|
||||
const modalProps = useDisclosure();
|
||||
|
||||
const openModal = (newDevice: GatewayDevice) => {
|
||||
setDevice(newDevice);
|
||||
modalProps.onOpen();
|
||||
};
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
modalProps,
|
||||
device,
|
||||
openModal,
|
||||
modal: <ScriptModal device={device} modalProps={modalProps} />,
|
||||
}),
|
||||
[device, modalProps.isOpen],
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const COLORS = [
|
||||
'#63b598',
|
||||
'#ce7d78',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const getRevision = (str?: string) => {
|
||||
if (!str) return '-';
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { axiosGw } from 'constants/axiosInstances';
|
||||
import { AxiosError } from 'models/Axios';
|
||||
|
||||
@@ -8,12 +11,21 @@ export type DeviceCommandHistory = {
|
||||
command: string;
|
||||
completed: number;
|
||||
custom: number;
|
||||
details: Record<string, unknown>;
|
||||
details?: {
|
||||
uri?: string;
|
||||
};
|
||||
errorCode: number;
|
||||
errorText: string;
|
||||
executed: number;
|
||||
executionTime: number;
|
||||
results: Record<string, unknown>;
|
||||
results?: {
|
||||
serial?: string;
|
||||
uuid?: string;
|
||||
status?: {
|
||||
error?: number;
|
||||
result?: string;
|
||||
};
|
||||
};
|
||||
serialNumber: string;
|
||||
status: string;
|
||||
submitted: number;
|
||||
@@ -124,3 +136,99 @@ export const useConfigureDevice = ({ serialNumber }: { serialNumber: string }) =
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export type DeviceScriptCommand =
|
||||
| {
|
||||
serialNumber: string;
|
||||
deferred: true;
|
||||
type: 'shell' | 'bundle';
|
||||
timeout?: undefined;
|
||||
script: string;
|
||||
when: number;
|
||||
signature?: string;
|
||||
uri?: string;
|
||||
}
|
||||
| {
|
||||
serialNumber: string;
|
||||
deferred: false;
|
||||
type: 'shell' | 'bundle';
|
||||
timeout: number;
|
||||
script: string;
|
||||
when: number;
|
||||
signature?: string;
|
||||
uri?: string;
|
||||
};
|
||||
|
||||
export type DeviceScriptResponse = {
|
||||
UUID: string;
|
||||
attachFile: number;
|
||||
command: 'script';
|
||||
completed: number;
|
||||
custom: number;
|
||||
details: DeviceScriptCommand;
|
||||
errorCode: number;
|
||||
errorText: string;
|
||||
executed: number;
|
||||
executionTime: number;
|
||||
results: {
|
||||
serial: string;
|
||||
status: {
|
||||
error: number;
|
||||
resultCode: number;
|
||||
resultText: string;
|
||||
text: string;
|
||||
};
|
||||
};
|
||||
serialNumber: string;
|
||||
status: string;
|
||||
submitted: number;
|
||||
submittedBy: string;
|
||||
waitingForFile: number;
|
||||
when: number;
|
||||
};
|
||||
|
||||
const startScript = (data: { serialNumber: string; timeout?: number; [k: string]: unknown }) =>
|
||||
axiosGw
|
||||
.post<DeviceScriptResponse>(`device/${data.serialNumber}/script`, data, {
|
||||
timeout: data.timeout ? data.timeout * 1000 + 10 : 5 * 60 * 1000,
|
||||
})
|
||||
.then((response: { data: DeviceCommandHistory }) => response.data);
|
||||
export const useDeviceScript = ({ serialNumber }: { serialNumber: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(startScript, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['commands', serialNumber]);
|
||||
},
|
||||
onError: (e) => {
|
||||
if (axios.isAxiosError(e)) {
|
||||
toast({
|
||||
id: 'script-error',
|
||||
title: t('common.error'),
|
||||
description: e?.response?.data?.ErrorDescription,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const downloadScript = (serialNumber: string, commandId: string) =>
|
||||
axiosGw.get(`file/${commandId}?serialNumber=${serialNumber}`, { responseType: 'arraybuffer' });
|
||||
|
||||
export const useDownloadScriptResult = ({ serialNumber, commandId }: { serialNumber: string; commandId: string }) =>
|
||||
useQuery(['download-script', serialNumber, commandId], () => downloadScript(serialNumber, commandId), {
|
||||
enabled: false,
|
||||
onSuccess: (response) => {
|
||||
const blob = new Blob([response.data], { type: 'application/octet-stream' });
|
||||
const link = document.createElement('a');
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = `Script_${commandId}.tar.gz`;
|
||||
link.click();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,8 +55,8 @@ export const useUpdateDeviceFirmware = ({ serialNumber, onClose }: { serialNumbe
|
||||
const toast = useToast();
|
||||
|
||||
return useMutation(
|
||||
({ keepRedirector, uri }: { keepRedirector: boolean; uri: string }) =>
|
||||
axiosGw.post(`device/${serialNumber}/upgrade`, { serialNumber, when: 0, keepRedirector, uri }),
|
||||
({ keepRedirector, uri, signature }: { keepRedirector: boolean; uri: string; signature?: string }) =>
|
||||
axiosGw.post(`device/${serialNumber}/upgrade`, { serialNumber, when: 0, keepRedirector, uri, signature }),
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { axiosProv } from 'constants/axiosInstances';
|
||||
import { AxiosError } from 'models/Axios';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { axiosSec } from '../../constants/axiosInstances';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const useGetRequirements = () =>
|
||||
useQuery(['get-requirements'], () => axiosSec.post('oauth2?requirements=true', {}).then(({ data }) => data), {
|
||||
staleTime: Infinity,
|
||||
|
||||
101
src/hooks/Network/Scripts.ts
Normal file
101
src/hooks/Network/Scripts.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { axiosGw } from 'constants/axiosInstances';
|
||||
import { UserRole } from 'models/User';
|
||||
|
||||
export type Script = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
uri: string;
|
||||
defaultUploadURI: string;
|
||||
content: string;
|
||||
version: string;
|
||||
deferred: boolean;
|
||||
timeout: number;
|
||||
type: 'shell' | 'bundle';
|
||||
created: number;
|
||||
modified: number;
|
||||
author: string;
|
||||
restricted: UserRole[];
|
||||
};
|
||||
|
||||
type ScriptResponse = {
|
||||
scripts: Script[];
|
||||
};
|
||||
|
||||
const getScripts = (limit: number, offset: number) =>
|
||||
axiosGw.get(`scripts?limit=${limit}&offset=${offset}`).then((response) => response.data as ScriptResponse);
|
||||
|
||||
const getAllScripts = async () => {
|
||||
let offset = 0;
|
||||
let scripts: Script[] = [];
|
||||
let response: ScriptResponse;
|
||||
do {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
response = await getScripts(100, offset);
|
||||
scripts = scripts.concat(response.scripts);
|
||||
offset += 100;
|
||||
} while (response.scripts.length === 500);
|
||||
return scripts;
|
||||
};
|
||||
|
||||
export const useGetAllDeviceScripts = () =>
|
||||
useQuery(['deviceScripts', 'all'], getAllScripts, {
|
||||
staleTime: 30000,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const getScript = (id: string) => axiosGw.get(`script/${id}`).then((response) => response.data as Script);
|
||||
export const useGetDeviceScript = ({ id }: { id?: string }) =>
|
||||
useQuery(['deviceScript', id], () => getScript(id ?? ''), {
|
||||
enabled: !!id,
|
||||
staleTime: 30000,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const deleteScript = async (id: string) => axiosGw.delete(`script/${id}`);
|
||||
export const useDeleteScript = ({ id }: { id?: string }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(deleteScript, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['deviceScripts']);
|
||||
queryClient.invalidateQueries(['deviceScript', id]);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateScript = async (data: {
|
||||
id: string;
|
||||
description?: string;
|
||||
name?: string;
|
||||
uri?: string;
|
||||
content?: string;
|
||||
defaultUploadURI?: string;
|
||||
version?: string;
|
||||
deferred?: boolean;
|
||||
timeout?: number;
|
||||
}) => axiosGw.put(`script/${data.id}`, data).then((response) => response.data as Script);
|
||||
export const useUpdateScript = ({ id }: { id?: string }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(updateScript, {
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries(['deviceScripts']);
|
||||
queryClient.setQueryData(['deviceScript', id], response);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createScript = async (data: Partial<Script>) =>
|
||||
axiosGw.post(`script/0`, data).then((response) => response.data as Script);
|
||||
export const useCreateScript = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(createScript, {
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries(['deviceScripts']);
|
||||
queryClient.setQueryData(['deviceScript', response.id], response);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { axiosGw } from 'constants/axiosInstances';
|
||||
import { AxiosError } from 'models/Axios';
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from 'react';
|
||||
import { secUrl } from '../constants/axiosInstances';
|
||||
import { useGetRequirements } from './Network/Requirements';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const useApiRequirements = () => {
|
||||
const { data: requirements } = useGetRequirements();
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useAuth } from 'contexts/AuthProvider';
|
||||
|
||||
export const useEndpointStatus = (endpoint: string) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface useFastFieldProps {
|
||||
|
||||
const _useFastField = <Type>({ name }: useFastFieldProps) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [{ value }, { touched, error }, { setValue, setTouched }] = useField(name);
|
||||
const [{ value }, { touched, error }, { setValue, setTouched }] = useField<Type>(name);
|
||||
|
||||
const onChange = useCallback((newValue: Type) => {
|
||||
setValue(newValue, true);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Ref, useCallback, useMemo, useState } from 'react';
|
||||
import { FormikProps } from 'formik';
|
||||
import { FormType } from '../models/Form';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const useFormRef = () => {
|
||||
const [form, setForm] = useState<FormType>({
|
||||
submitForm: () => {},
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box, useStyleConfig } from '@chakra-ui/react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const PanelContainer: React.FC<Props> = ({ children }) => {
|
||||
const styles = useStyleConfig('PanelContainer');
|
||||
// Pass the computed styles into the `__css` prop
|
||||
return <Box __css={styles}>{children}</Box>;
|
||||
};
|
||||
|
||||
export default PanelContainer;
|
||||
@@ -1,14 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box, useStyleConfig } from '@chakra-ui/react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const PanelContent: React.FC<Props> = ({ children }) => {
|
||||
const styles = useStyleConfig('PanelContent');
|
||||
|
||||
return <Box __css={styles}>{children}</Box>;
|
||||
};
|
||||
|
||||
export default PanelContent;
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, LayoutProps, useStyleConfig } from '@chakra-ui/react';
|
||||
|
||||
interface Props extends LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const MainPanel: React.FC<Props> = ({ children, ...props }) => {
|
||||
const styles = useStyleConfig('MainPanel');
|
||||
|
||||
return (
|
||||
<Box __css={styles} mb="16px" {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainPanel;
|
||||
@@ -16,69 +16,51 @@ import {
|
||||
IconButton,
|
||||
Tooltip,
|
||||
useBreakpoint,
|
||||
Portal,
|
||||
} from '@chakra-ui/react';
|
||||
import { ArrowCircleLeft } from 'phosphor-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import LanguageSwitcher from 'components/LanguageSwitcher';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from 'contexts/AuthProvider';
|
||||
import routes from 'router/routes';
|
||||
|
||||
interface Props {
|
||||
secondary: boolean;
|
||||
export type NavbarProps = {
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
activeRoute?: string;
|
||||
languageSwitcher?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Navbar: React.FC<Props> = ({ secondary, toggleSidebar }) => {
|
||||
export const Navbar = ({ toggleSidebar, activeRoute, languageSwitcher }: NavbarProps) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const breakpoint = useBreakpoint();
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const { logout, user, avatar } = useAuth();
|
||||
const getActiveRoute = () => {
|
||||
const route = routes.find(
|
||||
(r) => r.path === location.pathname || location.pathname.split('/')[1] === r.path.split('/')[1],
|
||||
);
|
||||
|
||||
if (route) return route.navName ?? route.name;
|
||||
const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md';
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
// Style variables
|
||||
let navbarPosition: 'absolute' | 'fixed' = 'absolute';
|
||||
let navbarFilter = 'none';
|
||||
let navbarBackdrop = 'blur(21px)';
|
||||
let navbarShadow = 'none';
|
||||
let navbarBg = 'none';
|
||||
let navbarBorder = 'transparent';
|
||||
let secondaryMargin = '0px';
|
||||
|
||||
// Values if scrolled
|
||||
const scrolledNavbarShadow = useColorModeValue('0px 7px 23px rgba(0, 0, 0, 0.05)', 'none');
|
||||
const scrolledNavbarBg = useColorModeValue(
|
||||
const boxShadow = useColorModeValue('0px 7px 23px rgba(0, 0, 0, 0.05)', 'none');
|
||||
const bg = useColorModeValue(
|
||||
'linear-gradient(112.83deg, rgba(255, 255, 255, 0.82) 0%, rgba(255, 255, 255, 0.8) 110.84%)',
|
||||
'linear-gradient(112.83deg, rgba(255, 255, 255, 0.21) 0%, rgba(255, 255, 255, 0) 110.84%)',
|
||||
);
|
||||
const scrolledNavbarBorder = useColorModeValue('#FFFFFF', 'rgba(255, 255, 255, 0.31)');
|
||||
const scrolledNavbarFilter = useColorModeValue('none', 'drop-shadow(0px 7px 23px rgba(0, 0, 0, 0.05))');
|
||||
const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md';
|
||||
|
||||
if (scrolled === true) {
|
||||
navbarPosition = 'fixed';
|
||||
navbarShadow = scrolledNavbarShadow;
|
||||
navbarBg = scrolledNavbarBg;
|
||||
navbarBorder = scrolledNavbarBorder;
|
||||
navbarFilter = scrolledNavbarFilter;
|
||||
}
|
||||
|
||||
if (secondary) {
|
||||
navbarBackdrop = 'none';
|
||||
navbarPosition = 'absolute';
|
||||
secondaryMargin = '22px';
|
||||
}
|
||||
const borderColor = useColorModeValue('#FFFFFF', 'rgba(255, 255, 255, 0.31)');
|
||||
const filter = useColorModeValue('none', 'drop-shadow(0px 7px 23px rgba(0, 0, 0, 0.05))');
|
||||
const scrollDependentStyles = scrolled
|
||||
? ({
|
||||
position: 'fixed',
|
||||
boxShadow,
|
||||
bg,
|
||||
borderColor,
|
||||
filter,
|
||||
} as const)
|
||||
: ({
|
||||
position: 'absolute',
|
||||
filter: 'none',
|
||||
boxShadow: 'none',
|
||||
bg: 'none',
|
||||
borderColor: 'transparent',
|
||||
} as const);
|
||||
|
||||
const goBack = () => navigate(-1);
|
||||
|
||||
@@ -95,84 +77,76 @@ const Navbar: React.FC<Props> = ({ secondary, toggleSidebar }) => {
|
||||
window.addEventListener('scroll', changeNavbar);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
position={navbarPosition}
|
||||
boxShadow={navbarShadow}
|
||||
bg={navbarBg}
|
||||
borderColor={navbarBorder}
|
||||
filter={navbarFilter}
|
||||
backdropFilter={navbarBackdrop}
|
||||
borderWidth="1.5px"
|
||||
borderStyle="solid"
|
||||
transitionDelay="0s, 0s, 0s, 0s"
|
||||
transitionDuration=" 0.25s, 0.25s, 0.25s, 0s"
|
||||
transition-property="box-shadow, background-color, filter, border"
|
||||
transitionTimingFunction="linear, linear, linear, linear"
|
||||
alignItems="center"
|
||||
borderRadius="16px"
|
||||
display="flex"
|
||||
minH="75px"
|
||||
justifyContent="center"
|
||||
lineHeight="25.6px"
|
||||
mx="auto"
|
||||
mt={secondaryMargin}
|
||||
pb="8px"
|
||||
right={{ base: '0px', sm: '0px' }}
|
||||
pl="30px"
|
||||
ps="12px"
|
||||
pt="8px"
|
||||
top="18px"
|
||||
w={isCompact ? '100%' : 'calc(100vw - 70px - 186px)'}
|
||||
>
|
||||
<Flex w="100%" flexDirection="row" alignItems="center">
|
||||
{isCompact && <HamburgerIcon w="24px" h="24px" onClick={toggleSidebar} mr={10} mt={1} />}
|
||||
<Heading>{t(getActiveRoute())}</Heading>
|
||||
<Tooltip label={t('common.go_back')}>
|
||||
<IconButton
|
||||
mt={2}
|
||||
ml={4}
|
||||
colorScheme="blue"
|
||||
aria-label={t('common.go_back')}
|
||||
onClick={goBack}
|
||||
size="sm"
|
||||
icon={<ArrowCircleLeft width={20} height={20} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Box ms="auto" w={{ base: 'unset' }}>
|
||||
<Flex alignItems="center" flexDirection="row">
|
||||
<Tooltip hasArrow label={t('common.theme')}>
|
||||
<IconButton
|
||||
aria-label={t('common.theme')}
|
||||
variant="ghost"
|
||||
icon={colorMode === 'light' ? <MoonIcon h="20px" w="20px" /> : <SunIcon h="20px" w="20px" />}
|
||||
onClick={toggleColorMode}
|
||||
/>
|
||||
</Tooltip>
|
||||
<LanguageSwitcher />
|
||||
<HStack spacing={{ base: '0', md: '6' }} ml={1} mr={4}>
|
||||
<Menu>
|
||||
<MenuButton py={2} transition="all 0.3s" _focus={{ boxShadow: 'none' }}>
|
||||
<HStack>
|
||||
{!isCompact && <Text fontWeight="bold">{user?.name}</Text>}
|
||||
<Avatar h="40px" w="40px" fontSize="0.8rem" lineHeight="2rem" src={avatar} name={user?.name} />
|
||||
</HStack>
|
||||
</MenuButton>
|
||||
<MenuList
|
||||
bg={useColorModeValue('white', 'gray.900')}
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
>
|
||||
<MenuItem onClick={goToProfile} w="100%">
|
||||
{t('account.title')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={logout}>{t('common.logout')}</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Portal>
|
||||
<Flex
|
||||
{...scrollDependentStyles}
|
||||
backdropFilter="blur(21px)"
|
||||
borderWidth="1.5px"
|
||||
borderStyle="solid"
|
||||
transitionDelay="0s, 0s, 0s, 0s"
|
||||
transitionDuration=" 0.25s, 0.25s, 0.25s, 0s"
|
||||
transition-property="box-shadow, background-color, filter, border"
|
||||
transitionTimingFunction="linear, linear, linear, linear"
|
||||
alignItems="center"
|
||||
borderRadius="15px"
|
||||
minH="75px"
|
||||
justifyContent="center"
|
||||
lineHeight="25.6px"
|
||||
pb="8px"
|
||||
right={{ base: '0px', sm: '0px', lg: '20px' }}
|
||||
ps="12px"
|
||||
pt="8px"
|
||||
top="15px"
|
||||
w={isCompact ? '100%' : 'calc(100vw - 271px)'}
|
||||
>
|
||||
<Flex w="100%" flexDirection="row" alignItems="center">
|
||||
{isCompact && <HamburgerIcon w="24px" h="24px" onClick={toggleSidebar} mr={10} mt={1} />}
|
||||
<Heading>{activeRoute}</Heading>
|
||||
<Tooltip label={t('common.go_back')}>
|
||||
<IconButton
|
||||
mt={2}
|
||||
ml={4}
|
||||
colorScheme="blue"
|
||||
aria-label={t('common.go_back')}
|
||||
onClick={goBack}
|
||||
size="sm"
|
||||
icon={<ArrowCircleLeft width={20} height={20} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Box ms="auto" w={{ base: 'unset' }}>
|
||||
<Flex alignItems="center" flexDirection="row">
|
||||
<Tooltip hasArrow label={t('common.theme')}>
|
||||
<IconButton
|
||||
aria-label={t('common.theme')}
|
||||
variant="ghost"
|
||||
icon={colorMode === 'light' ? <MoonIcon h="20px" w="20px" /> : <SunIcon h="20px" w="20px" />}
|
||||
onClick={toggleColorMode}
|
||||
/>
|
||||
</Tooltip>
|
||||
{languageSwitcher}
|
||||
<HStack spacing={{ base: '0', md: '6' }} ml={1} mr={4}>
|
||||
<Menu>
|
||||
<MenuButton py={2} transition="all 0.3s" _focus={{ boxShadow: 'none' }}>
|
||||
<HStack>
|
||||
{!isCompact && <Text fontWeight="bold">{user?.name}</Text>}
|
||||
<Avatar h="40px" w="40px" fontSize="0.8rem" lineHeight="2rem" src={avatar} name={user?.name} />
|
||||
</HStack>
|
||||
</MenuButton>
|
||||
<MenuList
|
||||
bg={useColorModeValue('white', 'gray.900')}
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
>
|
||||
<MenuItem onClick={goToProfile} w="100%">
|
||||
{t('account.title')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={logout}>{t('common.logout')}</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
|
||||
48
src/layout/PageContainer/index.tsx
Normal file
48
src/layout/PageContainer/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Center, Flex, Spinner, useBreakpoint } from '@chakra-ui/react';
|
||||
import { useAuth } from 'contexts/AuthProvider';
|
||||
|
||||
export type PageContainerProps = {
|
||||
waitForUser: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const PageContainer = ({ waitForUser, children }: PageContainerProps) => {
|
||||
const { isUserLoaded } = useAuth();
|
||||
const breakpoint = useBreakpoint('xl');
|
||||
const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md';
|
||||
|
||||
return (
|
||||
<Box
|
||||
w={isCompact ? 'calc(100%)' : 'calc(100% - 210px)'}
|
||||
float="right"
|
||||
position="relative"
|
||||
transition="all 0.33s cubic-bezier(0.685, 0.0473, 0.346, 1)"
|
||||
transitionDelay=".2s, .2s, .35s"
|
||||
transitionProperty="top, bottom, width"
|
||||
transitionTimingFunction="linear, linear, ease"
|
||||
px="15px"
|
||||
pb="15px"
|
||||
>
|
||||
<Box minH="calc(100vh - 123px)" pt="105px" pl="10px" pr="5px" pb="0px">
|
||||
<Flex flexDirection="column">
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<Center mt="100px">
|
||||
<Spinner size="xl" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
{waitForUser && !isUserLoaded ? (
|
||||
<Center mt="100px">
|
||||
<Spinner size="xl" />
|
||||
</Center>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</React.Suspense>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import NavLinkButton from './NavLinkButton';
|
||||
import { Route } from 'models/Routes';
|
||||
|
||||
const createLinks = (
|
||||
routes: Route[],
|
||||
activeRoute: (path: string, otherRoute: string | undefined) => string,
|
||||
role: string,
|
||||
) =>
|
||||
routes.map((route) => (
|
||||
<NavLinkButton key={uuid()} activeRoute={activeRoute} role={role} route={route} />
|
||||
));
|
||||
|
||||
export default createLinks;
|
||||
@@ -8,45 +8,47 @@ import { Route } from 'models/Routes';
|
||||
|
||||
const variantChange = '0.2s linear';
|
||||
|
||||
interface Props {
|
||||
activeRoute: (path: string, otherRoute: string | undefined) => string;
|
||||
route: Route;
|
||||
role: string;
|
||||
}
|
||||
const commonStyle = {
|
||||
boxSize: 'initial',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
transition: variantChange,
|
||||
bg: 'transparent',
|
||||
ps: '6px',
|
||||
py: '12px',
|
||||
pe: '4px',
|
||||
w: '100%',
|
||||
borderRadius: '15px',
|
||||
_active: {
|
||||
bg: 'inherit',
|
||||
transform: 'none',
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
_focus: {
|
||||
boxShadow: '0px 7px 11px rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const NavLinkButton: React.FC<Props> = ({ activeRoute, route, role }) => {
|
||||
type Props = {
|
||||
isActive: boolean;
|
||||
route: Route;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
export const NavLinkButton = ({ isActive, route, toggleSidebar }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const activeTextColor = useColorModeValue('gray.700', 'white');
|
||||
const inactiveTextColor = useColorModeValue('gray.600', 'gray.200');
|
||||
const inactiveIconColor = useColorModeValue('gray.100', 'gray.600');
|
||||
|
||||
if (route.navButton) {
|
||||
return route.navButton(isActive, toggleSidebar, route) as JSX.Element;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink to={route.path} key={uuid()}>
|
||||
{activeRoute(route.path, undefined) === 'active' ? (
|
||||
<Button
|
||||
hidden={route.hidden || !route.authorized.includes(role)}
|
||||
boxSize="initial"
|
||||
justifyContent="flex-start"
|
||||
alignItems="center"
|
||||
boxShadow="none"
|
||||
bg="transparent"
|
||||
transition={variantChange}
|
||||
mb="12px"
|
||||
mx="auto"
|
||||
ps="10px"
|
||||
py="12px"
|
||||
ml={4}
|
||||
w="90%"
|
||||
borderRadius="15px"
|
||||
_active={{
|
||||
bg: 'inherit',
|
||||
transform: 'none',
|
||||
borderColor: 'transparent',
|
||||
}}
|
||||
_focus={{
|
||||
boxShadow: '0px 7px 11px rgba(0, 0, 0, 0.04)',
|
||||
}}
|
||||
>
|
||||
<NavLink to={route.path.replace(':id', '0')} key={uuid()} style={{ width: '100%' }}>
|
||||
{isActive ? (
|
||||
<Button {...commonStyle} boxShadow="none">
|
||||
<Flex>
|
||||
<IconBox bg="blue.300" color="white" h="38px" w="38px" me="6px" transition={variantChange}>
|
||||
{route.icon(true)}
|
||||
@@ -58,22 +60,8 @@ const NavLinkButton: React.FC<Props> = ({ activeRoute, route, role }) => {
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
hidden={route.hidden || !route.authorized.includes(role)}
|
||||
boxSize="initial"
|
||||
justifyContent="flex-start"
|
||||
alignItems="center"
|
||||
bg="transparent"
|
||||
mb="12px"
|
||||
py="12px"
|
||||
{...commonStyle}
|
||||
ps="6px"
|
||||
borderRadius="15px"
|
||||
w="90%"
|
||||
ml={2}
|
||||
_active={{
|
||||
bg: 'inherit',
|
||||
transform: 'none',
|
||||
borderColor: 'transparent',
|
||||
}}
|
||||
_focus={{
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
@@ -91,5 +79,3 @@ const NavLinkButton: React.FC<Props> = ({ activeRoute, route, role }) => {
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(NavLinkButton);
|
||||
|
||||
@@ -8,70 +8,85 @@ import {
|
||||
DrawerOverlay,
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
useColorMode,
|
||||
Text,
|
||||
Spacer,
|
||||
useBreakpoint,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import createLinks from './CreateLinks';
|
||||
import SidebarDevices from './Devices';
|
||||
import darkLogo from 'assets/Logo_Dark_Mode.svg';
|
||||
import lightLogo from 'assets/Logo_Light_Mode.svg';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { NavLinkButton } from './NavLinkButton';
|
||||
import { useAuth } from 'contexts/AuthProvider';
|
||||
import { Route } from 'models/Routes';
|
||||
|
||||
const variantChange = '0.2s linear';
|
||||
|
||||
interface Props {
|
||||
export type SidebarProps = {
|
||||
routes: Route[];
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
logo: React.ReactNode;
|
||||
version: string;
|
||||
children?: React.ReactNode;
|
||||
topNav?: (isRouteActive: (str: string, str2: string) => boolean, toggleSidebar: () => void) => React.ReactNode;
|
||||
};
|
||||
|
||||
const Sidebar = ({ routes, isOpen, toggle }: Props) => {
|
||||
export const Sidebar = ({ routes, isOpen, toggle, logo, version, topNav, children }: SidebarProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const location = useLocation();
|
||||
const { colorMode } = useColorMode();
|
||||
const navbarShadow = useColorModeValue('0px 7px 23px rgba(0, 0, 0, 0.05)', 'none');
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
const activeRoute = (routeName: string, otherRoute: string | undefined) => {
|
||||
const isRouteActive = (routeName: string, otherRoute?: string) => {
|
||||
if (otherRoute)
|
||||
return location.pathname.split('/')[1] === routeName.split('/')[1] ||
|
||||
return (
|
||||
location.pathname.split('/')[1] === routeName.split('/')[1] ||
|
||||
location.pathname.split('/')[1] === otherRoute.split('/')[1]
|
||||
? 'active'
|
||||
: '';
|
||||
);
|
||||
|
||||
return location.pathname === routeName ? 'active' : '';
|
||||
return location.pathname === routeName.replace(':id', '0');
|
||||
};
|
||||
|
||||
const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md';
|
||||
|
||||
const brand = (
|
||||
<Box pt="25px" mb="12px">
|
||||
<img
|
||||
src={colorMode === 'light' ? lightLogo : darkLogo}
|
||||
alt="OpenWifi"
|
||||
width="180px"
|
||||
height="100px"
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
}}
|
||||
/>
|
||||
<Box pt="25px" mb="15px" px="12px">
|
||||
{logo}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const sidebarContent = React.useMemo(
|
||||
() => (
|
||||
<>
|
||||
<VStack spacing={2} alignItems="start" w="100%" px={4}>
|
||||
{topNav ? topNav(isRouteActive, toggle) : null}
|
||||
{routes
|
||||
.filter(({ hidden, authorized }) => !hidden && authorized.includes(user?.userRole ?? ''))
|
||||
.map((route) => (
|
||||
<NavLinkButton key={uuid()} isActive={isRouteActive(route.path)} route={route} toggleSidebar={toggle} />
|
||||
))}
|
||||
</VStack>
|
||||
<Spacer />
|
||||
<Box mb={2}>{children}</Box>
|
||||
<Box>
|
||||
<Text color="gray.400">
|
||||
{t('footer.version')} {version}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
),
|
||||
[user?.userRole, location],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer isOpen={isCompact && isOpen} onClose={toggle} placement="left">
|
||||
<DrawerOverlay />
|
||||
<DrawerContent
|
||||
w="200px"
|
||||
maxW="200px"
|
||||
w="250px"
|
||||
maxW="250px"
|
||||
ms={{
|
||||
base: '16px',
|
||||
}}
|
||||
@@ -81,20 +96,11 @@ const Sidebar = ({ routes, isOpen, toggle }: Props) => {
|
||||
borderRadius="16px"
|
||||
>
|
||||
<DrawerCloseButton />
|
||||
<DrawerBody w="200px" px={0}>
|
||||
<Box maxW="200px" h="90vh">
|
||||
<Box>{brand}</Box>
|
||||
<DrawerBody maxW="250px" px="1rem">
|
||||
<Box maxW="100%" h="90vh">
|
||||
{brand}
|
||||
<Flex direction="column" mb="40px" h="calc(100vh - 200px)" alignItems="center" overflowY="auto">
|
||||
<Box>{createLinks(routes, activeRoute, user?.userRole ?? '')}</Box>
|
||||
<Spacer />
|
||||
<Box mb={2}>
|
||||
<SidebarDevices />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="gray.400">
|
||||
{t('footer.version')} {__APP_VERSION__}
|
||||
</Text>
|
||||
</Box>
|
||||
{sidebarContent}
|
||||
</Flex>
|
||||
</Box>
|
||||
</DrawerBody>
|
||||
@@ -108,24 +114,14 @@ const Sidebar = ({ routes, isOpen, toggle }: Props) => {
|
||||
transition={variantChange}
|
||||
w="200px"
|
||||
maxW="200px"
|
||||
ms="14px"
|
||||
h="calc(100vh - 32px)"
|
||||
mt="16px"
|
||||
my="16px"
|
||||
ml="16px"
|
||||
borderRadius="16px"
|
||||
>
|
||||
<Box>{brand}</Box>
|
||||
{brand}
|
||||
<Flex direction="column" h="calc(100vh - 160px)" alignItems="center" overflowY="auto">
|
||||
<Box>{createLinks(routes, activeRoute, user?.userRole ?? '')}</Box>
|
||||
<Spacer />
|
||||
<Box mb={2}>
|
||||
<SidebarDevices />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="gray.400">
|
||||
{t('footer.version')} {__APP_VERSION__}
|
||||
</Text>
|
||||
</Box>
|
||||
{sidebarContent}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -133,5 +129,3 @@ const Sidebar = ({ routes, isOpen, toggle }: Props) => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
@@ -1,52 +1,68 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Center, Flex, Portal, Spinner, useBoolean, useBreakpoint } from '@chakra-ui/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { useBoolean, useColorMode } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import PanelContainer from './Containers/PanelContainer';
|
||||
import PanelContent from './Containers/PanelContent';
|
||||
import MainPanel from './MainPanel';
|
||||
import Navbar from './Navbar';
|
||||
import Sidebar from './Sidebar';
|
||||
import SidebarDevices from './Devices';
|
||||
import { Navbar } from './Navbar';
|
||||
import { PageContainer } from './PageContainer';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import darkLogo from 'assets/Logo_Dark_Mode.svg';
|
||||
import lightLogo from 'assets/Logo_Light_Mode.svg';
|
||||
import LanguageSwitcher from 'components/LanguageSwitcher';
|
||||
import { Route as RouteProps } from 'models/Routes';
|
||||
import NotFoundPage from 'pages/NotFound';
|
||||
import routes from 'router/routes';
|
||||
|
||||
const Layout = () => {
|
||||
const breakpoint = useBreakpoint('xl');
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { colorMode } = useColorMode();
|
||||
const [isSidebarOpen, { toggle: toggleSidebar }] = useBoolean(false);
|
||||
document.documentElement.dir = 'ltr';
|
||||
|
||||
const activeRoute = React.useMemo(() => {
|
||||
const route = routes.find(
|
||||
(r) => r.path === location.pathname || location.pathname.split('/')[1] === r.path.split('/')[1],
|
||||
);
|
||||
|
||||
if (route) return route.navName ? t(route.navName) : t(route.name);
|
||||
|
||||
return '';
|
||||
}, [t, location.pathname]);
|
||||
|
||||
const getRoutes = (r: RouteProps[]) =>
|
||||
// @ts-ignore
|
||||
r.map((route: RouteProps) => <Route path={route.path} element={<route.component />} key={uuid()} />);
|
||||
|
||||
const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar routes={routes} isOpen={isSidebarOpen} toggle={toggleSidebar} />
|
||||
<Portal>
|
||||
<Navbar secondary={false} toggleSidebar={toggleSidebar} />
|
||||
</Portal>
|
||||
<MainPanel w={isCompact ? 'calc(100%)' : 'calc(100% - 210px)'}>
|
||||
<PanelContent>
|
||||
<PanelContainer>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Flex flexDirection="column" pt="75px">
|
||||
<Center mt={10}>
|
||||
<Spinner />
|
||||
</Center>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
{[...getRoutes(routes as RouteProps[]), <Route path="*" element={<NotFoundPage />} key={uuid()} />]}
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</PanelContainer>
|
||||
</PanelContent>
|
||||
</MainPanel>
|
||||
<Sidebar
|
||||
routes={routes}
|
||||
isOpen={isSidebarOpen}
|
||||
toggle={toggleSidebar}
|
||||
version={__APP_VERSION__}
|
||||
logo={
|
||||
<img
|
||||
src={colorMode === 'light' ? lightLogo : darkLogo}
|
||||
alt="OpenWifi"
|
||||
width="180px"
|
||||
height="100px"
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SidebarDevices />
|
||||
</Sidebar>
|
||||
<Navbar toggleSidebar={toggleSidebar} languageSwitcher={<LanguageSwitcher />} activeRoute={activeRoute} />
|
||||
<PageContainer waitForUser>
|
||||
<Routes>
|
||||
{[...getRoutes(routes as RouteProps[]), <Route path="*" element={<NotFoundPage />} key={uuid()} />]}
|
||||
</Routes>
|
||||
</PageContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface WifiScanResult {
|
||||
completed: number;
|
||||
errorCode: number;
|
||||
errorText: string;
|
||||
serialNumber: string;
|
||||
executed: number;
|
||||
executionTime: number;
|
||||
results: {
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface Route {
|
||||
name: string;
|
||||
navName?: string;
|
||||
icon: (active: boolean) => ReactNode;
|
||||
navButton?: (isActive: boolean, toggleSidebar: () => void, route: Route) => React.ReactNode;
|
||||
isEntity?: boolean;
|
||||
component: unknown;
|
||||
hidden?: boolean;
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { Note } from './Note';
|
||||
|
||||
export type UserRole =
|
||||
| 'root'
|
||||
| 'admin'
|
||||
| 'subscriber'
|
||||
| 'partner'
|
||||
| 'csr'
|
||||
| 'system'
|
||||
| 'installer'
|
||||
| 'noc'
|
||||
| 'accounting';
|
||||
|
||||
export interface User {
|
||||
name: string;
|
||||
avatar: string;
|
||||
@@ -7,7 +18,7 @@ export interface User {
|
||||
currentPassword?: string;
|
||||
id: string;
|
||||
email: string;
|
||||
userRole: string;
|
||||
userRole: UserRole;
|
||||
userTypeProprietaryInfo: {
|
||||
authenticatorSecret: string;
|
||||
mfa: {
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import DefaultConfigurationsList from './List';
|
||||
import { useAuth } from 'contexts/AuthProvider';
|
||||
|
||||
const DefaultConfigurationsPage = () => {
|
||||
const { isUserLoaded } = useAuth();
|
||||
return (
|
||||
<Flex flexDirection="column" pt="75px">
|
||||
{isUserLoaded && <DefaultConfigurationsList />}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
const DefaultConfigurationsPage = () => <DefaultConfigurationsList />;
|
||||
|
||||
export default DefaultConfigurationsPage;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import * as Yup from 'yup';
|
||||
import { testJson } from 'helpers/formTests';
|
||||
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Box,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
useColorMode,
|
||||
} from '@chakra-ui/react';
|
||||
import { JsonViewer } from '@textea/json-viewer';
|
||||
import { Download } from 'phosphor-react';
|
||||
import { CSVLink } from 'react-csv';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ResponsiveButton } from 'components/Buttons/ResponsiveButton';
|
||||
import { Modal } from 'components/Modals/Modal';
|
||||
import WifiScanResultDisplay from 'components/Modals/WifiScanModal/ResultDisplay';
|
||||
import { compactDate, dateForFilename } from 'helpers/dateFormatting';
|
||||
import { uppercaseFirstLetter } from 'helpers/stringHelper';
|
||||
import { DeviceCommandHistory } from 'hooks/Network/Commands';
|
||||
import { useDownloadTrace } from 'hooks/Network/Trace';
|
||||
import { DeviceScanResult } from 'models/Device';
|
||||
|
||||
type Props = {
|
||||
modalProps: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
command?: DeviceCommandHistory;
|
||||
};
|
||||
|
||||
const CommandDetailsModal = ({ modalProps, command }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { colorMode } = useColorMode();
|
||||
const download = useDownloadTrace({ serialNumber: command?.serialNumber ?? '', commandId: command?.UUID ?? '' });
|
||||
const [csvData, setCsvData] = React.useState<DeviceScanResult[] | undefined>(undefined);
|
||||
|
||||
if (!command) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={modalProps.isOpen}
|
||||
onClose={modalProps.onClose}
|
||||
title={`${uppercaseFirstLetter(command.command)} - ${compactDate(command.completed)} `}
|
||||
topRightButtons={
|
||||
<>
|
||||
{csvData ? (
|
||||
<CSVLink
|
||||
filename={`wifi_scan_${command.serialNumber}_${dateForFilename(new Date().getTime() / 1000)}.csv`}
|
||||
data={csvData as object[]}
|
||||
>
|
||||
<ResponsiveButton
|
||||
color="gray"
|
||||
icon={<Download size={20} />}
|
||||
isCompact
|
||||
label={t('common.download')}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</CSVLink>
|
||||
) : undefined}
|
||||
{command?.command === 'trace' && (
|
||||
<ResponsiveButton
|
||||
color="gray"
|
||||
icon={<Download size={20} />}
|
||||
isCompact
|
||||
label={t('common.download')}
|
||||
isLoading={download.isFetching}
|
||||
onClick={download.refetch}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Tabs variant="enclosed" w="100%">
|
||||
<TabList>
|
||||
<Tab>{t('controller.devices.results')}</Tab>
|
||||
<Tab>{t('controller.devices.complete_data')}</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{command.command === 'wifiscan' ? (
|
||||
<TabPanel>
|
||||
{
|
||||
// @ts-ignore
|
||||
<WifiScanResultDisplay results={command as WifiScanResult} setCsvData={setCsvData} />
|
||||
}
|
||||
</TabPanel>
|
||||
) : (
|
||||
<TabPanel>
|
||||
<Accordion defaultIndex={0} allowToggle>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
{t('common.preview')}
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<JsonViewer
|
||||
rootName={false}
|
||||
displayDataTypes={false}
|
||||
enableClipboard={false}
|
||||
theme={colorMode === 'light' ? undefined : 'dark'}
|
||||
defaultInspectDepth={1}
|
||||
value={command.results?.status as object}
|
||||
style={{ background: 'unset', display: 'unset' }}
|
||||
/>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
{t('analytics.raw_data')}
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4} overflowX="auto" overflowY="auto" maxH="500px">
|
||||
<pre>{JSON.stringify(command.results?.status, null, 2)}</pre>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabPanel>
|
||||
)}
|
||||
<TabPanel>
|
||||
<Accordion defaultIndex={0} allowToggle>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
{t('common.preview')}
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<JsonViewer
|
||||
rootName={false}
|
||||
displayDataTypes={false}
|
||||
enableClipboard={false}
|
||||
theme={colorMode === 'light' ? undefined : 'dark'}
|
||||
defaultInspectDepth={1}
|
||||
value={command as object}
|
||||
style={{ background: 'unset', display: 'unset' }}
|
||||
/>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
{t('analytics.raw_data')}
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4} overflowX="auto" overflowY="auto" maxH="500px">
|
||||
<pre>{JSON.stringify(command, null, 2)}</pre>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandDetailsModal;
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { Download } from 'phosphor-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ResponsiveButton } from 'components/Buttons/ResponsiveButton';
|
||||
import { DeviceCommandHistory, useDownloadScriptResult } from 'hooks/Network/Commands';
|
||||
|
||||
type Props = {
|
||||
command?: DeviceCommandHistory;
|
||||
};
|
||||
const DownloadScriptButton = ({ command }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const download = useDownloadScriptResult({
|
||||
serialNumber: command?.serialNumber ?? '',
|
||||
commandId: command?.UUID ?? '',
|
||||
});
|
||||
|
||||
if (!command || command.command !== 'script' || command.details?.uri === undefined || command.details?.uri === '')
|
||||
return null;
|
||||
|
||||
return (
|
||||
<ResponsiveButton
|
||||
color="gray"
|
||||
icon={<Download size={20} />}
|
||||
isCompact
|
||||
label={t('common.download')}
|
||||
isLoading={download.isFetching}
|
||||
onClick={download.refetch}
|
||||
isDisabled={command.waitingForFile === 1}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadScriptButton;
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import { Download } from 'phosphor-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ResponsiveButton } from 'components/Buttons/ResponsiveButton';
|
||||
import { DeviceCommandHistory } from 'hooks/Network/Commands';
|
||||
import { useDownloadTrace } from 'hooks/Network/Trace';
|
||||
|
||||
type Props = {
|
||||
command?: DeviceCommandHistory;
|
||||
};
|
||||
const DownloadTraceButton = ({ command }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const download = useDownloadTrace({ serialNumber: command?.serialNumber ?? '', commandId: command?.UUID ?? '' });
|
||||
|
||||
if (!command || command.command !== 'trace') return null;
|
||||
|
||||
return (
|
||||
<ResponsiveButton
|
||||
color="gray"
|
||||
icon={<Download size={20} />}
|
||||
isCompact
|
||||
label={t('common.download')}
|
||||
isLoading={download.isFetching}
|
||||
onClick={download.refetch}
|
||||
isDisabled={command.waitingForFile === 1}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadTraceButton;
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from 'react';
|
||||
import { Download } from 'phosphor-react';
|
||||
import { CSVLink } from 'react-csv';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ResponsiveButton } from 'components/Buttons/ResponsiveButton';
|
||||
import { dateForFilename } from 'helpers/dateFormatting';
|
||||
import { parseDbm } from 'helpers/stringHelper';
|
||||
import { DeviceScanResult, ScanChannel, WifiScanResult } from 'models/Device';
|
||||
|
||||
type Props = {
|
||||
command?: WifiScanResult;
|
||||
};
|
||||
|
||||
const DownloadWifiScanButton = ({ command }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const scanResults = React.useMemo(() => {
|
||||
if (!command) return undefined;
|
||||
try {
|
||||
const createdChannels: { [key: string]: ScanChannel } = {};
|
||||
const listCsv: DeviceScanResult[] = [];
|
||||
|
||||
for (const scan of command.results.status.scan) {
|
||||
if (!createdChannels[scan.channel]) {
|
||||
const channel: ScanChannel = {
|
||||
channel: scan.channel,
|
||||
devices: [],
|
||||
};
|
||||
for (const deviceResult of command.results.status.scan) {
|
||||
if (deviceResult.channel === scan.channel) {
|
||||
let ssid = '';
|
||||
const signal: number | string = parseDbm(deviceResult.signal);
|
||||
if (deviceResult.ssid && deviceResult.ssid.length > 0) ssid = deviceResult.ssid;
|
||||
else ssid = deviceResult.meshid && deviceResult.meshid.length > 0 ? deviceResult.meshid : 'N/A';
|
||||
channel.devices.push({ ...deviceResult, ssid, signal });
|
||||
// @ts-ignore
|
||||
listCsv.push({ ...deviceResult, ssid, signal, ies: JSON.stringify(deviceResult.ies) });
|
||||
}
|
||||
}
|
||||
createdChannels[scan.channel] = channel;
|
||||
}
|
||||
}
|
||||
return listCsv;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}, [command?.results]);
|
||||
|
||||
if (!scanResults || !command) return null;
|
||||
|
||||
return (
|
||||
<CSVLink
|
||||
filename={`wifi_scan_${command.serialNumber}_${dateForFilename(new Date().getTime() / 1000)}.csv`}
|
||||
data={scanResults as object[]}
|
||||
>
|
||||
<ResponsiveButton
|
||||
color="gray"
|
||||
icon={<Download size={20} />}
|
||||
isCompact
|
||||
label={t('common.download')}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</CSVLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadWifiScanButton;
|
||||
179
src/pages/Device/LogsCard/CommandHistory/ResultModal/index.tsx
Normal file
179
src/pages/Device/LogsCard/CommandHistory/ResultModal/index.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionButton,
|
||||
AccordionIcon,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
Box,
|
||||
Center,
|
||||
Code,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
useColorMode,
|
||||
} from '@chakra-ui/react';
|
||||
import { JsonViewer } from '@textea/json-viewer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DownloadScriptButton from './DownloadScriptButton';
|
||||
import DownloadTraceButton from './DownloadTraceButton';
|
||||
import DownloadWifiScanButton from './DownloadWifiScanButton';
|
||||
import { Modal } from 'components/Modals/Modal';
|
||||
import WifiScanResultDisplay from 'components/Modals/WifiScanModal/ResultDisplay';
|
||||
import { compactDate } from 'helpers/dateFormatting';
|
||||
import { uppercaseFirstLetter } from 'helpers/stringHelper';
|
||||
import { DeviceCommandHistory } from 'hooks/Network/Commands';
|
||||
import { WifiScanResult } from 'models/Device';
|
||||
|
||||
type Props = {
|
||||
modalProps: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
command?: DeviceCommandHistory;
|
||||
};
|
||||
|
||||
const CommandResultModal = ({ modalProps, command }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
if (!command) return null;
|
||||
|
||||
const results = () => {
|
||||
if (command.status === 'failed') {
|
||||
return (
|
||||
<Center my="50px">
|
||||
<Alert w="unset" status="error">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<AlertTitle>{uppercaseFirstLetter(command.status)}</AlertTitle>
|
||||
<AlertDescription>{command.errorText}</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (command.command === 'wifiscan') {
|
||||
return <WifiScanResultDisplay results={command as unknown as WifiScanResult} setCsvData={() => {}} />;
|
||||
}
|
||||
// If it's a non-deferred script
|
||||
if (
|
||||
command.command === 'script' &&
|
||||
(command.details?.uri === undefined || command.details?.uri === '') &&
|
||||
command.status === 'completed'
|
||||
) {
|
||||
return (
|
||||
<Code whiteSpace="pre-line">{command.results?.status?.result ?? JSON.stringify(command.results, null, 2)}</Code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Accordion defaultIndex={0} allowToggle>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
{t('common.preview')}
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<JsonViewer
|
||||
rootName={false}
|
||||
displayDataTypes={false}
|
||||
enableClipboard={false}
|
||||
theme={colorMode === 'light' ? undefined : 'dark'}
|
||||
defaultInspectDepth={1}
|
||||
value={command.results?.status as object}
|
||||
style={{ background: 'unset', display: 'unset' }}
|
||||
/>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
{t('analytics.raw_data')}
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4} overflowX="auto" overflowY="auto" maxH="500px">
|
||||
<pre>{JSON.stringify(command.results?.status, null, 2)}</pre>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={modalProps.isOpen}
|
||||
onClose={modalProps.onClose}
|
||||
title={`${uppercaseFirstLetter(command.command)} - ${compactDate(command.submitted)} `}
|
||||
topRightButtons={
|
||||
<>
|
||||
<DownloadWifiScanButton command={command as unknown as WifiScanResult} />
|
||||
<DownloadTraceButton command={command} />
|
||||
<DownloadScriptButton command={command} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Tabs variant="enclosed" w="100%">
|
||||
<TabList>
|
||||
<Tab>{t('controller.devices.results')}</Tab>
|
||||
<Tab>{t('controller.devices.complete_data')}</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>{results()}</TabPanel>
|
||||
<TabPanel>
|
||||
<Accordion defaultIndex={0} allowToggle>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
{t('common.preview')}
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<JsonViewer
|
||||
rootName={false}
|
||||
displayDataTypes={false}
|
||||
enableClipboard={false}
|
||||
theme={colorMode === 'light' ? undefined : 'dark'}
|
||||
defaultInspectDepth={1}
|
||||
value={command as object}
|
||||
style={{ background: 'unset', display: 'unset' }}
|
||||
/>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem>
|
||||
<h2>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
{t('analytics.raw_data')}
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4} overflowX="auto" overflowY="auto" maxH="500px">
|
||||
<pre>{JSON.stringify(command, null, 2)}</pre>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandResultModal;
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Button, Center, Heading } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CommandDetailsModal from './Modal';
|
||||
import CommandResultModal from './ResultModal';
|
||||
import useCommandHistoryTable from './useCommandHistoryTable';
|
||||
import { RefreshButton } from 'components/Buttons/RefreshButton';
|
||||
import { ColumnPicker } from 'components/DataTables/ColumnPicker';
|
||||
@@ -64,7 +64,7 @@ const CommandHistory = ({ serialNumber }: Props) => {
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
<CommandDetailsModal command={selectedCommand} modalProps={detailsModalProps} />
|
||||
<CommandResultModal command={selectedCommand} modalProps={detailsModalProps} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,19 +45,21 @@ const InterfaceChart = ({ data }: Props) => {
|
||||
const points = {
|
||||
labels: data.recorded.map((recorded) => new Date(recorded * 1000).toLocaleTimeString()),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Tx',
|
||||
data: data.tx.map((tx) => Math.floor((tx / factor) * 100) / 100),
|
||||
borderColor: colorMode === 'light' ? '#63B3ED' : '#BEE3F8', // blue-300 - blue-100
|
||||
backgroundColor: colorMode === 'light' ? '#63B3ED' : '#BEE3F8', // blue-300 - blue-100
|
||||
},
|
||||
{
|
||||
label: 'Rx',
|
||||
data: data.rx.map((rx) => Math.floor((rx / factor) * 100) / 100),
|
||||
borderColor: colorMode === 'light' ? '#48BB78' : '#9AE6B4', // green-400 - green-200
|
||||
backgroundColor: colorMode === 'light' ? '#48BB78' : '#9AE6B4', // green-400 - green-200
|
||||
},
|
||||
],
|
||||
{
|
||||
// Real 'Tx', but shown as 'Rx'
|
||||
label: 'Tx',
|
||||
data: data.rx.map((tx) => Math.floor((tx / factor) * 100) / 100),
|
||||
borderColor: colorMode === 'light' ? '#63B3ED' : '#BEE3F8', // blue-300 - blue-100
|
||||
backgroundColor: colorMode === 'light' ? '#63B3ED' : '#BEE3F8', // blue-300 - blue-100
|
||||
},
|
||||
{
|
||||
// Real 'Rx', but shown as 'Tx'
|
||||
label: 'Rx',
|
||||
data: data.tx.map((rx) => Math.floor((rx / factor) * 100) / 100),
|
||||
borderColor: colorMode === 'light' ? '#48BB78' : '#9AE6B4', // green-400 - green-200
|
||||
backgroundColor: colorMode === 'light' ? '#48BB78' : '#9AE6B4', // green-400 - green-200
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React from 'react';
|
||||
import { DeviceStatistics, useGetDeviceNewestStats, useGetDeviceStatsWithTimestamps } from 'hooks/Network/Statistics';
|
||||
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import DevicePageWrapper from './Wrapper';
|
||||
import { useAuth } from 'contexts/AuthProvider';
|
||||
|
||||
const DevicePage = () => {
|
||||
const { id } = useParams();
|
||||
const { isUserLoaded } = useAuth();
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" pt="75px">
|
||||
{isUserLoaded && id !== undefined && <DevicePageWrapper serialNumber={id.toLowerCase()} />}
|
||||
</Flex>
|
||||
);
|
||||
return id !== undefined ? <DevicePageWrapper serialNumber={id.toLowerCase()} /> : null;
|
||||
};
|
||||
|
||||
export default DevicePage;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DeviceActionDropdown from 'components/Buttons/DeviceActionDropdown';
|
||||
import { DeviceWithStatus, useDeleteDevice } from 'hooks/Network/Devices';
|
||||
import { GatewayDevice } from 'models/Device';
|
||||
|
||||
interface Props {
|
||||
device: DeviceWithStatus;
|
||||
@@ -32,6 +33,7 @@ interface Props {
|
||||
onOpenEventQueue: (serialNumber: string) => void;
|
||||
onOpenConfigureModal: (serialNumber: string) => void;
|
||||
onOpenTelemetryModal: (serialNumber: string) => void;
|
||||
onOpenScriptModal: (device: GatewayDevice) => void;
|
||||
}
|
||||
|
||||
const Actions: React.FC<Props> = ({
|
||||
@@ -44,6 +46,7 @@ const Actions: React.FC<Props> = ({
|
||||
onOpenEventQueue,
|
||||
onOpenConfigureModal,
|
||||
onOpenTelemetryModal,
|
||||
onOpenScriptModal,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -100,6 +103,7 @@ const Actions: React.FC<Props> = ({
|
||||
onOpenEventQueue={onOpenEventQueue}
|
||||
onOpenConfigureModal={onOpenConfigureModal}
|
||||
onOpenTelemetryModal={onOpenTelemetryModal}
|
||||
onOpenScriptModal={onOpenScriptModal}
|
||||
/>
|
||||
<Tooltip hasArrow label={t('common.view_details')} placement="top">
|
||||
<IconButton
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ConfigureModal } from 'components/Modals/ConfigureModal';
|
||||
import { EventQueueModal } from 'components/Modals/EventQueueModal';
|
||||
import FactoryResetModal from 'components/Modals/FactoryResetModal';
|
||||
import { FirmwareUpgradeModal } from 'components/Modals/FirmwareUpgradeModal';
|
||||
import { useScriptModal } from 'components/Modals/ScriptModal/useScriptModal';
|
||||
import { TelemetryModal } from 'components/Modals/TelemetryModal';
|
||||
import { TraceModal } from 'components/Modals/TraceModal';
|
||||
import { WifiScanModal } from 'components/Modals/WifiScanModal';
|
||||
@@ -59,6 +60,7 @@ const DeviceListCard = () => {
|
||||
const eventQueueProps = useDisclosure();
|
||||
const telemetryModalProps = useDisclosure();
|
||||
const configureModalProps = useDisclosure();
|
||||
const scriptModal = useScriptModal();
|
||||
const getCount = useGetDeviceCount({ enabled: true });
|
||||
const getDevices = useGetDevices({
|
||||
pageInfo,
|
||||
@@ -213,6 +215,7 @@ const DeviceListCard = () => {
|
||||
onOpenEventQueue={onOpenEventQueue}
|
||||
onOpenConfigureModal={onOpenConfigure}
|
||||
onOpenTelemetryModal={onOpenTelemetry}
|
||||
onOpenScriptModal={scriptModal.openModal}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
@@ -403,6 +406,7 @@ const DeviceListCard = () => {
|
||||
// @ts-ignore
|
||||
setPageInfo={setPageInfo}
|
||||
saveSettingsId="gateway.devices.table"
|
||||
minHeight="600px"
|
||||
/>
|
||||
</Box>
|
||||
</CardBody>
|
||||
@@ -413,6 +417,7 @@ const DeviceListCard = () => {
|
||||
<EventQueueModal modalProps={eventQueueProps} serialNumber={serialNumber} />
|
||||
<ConfigureModal modalProps={configureModalProps} serialNumber={serialNumber} />
|
||||
<TelemetryModal modalProps={telemetryModalProps} serialNumber={serialNumber} />
|
||||
{scriptModal.modal}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Heading, Input, Select, Spacer, Table, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { CardBody } from 'components/Containers/Card/CardBody';
|
||||
import { CardHeader } from 'components/Containers/Card/CardHeader';
|
||||
import { useControllerStore, WebSocketMessage } from 'contexts/ControllerSocketProvider/useStore';
|
||||
|
||||
const LogsCard = () => {
|
||||
const { t } = useTranslation();
|
||||
const logs = useControllerStore((state) => state.allMessages);
|
||||
const [show, setShow] = React.useState<'' | 'connections'>('');
|
||||
const [serialNumber, setSerialNumber] = React.useState<string>('');
|
||||
|
||||
const labels = {
|
||||
DEVICE_CONNECTION: t('common.connected'),
|
||||
DEVICE_DISCONNECTION: t('common.disconnected'),
|
||||
DEVICE_STATISTICS: t('controller.devices.new_statistics'),
|
||||
DEVICE_CONNECTIONS_STATISTICS: t('controller.dashboard.device_dashboard_refresh'),
|
||||
DEVICE_SEARCH_RESULTS: undefined,
|
||||
};
|
||||
|
||||
const row = React.useCallback(
|
||||
(msg: WebSocketMessage) => {
|
||||
if (msg.type === 'NOTIFICATION') {
|
||||
return (
|
||||
<Tr key={uuid()}>
|
||||
<Td>{msg.timestamp.toLocaleTimeString()}</Td>
|
||||
<Td fontFamily="monospace" pt={2} fontSize="md">
|
||||
{msg.data?.serialNumber ?? '-'}
|
||||
</Td>
|
||||
<Td whiteSpace="nowrap">
|
||||
<Box>{labels[msg.data.type] ?? msg.data.type}</Box>
|
||||
</Td>
|
||||
<Td whiteSpace="nowrap">{JSON.stringify(msg.data)}</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tr key={uuid()}>
|
||||
<Td>{msg.timestamp.toLocaleTimeString()}</Td>
|
||||
<Td fontFamily="monospace" pt={2} fontSize="md">
|
||||
-
|
||||
</Td>
|
||||
<Td whiteSpace="nowrap">
|
||||
<Box>{t('common.unknown')}</Box>
|
||||
</Td>
|
||||
<Td whiteSpace="nowrap">{JSON.stringify(msg.data)}</Td>
|
||||
</Tr>
|
||||
);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const rows = React.useMemo(() => {
|
||||
let reversed = [...logs];
|
||||
if (show === 'connections') {
|
||||
reversed = reversed.filter(
|
||||
(msg) =>
|
||||
msg.type === 'NOTIFICATION' &&
|
||||
(msg.data.type === 'DEVICE_CONNECTION' || msg.data.type === 'DEVICE_DISCONNECTION'),
|
||||
);
|
||||
}
|
||||
if (serialNumber.trim().length > 0) {
|
||||
reversed = reversed.filter(
|
||||
(msg) =>
|
||||
msg.data?.serialNumber !== undefined &&
|
||||
typeof msg.data.serialNumber === 'string' &&
|
||||
msg.data?.serialNumber.includes(serialNumber.trim()),
|
||||
);
|
||||
}
|
||||
reversed.reverse();
|
||||
return reversed.map(row);
|
||||
}, [logs, row, show, serialNumber]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader px={4} pt={4}>
|
||||
<Heading size="md" my="auto" mr={2}>
|
||||
{t('controller.devices.logs')} ({rows.length})
|
||||
</Heading>
|
||||
<Input
|
||||
ml={2}
|
||||
placeholder={t('inventory.serial_number')}
|
||||
value={serialNumber}
|
||||
onChange={(e) => setSerialNumber(e.target.value)}
|
||||
w="160px"
|
||||
/>
|
||||
<Spacer />
|
||||
<Select size="md" value={show} onChange={(e) => setShow(e.target.value as '' | 'connections')} w="200px">
|
||||
<option value="">{t('common.select_all')}</option>
|
||||
<option value="connections">{t('controller.devices.connection_changes')}</option>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardBody p={4}>
|
||||
<Box overflowX="auto" overflowY="auto" maxH="calc(70vh)" w="100%">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Th w="100px">{t('common.time')}</Th>
|
||||
<Th w="140px">{t('inventory.serial_number')}</Th>
|
||||
<Th w="200px">{t('common.type')}</Th>
|
||||
<Th minW="100%">{t('analytics.raw_data')}</Th>
|
||||
</Thead>
|
||||
<Tbody>{rows}</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</CardBody>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogsCard;
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
|
||||
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Blacklist from './Blacklist';
|
||||
import DevicesDashboard from './Dashboard';
|
||||
import DeviceListCard from './ListCard';
|
||||
import LogsCard from './Logs';
|
||||
import { Card } from 'components/Containers/Card';
|
||||
import { CardHeader } from 'components/Containers/Card/CardHeader';
|
||||
import { useAuth } from 'contexts/AuthProvider';
|
||||
|
||||
const getDefaultTabIndex = () => {
|
||||
const index = localStorage.getItem('devices-tab-index') || '0';
|
||||
@@ -20,7 +18,6 @@ const getDefaultTabIndex = () => {
|
||||
|
||||
const DevicesPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isUserLoaded } = useAuth();
|
||||
const [tabIndex, setTabIndex] = React.useState(getDefaultTabIndex());
|
||||
|
||||
const handleTabChange = (index: number) => {
|
||||
@@ -29,72 +26,55 @@ const DevicesPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" pt="75px">
|
||||
{isUserLoaded && (
|
||||
<Card p={0}>
|
||||
<Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
|
||||
<TabList>
|
||||
<CardHeader>
|
||||
<Tab>{t('analytics.dashboard')}</Tab>
|
||||
<Tab>{t('devices.title')}</Tab>
|
||||
<Tab>{t('controller.devices.blacklist')}</Tab>
|
||||
<Tab>{t('devices.notifications')}</Tab>
|
||||
</CardHeader>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<DevicesDashboard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<DeviceListCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<Blacklist />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<LogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Card>
|
||||
)}
|
||||
</Flex>
|
||||
<Card p={0}>
|
||||
<Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
|
||||
<TabList>
|
||||
<CardHeader>
|
||||
<Tab>{t('analytics.dashboard')}</Tab>
|
||||
<Tab>{t('devices.title')}</Tab>
|
||||
<Tab>{t('controller.devices.blacklist')}</Tab>
|
||||
</CardHeader>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<DevicesDashboard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<DeviceListCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<Blacklist />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
|
||||
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FirmwareDashboard from './Dashboard';
|
||||
import FirmwareListTable from './List';
|
||||
import { Card } from 'components/Containers/Card';
|
||||
import { CardHeader } from 'components/Containers/Card/CardHeader';
|
||||
import { useAuth } from 'contexts/AuthProvider';
|
||||
|
||||
const STORAGE_KEY = 'firmware-tab-index';
|
||||
|
||||
@@ -20,7 +19,6 @@ const getDefaultTabIndex = () => {
|
||||
|
||||
const FirmwarePage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isUserLoaded } = useAuth();
|
||||
const [tabIndex, setTabIndex] = React.useState(getDefaultTabIndex());
|
||||
|
||||
const handleTabChange = (index: number) => {
|
||||
@@ -29,46 +27,42 @@ const FirmwarePage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" pt="75px">
|
||||
{isUserLoaded && (
|
||||
<Card p={0}>
|
||||
<Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
|
||||
<TabList>
|
||||
<CardHeader>
|
||||
<Tab>{t('analytics.dashboard')}</Tab>
|
||||
<Tab>{t('analytics.firmware')}</Tab>
|
||||
</CardHeader>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<FirmwareDashboard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<FirmwareListTable />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Card>
|
||||
)}
|
||||
</Flex>
|
||||
<Card p={0}>
|
||||
<Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
|
||||
<TabList>
|
||||
<CardHeader>
|
||||
<Tab>{t('analytics.dashboard')}</Tab>
|
||||
<Tab>{t('analytics.firmware')}</Tab>
|
||||
</CardHeader>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<FirmwareDashboard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<FirmwareListTable />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Flex, Heading } from '@chakra-ui/react';
|
||||
import { Heading } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const NotFoundPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" pt="75px">
|
||||
<Heading size="lg">{t('common.not_found')}</Heading>
|
||||
</Flex>
|
||||
);
|
||||
return <Heading size="lg">{t('common.not_found')}</Heading>;
|
||||
};
|
||||
|
||||
export default NotFoundPage;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
|
||||
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LogsCard from './DeviceLogs';
|
||||
import FmsLogsCard from './FmsLogs';
|
||||
@@ -7,7 +7,6 @@ import GeneralLogsCard from './GeneralLogs';
|
||||
import SecLogsCard from './SecLogs';
|
||||
import { Card } from 'components/Containers/Card';
|
||||
import { CardHeader } from 'components/Containers/Card/CardHeader';
|
||||
import { useAuth } from 'contexts/AuthProvider';
|
||||
|
||||
const INDEX_PARAM = 'notifications-tab-index';
|
||||
|
||||
@@ -22,7 +21,6 @@ const getDefaultTabIndex = () => {
|
||||
|
||||
const NotificationsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isUserLoaded } = useAuth();
|
||||
const [tabIndex, setTabIndex] = React.useState(getDefaultTabIndex());
|
||||
|
||||
const handleTabChange = (index: number) => {
|
||||
@@ -31,72 +29,68 @@ const NotificationsPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" pt="75px">
|
||||
{isUserLoaded && (
|
||||
<Card p={0}>
|
||||
<Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
|
||||
<TabList>
|
||||
<CardHeader>
|
||||
<Tab>{t('devices.notifications')}</Tab>
|
||||
<Tab>{t('simulation.controller')}</Tab>
|
||||
<Tab>{t('logs.security')}</Tab>
|
||||
<Tab>{t('logs.firmware')}</Tab>
|
||||
</CardHeader>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<LogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<GeneralLogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<SecLogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<FmsLogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Card>
|
||||
)}
|
||||
</Flex>
|
||||
<Card p={0}>
|
||||
<Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
|
||||
<TabList>
|
||||
<CardHeader>
|
||||
<Tab>{t('devices.notifications')}</Tab>
|
||||
<Tab>{t('simulation.controller')}</Tab>
|
||||
<Tab>{t('logs.security')}</Tab>
|
||||
<Tab>{t('logs.firmware')}</Tab>
|
||||
</CardHeader>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<LogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<GeneralLogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<SecLogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
<Box
|
||||
borderLeft="1px solid"
|
||||
borderRight="1px solid"
|
||||
borderBottom="1px solid"
|
||||
borderColor="var(--chakra-colors-chakra-border-color)"
|
||||
borderBottomLeftRadius="15px"
|
||||
borderBottomRightRadius="15px"
|
||||
>
|
||||
<FmsLogsCard />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { Center, Flex, Spinner } from '@chakra-ui/react';
|
||||
import ProfileLayout from './Layout';
|
||||
import { useAuth } from 'contexts/AuthProvider';
|
||||
|
||||
const ProfilePage = () => {
|
||||
const { isUserLoaded } = useAuth();
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" pt="75px">
|
||||
{!isUserLoaded ? (
|
||||
<Center mt={40}>
|
||||
<Spinner size="xl" />
|
||||
</Center>
|
||||
) : (
|
||||
<ProfileLayout />
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
const ProfilePage = () => <ProfileLayout />;
|
||||
|
||||
export default ProfilePage;
|
||||
|
||||
92
src/pages/Scripts/TableCard/Actions.tsx
Normal file
92
src/pages/Scripts/TableCard/Actions.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
HStack,
|
||||
IconButton,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
PopoverCloseButton,
|
||||
PopoverContent,
|
||||
PopoverFooter,
|
||||
PopoverHeader,
|
||||
PopoverTrigger,
|
||||
Text,
|
||||
Tooltip,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { MagnifyingGlass, Trash } from 'phosphor-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Script, useDeleteScript } from 'hooks/Network/Scripts';
|
||||
|
||||
type Props = {
|
||||
script: Script;
|
||||
onSelect: (newId: string) => void;
|
||||
};
|
||||
const ScriptTableActions = ({ script, onSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const deleteScript = useDeleteScript({ id: script.id });
|
||||
const { id } = useParams();
|
||||
|
||||
const handleDeleteClick = React.useCallback(() => {
|
||||
deleteScript.mutate(script.id, {
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
const handleSelectClick = () => {
|
||||
onSelect(script.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack mx="auto">
|
||||
<Popover isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
|
||||
<Tooltip hasArrow label={t('crud.delete')} placement="top" isDisabled={isOpen}>
|
||||
<Box>
|
||||
<PopoverTrigger>
|
||||
<IconButton aria-label="delete-script" colorScheme="red" icon={<Trash size={20} />} size="sm" />
|
||||
</PopoverTrigger>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<PopoverContent w="340px">
|
||||
<PopoverArrow />
|
||||
<PopoverCloseButton />
|
||||
<PopoverHeader>
|
||||
{t('crud.delete')} {script.name}
|
||||
</PopoverHeader>
|
||||
<PopoverBody>
|
||||
<Text whiteSpace="break-spaces">{t('crud.delete_confirm', { obj: t('script.one') })}</Text>
|
||||
</PopoverBody>
|
||||
<PopoverFooter>
|
||||
<Center>
|
||||
<Button colorScheme="gray" mr="1" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button colorScheme="red" ml="1" onClick={handleDeleteClick} isLoading={deleteScript.isLoading}>
|
||||
{t('common.yes')}
|
||||
</Button>
|
||||
</Center>
|
||||
</PopoverFooter>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Tooltip hasArrow label={t('common.view_details')} placement="top">
|
||||
<IconButton
|
||||
aria-label={t('common.view_details')}
|
||||
ml={2}
|
||||
colorScheme="blue"
|
||||
icon={<MagnifyingGlass size={20} />}
|
||||
size="sm"
|
||||
onClick={handleSelectClick}
|
||||
isDisabled={id === script.id}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptTableActions;
|
||||
207
src/pages/Scripts/TableCard/CreateButton.tsx
Normal file
207
src/pages/Scripts/TableCard/CreateButton.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Flex, SimpleGrid, useDisclosure, useToast } from '@chakra-ui/react';
|
||||
import axios from 'axios';
|
||||
import { Formik, FormikProps } from 'formik';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Yup from 'yup';
|
||||
import RolesInput from './RolesInput';
|
||||
import ScriptFileInput from './ScripFile';
|
||||
import ScriptUploadField from './UploadField';
|
||||
import { CreateButton } from 'components/Buttons/CreateButton';
|
||||
import { SaveButton } from 'components/Buttons/SaveButton';
|
||||
import { NumberField } from 'components/Form/Fields/NumberField';
|
||||
import { SelectField } from 'components/Form/Fields/SelectField';
|
||||
import { StringField } from 'components/Form/Fields/StringField';
|
||||
import { ToggleField } from 'components/Form/Fields/ToggleField';
|
||||
import { ConfirmCloseAlertModal } from 'components/Modals/ConfirmCloseAlert';
|
||||
import { Modal } from 'components/Modals/Modal';
|
||||
import { useAuth } from 'contexts/AuthProvider';
|
||||
import { Script, useCreateScript } from 'hooks/Network/Scripts';
|
||||
import { useFormModal } from 'hooks/useFormModal';
|
||||
import { useFormRef } from 'hooks/useFormRef';
|
||||
|
||||
export const ScriptSchema = (t: (str: string) => string, defaultAuthor?: string) =>
|
||||
Yup.object()
|
||||
.shape({
|
||||
name: Yup.string().required(t('form.required')),
|
||||
description: Yup.string(),
|
||||
deferred: Yup.boolean().required(t('form.required')),
|
||||
author: Yup.string().required(t('form.required')),
|
||||
type: Yup.string().required(t('form.required')),
|
||||
content: Yup.string().required(t('form.required')),
|
||||
timeout: Yup.number().when('deferred', {
|
||||
is: false,
|
||||
then: Yup.number()
|
||||
.required(t('form.required'))
|
||||
.moreThan(10)
|
||||
.lessThan(5 * 60), // 5 mins
|
||||
}),
|
||||
version: Yup.string().min(1, t('form.required')).max(15, '15 chars. limit').required(t('form.required')),
|
||||
uri: Yup.string(),
|
||||
defaultUploadDestination: Yup.string(),
|
||||
})
|
||||
.default({
|
||||
name: '',
|
||||
description: '',
|
||||
content: '',
|
||||
author: defaultAuthor,
|
||||
deferred: false,
|
||||
timeout: 30,
|
||||
type: 'shell',
|
||||
version: '1.0.0',
|
||||
restricted: ['root'],
|
||||
});
|
||||
|
||||
type Props = {
|
||||
onIdSelect: (newId: string) => void;
|
||||
};
|
||||
|
||||
const CreateScriptButton = ({ onIdSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { user } = useAuth();
|
||||
const modalProps = useDisclosure();
|
||||
const create = useCreateScript();
|
||||
const { form, formRef } = useFormRef();
|
||||
const { isConfirmOpen, closeConfirm, closeModal, closeCancelAndForm } = useFormModal({
|
||||
isDirty: form?.dirty,
|
||||
onModalClose: modalProps.onClose,
|
||||
});
|
||||
|
||||
const isDisabled = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateButton onClick={modalProps.onOpen} isCompact />
|
||||
<Modal
|
||||
{...modalProps}
|
||||
onClose={closeModal}
|
||||
title={t('crud.create_object', { obj: t('script.one') })}
|
||||
topRightButtons={
|
||||
<SaveButton onClick={form.submitForm} isLoading={form.isSubmitting} isDisabled={!form.isValid} />
|
||||
}
|
||||
>
|
||||
<Box>
|
||||
<Formik<Script>
|
||||
enableReinitialize
|
||||
innerRef={formRef as React.Ref<FormikProps<Script>>}
|
||||
initialValues={ScriptSchema(t, user?.email).cast()}
|
||||
validationSchema={ScriptSchema(t, user?.email)}
|
||||
onSubmit={(data, { setSubmitting, resetForm }) => {
|
||||
create.mutateAsync(
|
||||
{ ...data, author: user?.email },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
toast({
|
||||
id: `script-create-success`,
|
||||
title: t('common.success'),
|
||||
description: t('script.create_success'),
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
setSubmitting(false);
|
||||
resetForm();
|
||||
modalProps.onClose();
|
||||
onIdSelect(response.id);
|
||||
},
|
||||
onError: (e) => {
|
||||
setSubmitting(false);
|
||||
if (axios.isAxiosError(e))
|
||||
toast({
|
||||
id: `script-create-error`,
|
||||
title: t('common.error'),
|
||||
description: e?.response?.data?.ErrorDescription,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(props: { setFieldValue: (k: string, v: unknown) => void; values: Script }) => (
|
||||
<Box>
|
||||
<SimpleGrid spacing={4} minChildWidth="200px">
|
||||
<StringField name="name" label={t('common.name')} isRequired isDisabled={isDisabled} />
|
||||
<StringField name="description" label={t('common.description')} isDisabled={isDisabled} />
|
||||
</SimpleGrid>
|
||||
<Box mt={2} maxW="460px">
|
||||
<StringField name="uri" label={t('script.helper')} isDisabled={isDisabled} />
|
||||
</Box>
|
||||
<Flex mt={2}>
|
||||
<Box w="120px" mr={2} mb={4}>
|
||||
<ToggleField
|
||||
name="deferred"
|
||||
label={t('script.deferred')}
|
||||
onChangeCallback={(v) => {
|
||||
if (v) {
|
||||
props.setFieldValue('timeout', undefined);
|
||||
} else {
|
||||
props.setFieldValue('timeout', 30);
|
||||
}
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
{!props.values.deferred && (
|
||||
<NumberField
|
||||
name="timeout"
|
||||
label={t('script.timeout')}
|
||||
isDisabled={isDisabled}
|
||||
unit="s"
|
||||
isRequired
|
||||
w="100px"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box mt={2}>
|
||||
<RolesInput name="restricted" label={t('script.restricted')} isDisabled={isDisabled} />
|
||||
</Box>
|
||||
<Box mt={2}>
|
||||
<ScriptUploadField name="defaultUploadURI" isDisabled={isDisabled} />
|
||||
</Box>
|
||||
<Flex mt={2}>
|
||||
<Box w="120px">
|
||||
<SelectField
|
||||
name="type"
|
||||
label={t('common.type')}
|
||||
options={[
|
||||
{ value: 'shell', label: 'Shell' },
|
||||
{ value: 'bundle', label: 'Bundle' },
|
||||
]}
|
||||
isRequired
|
||||
isDisabled={isDisabled}
|
||||
w="120px"
|
||||
/>
|
||||
</Box>
|
||||
<Box mx={2}>
|
||||
<StringField
|
||||
name="version"
|
||||
label={t('footer.version')}
|
||||
isRequired
|
||||
isDisabled={isDisabled}
|
||||
w="160px"
|
||||
/>
|
||||
</Box>
|
||||
<StringField name="author" label={t('script.author')} isRequired isDisabled={isDisabled} />
|
||||
</Flex>
|
||||
<Box mt={2}>
|
||||
<ScriptFileInput isDisabled={isDisabled} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Formik>
|
||||
</Box>
|
||||
</Modal>
|
||||
<ConfirmCloseAlertModal isOpen={isConfirmOpen} confirm={closeCancelAndForm} cancel={closeConfirm} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateScriptButton;
|
||||
76
src/pages/Scripts/TableCard/RolesInput.tsx
Normal file
76
src/pages/Scripts/TableCard/RolesInput.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { FormControl, FormErrorMessage, FormLabel } from '@chakra-ui/react';
|
||||
import { GroupBase, MultiValue, OptionBase, Select } from 'chakra-react-select';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { useFastField } from 'hooks/useFastField';
|
||||
|
||||
interface Option extends OptionBase {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const OPTIONS: Option[] = [
|
||||
{ value: 'root', label: 'Root', isFixed: true },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'subscriber', label: 'Subscriber' },
|
||||
{ value: 'partner', label: 'Partner' },
|
||||
{ value: 'csr', label: 'CSR' },
|
||||
{ value: 'system', label: 'System' },
|
||||
{ value: 'installer', label: 'Installer' },
|
||||
{ value: 'noc', label: 'NOC' },
|
||||
{ value: 'accounting', label: 'Accounting' },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
label: string;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
const FastMultiSelectInput = ({ name, label, isDisabled }: Props) => {
|
||||
const { value, onChange, onBlur, error, isError } = useFastField<string[]>({ name });
|
||||
|
||||
const handleChange = (newValue: MultiValue<Option>) => {
|
||||
if (newValue.length === 0) onChange(['root']);
|
||||
else onChange(newValue.map((v) => v.value));
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={isError}>
|
||||
<FormLabel ms="4px" fontSize="md" fontWeight="normal" _disabled={{ opacity: 0.8 }}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
<Select<Option, true, GroupBase<Option>>
|
||||
chakraStyles={{
|
||||
control: (provided) => ({
|
||||
...provided,
|
||||
borderRadius: '15px',
|
||||
opacity: isDisabled ? '0.8 !important' : '1',
|
||||
border: '2px solid',
|
||||
}),
|
||||
dropdownIndicator: (provided) => ({
|
||||
...provided,
|
||||
backgroundColor: 'unset',
|
||||
border: 'unset',
|
||||
}),
|
||||
}}
|
||||
isMulti
|
||||
closeMenuOnSelect={false}
|
||||
options={OPTIONS}
|
||||
value={
|
||||
value
|
||||
?.map((val) => OPTIONS.find((opt) => opt.value === val) ?? { value: '', label: '' })
|
||||
.filter((val) => val !== undefined)
|
||||
.sort((a) => (a.value === 'root' ? -1 : 1)) as MultiValue<Option>
|
||||
}
|
||||
isClearable={value.length > 1}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
<FormErrorMessage>{error}</FormErrorMessage>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(FastMultiSelectInput, isEqual);
|
||||
125
src/pages/Scripts/TableCard/ScripFile.tsx
Normal file
125
src/pages/Scripts/TableCard/ScripFile.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
InputGroup,
|
||||
Spacer,
|
||||
Text,
|
||||
Textarea,
|
||||
useBoolean,
|
||||
useClipboard,
|
||||
} from '@chakra-ui/react';
|
||||
import { UploadSimple } from 'phosphor-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { useFastField } from 'hooks/useFastField';
|
||||
|
||||
type Props = {
|
||||
isDisabled: boolean;
|
||||
};
|
||||
const ScriptFileInput = ({ isDisabled }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [fileKey, setFileKey] = React.useState(uuid());
|
||||
const fileInputRef = React.useRef<HTMLInputElement>();
|
||||
const { onChange, error, isError, value, onBlur } = useFastField<string | undefined>({ name: 'content' });
|
||||
const [isTooLarge, { on, off }] = useBoolean();
|
||||
const { hasCopied, onCopy, setValue } = useClipboard(value ?? '');
|
||||
|
||||
let fileReader: FileReader | undefined;
|
||||
|
||||
const handleStringFileRead = () => {
|
||||
if (fileReader) {
|
||||
const content = fileReader.result;
|
||||
if (content) {
|
||||
setFileKey(uuid());
|
||||
onChange(content as string);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
if (fileInputRef.current) fileInputRef?.current?.click();
|
||||
};
|
||||
|
||||
const changeFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files ? e.target.files[0] : undefined;
|
||||
off();
|
||||
|
||||
// File has to be under 2MB
|
||||
if (file && file.size < 500 * 1024) {
|
||||
fileReader = new FileReader();
|
||||
fileReader.onloadend = handleStringFileRead;
|
||||
fileReader.readAsText(file);
|
||||
} else {
|
||||
on();
|
||||
}
|
||||
};
|
||||
|
||||
const onInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value) setValue(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={isError} isDisabled={isDisabled} mt={2}>
|
||||
<FormLabel ms="4px" fontSize="md" fontWeight="normal" _disabled={{ opacity: 0.8 }} display="flex">
|
||||
<Text my="auto">{t('script.one')}</Text>
|
||||
<Flex>
|
||||
<Input
|
||||
borderRadius="15px"
|
||||
pt={1}
|
||||
fontSize="sm"
|
||||
type="file"
|
||||
onChange={changeFile}
|
||||
key={fileKey}
|
||||
isDisabled={isDisabled}
|
||||
w="300px"
|
||||
mb={2}
|
||||
ref={fileInputRef as React.LegacyRef<HTMLInputElement> | undefined}
|
||||
hidden
|
||||
/>
|
||||
<Button
|
||||
onClick={handleUploadClick}
|
||||
rightIcon={<UploadSimple />}
|
||||
size="sm"
|
||||
my="auto"
|
||||
ml={2}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{t('script.upload_file')}
|
||||
</Button>
|
||||
{isTooLarge && (
|
||||
<Text ml={2} fontWeight="bold" textColor="red" my="auto">
|
||||
{t('script.file_too_large')}
|
||||
</Text>
|
||||
)}
|
||||
<Spacer />
|
||||
<Button onClick={onCopy} size="md" ml={2} colorScheme="teal">
|
||||
{hasCopied ? t('common.copied') : t('common.copy')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</FormLabel>
|
||||
<InputGroup size="md">
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={onInputChange}
|
||||
onBlur={onBlur}
|
||||
borderRadius="15px"
|
||||
fontSize="sm"
|
||||
minH="200px"
|
||||
_disabled={{ opacity: 0.8, cursor: 'not-allowed' }}
|
||||
/>
|
||||
</InputGroup>
|
||||
<FormErrorMessage>{error}</FormErrorMessage>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptFileInput;
|
||||
92
src/pages/Scripts/TableCard/UploadField.tsx
Normal file
92
src/pages/Scripts/TableCard/UploadField.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
InputGroup,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Text,
|
||||
useBreakpoint,
|
||||
} from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFastField } from 'hooks/useFastField';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
isDisabled: boolean;
|
||||
largeVersion?: boolean;
|
||||
};
|
||||
const ScriptUploadField = ({ name, isDisabled, largeVersion }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const breakpoint = useBreakpoint('lg');
|
||||
const { value, onChange, isError, error } = useFastField<string | undefined>({ name });
|
||||
|
||||
const onRadioChange = (v: string) => {
|
||||
if (v === '0') onChange(undefined);
|
||||
else onChange('');
|
||||
};
|
||||
|
||||
const isCompact = React.useMemo(
|
||||
() => breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md',
|
||||
[breakpoint],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={isError} isRequired isDisabled={isDisabled}>
|
||||
<FormLabel ms={0} mb={0} fontSize="md" fontWeight="normal" _disabled={{ opacity: 0.8 }}>
|
||||
{t('script.upload_destination')}
|
||||
</FormLabel>
|
||||
<Flex h="40px">
|
||||
<RadioGroup onChange={onRadioChange} defaultValue={value === undefined ? '0' : '1'}>
|
||||
<Stack spacing={5} direction="row">
|
||||
<Radio colorScheme="blue" value="0">
|
||||
{t('script.automatic')}
|
||||
</Radio>
|
||||
<Radio colorScheme="green" value="1">
|
||||
<Flex>
|
||||
<Text my="auto" mr={2} w="180px">
|
||||
{t('script.custom_domain')}
|
||||
</Text>
|
||||
{!isCompact && (
|
||||
<InputGroup>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
isDisabled={isDisabled || value === undefined}
|
||||
onClick={(e) => {
|
||||
e.target.select();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
minW={largeVersion ? '600px' : '400px'}
|
||||
/>
|
||||
</InputGroup>
|
||||
)}
|
||||
</Flex>
|
||||
</Radio>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</Flex>
|
||||
{isCompact && (
|
||||
<InputGroup>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
isDisabled={isDisabled || value === undefined}
|
||||
onClick={(e) => {
|
||||
e.target.select();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
w="100%"
|
||||
/>
|
||||
</InputGroup>
|
||||
)}
|
||||
<FormErrorMessage>{error}</FormErrorMessage>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptUploadField;
|
||||
128
src/pages/Scripts/TableCard/index.tsx
Normal file
128
src/pages/Scripts/TableCard/index.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Button, Heading, HStack, Spacer } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ScriptTableActions from './Actions';
|
||||
import CreateScriptButton from './CreateButton';
|
||||
import useScriptsTable from './useScriptsTable';
|
||||
import { RefreshButton } from 'components/Buttons/RefreshButton';
|
||||
import { Card } from 'components/Containers/Card';
|
||||
import { CardBody } from 'components/Containers/Card/CardBody';
|
||||
import { CardHeader } from 'components/Containers/Card/CardHeader';
|
||||
import { ColumnPicker } from 'components/DataTables/ColumnPicker';
|
||||
import { DataTable } from 'components/DataTables/DataTable';
|
||||
import FormattedDate from 'components/InformationDisplays/FormattedDate';
|
||||
import { Script } from 'hooks/Network/Scripts';
|
||||
import { Column } from 'models/Table';
|
||||
|
||||
type Props = {
|
||||
onIdSelect: (newId: string) => void;
|
||||
};
|
||||
|
||||
const ScriptTableCard = ({ onIdSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { query, hiddenColumns } = useScriptsTable();
|
||||
|
||||
const dateCell = React.useCallback((date: number) => <FormattedDate date={date} />, []);
|
||||
const actionCell = React.useCallback(
|
||||
(script: Script) => <ScriptTableActions script={script} onSelect={onIdSelect} />,
|
||||
[],
|
||||
);
|
||||
const nameCell = React.useCallback(
|
||||
(script: Script) => (
|
||||
<Button variant="link" onClick={() => onIdSelect(script.id)} size="sm">
|
||||
{script.name}
|
||||
</Button>
|
||||
),
|
||||
[],
|
||||
);
|
||||
const columns = React.useMemo(
|
||||
(): Column<Script>[] => [
|
||||
{
|
||||
id: 'name',
|
||||
Header: t('common.name'),
|
||||
Footer: '',
|
||||
accessor: 'name',
|
||||
alwaysShow: true,
|
||||
Cell: ({ cell }) => nameCell(cell.row.original),
|
||||
customWidth: '120px',
|
||||
},
|
||||
{
|
||||
id: 'author',
|
||||
Header: t('script.author'),
|
||||
Footer: '',
|
||||
accessor: 'author',
|
||||
customWidth: '120px',
|
||||
},
|
||||
{
|
||||
id: 'version',
|
||||
Header: t('footer.version'),
|
||||
Footer: '',
|
||||
accessor: 'version',
|
||||
customWidth: '120px',
|
||||
},
|
||||
{
|
||||
id: 'modified',
|
||||
Header: t('common.modified'),
|
||||
Footer: '',
|
||||
Cell: ({ cell }) => dateCell(cell.row.original.modified),
|
||||
accessor: 'modified',
|
||||
customWidth: '120px',
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
Header: t('common.description'),
|
||||
Footer: '',
|
||||
accessor: 'description',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
Header: t('common.actions'),
|
||||
Footer: '',
|
||||
Cell: (v) => actionCell(v.cell.row.original),
|
||||
disableSortBy: true,
|
||||
customWidth: '120px',
|
||||
alwaysShow: true,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="md">
|
||||
{t('script.other')} {query.data ? `(${query.data.length})` : ''}
|
||||
</Heading>
|
||||
<Spacer />
|
||||
<HStack>
|
||||
<CreateScriptButton onIdSelect={onIdSelect} />
|
||||
<ColumnPicker
|
||||
columns={columns as Column<unknown>[]}
|
||||
hiddenColumns={hiddenColumns[0]}
|
||||
setHiddenColumns={hiddenColumns[1]}
|
||||
preference="scripts.page.table.hiddenColumns"
|
||||
isCompact
|
||||
/>
|
||||
<RefreshButton onClick={query.refetch} isFetching={query.isFetching} isCompact colorScheme="blue" />
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Box w="100%" h="300px" overflowY="auto">
|
||||
<DataTable
|
||||
columns={columns as Column<object>[]}
|
||||
saveSettingsId="apiKeys.profile.table"
|
||||
data={query.data ?? []}
|
||||
obj={t('script.other')}
|
||||
sortBy={[{ id: 'name', desc: false }]}
|
||||
minHeight="300px"
|
||||
hiddenColumns={hiddenColumns[0]}
|
||||
showAllRows
|
||||
hideControls
|
||||
/>
|
||||
</Box>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptTableCard;
|
||||
14
src/pages/Scripts/TableCard/useScriptsTable.ts
Normal file
14
src/pages/Scripts/TableCard/useScriptsTable.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useGetAllDeviceScripts } from 'hooks/Network/Scripts';
|
||||
|
||||
const useScriptsTable = () => {
|
||||
const getScripts = useGetAllDeviceScripts();
|
||||
const hiddenColumns = React.useState<string[]>([]);
|
||||
|
||||
return {
|
||||
query: getScripts,
|
||||
hiddenColumns,
|
||||
};
|
||||
};
|
||||
|
||||
export default useScriptsTable;
|
||||
156
src/pages/Scripts/ViewScriptCard/Form.tsx
Normal file
156
src/pages/Scripts/ViewScriptCard/Form.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import * as React from 'react';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import { Box, Flex, IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import { Formik, FormikProps } from 'formik';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ScriptSchema } from '../TableCard/CreateButton';
|
||||
import RolesInput from '../TableCard/RolesInput';
|
||||
import ScriptFileInput from '../TableCard/ScripFile';
|
||||
import ScriptUploadField from '../TableCard/UploadField';
|
||||
import { NumberField } from 'components/Form/Fields/NumberField';
|
||||
import { SelectField } from 'components/Form/Fields/SelectField';
|
||||
import { StringField } from 'components/Form/Fields/StringField';
|
||||
import { ToggleField } from 'components/Form/Fields/ToggleField';
|
||||
import { Script } from 'hooks/Network/Scripts';
|
||||
|
||||
type Props = {
|
||||
script: Script;
|
||||
formRef: React.Ref<FormikProps<Script>>;
|
||||
isEditing: boolean;
|
||||
onSubmit: (
|
||||
data: {
|
||||
id: string;
|
||||
description?: string | undefined;
|
||||
name?: string | undefined;
|
||||
uri?: string | undefined;
|
||||
content?: string | undefined;
|
||||
version?: string | undefined;
|
||||
deferred?: boolean | undefined;
|
||||
timeout?: number | undefined;
|
||||
},
|
||||
onSuccess: () => void,
|
||||
onError: () => void,
|
||||
) => Promise<Script>;
|
||||
};
|
||||
|
||||
const EditScriptForm = ({ script, formRef, isEditing, onSubmit }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [formKey, setFormKey] = React.useState(uuid());
|
||||
|
||||
const isDisabled = !isEditing;
|
||||
|
||||
const goToHelper = () => {
|
||||
window.open(script.uri, '_blank')?.focus();
|
||||
};
|
||||
|
||||
React.useEffect(() => setFormKey(uuid()), [script, isEditing]);
|
||||
|
||||
return (
|
||||
<Formik<Script>
|
||||
key={formKey}
|
||||
enableReinitialize
|
||||
innerRef={formRef as React.Ref<FormikProps<Script>>}
|
||||
initialValues={{
|
||||
...script,
|
||||
defaultUploadURI: script.defaultUploadURI.length === 0 ? undefined : script.defaultUploadURI,
|
||||
}}
|
||||
validationSchema={ScriptSchema(t)}
|
||||
onSubmit={async (data, { setSubmitting, resetForm }) =>
|
||||
onSubmit(
|
||||
data,
|
||||
() => {
|
||||
setSubmitting(false);
|
||||
resetForm();
|
||||
},
|
||||
() => setSubmitting(false),
|
||||
)
|
||||
}
|
||||
>
|
||||
{(props: { setFieldValue: (k: string, v: unknown) => void; values: Script }) => (
|
||||
<Box w="100%">
|
||||
<Flex>
|
||||
<Box w="240px" mr={2}>
|
||||
<StringField name="name" label={t('common.name')} isRequired isDisabled={isDisabled} />
|
||||
</Box>
|
||||
<StringField name="description" label={t('common.description')} isDisabled={isDisabled} />
|
||||
</Flex>
|
||||
<Flex mt={2}>
|
||||
<Box w="120px">
|
||||
<SelectField
|
||||
name="type"
|
||||
label={t('common.type')}
|
||||
options={[
|
||||
{ value: 'shell', label: 'Shell' },
|
||||
{ value: 'bundle', label: 'Bundle' },
|
||||
]}
|
||||
isRequired
|
||||
isDisabled
|
||||
w="120px"
|
||||
/>
|
||||
</Box>
|
||||
<Box mx={2}>
|
||||
<StringField name="version" label={t('footer.version')} isRequired isDisabled={isDisabled} w="160px" />
|
||||
</Box>
|
||||
<Box w="100%" maxW="240px" mr={2}>
|
||||
<StringField name="author" label={t('script.author')} isRequired isDisabled />
|
||||
</Box>
|
||||
<Box maxW="460px" display="flex">
|
||||
<StringField name="uri" label={t('script.helper')} isDisabled={isDisabled} />
|
||||
<Box ml={2} mt="auto">
|
||||
<Tooltip label={t('script.visit_external_website')}>
|
||||
<IconButton
|
||||
aria-label="Go to helper"
|
||||
colorScheme="teal"
|
||||
icon={<ExternalLinkIcon />}
|
||||
onClick={goToHelper}
|
||||
isDisabled={script.uri.length === 0}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex mt={2}>
|
||||
<Box w="120px" mr={2} mb={4}>
|
||||
<ToggleField
|
||||
name="deferred"
|
||||
label={t('script.deferred')}
|
||||
onChangeCallback={(v) => {
|
||||
if (v) {
|
||||
props.setFieldValue('timeout', undefined);
|
||||
} else {
|
||||
props.setFieldValue('timeout', 30);
|
||||
}
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
{!props.values.deferred && (
|
||||
<NumberField
|
||||
name="timeout"
|
||||
label={t('script.timeout')}
|
||||
isDisabled={isDisabled}
|
||||
unit="s"
|
||||
isRequired
|
||||
w="100px"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box mt={2} maxW="560px">
|
||||
<RolesInput name="restricted" label={t('script.restricted')} isDisabled={isDisabled} />
|
||||
</Box>
|
||||
<Box mt={2}>
|
||||
<ScriptUploadField name="defaultUploadURI" isDisabled={isDisabled} largeVersion />
|
||||
</Box>
|
||||
<Box mt={2}>
|
||||
<ScriptFileInput isDisabled={isDisabled} isLargeVersion />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditScriptForm;
|
||||
162
src/pages/Scripts/ViewScriptCard/index.tsx
Normal file
162
src/pages/Scripts/ViewScriptCard/index.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
Box,
|
||||
Center,
|
||||
Heading,
|
||||
HStack,
|
||||
Spacer,
|
||||
Spinner,
|
||||
useBoolean,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import axios from 'axios';
|
||||
import { FormikProps } from 'formik';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import EditScriptForm from './Form';
|
||||
import { RefreshButton } from 'components/Buttons/RefreshButton';
|
||||
import { SaveButton } from 'components/Buttons/SaveButton';
|
||||
import { ToggleEditButton } from 'components/Buttons/ToggleEditButton';
|
||||
import { Card } from 'components/Containers/Card';
|
||||
import { CardBody } from 'components/Containers/Card/CardBody';
|
||||
import { CardHeader } from 'components/Containers/Card/CardHeader';
|
||||
import { Script, useGetDeviceScript, useUpdateScript } from 'hooks/Network/Scripts';
|
||||
import { useFormRef } from 'hooks/useFormRef';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
onIdSelect: (id: string) => void;
|
||||
};
|
||||
|
||||
const ViewScriptCard = ({ id, onIdSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const getScript = useGetDeviceScript({ id });
|
||||
const update = useUpdateScript({ id });
|
||||
const [isEditing, { toggle, off }] = useBoolean();
|
||||
const { form, formRef } = useFormRef();
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
async (
|
||||
data: {
|
||||
id: string;
|
||||
description?: string | undefined;
|
||||
name?: string | undefined;
|
||||
uri?: string | undefined;
|
||||
content?: string | undefined;
|
||||
defaultUploadURI?: string | undefined;
|
||||
version?: string | undefined;
|
||||
deferred?: boolean | undefined;
|
||||
timeout?: number | undefined;
|
||||
restricted?: string[];
|
||||
},
|
||||
onSuccess: () => void,
|
||||
onError: () => void,
|
||||
) =>
|
||||
update.mutateAsync(
|
||||
{ ...data, defaultUploadURI: data.defaultUploadURI ?? '' },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
id: `script-update-success`,
|
||||
title: t('common.success'),
|
||||
description: t('script.update_success'),
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
onSuccess();
|
||||
toggle();
|
||||
},
|
||||
onError: (e) => {
|
||||
onError();
|
||||
if (axios.isAxiosError(e))
|
||||
toast({
|
||||
id: `script-create-error`,
|
||||
title: t('common.error'),
|
||||
description: e?.response?.data?.ErrorDescription,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
},
|
||||
},
|
||||
),
|
||||
[toggle, t, toast, getScript.data],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
off();
|
||||
}, [id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (axios.isAxiosError(getScript.error)) {
|
||||
onIdSelect('0');
|
||||
}
|
||||
}, [getScript.error]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="md">{getScript.data?.name}</Heading>
|
||||
<Spacer />
|
||||
<HStack>
|
||||
{isEditing && (
|
||||
<SaveButton
|
||||
onClick={form.submitForm}
|
||||
isLoading={form.isSubmitting}
|
||||
isDisabled={!form.isValid || !form.dirty}
|
||||
/>
|
||||
)}
|
||||
<ToggleEditButton
|
||||
isEditing={isEditing}
|
||||
toggleEdit={toggle}
|
||||
isDirty={form.dirty}
|
||||
isDisabled={getScript.isFetching}
|
||||
isCompact
|
||||
/>
|
||||
<RefreshButton
|
||||
onClick={getScript.refetch}
|
||||
isFetching={getScript.isFetching}
|
||||
isDisabled={isEditing}
|
||||
isCompact
|
||||
colorScheme="blue"
|
||||
/>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Box w="100%">
|
||||
{getScript.error && (
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
{axios.isAxiosError(getScript.error)
|
||||
? getScript.error.response?.data?.ErrorDescription
|
||||
: t('common.error')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{getScript.isFetching && (
|
||||
<Center my="100px" w="100%">
|
||||
<Spinner size="xl" />
|
||||
</Center>
|
||||
)}
|
||||
{getScript.data && (
|
||||
<EditScriptForm
|
||||
script={getScript.data}
|
||||
formRef={formRef as React.Ref<FormikProps<Script>>}
|
||||
isEditing={isEditing}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewScriptCard;
|
||||
33
src/pages/Scripts/index.tsx
Normal file
33
src/pages/Scripts/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { VStack } from '@chakra-ui/react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import ScriptTableCard from './TableCard';
|
||||
import ViewScriptCard from './ViewScriptCard';
|
||||
import { useGetAllDeviceScripts } from 'hooks/Network/Scripts';
|
||||
|
||||
const ScriptsPage = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const getScripts = useGetAllDeviceScripts();
|
||||
const isScriptSelected = !!id && id !== '0';
|
||||
|
||||
const onIdSelect = React.useCallback(
|
||||
(newId: string) => {
|
||||
if (newId !== id) {
|
||||
navigate(`/scripts/${newId}`);
|
||||
}
|
||||
},
|
||||
[id],
|
||||
);
|
||||
|
||||
const idToUse = isScriptSelected ? id : getScripts.data?.[0]?.id;
|
||||
|
||||
return (
|
||||
<VStack spacing={4}>
|
||||
<ScriptTableCard onIdSelect={onIdSelect} />
|
||||
{idToUse !== undefined ? <ViewScriptCard id={idToUse} onIdSelect={onIdSelect} /> : null}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptsPage;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Flex, Heading, SimpleGrid, Spacer } from '@chakra-ui/react';
|
||||
import { Heading, SimpleGrid, Spacer } from '@chakra-ui/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import SystemTile from './SystemTile';
|
||||
@@ -12,11 +12,11 @@ import { useGetEndpoints } from 'hooks/Network/Endpoints';
|
||||
|
||||
const SystemPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, isUserLoaded } = useAuth();
|
||||
const { token } = useAuth();
|
||||
const { data: endpoints, refetch, isFetching } = useGetEndpoints({ onSuccess: () => {} });
|
||||
|
||||
const endpointsList = React.useMemo(() => {
|
||||
if (!endpoints || !token || !isUserLoaded) return null;
|
||||
if (!endpoints || !token) return null;
|
||||
|
||||
const endpointList = [...endpoints];
|
||||
endpointList.push({
|
||||
@@ -34,12 +34,10 @@ const SystemPage = () => {
|
||||
return 0;
|
||||
})
|
||||
.map((endpoint) => <SystemTile key={uuid()} endpoint={endpoint} token={token} />);
|
||||
}, [endpoints, token, isUserLoaded]);
|
||||
|
||||
if (!isUserLoaded) return null;
|
||||
}, [endpoints, token]);
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" pt="75px">
|
||||
<>
|
||||
<Card mb={4} py={2} px={4}>
|
||||
<CardHeader>
|
||||
<Heading size="md" my="auto">
|
||||
@@ -52,7 +50,7 @@ const SystemPage = () => {
|
||||
<SimpleGrid minChildWidth="500px" spacing="20px" mb={3}>
|
||||
{endpointsList}
|
||||
</SimpleGrid>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default SystemPage;
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import UserTable from './Table';
|
||||
import { useAuth } from 'contexts/AuthProvider';
|
||||
|
||||
const UsersPage = () => {
|
||||
const { isUserLoaded } = useAuth();
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" pt="75px">
|
||||
{isUserLoaded && <UserTable />}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
const UsersPage = () => <UserTable />;
|
||||
|
||||
export default UsersPage;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@chakra-ui/react';
|
||||
import { Barcode, FloppyDisk, Info, ListBullets, UsersThree, WifiHigh } from 'phosphor-react';
|
||||
import { Barcode, FloppyDisk, Info, ListBullets, TerminalWindow, UsersThree, WifiHigh } from 'phosphor-react';
|
||||
import { Route } from 'models/Routes';
|
||||
|
||||
const DefaultConfigurationsPage = React.lazy(() => import('pages/DefaultConfigurations'));
|
||||
@@ -9,6 +9,7 @@ const DevicesPage = React.lazy(() => import('pages/Devices'));
|
||||
const FirmwarePage = React.lazy(() => import('pages/Firmware'));
|
||||
const NotificationsPage = React.lazy(() => import('pages/Notifications'));
|
||||
const ProfilePage = React.lazy(() => import('pages/Profile'));
|
||||
const ScriptsPage = React.lazy(() => import('pages/Scripts'));
|
||||
const SystemPage = React.lazy(() => import('pages/SystemPage'));
|
||||
const UsersPage = React.lazy(() => import('pages/UsersPage'));
|
||||
|
||||
@@ -31,6 +32,15 @@ const routes: Route[] = [
|
||||
),
|
||||
component: FirmwarePage,
|
||||
},
|
||||
{
|
||||
authorized: ['root'],
|
||||
path: '/scripts/:id',
|
||||
name: 'script.other',
|
||||
icon: (active: boolean) => (
|
||||
<Icon as={TerminalWindow} color="inherit" h={active ? '32px' : '24px'} w={active ? '32px' : '24px'} />
|
||||
),
|
||||
component: ScriptsPage,
|
||||
},
|
||||
{
|
||||
authorized: ['root', 'partner', 'admin', 'csr', 'system'],
|
||||
path: '/configurations',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
"target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
@@ -28,7 +28,7 @@
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs" /* Specify what module code is generated. */,
|
||||
"module": "ES2022" /* Specify what module code is generated. */,
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
||||
"baseUrl": "src" /* Specify the base directory to resolve non-relative module names. */,
|
||||
|
||||
Reference in New Issue
Block a user