[WIFI-11542] AP Scripts

Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
Charles
2022-12-01 16:12:30 +00:00
parent d01453ea1d
commit 21452d091f
80 changed files with 3202 additions and 937 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "ucentral-client",
"version": "2.8.0(30)",
"version": "2.8.0(31)",
"description": "",
"private": true,
"main": "index.tsx",

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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>
);
};

View File

@@ -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> = ({

View File

@@ -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}>

View File

@@ -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 />} />

View 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);

View File

@@ -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} />
)}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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}
/>
</>
);
};

View 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],
);
};

View File

@@ -1,4 +1,3 @@
/* eslint-disable import/prefer-default-export */
export const COLORS = [
'#63b598',
'#ce7d78',

View File

@@ -1,4 +1,3 @@
/* eslint-disable import/prefer-default-export */
export const getRevision = (str?: string) => {
if (!str) return '-';

View File

@@ -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();
},
});

View File

@@ -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({

View File

@@ -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';

View File

@@ -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,

View 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);
},
});
};

View File

@@ -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';

View File

@@ -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();

View File

@@ -1,4 +1,3 @@
/* eslint-disable import/prefer-default-export */
import { useAuth } from 'contexts/AuthProvider';
export const useEndpointStatus = (endpoint: string) => {

View File

@@ -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);

View File

@@ -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: () => {},

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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>
);
};

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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>
</>
);
};

View File

@@ -112,6 +112,7 @@ export interface WifiScanResult {
completed: number;
errorCode: number;
errorText: string;
serialNumber: string;
executed: number;
executionTime: number;
results: {

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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;

View File

@@ -1,4 +1,3 @@
/* eslint-disable import/prefer-default-export */
import * as Yup from 'yup';
import { testJson } from 'helpers/formTests';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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>
);
};

View File

@@ -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 (

View File

@@ -1,4 +1,3 @@
/* eslint-disable import/prefer-default-export */
import React from 'react';
import { DeviceStatistics, useGetDeviceNewestStats, useGetDeviceStatsWithTimestamps } from 'hooks/Network/Statistics';

View File

@@ -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;

View File

@@ -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

View File

@@ -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}
</>
);
};

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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;

View 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;

View 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;

View 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);

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',

View File

@@ -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. */,