mirror of
				https://github.com/Telecominfraproject/wlan-cloud-ucentralgw-ui.git
				synced 2025-10-30 18:27:53 +00:00 
			
		
		
		
	Compare commits
	
		
			38 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 177d24e508 | ||
|   | 69b0d1ee9d | ||
|   | ef52497b04 | ||
|   | 039e641046 | ||
|   | f1f62efe6f | ||
|   | b3053f32b2 | ||
|   | 09184b0402 | ||
|   | 98562fd967 | ||
|   | 65e9e64cb4 | ||
|   | 573ecbd58d | ||
|   | a801fcca49 | ||
|   | e9d16ee172 | ||
|   | db4dfc93e8 | ||
|   | 975b715a7c | ||
|   | cf17f03ae0 | ||
|   | 64f3ee797e | ||
|   | e287705e88 | ||
|   | 9583b2bae0 | ||
|   | 2698993a6d | ||
|   | a14b595e8c | ||
|   | d7957b85ae | ||
|   | 227a51423d | ||
|   | ea0e7340cc | ||
|   | 999680e94b | ||
|   | 566dbbb157 | ||
|   | 75d995d54e | ||
|   | 908faa491b | ||
|   | 7a254e343e | ||
|   | 016ac336b9 | ||
|   | 1cfd3a10ad | ||
|   | 1838029d22 | ||
|   | 7767043a5a | ||
|   | b1cfa6db19 | ||
|   | 623d5a5546 | ||
|   | 8c676eb965 | ||
|   | 1e4ccce36c | ||
|   | 1808206e74 | ||
|   | 0fbc2b92aa | 
| @@ -8,7 +8,7 @@ fullnameOverride: "" | ||||
| images: | ||||
|   owgwui: | ||||
|     repository: tip-tip-wlan-cloud-ucentral.jfrog.io/owgw-ui | ||||
|     tag: main | ||||
|     tag: v2.9.0 | ||||
|     pullPolicy: Always | ||||
|  | ||||
| services: | ||||
|   | ||||
							
								
								
									
										97
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										97
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -1,12 +1,12 @@ | ||||
| { | ||||
|   "name": "ucentral-client", | ||||
|   "version": "2.8.0(44)", | ||||
|   "version": "2.9.0(23)", | ||||
|   "lockfileVersion": 2, | ||||
|   "requires": true, | ||||
|   "packages": { | ||||
|     "": { | ||||
|       "name": "ucentral-client", | ||||
|       "version": "2.8.0(44)", | ||||
|       "version": "2.9.0(23)", | ||||
|       "license": "ISC", | ||||
|       "dependencies": { | ||||
|         "@chakra-ui/icons": "^2.0.11", | ||||
| @@ -16,6 +16,8 @@ | ||||
|         "@emotion/react": "^11.10.4", | ||||
|         "@emotion/styled": "^11.10.4", | ||||
|         "@fontsource/inter": "^4.5.14", | ||||
|         "@googlemaps/react-wrapper": "^1.1.35", | ||||
|         "@googlemaps/typescript-guards": "^2.0.3", | ||||
|         "@react-spring/web": "^9.5.5", | ||||
|         "@tanstack/react-query": "^4.12.0", | ||||
|         "@textea/json-viewer": "^2.10.0", | ||||
| @@ -24,6 +26,7 @@ | ||||
|         "chakra-react-select": "^4.3.0", | ||||
|         "chart.js": "^3.9.1", | ||||
|         "dagre": "^0.8.5", | ||||
|         "fast-equals": "^4.0.3", | ||||
|         "formik": "^2.2.9", | ||||
|         "framer-motion": "^7.6.1", | ||||
|         "i18next": "^22.0.0", | ||||
| @@ -54,6 +57,7 @@ | ||||
|         "zustand": "^4.1.2" | ||||
|       }, | ||||
|       "devDependencies": { | ||||
|         "@types/google.maps": "^3.51.0", | ||||
|         "@types/node": "^18.11.2", | ||||
|         "@types/react": "^18.0.21", | ||||
|         "@types/react-csv": "^1.1.3", | ||||
| @@ -2848,6 +2852,30 @@ | ||||
|       "version": "4.5.14", | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@googlemaps/js-api-loader": { | ||||
|       "version": "1.15.1", | ||||
|       "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.15.1.tgz", | ||||
|       "integrity": "sha512-AsnEgNsB7S/VdrHGEQUaUM2e5tmjFGKBAfzR/AqO8O7TPq/jQGvoRw5liPBw4EMF38RDsHmKDV89q/X+qiUREQ==", | ||||
|       "dependencies": { | ||||
|         "fast-deep-equal": "^3.1.3" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@googlemaps/react-wrapper": { | ||||
|       "version": "1.1.35", | ||||
|       "resolved": "https://registry.npmjs.org/@googlemaps/react-wrapper/-/react-wrapper-1.1.35.tgz", | ||||
|       "integrity": "sha512-vK+BDQMHN0Oqr66cW3ZPWVK43BUmJJBu6P8T74tc6/fKpUJUlFEaZsupgIIRRRDW9ejB8uGagUmwOnA2gdcvbw==", | ||||
|       "dependencies": { | ||||
|         "@googlemaps/js-api-loader": "^1.13.2" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "react": ">=16.8.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@googlemaps/typescript-guards": { | ||||
|       "version": "2.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/@googlemaps/typescript-guards/-/typescript-guards-2.0.3.tgz", | ||||
|       "integrity": "sha512-3iHuO8H0jPehftsMK0kgyJzPYU/g/oiTRw+wu/yltqSZ7wJPt3vfsJHkPiuRpQjbnnWygX+T3mkRGyK/eyZ/lw==" | ||||
|     }, | ||||
|     "node_modules/@humanwhocodes/config-array": { | ||||
|       "version": "0.10.7", | ||||
|       "dev": true, | ||||
| @@ -3501,6 +3529,12 @@ | ||||
|       "dev": true, | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/@types/google.maps": { | ||||
|       "version": "3.51.0", | ||||
|       "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.51.0.tgz", | ||||
|       "integrity": "sha512-44/oQYjc5D6kxBcI3Qk9rk3IIOMwnlEMWDV7pwPJ2YI89s5Q1OzDrFvR7QJ3LFrpVXEhig+gyagFg54+foinFg==", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/@types/json-schema": { | ||||
|       "version": "7.0.11", | ||||
|       "dev": true, | ||||
| @@ -5529,7 +5563,6 @@ | ||||
|     }, | ||||
|     "node_modules/fast-deep-equal": { | ||||
|       "version": "3.1.3", | ||||
|       "dev": true, | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/fast-diff": { | ||||
| @@ -5537,6 +5570,11 @@ | ||||
|       "dev": true, | ||||
|       "license": "Apache-2.0" | ||||
|     }, | ||||
|     "node_modules/fast-equals": { | ||||
|       "version": "4.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", | ||||
|       "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==" | ||||
|     }, | ||||
|     "node_modules/fast-glob": { | ||||
|       "version": "3.2.12", | ||||
|       "dev": true, | ||||
| @@ -6642,8 +6680,9 @@ | ||||
|       "license": "MIT" | ||||
|     }, | ||||
|     "node_modules/json5": { | ||||
|       "version": "2.2.1", | ||||
|       "license": "MIT", | ||||
|       "version": "2.2.3", | ||||
|       "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", | ||||
|       "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", | ||||
|       "bin": { | ||||
|         "json5": "lib/cli.js" | ||||
|       }, | ||||
| @@ -8853,9 +8892,10 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/tsconfig-paths/node_modules/json5": { | ||||
|       "version": "1.0.1", | ||||
|       "version": "1.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", | ||||
|       "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", | ||||
|       "dev": true, | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "minimist": "^1.2.0" | ||||
|       }, | ||||
| @@ -11425,6 +11465,27 @@ | ||||
|     "@fontsource/inter": { | ||||
|       "version": "4.5.14" | ||||
|     }, | ||||
|     "@googlemaps/js-api-loader": { | ||||
|       "version": "1.15.1", | ||||
|       "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.15.1.tgz", | ||||
|       "integrity": "sha512-AsnEgNsB7S/VdrHGEQUaUM2e5tmjFGKBAfzR/AqO8O7TPq/jQGvoRw5liPBw4EMF38RDsHmKDV89q/X+qiUREQ==", | ||||
|       "requires": { | ||||
|         "fast-deep-equal": "^3.1.3" | ||||
|       } | ||||
|     }, | ||||
|     "@googlemaps/react-wrapper": { | ||||
|       "version": "1.1.35", | ||||
|       "resolved": "https://registry.npmjs.org/@googlemaps/react-wrapper/-/react-wrapper-1.1.35.tgz", | ||||
|       "integrity": "sha512-vK+BDQMHN0Oqr66cW3ZPWVK43BUmJJBu6P8T74tc6/fKpUJUlFEaZsupgIIRRRDW9ejB8uGagUmwOnA2gdcvbw==", | ||||
|       "requires": { | ||||
|         "@googlemaps/js-api-loader": "^1.13.2" | ||||
|       } | ||||
|     }, | ||||
|     "@googlemaps/typescript-guards": { | ||||
|       "version": "2.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/@googlemaps/typescript-guards/-/typescript-guards-2.0.3.tgz", | ||||
|       "integrity": "sha512-3iHuO8H0jPehftsMK0kgyJzPYU/g/oiTRw+wu/yltqSZ7wJPt3vfsJHkPiuRpQjbnnWygX+T3mkRGyK/eyZ/lw==" | ||||
|     }, | ||||
|     "@humanwhocodes/config-array": { | ||||
|       "version": "0.10.7", | ||||
|       "dev": true, | ||||
| @@ -11786,6 +11847,12 @@ | ||||
|       "version": "0.0.39", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "@types/google.maps": { | ||||
|       "version": "3.51.0", | ||||
|       "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.51.0.tgz", | ||||
|       "integrity": "sha512-44/oQYjc5D6kxBcI3Qk9rk3IIOMwnlEMWDV7pwPJ2YI89s5Q1OzDrFvR7QJ3LFrpVXEhig+gyagFg54+foinFg==", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "@types/json-schema": { | ||||
|       "version": "7.0.11", | ||||
|       "dev": true | ||||
| @@ -13025,13 +13092,17 @@ | ||||
|       } | ||||
|     }, | ||||
|     "fast-deep-equal": { | ||||
|       "version": "3.1.3", | ||||
|       "dev": true | ||||
|       "version": "3.1.3" | ||||
|     }, | ||||
|     "fast-diff": { | ||||
|       "version": "1.2.0", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "fast-equals": { | ||||
|       "version": "4.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", | ||||
|       "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==" | ||||
|     }, | ||||
|     "fast-glob": { | ||||
|       "version": "3.2.12", | ||||
|       "dev": true, | ||||
| @@ -13688,7 +13759,9 @@ | ||||
|       "dev": true | ||||
|     }, | ||||
|     "json5": { | ||||
|       "version": "2.2.1" | ||||
|       "version": "2.2.3", | ||||
|       "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", | ||||
|       "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" | ||||
|     }, | ||||
|     "jsonfile": { | ||||
|       "version": "6.1.0", | ||||
| @@ -14981,7 +15054,9 @@ | ||||
|       }, | ||||
|       "dependencies": { | ||||
|         "json5": { | ||||
|           "version": "1.0.1", | ||||
|           "version": "1.0.2", | ||||
|           "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", | ||||
|           "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", | ||||
|           "dev": true, | ||||
|           "requires": { | ||||
|             "minimist": "^1.2.0" | ||||
|   | ||||
| @@ -1,13 +1,13 @@ | ||||
| { | ||||
|   "name": "ucentral-client", | ||||
|   "version": "2.8.0(44)", | ||||
|   "version": "2.9.0(23)", | ||||
|   "description": "", | ||||
|   "private": true, | ||||
|   "main": "index.tsx", | ||||
|   "scripts": { | ||||
|     "dev": "vite", | ||||
|     "build": "vite build", | ||||
|     "format": "prettier --write \"src/**/*.js\"", | ||||
|     "format": "prettier --write \"src/**/*x.{ts,tsx,js,jsx}\"", | ||||
|     "analyze": "source-map-explorer 'build/static/js/*.js'", | ||||
|     "lint": "TIMING=1 eslint \"src/**/*.{ts,tsx,js,jsx}\" --fix", | ||||
|     "clean": "rm -rf node_modules && rm -rf build" | ||||
| @@ -22,12 +22,15 @@ | ||||
|     "@emotion/react": "^11.10.4", | ||||
|     "@emotion/styled": "^11.10.4", | ||||
|     "@fontsource/inter": "^4.5.14", | ||||
|     "@googlemaps/react-wrapper": "^1.1.35", | ||||
|     "@googlemaps/typescript-guards": "^2.0.3", | ||||
|     "@react-spring/web": "^9.5.5", | ||||
|     "axios": "^1.1.3", | ||||
|     "buffer": "^6.0.3", | ||||
|     "chakra-react-select": "^4.3.0", | ||||
|     "dagre": "^0.8.5", | ||||
|     "formik": "^2.2.9", | ||||
|     "fast-equals": "^4.0.3", | ||||
|     "framer-motion": "^7.6.1", | ||||
|     "i18next": "^22.0.0", | ||||
|     "i18next-browser-languagedetector": "^6.1.8", | ||||
| @@ -60,6 +63,7 @@ | ||||
|     "zustand": "^4.1.2" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/google.maps": "^3.51.0", | ||||
|     "@types/node": "^18.11.2", | ||||
|     "@types/react": "^18.0.21", | ||||
|     "@types/react-csv": "^1.1.3", | ||||
|   | ||||
| @@ -79,8 +79,11 @@ | ||||
| 		"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", | ||||
| 		"missing_board": "Analytics-Überwachung an diesem Ort ist nicht mehr aktiv. Klicken Sie hier, um die Überwachung neu zu starten", | ||||
| 		"mode": "Modus", | ||||
| 		"monitoring": "Überwachung", | ||||
| 		"no_board": "Keine Überwachung", | ||||
| 		"no_board_description": "Sie überwachen diesen Veranstaltungsort derzeit nicht, klicken Sie hier, um zu beginnen", | ||||
| 		"noise": "Lärm", | ||||
| 		"packets": "Pakete", | ||||
| 		"radio": "RADIO", | ||||
| @@ -91,6 +94,8 @@ | ||||
| 		"retries": "Wiederholungen", | ||||
| 		"search_serials": "Zeitschriften suchen", | ||||
| 		"stop_monitoring": "Beenden Sie die Überwachung", | ||||
| 		"stop_monitoring_success": "Überwachungsort gestoppt!", | ||||
| 		"stop_monitoring_warning": "Bist du sicher? Dadurch werden alle aufgezeichneten Überwachungsdaten für diesen Veranstaltungsort gelöscht", | ||||
| 		"temperature": "Temperatur", | ||||
| 		"title": "ANALYTICS", | ||||
| 		"total_data": "Gesamtdaten", | ||||
| @@ -175,6 +180,7 @@ | ||||
| 		"other": "Befehle", | ||||
| 		"override_dfs": "DFS überschreiben", | ||||
| 		"reboot": "Starten Sie neu", | ||||
| 		"reboot_description": "Möchten Sie dieses Gerät neu starten?", | ||||
| 		"reboot_error": "Fehler beim Senden des Neustartbefehls: {{e}}", | ||||
| 		"reboot_success": "Neustartbefehl erfolgreich gesendet!", | ||||
| 		"revision": "Revision", | ||||
| @@ -391,6 +397,7 @@ | ||||
| 		"warning_pushes_one": "Warten auf Geräteverbindung: {{count}}", | ||||
| 		"warning_pushes_other": "Warten auf Geräteverbindung: {{count}}", | ||||
| 		"weight": "Gewicht", | ||||
| 		"wifi_bands_max": "Es können nicht mehr als 8 SSIDs dieses WLAN-Band verwenden", | ||||
| 		"wifi_frames": "WiFi-Frames" | ||||
| 	}, | ||||
| 	"contacts": { | ||||
| @@ -600,6 +607,7 @@ | ||||
| 		"certificate_expires_in": "Zertifikat läuft ab in", | ||||
| 		"certificate_expiry": "Zert. Läuft ab in", | ||||
| 		"connected": "In Verbindung gebracht", | ||||
| 		"crash_logs": "Absturzprotokolle", | ||||
| 		"create_errors": "Fehler beim Versuch, Geräte zu erstellen", | ||||
| 		"create_success": " Geräte erfolgreich erstellt", | ||||
| 		"current_firmware": "Aktuelle Firmware", | ||||
| @@ -613,6 +621,7 @@ | ||||
| 		"import_device_warning": "Bitte stellen Sie sicher, dass am Anfang oder Ende von Werten keine zusätzlichen Leerzeichen stehen, es sei denn, es handelt sich um einen Teil des gewünschten Werts", | ||||
| 		"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)", | ||||
| 		"logs_one": "Log", | ||||
| 		"new_devices": "Neue Geräte", | ||||
| 		"no_model_image": "Kein Modellbild gefunden", | ||||
| 		"not_connected": "Nicht verbunden", | ||||
| @@ -621,6 +630,8 @@ | ||||
| 		"one": "Gerät", | ||||
| 		"reassign_already_owned": "Geräte neu zuweisen, die bereits vorhanden sind und einem anderen Unternehmen/Veranstaltungsort/Abonnenten gehören?", | ||||
| 		"restricted": "Beschränkt", | ||||
| 		"restricted_overriden": "Dies ist ein eingeschränktes Gerät, aber es befindet sich im Entwicklungsmodus. Alle Einschränkungen werden derzeit ignoriert", | ||||
| 		"restrictions_overriden_title": "Dev-Modus", | ||||
| 		"sanity": "Gesundheit", | ||||
| 		"start_import": "Geräteimport starten", | ||||
| 		"test_batch": "Testen Sie Importdaten", | ||||
| @@ -671,7 +682,15 @@ | ||||
| 		"test_digicert_creds": "Anmeldeinformationen testen", | ||||
| 		"title": "Entitäten", | ||||
| 		"tree": "Entitätsbaum", | ||||
| 		"venues_under_root": "Veranstaltungsorte können nicht direkt unter der Root-Entität erstellt werden. Bitte erstellen Sie neue Entitäten und erstellen Sie Veranstaltungsorte unter diesen." | ||||
| 		"update_success": "Entität aktualisiert!", | ||||
| 		"venues_under_root": "Veranstaltungsorte können nicht direkt unter der Root-Entität erstellt werden" | ||||
| 	}, | ||||
| 	"firmware": { | ||||
| 		"db_update_warning": "Dieser Vorgang wird täglich automatisch durchgeführt, ohne dass dieses manuelle Update verwendet werden muss. Die Aktualisierung dieser Datenbank kann bis zu 25 Minuten dauern", | ||||
| 		"last_db_update_modal": "Firmware-Datenbank", | ||||
| 		"last_db_update_title": "Datenbank", | ||||
| 		"start_db_update": "Datenbankaktualisierung starten", | ||||
| 		"started_db_update": "Datenbankaktualisierung gestartet, dieser Vorgang sollte bis zu 25 Minuten dauern" | ||||
| 	}, | ||||
| 	"footer": { | ||||
| 		"powered_by": "Unterstützt von", | ||||
| @@ -765,13 +784,17 @@ | ||||
| 		"city": "Stadt", | ||||
| 		"claim_explanation": "Um Standorte zu beanspruchen, können Sie die folgende Tabelle verwenden", | ||||
| 		"country": "Land", | ||||
| 		"elevation": "Elevation", | ||||
| 		"geocode": "Geo-Code", | ||||
| 		"lat": "Breite", | ||||
| 		"longitude": "Längengrad", | ||||
| 		"one": "Ort", | ||||
| 		"other": "Standorte", | ||||
| 		"postal": "Postleitzahl", | ||||
| 		"state": "Bundesstaat / Provinz", | ||||
| 		"title": "Standorte", | ||||
| 		"to_claim": "Standorte zu beanspruchen" | ||||
| 		"to_claim": "Standorte zu beanspruchen", | ||||
| 		"view_gps": "" | ||||
| 	}, | ||||
| 	"login": { | ||||
| 		"access_policy": "Zugangsrichtlinien", | ||||
| @@ -797,6 +820,7 @@ | ||||
| 		"reset_password": "Passwort zurücksetzen", | ||||
| 		"sign_in": "Einloggen", | ||||
| 		"sms_instructions": "Sie sollten bald einen 6-stelligen Code auf Ihrem Telefon erhalten. Bitte geben Sie es unten ein, um sich anzumelden", | ||||
| 		"suspended_error": "Konto gesperrt, wenden Sie sich bitte an Ihren Administrator", | ||||
| 		"verification": "Bestätigen Sie Ihre Anmeldung", | ||||
| 		"waiting_for_email_verification": "Konto noch nicht per E-Mail validiert. Bitte sehen Sie in Ihrem Posteingang nach oder bitten Sie Ihren Administrator, eine Bestätigung erneut zu senden", | ||||
| 		"welcome_back": "Willkommen zurück!", | ||||
| @@ -902,7 +926,7 @@ | ||||
| 		"dfs": "DFS-Überschreibung", | ||||
| 		"gw_commands": "Gateway-Befehle", | ||||
| 		"identifier": "Identifikator", | ||||
| 		"key_verification": "Überprüfung des Signaturschlüssels", | ||||
| 		"key_verification": "Signieren von Schlüsselinformationen", | ||||
| 		"restricted": "Beschränkt", | ||||
| 		"signed_upgrade": "Nur signiertes Upgrade", | ||||
| 		"title": "Beschränkungen", | ||||
| @@ -1022,6 +1046,7 @@ | ||||
| 	}, | ||||
| 	"system": { | ||||
| 		"backend_logs": "Back-End-Protokolle", | ||||
| 		"configuration": "Aufbau", | ||||
| 		"could_not_retrieve": "Fehler: {{name}} Systeminformationen konnten nicht abgerufen werden", | ||||
| 		"endpoint": "Endpunkt", | ||||
| 		"hostname": "Hostname", | ||||
| @@ -1032,6 +1057,10 @@ | ||||
| 		"os": "Betriebssystem", | ||||
| 		"processors": "Prozessoren", | ||||
| 		"reload_chosen_subsystems": "Ausgewählte Subsysteme neu laden", | ||||
| 		"secrets": "Geheimnisse", | ||||
| 		"secrets_create": "Geheimnis erstellen", | ||||
| 		"secrets_one": "Geheimnis", | ||||
| 		"services": "dienstleistungen", | ||||
| 		"start": "Start", | ||||
| 		"subsystems": "Subsysteme", | ||||
| 		"success_reload": "Reload-Befehl erfolgreich gesendet!", | ||||
| @@ -1053,9 +1082,11 @@ | ||||
| 		"previous_page": "Vorherige Seite" | ||||
| 	}, | ||||
| 	"user": { | ||||
| 		"email_not_validated": "E-Mail nicht validiert", | ||||
| 		"error_fetching": "Fehler beim Abrufen der Benutzerinformationen: {{e}}", | ||||
| 		"password": "Passwort", | ||||
| 		"role": "Rolle", | ||||
| 		"suspended": "Suspendiert", | ||||
| 		"title": "Nutzer" | ||||
| 	}, | ||||
| 	"users": { | ||||
| @@ -1100,9 +1131,11 @@ | ||||
| 		"successfully_update_devices": " {{num}} Geräte werden aktualisiert!", | ||||
| 		"title": "Veranstaltungsorte", | ||||
| 		"update_all_devices": "Alle Gerätekonfigurationen aktualisieren", | ||||
| 		"upgrade_all_devices": "Aktualisieren Sie alle Geräte auf die neueste Firmware", | ||||
| 		"update_success": "Veranstaltungsort aktualisiert!", | ||||
| 		"upgrade_all_devices": "Aktualisieren Sie die Firmware aller Geräte", | ||||
| 		"upgrade_all_devices_error": "Fehler beim Aktualisieren von Geräten: {{e}}", | ||||
| 		"upgrade_all_devices_success": "Upgrade von Geräten erfolgreich gestartet!", | ||||
| 		"upgrade_options_available": "Hier sind alle verfügbaren Revisionen, bitte wählen Sie diejenige aus, auf die ALLE Geräte dieses Veranstaltungsortes aktualisiert werden sollen", | ||||
| 		"use_existing": "Benutze existierendes", | ||||
| 		"use_existing_contacts": "Verwenden Sie vorhandene Kontakte", | ||||
| 		"use_this_contact": "Verwenden Sie diesen Kontakt" | ||||
|   | ||||
| @@ -79,8 +79,11 @@ | ||||
| 		"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", | ||||
| 		"missing_board": "Analytics monitoring on this venue is no longer active. Click here to restart monitoring", | ||||
| 		"mode": "Mode", | ||||
| 		"monitoring": "Monitoring", | ||||
| 		"no_board": "No Monitoring", | ||||
| 		"no_board_description": "You are not monitoring this Venue at the moment, click here to start", | ||||
| 		"noise": "Noise", | ||||
| 		"packets": "Packets", | ||||
| 		"radio": "Radio", | ||||
| @@ -91,6 +94,8 @@ | ||||
| 		"retries": "Retries", | ||||
| 		"search_serials": "Search Serials", | ||||
| 		"stop_monitoring": "Stop Monitoring", | ||||
| 		"stop_monitoring_success": "Stopped Monitoring Venue!", | ||||
| 		"stop_monitoring_warning": "Are you sure? This will erase all recorded monitoring data for this venue", | ||||
| 		"temperature": "Temperature", | ||||
| 		"title": "Analytics", | ||||
| 		"total_data": "Total Data", | ||||
| @@ -175,6 +180,7 @@ | ||||
| 		"other": "Commands", | ||||
| 		"override_dfs": "Override DFS", | ||||
| 		"reboot": "Reboot", | ||||
| 		"reboot_description": "Do you want to reboot this device?", | ||||
| 		"reboot_error": "Error while sending reboot command: {{e}}", | ||||
| 		"reboot_success": "Successfully sent reboot command!", | ||||
| 		"revision": "Revision", | ||||
| @@ -391,6 +397,7 @@ | ||||
| 		"warning_pushes_one": "Waiting for devices to connect: {{count}}", | ||||
| 		"warning_pushes_other": "Waiting for devices to connect: {{count}}", | ||||
| 		"weight": "Weight", | ||||
| 		"wifi_bands_max": "There cannot be more than 8 SSIDs using this wifi-band", | ||||
| 		"wifi_frames": "WiFi Frames" | ||||
| 	}, | ||||
| 	"contacts": { | ||||
| @@ -600,6 +607,7 @@ | ||||
| 		"certificate_expires_in": "Certificate Expiry", | ||||
| 		"certificate_expiry": "Cert. Expires In", | ||||
| 		"connected": "Connected", | ||||
| 		"crash_logs": "Crash Logs", | ||||
| 		"create_errors": "errors while trying to create devices", | ||||
| 		"create_success": " devices successfully created", | ||||
| 		"current_firmware": "Current Firmware", | ||||
| @@ -613,6 +621,7 @@ | ||||
| 		"import_device_warning": "Please make sure there are no extra spaces at the start or end of any values unless it is part of the value desired", | ||||
| 		"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)", | ||||
| 		"logs_one": "Log", | ||||
| 		"new_devices": "new devices", | ||||
| 		"no_model_image": "No Model Image Found", | ||||
| 		"not_connected": "Not Connected", | ||||
| @@ -621,6 +630,8 @@ | ||||
| 		"one": "Device", | ||||
| 		"reassign_already_owned": "Reassign devices which already exist and are owned by another entity/venue/subscriber?", | ||||
| 		"restricted": "Restricted", | ||||
| 		"restricted_overriden": "This is a restricted device, but it is in development mode. All restrictions are currently ignored", | ||||
| 		"restrictions_overriden_title": "Dev Mode", | ||||
| 		"sanity": "Sanity", | ||||
| 		"start_import": "Start Device Importation", | ||||
| 		"test_batch": "Test Import Data", | ||||
| @@ -671,7 +682,15 @@ | ||||
| 		"test_digicert_creds": "Test Credentials", | ||||
| 		"title": "Entities", | ||||
| 		"tree": "Entity Tree", | ||||
| 		"venues_under_root": "Venues cannot be created directly under the root entity. Please create new entities and create venues under these." | ||||
| 		"update_success": "Entity updated!", | ||||
| 		"venues_under_root": "Venues cannot be created directly under the root entity" | ||||
| 	}, | ||||
| 	"firmware": { | ||||
| 		"db_update_warning": "This operation is done daily automatically without need to use this manual update. Updating this database can take up to 25 minutes", | ||||
| 		"last_db_update_modal": "Firmware Database", | ||||
| 		"last_db_update_title": "Database", | ||||
| 		"start_db_update": "Start Database Update", | ||||
| 		"started_db_update": "Started database update, this operation should take up to 25 minutes to complete" | ||||
| 	}, | ||||
| 	"footer": { | ||||
| 		"powered_by": "Powered By", | ||||
| @@ -765,13 +784,17 @@ | ||||
| 		"city": "City", | ||||
| 		"claim_explanation": "To claim locations you can use the table below", | ||||
| 		"country": "Country", | ||||
| 		"elevation": "Elevation", | ||||
| 		"geocode": "Geo Code", | ||||
| 		"lat": "Latitude", | ||||
| 		"longitude": "Longitude", | ||||
| 		"one": "Location", | ||||
| 		"other": "Locations", | ||||
| 		"postal": "ZIP/Postal Code", | ||||
| 		"state": "State/Province", | ||||
| 		"title": "Locations", | ||||
| 		"to_claim": "Locations to claim" | ||||
| 		"to_claim": "Locations to claim", | ||||
| 		"view_gps": "View GPS Location" | ||||
| 	}, | ||||
| 	"login": { | ||||
| 		"access_policy": "Access Policy", | ||||
| @@ -797,6 +820,7 @@ | ||||
| 		"reset_password": "Reset Password", | ||||
| 		"sign_in": "Sign In", | ||||
| 		"sms_instructions": "You should receive a 6-digit code on your phone soon. Please enter it below to login", | ||||
| 		"suspended_error": "Suspended account, please contact your administrator", | ||||
| 		"verification": "Verify your login", | ||||
| 		"waiting_for_email_verification": "Account not yet email validated. Please look at your inbox or ask your administrator to resend a validation", | ||||
| 		"welcome_back": "Welcome Back!", | ||||
| @@ -902,7 +926,7 @@ | ||||
| 		"dfs": "DFS Override", | ||||
| 		"gw_commands": "Gateway Commands", | ||||
| 		"identifier": "Identifier", | ||||
| 		"key_verification": "Signing Key Verification", | ||||
| 		"key_verification": "Signing Key Information", | ||||
| 		"restricted": "Restricted", | ||||
| 		"signed_upgrade": "Signed Upgrade Only", | ||||
| 		"title": "Restrictions", | ||||
| @@ -1022,6 +1046,7 @@ | ||||
| 	}, | ||||
| 	"system": { | ||||
| 		"backend_logs": "Back-End Logs", | ||||
| 		"configuration": "Configuration", | ||||
| 		"could_not_retrieve": "Error: could not retrieve {{name}} system information", | ||||
| 		"endpoint": "Endpoint", | ||||
| 		"hostname": "Host Name", | ||||
| @@ -1032,6 +1057,10 @@ | ||||
| 		"os": "Operating System", | ||||
| 		"processors": "Processors", | ||||
| 		"reload_chosen_subsystems": "Reload Chosen Subsystems", | ||||
| 		"secrets": "Secrets", | ||||
| 		"secrets_create": "Create Secret", | ||||
| 		"secrets_one": "Secret", | ||||
| 		"services": "Services", | ||||
| 		"start": "Start", | ||||
| 		"subsystems": "Subsystems", | ||||
| 		"success_reload": "Successfully sent reload command!", | ||||
| @@ -1053,9 +1082,11 @@ | ||||
| 		"previous_page": "Previous Page" | ||||
| 	}, | ||||
| 	"user": { | ||||
| 		"email_not_validated": "email not validated", | ||||
| 		"error_fetching": "Error fetching user information: {{e}}", | ||||
| 		"password": "Password", | ||||
| 		"role": "Role", | ||||
| 		"suspended": "suspended", | ||||
| 		"title": "User" | ||||
| 	}, | ||||
| 	"users": { | ||||
| @@ -1100,9 +1131,11 @@ | ||||
| 		"successfully_update_devices": "Updating {{num}} devices!", | ||||
| 		"title": "Venues", | ||||
| 		"update_all_devices": "Update All Device Configurations", | ||||
| 		"upgrade_all_devices": "Upgrade All Devices to Latest Firmware", | ||||
| 		"update_success": "Venue updated!", | ||||
| 		"upgrade_all_devices": "Upgrade All Devices Firmware", | ||||
| 		"upgrade_all_devices_error": "Error upgrading devices: {{e}}", | ||||
| 		"upgrade_all_devices_success": "Successfully started upgrading devices!", | ||||
| 		"upgrade_options_available": "Here are all available revisions, please select the one you want ALL of this venue's devices to be upgrade to", | ||||
| 		"use_existing": "Use Existing", | ||||
| 		"use_existing_contacts": "Use Existing Contacts", | ||||
| 		"use_this_contact": "Use this contact" | ||||
|   | ||||
| @@ -79,8 +79,11 @@ | ||||
| 		"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", | ||||
| 		"missing_board": "El monitoreo analítico en este lugar ya no está activo. Haga clic aquí para reiniciar el monitoreo", | ||||
| 		"mode": "Modo", | ||||
| 		"monitoring": "Vigilancia", | ||||
| 		"no_board": "Sin monitoreo", | ||||
| 		"no_board_description": "No está monitoreando este lugar en este momento, haga clic aquí para comenzar", | ||||
| 		"noise": "Ruido", | ||||
| 		"packets": "Paquetes", | ||||
| 		"radio": "RADIO", | ||||
| @@ -91,6 +94,8 @@ | ||||
| 		"retries": "Reintentos", | ||||
| 		"search_serials": "Buscar seriales", | ||||
| 		"stop_monitoring": "Dejar de monitorear", | ||||
| 		"stop_monitoring_success": "¡Se detuvo el lugar de monitoreo!", | ||||
| 		"stop_monitoring_warning": "¿Está seguro? Esto borrará todos los datos de monitoreo grabados para este lugar.", | ||||
| 		"temperature": "temperatura", | ||||
| 		"title": "ANALÍTICA", | ||||
| 		"total_data": "Datos totales", | ||||
| @@ -175,6 +180,7 @@ | ||||
| 		"other": "comandos", | ||||
| 		"override_dfs": "Anular DFS", | ||||
| 		"reboot": "Reiniciar", | ||||
| 		"reboot_description": "¿Quieres reiniciar este dispositivo?", | ||||
| 		"reboot_error": "Error al enviar el comando de reinicio: {{e}}", | ||||
| 		"reboot_success": "¡Comando de reinicio enviado con éxito!", | ||||
| 		"revision": "revisión", | ||||
| @@ -391,6 +397,7 @@ | ||||
| 		"warning_pushes_one": "Esperando a que los dispositivos se conecten: {{count}}", | ||||
| 		"warning_pushes_other": "Esperando a que los dispositivos se conecten: {{count}}", | ||||
| 		"weight": "Peso", | ||||
| 		"wifi_bands_max": "No puede haber más de 8 SSID usando esta banda wifi", | ||||
| 		"wifi_frames": "Marcos WiFi" | ||||
| 	}, | ||||
| 	"contacts": { | ||||
| @@ -600,6 +607,7 @@ | ||||
| 		"certificate_expires_in": "El certificado caduca en", | ||||
| 		"certificate_expiry": "Cert. Expira en", | ||||
| 		"connected": "Conectado", | ||||
| 		"crash_logs": "Registros de fallas", | ||||
| 		"create_errors": "errores al intentar crear dispositivos", | ||||
| 		"create_success": " dispositivos creados con éxito", | ||||
| 		"current_firmware": "Firmware actual", | ||||
| @@ -613,6 +621,7 @@ | ||||
| 		"import_device_warning": "Asegúrese de que no haya espacios adicionales al principio o al final de ningún valor a menos que sea parte del valor deseado", | ||||
| 		"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)", | ||||
| 		"logs_one": "Iniciar sesión", | ||||
| 		"new_devices": "Nuevos dispositivos", | ||||
| 		"no_model_image": "No se encontró ninguna imagen de modelo", | ||||
| 		"not_connected": "No conectado", | ||||
| @@ -621,6 +630,8 @@ | ||||
| 		"one": "Dispositivo", | ||||
| 		"reassign_already_owned": "¿Reasignar dispositivos que ya existen y son propiedad de otra entidad/lugar/suscriptor?", | ||||
| 		"restricted": "Restringido", | ||||
| 		"restricted_overriden": "Este es un dispositivo restringido, pero está en modo de desarrollo. Actualmente se ignoran todas las restricciones.", | ||||
| 		"restrictions_overriden_title": "MODO DE DESARROLLO", | ||||
| 		"sanity": "Cordura", | ||||
| 		"start_import": "Iniciar la importación de dispositivos", | ||||
| 		"test_batch": "Datos de importación de prueba", | ||||
| @@ -671,7 +682,15 @@ | ||||
| 		"test_digicert_creds": "Credenciales de prueba", | ||||
| 		"title": "entidades", | ||||
| 		"tree": "Árbol de entidades", | ||||
| 		"venues_under_root": "Los lugares no se pueden crear directamente bajo la entidad raíz. Cree nuevas entidades y cree lugares bajo estas." | ||||
| 		"update_success": "¡Entidad actualizada!", | ||||
| 		"venues_under_root": "Los lugares no se pueden crear directamente bajo la entidad raíz" | ||||
| 	}, | ||||
| 	"firmware": { | ||||
| 		"db_update_warning": "Esta operación se realiza automáticamente todos los días de forma automática sin necesidad de utilizar esta actualización manual. La actualización de esta base de datos puede tardar hasta 25 minutos", | ||||
| 		"last_db_update_modal": "Base de datos de firmware", | ||||
| 		"last_db_update_title": "Base de datos", | ||||
| 		"start_db_update": "Iniciar actualización de la base de datos", | ||||
| 		"started_db_update": "Actualización de la base de datos iniciada, esta operación debería tardar hasta 25 minutos en completarse" | ||||
| 	}, | ||||
| 	"footer": { | ||||
| 		"powered_by": "energizado por", | ||||
| @@ -765,13 +784,17 @@ | ||||
| 		"city": "ciudad", | ||||
| 		"claim_explanation": "Para reclamar ubicaciones, puede usar la tabla a continuación", | ||||
| 		"country": "País", | ||||
| 		"elevation": "Elevación", | ||||
| 		"geocode": "Código geográfico", | ||||
| 		"lat": "Latitud", | ||||
| 		"longitude": "Longitud", | ||||
| 		"one": "Ubicación", | ||||
| 		"other": "Ubicaciones", | ||||
| 		"postal": "código postal", | ||||
| 		"state": "Provincia del estado", | ||||
| 		"title": "Ubicaciones", | ||||
| 		"to_claim": "Ubicaciones para reclamar" | ||||
| 		"to_claim": "Ubicaciones para reclamar", | ||||
| 		"view_gps": "" | ||||
| 	}, | ||||
| 	"login": { | ||||
| 		"access_policy": "Política de acceso", | ||||
| @@ -797,6 +820,7 @@ | ||||
| 		"reset_password": "Restablecer la contraseña", | ||||
| 		"sign_in": "Registrarse", | ||||
| 		"sms_instructions": "Debería recibir un código de 6 dígitos en su teléfono pronto. Por favor, introdúzcalo a continuación para iniciar sesión", | ||||
| 		"suspended_error": "Cuenta suspendida, comuníquese con su administrador", | ||||
| 		"verification": "Verifica tu inicio de sesión", | ||||
| 		"waiting_for_email_verification": "Cuenta aún no validada por correo electrónico. Mire su bandeja de entrada o solicite a su administrador que vuelva a enviar una validación.", | ||||
| 		"welcome_back": "¡Dar una buena acogida!", | ||||
| @@ -902,7 +926,7 @@ | ||||
| 		"dfs": "Anulación de DFS", | ||||
| 		"gw_commands": "Comandos de puerta de enlace", | ||||
| 		"identifier": "Identificador", | ||||
| 		"key_verification": "Verificación de clave de firma", | ||||
| 		"key_verification": "Información clave de firma", | ||||
| 		"restricted": "Restringido", | ||||
| 		"signed_upgrade": "Solo actualización firmada", | ||||
| 		"title": "Las restricciones", | ||||
| @@ -1022,6 +1046,7 @@ | ||||
| 	}, | ||||
| 	"system": { | ||||
| 		"backend_logs": "Registros de back-end", | ||||
| 		"configuration": "Configuración", | ||||
| 		"could_not_retrieve": "Error: no se pudo recuperar la información del sistema {{name}} ", | ||||
| 		"endpoint": "punto final", | ||||
| 		"hostname": "Nombre de host", | ||||
| @@ -1032,6 +1057,10 @@ | ||||
| 		"os": "sistema operativo", | ||||
| 		"processors": "Procesadores", | ||||
| 		"reload_chosen_subsystems": "Recargar subsistemas elegidos", | ||||
| 		"secrets": "Misterios", | ||||
| 		"secrets_create": "Crear secreto", | ||||
| 		"secrets_one": "secreto", | ||||
| 		"services": "Servicios", | ||||
| 		"start": "comienzo", | ||||
| 		"subsystems": "Subsistemas", | ||||
| 		"success_reload": "¡Comando de recarga enviado con éxito!", | ||||
| @@ -1053,9 +1082,11 @@ | ||||
| 		"previous_page": "Página anterior" | ||||
| 	}, | ||||
| 	"user": { | ||||
| 		"email_not_validated": "correo electrónico no validado", | ||||
| 		"error_fetching": "Error al obtener la información del usuario: {{e}}", | ||||
| 		"password": "Contraseña", | ||||
| 		"role": "papel", | ||||
| 		"suspended": "Suspendido", | ||||
| 		"title": "Usuario" | ||||
| 	}, | ||||
| 	"users": { | ||||
| @@ -1100,9 +1131,11 @@ | ||||
| 		"successfully_update_devices": "¡Actualizando {{num}} dispositivos!", | ||||
| 		"title": "Sedes", | ||||
| 		"update_all_devices": "Actualizar todas las configuraciones de dispositivos", | ||||
| 		"upgrade_all_devices": "Actualice todos los dispositivos al firmware más reciente", | ||||
| 		"update_success": "Lugar actualizado!", | ||||
| 		"upgrade_all_devices": "Actualizar el firmware de todos los dispositivos", | ||||
| 		"upgrade_all_devices_error": "Error al actualizar dispositivos: {{e}}", | ||||
| 		"upgrade_all_devices_success": "¡Comenzó con éxito la actualización de dispositivos!", | ||||
| 		"upgrade_options_available": "Aquí están todas las revisiones disponibles, seleccione la que desea que TODOS los dispositivos de este lugar se actualicen", | ||||
| 		"use_existing": "Utilizar existente", | ||||
| 		"use_existing_contacts": "Usar contactos existentes", | ||||
| 		"use_this_contact": "Usa este contacto" | ||||
|   | ||||
| @@ -79,8 +79,11 @@ | ||||
| 		"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", | ||||
| 		"missing_board": "La surveillance analytique sur ce site n'est plus active. Cliquez ici pour redémarrer la surveillance", | ||||
| 		"mode": "Mode", | ||||
| 		"monitoring": "surveillance", | ||||
| 		"no_board": "Aucune surveillance", | ||||
| 		"no_board_description": "Vous ne surveillez pas ce lieu pour le moment, cliquez ici pour commencer", | ||||
| 		"noise": "Bruit", | ||||
| 		"packets": "Paquets", | ||||
| 		"radio": "Radio", | ||||
| @@ -91,6 +94,8 @@ | ||||
| 		"retries": "Tentatives", | ||||
| 		"search_serials": "Rechercher des publications en série", | ||||
| 		"stop_monitoring": "Arrêter la surveillance", | ||||
| 		"stop_monitoring_success": "Lieu de surveillance arrêté !", | ||||
| 		"stop_monitoring_warning": "Êtes-vous sûr? Cela effacera toutes les données de surveillance enregistrées pour ce lieu", | ||||
| 		"temperature": "Température", | ||||
| 		"title": "ANALYTIQUE", | ||||
| 		"total_data": "Données totales", | ||||
| @@ -175,6 +180,7 @@ | ||||
| 		"other": "Les commandes", | ||||
| 		"override_dfs": "Remplacer DFS", | ||||
| 		"reboot": "Redémarrer", | ||||
| 		"reboot_description": "Voulez-vous redémarrer cet appareil ?", | ||||
| 		"reboot_error": "Erreur lors de l'envoi de la commande de redémarrage : {{e}}", | ||||
| 		"reboot_success": "Commande de redémarrage envoyée avec succès !", | ||||
| 		"revision": "Révision", | ||||
| @@ -391,6 +397,7 @@ | ||||
| 		"warning_pushes_one": "En attente de connexion des appareils : {{count}}", | ||||
| 		"warning_pushes_other": "En attente de connexion des appareils : {{count}}", | ||||
| 		"weight": "Poids", | ||||
| 		"wifi_bands_max": "Il ne peut y avoir plus de 8 SSID utilisant cette bande wifi", | ||||
| 		"wifi_frames": "Cadres Wi-Fi" | ||||
| 	}, | ||||
| 	"contacts": { | ||||
| @@ -600,6 +607,7 @@ | ||||
| 		"certificate_expires_in": "Le certificat expire dans", | ||||
| 		"certificate_expiry": "Cert. Expire dans", | ||||
| 		"connected": "Connecté", | ||||
| 		"crash_logs": "Journaux des plantages", | ||||
| 		"create_errors": "erreurs lors de la tentative de création d'appareils", | ||||
| 		"create_success": " appareils créés avec succès", | ||||
| 		"current_firmware": "Firmware actuel", | ||||
| @@ -613,6 +621,7 @@ | ||||
| 		"import_device_warning": "Veuillez vous assurer qu'il n'y a pas d'espaces supplémentaires au début ou à la fin des valeurs, sauf si cela fait partie de la valeur souhaitée", | ||||
| 		"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)", | ||||
| 		"logs_one": "Bûche", | ||||
| 		"new_devices": "nouveaux appareils", | ||||
| 		"no_model_image": "Aucune image de modèle trouvée", | ||||
| 		"not_connected": "Pas connecté", | ||||
| @@ -621,6 +630,8 @@ | ||||
| 		"one": "Dispositif", | ||||
| 		"reassign_already_owned": "Réattribuer des appareils qui existent déjà et qui appartiennent à une autre entité/salle/abonné ?", | ||||
| 		"restricted": "Limité", | ||||
| 		"restricted_overriden": "Il s'agit d'un appareil restreint, mais il est en mode développement. Toutes les restrictions sont actuellement ignorées", | ||||
| 		"restrictions_overriden_title": "Mode développement", | ||||
| 		"sanity": "Santé mentale", | ||||
| 		"start_import": "Démarrer l'importation de l'appareil", | ||||
| 		"test_batch": "Tester les données d'importation", | ||||
| @@ -671,7 +682,15 @@ | ||||
| 		"test_digicert_creds": "Tester les informations d'identification", | ||||
| 		"title": "Entités", | ||||
| 		"tree": "Arborescence des entités", | ||||
| 		"venues_under_root": "Les sites ne peuvent pas être créés directement sous l'entité racine. Veuillez créer de nouvelles entités et créer des lieux sous celles-ci." | ||||
| 		"update_success": "Entité mise à jour !", | ||||
| 		"venues_under_root": "Les lieux ne peuvent pas être créés directement sous l'entité racine" | ||||
| 	}, | ||||
| 	"firmware": { | ||||
| 		"db_update_warning": "Cette opération se fait automatiquement quotidiennement sans avoir besoin d'utiliser cette mise à jour manuelle. La mise à jour de cette base de données peut prendre jusqu'à 25 minutes", | ||||
| 		"last_db_update_modal": "Base de données du micrologiciel", | ||||
| 		"last_db_update_title": "Base de données", | ||||
| 		"start_db_update": "Démarrer la mise à jour de la base de données", | ||||
| 		"started_db_update": "Mise à jour de la base de données démarrée, cette opération devrait prendre jusqu'à 25 minutes" | ||||
| 	}, | ||||
| 	"footer": { | ||||
| 		"powered_by": "Alimenté par", | ||||
| @@ -765,13 +784,17 @@ | ||||
| 		"city": "Ville", | ||||
| 		"claim_explanation": "Pour revendiquer des emplacements, vous pouvez utiliser le tableau ci-dessous", | ||||
| 		"country": "Pays", | ||||
| 		"elevation": "Élévation", | ||||
| 		"geocode": "Geo code", | ||||
| 		"lat": "Latitude", | ||||
| 		"longitude": "Longitude", | ||||
| 		"one": "Emplacement", | ||||
| 		"other": "Emplacements", | ||||
| 		"postal": "Zip / code postal", | ||||
| 		"state": "Etat / Province", | ||||
| 		"title": "Emplacements", | ||||
| 		"to_claim": "Emplacements à réclamer" | ||||
| 		"to_claim": "Emplacements à réclamer", | ||||
| 		"view_gps": "" | ||||
| 	}, | ||||
| 	"login": { | ||||
| 		"access_policy": "Politique d'accès", | ||||
| @@ -797,6 +820,7 @@ | ||||
| 		"reset_password": "Réinitialiser le mot de passe", | ||||
| 		"sign_in": "se connecter", | ||||
| 		"sms_instructions": "Vous devriez bientôt recevoir un code à 6 chiffres sur votre téléphone. Veuillez le saisir ci-dessous pour vous connecter", | ||||
| 		"suspended_error": "Compte suspendu, veuillez contacter votre administrateur", | ||||
| 		"verification": "Vérifiez votre connexion", | ||||
| 		"waiting_for_email_verification": "Compte pas encore e-mail validé. Veuillez consulter votre boîte de réception ou demander à votre administrateur de renvoyer une validation", | ||||
| 		"welcome_back": "Nous saluons le retour!", | ||||
| @@ -902,7 +926,7 @@ | ||||
| 		"dfs": "Remplacement DFS", | ||||
| 		"gw_commands": "Commandes de passerelle", | ||||
| 		"identifier": "Identifiant", | ||||
| 		"key_verification": "Vérification de la clé de signature", | ||||
| 		"key_verification": "Signature des informations clés", | ||||
| 		"restricted": "Limité", | ||||
| 		"signed_upgrade": "Mise à niveau signée uniquement", | ||||
| 		"title": "Restrictions", | ||||
| @@ -1022,6 +1046,7 @@ | ||||
| 	}, | ||||
| 	"system": { | ||||
| 		"backend_logs": "Journaux principaux", | ||||
| 		"configuration": "Configuration", | ||||
| 		"could_not_retrieve": "Erreur : impossible de récupérer les informations système {{name}} ", | ||||
| 		"endpoint": "Point final", | ||||
| 		"hostname": "nom d'hôte", | ||||
| @@ -1032,6 +1057,10 @@ | ||||
| 		"os": "Système opérateur", | ||||
| 		"processors": "Processeurs", | ||||
| 		"reload_chosen_subsystems": "Recharger les sous-systèmes choisis", | ||||
| 		"secrets": "Secrets", | ||||
| 		"secrets_create": "Créer un secret", | ||||
| 		"secrets_one": "Secret", | ||||
| 		"services": "Prestations de service", | ||||
| 		"start": "Début", | ||||
| 		"subsystems": "Sous-systèmes", | ||||
| 		"success_reload": "Commande de rechargement envoyée avec succès !", | ||||
| @@ -1053,9 +1082,11 @@ | ||||
| 		"previous_page": "Page précédente" | ||||
| 	}, | ||||
| 	"user": { | ||||
| 		"email_not_validated": "Mail non valide", | ||||
| 		"error_fetching": "Erreur lors de la récupération des informations utilisateur : {{e}}", | ||||
| 		"password": "Mot de passe", | ||||
| 		"role": "Rôle", | ||||
| 		"suspended": "Suspendu", | ||||
| 		"title": "Utilisateur" | ||||
| 	}, | ||||
| 	"users": { | ||||
| @@ -1100,9 +1131,11 @@ | ||||
| 		"successfully_update_devices": "Mise à jour de {{num}}  appareils !", | ||||
| 		"title": "Les lieux", | ||||
| 		"update_all_devices": "Mettre à jour toutes les configurations de périphérique", | ||||
| 		"upgrade_all_devices": "Mettre à niveau tous les appareils vers le dernier micrologiciel", | ||||
| 		"update_success": "Lieu mis à jour !", | ||||
| 		"upgrade_all_devices": "Mettre à niveau le micrologiciel de tous les appareils", | ||||
| 		"upgrade_all_devices_error": "Erreur lors de la mise à jour des appareils : {{e}}", | ||||
| 		"upgrade_all_devices_success": "La mise à niveau des appareils a démarré avec succès !", | ||||
| 		"upgrade_options_available": "Voici toutes les révisions disponibles, veuillez sélectionner celle vers laquelle vous souhaitez que TOUS les appareils de ce lieu soient mis à niveau", | ||||
| 		"use_existing": "Utiliser l'existant", | ||||
| 		"use_existing_contacts": "Utiliser les contacts existants", | ||||
| 		"use_this_contact": "Utilisez ce contact" | ||||
|   | ||||
| @@ -79,8 +79,11 @@ | ||||
| 		"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", | ||||
| 		"missing_board": "O monitoramento analítico neste local não está mais ativo. Clique aqui para reiniciar o monitoramento", | ||||
| 		"mode": "Modo", | ||||
| 		"monitoring": "Monitoramento", | ||||
| 		"no_board": "Sem monitoramento", | ||||
| 		"no_board_description": "Você não está monitorando este local no momento, clique aqui para começar", | ||||
| 		"noise": "Barulho", | ||||
| 		"packets": "Pacotes", | ||||
| 		"radio": "Rádio", | ||||
| @@ -91,6 +94,8 @@ | ||||
| 		"retries": "Novas tentativas", | ||||
| 		"search_serials": "Pesquisar séries", | ||||
| 		"stop_monitoring": "Parar o monitoramento", | ||||
| 		"stop_monitoring_success": "Local de monitoramento interrompido!", | ||||
| 		"stop_monitoring_warning": "Tem certeza? Isso apagará todos os dados de monitoramento gravados para este local", | ||||
| 		"temperature": "Temperatura", | ||||
| 		"title": "Analytics", | ||||
| 		"total_data": "Dados totais", | ||||
| @@ -175,6 +180,7 @@ | ||||
| 		"other": "comandos", | ||||
| 		"override_dfs": "Substituir DFS", | ||||
| 		"reboot": "Reiniciar", | ||||
| 		"reboot_description": "Deseja reiniciar este dispositivo?", | ||||
| 		"reboot_error": "Erro ao enviar o comando de reinicialização: {{e}}", | ||||
| 		"reboot_success": "Comando de reinicialização enviado com sucesso!", | ||||
| 		"revision": "revisão", | ||||
| @@ -391,6 +397,7 @@ | ||||
| 		"warning_pushes_one": "Aguardando a conexão dos dispositivos: {{count}}", | ||||
| 		"warning_pushes_other": "Aguardando a conexão dos dispositivos: {{count}}", | ||||
| 		"weight": "Peso", | ||||
| 		"wifi_bands_max": "Não pode haver mais de 8 SSIDs usando esta banda wi-fi", | ||||
| 		"wifi_frames": "Quadros WiFi" | ||||
| 	}, | ||||
| 	"contacts": { | ||||
| @@ -600,6 +607,7 @@ | ||||
| 		"certificate_expires_in": "Certificado expira em", | ||||
| 		"certificate_expiry": "Certificado expira em", | ||||
| 		"connected": "Conectado", | ||||
| 		"crash_logs": "Registros de falhas", | ||||
| 		"create_errors": "erros ao tentar criar dispositivos", | ||||
| 		"create_success": " dispositivos criados com sucesso", | ||||
| 		"current_firmware": "Firmware atual", | ||||
| @@ -613,6 +621,7 @@ | ||||
| 		"import_device_warning": "Certifique-se de que não há espaços extras no início ou no final de nenhum valor, a menos que faça parte do valor desejado", | ||||
| 		"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)", | ||||
| 		"logs_one": "Registro", | ||||
| 		"new_devices": "novos dispositivos", | ||||
| 		"no_model_image": "Nenhuma imagem de modelo encontrada", | ||||
| 		"not_connected": "Não conectado", | ||||
| @@ -621,6 +630,8 @@ | ||||
| 		"one": "Dispositivo", | ||||
| 		"reassign_already_owned": "Reatribuir dispositivos que já existem e são de propriedade de outra entidade/local/assinante?", | ||||
| 		"restricted": "Restrito", | ||||
| 		"restricted_overriden": "Este é um dispositivo restrito, mas está em modo de desenvolvimento. Todas as restrições são atualmente ignoradas", | ||||
| 		"restrictions_overriden_title": "Modo de desenvolvedor", | ||||
| 		"sanity": "Sanidade", | ||||
| 		"start_import": "Iniciar importação de dispositivos", | ||||
| 		"test_batch": "Dados de importação de teste", | ||||
| @@ -671,7 +682,15 @@ | ||||
| 		"test_digicert_creds": "Credenciais de teste", | ||||
| 		"title": "Entidades", | ||||
| 		"tree": "Árvore de entidades", | ||||
| 		"venues_under_root": "Os locais não podem ser criados diretamente na entidade raiz. Por favor, crie novas entidades e crie locais sob elas." | ||||
| 		"update_success": "Entidade atualizada!", | ||||
| 		"venues_under_root": "Os locais não podem ser criados diretamente na entidade raiz" | ||||
| 	}, | ||||
| 	"firmware": { | ||||
| 		"db_update_warning": "Esta operação é feita automaticamente diariamente sem necessidade de usar esta atualização manual. A atualização deste banco de dados pode levar até 25 minutos", | ||||
| 		"last_db_update_modal": "banco de dados de firmware", | ||||
| 		"last_db_update_title": "base de dados", | ||||
| 		"start_db_update": "Iniciar atualização do banco de dados", | ||||
| 		"started_db_update": "Atualização do banco de dados iniciada, esta operação deve levar até 25 minutos para ser concluída" | ||||
| 	}, | ||||
| 	"footer": { | ||||
| 		"powered_by": "Distribuído por", | ||||
| @@ -765,13 +784,17 @@ | ||||
| 		"city": "Cidade", | ||||
| 		"claim_explanation": "Para reivindicar locais, você pode usar a tabela abaixo", | ||||
| 		"country": "País", | ||||
| 		"elevation": "elevação", | ||||
| 		"geocode": "Código geográfico", | ||||
| 		"lat": "Latitude", | ||||
| 		"longitude": "Longitude", | ||||
| 		"one": "Localização", | ||||
| 		"other": "Localizações", | ||||
| 		"postal": "CEP / Código Postal", | ||||
| 		"state": "Estado / Província", | ||||
| 		"title": "Localizações", | ||||
| 		"to_claim": "Locais para reivindicar" | ||||
| 		"to_claim": "Locais para reivindicar", | ||||
| 		"view_gps": "" | ||||
| 	}, | ||||
| 	"login": { | ||||
| 		"access_policy": "Política de Acesso", | ||||
| @@ -797,6 +820,7 @@ | ||||
| 		"reset_password": "Redefinir senha", | ||||
| 		"sign_in": "assinar em", | ||||
| 		"sms_instructions": "Você deve receber um código de 6 dígitos em seu telefone em breve. Por favor, insira-o abaixo para fazer login", | ||||
| 		"suspended_error": "Conta suspensa, entre em contato com seu administrador", | ||||
| 		"verification": "Verifique seu login", | ||||
| 		"waiting_for_email_verification": "Conta ainda não validada por e-mail. Verifique sua caixa de entrada ou peça ao administrador para reenviar uma validação", | ||||
| 		"welcome_back": "Bem vindo de volta!", | ||||
| @@ -902,7 +926,7 @@ | ||||
| 		"dfs": "Substituição DFS", | ||||
| 		"gw_commands": "Comandos de gateway", | ||||
| 		"identifier": "Identificador", | ||||
| 		"key_verification": "Verificação da chave de assinatura", | ||||
| 		"key_verification": "Informações Chave de Assinatura", | ||||
| 		"restricted": "Restrito", | ||||
| 		"signed_upgrade": "Somente atualização assinada", | ||||
| 		"title": "RESTRIÇÕES", | ||||
| @@ -1022,6 +1046,7 @@ | ||||
| 	}, | ||||
| 	"system": { | ||||
| 		"backend_logs": "Registros de back-end", | ||||
| 		"configuration": "Configuração", | ||||
| 		"could_not_retrieve": "Erro: não foi possível recuperar {{name}} informações do sistema", | ||||
| 		"endpoint": "Ponto final", | ||||
| 		"hostname": "Nome de anfitrião", | ||||
| @@ -1032,6 +1057,10 @@ | ||||
| 		"os": "Sistema Operacional", | ||||
| 		"processors": "Processadores", | ||||
| 		"reload_chosen_subsystems": "Recarregar Subsistemas Escolhidos", | ||||
| 		"secrets": "Segredos", | ||||
| 		"secrets_create": "Criar Segredo", | ||||
| 		"secrets_one": "Segredo", | ||||
| 		"services": "Serviços", | ||||
| 		"start": "Começar", | ||||
| 		"subsystems": "Subsistemas", | ||||
| 		"success_reload": "Comando de recarga enviado com sucesso!", | ||||
| @@ -1053,9 +1082,11 @@ | ||||
| 		"previous_page": "Página anterior" | ||||
| 	}, | ||||
| 	"user": { | ||||
| 		"email_not_validated": "e-mail não validado", | ||||
| 		"error_fetching": "Erro ao buscar informações do usuário: {{e}}", | ||||
| 		"password": "Senha", | ||||
| 		"role": "Função", | ||||
| 		"suspended": "Suspenso", | ||||
| 		"title": "Do utilizador" | ||||
| 	}, | ||||
| 	"users": { | ||||
| @@ -1100,9 +1131,11 @@ | ||||
| 		"successfully_update_devices": "Atualizando {{num}} dispositivos!", | ||||
| 		"title": "Locais", | ||||
| 		"update_all_devices": "Atualizar todas as configurações do dispositivo", | ||||
| 		"upgrade_all_devices": "Atualize todos os dispositivos para o firmware mais recente", | ||||
| 		"update_success": "Local atualizado!", | ||||
| 		"upgrade_all_devices": "Atualize o firmware de todos os dispositivos", | ||||
| 		"upgrade_all_devices_error": "Erro ao atualizar dispositivos: {{e}}", | ||||
| 		"upgrade_all_devices_success": "Atualização de dispositivos iniciada com sucesso!", | ||||
| 		"upgrade_options_available": "Aqui estão todas as revisões disponíveis, selecione aquela para a qual você deseja que TODOS os dispositivos deste local sejam atualizados", | ||||
| 		"use_existing": "Usar existente", | ||||
| 		"use_existing_contacts": "Usar contatos existentes", | ||||
| 		"use_this_contact": "Use este contato" | ||||
|   | ||||
| @@ -12,7 +12,14 @@ export interface AlertButtonProps extends ThemeProps { | ||||
|   label?: string; | ||||
| } | ||||
|  | ||||
| const _AlertButton: React.FC<AlertButtonProps> = ({ onClick, isDisabled, isLoading, isCompact, label, ...props }) => { | ||||
| const _AlertButton: React.FC<AlertButtonProps> = ({ | ||||
|   onClick, | ||||
|   isDisabled, | ||||
|   isLoading, | ||||
|   isCompact = true, | ||||
|   label, | ||||
|   ...props | ||||
| }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const breakpoint = useBreakpoint(); | ||||
|  | ||||
|   | ||||
| @@ -11,7 +11,14 @@ export interface CreateButtonProps extends SpaceProps { | ||||
|   label?: string; | ||||
| } | ||||
|  | ||||
| const _CreateButton: React.FC<CreateButtonProps> = ({ onClick, isDisabled, isLoading, isCompact, label, ...props }) => { | ||||
| const _CreateButton: React.FC<CreateButtonProps> = ({ | ||||
|   onClick, | ||||
|   isDisabled, | ||||
|   isLoading, | ||||
|   isCompact = true, | ||||
|   label, | ||||
|   ...props | ||||
| }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const breakpoint = useBreakpoint(); | ||||
|  | ||||
|   | ||||
| @@ -16,7 +16,7 @@ const _DeleteButton: React.FC<DeleteButtonProps> = ({ | ||||
|   onClick, | ||||
|   isDisabled, | ||||
|   isLoading, | ||||
|   isCompact, | ||||
|   isCompact = true, | ||||
|   label, | ||||
|   ml, | ||||
|   ...props | ||||
|   | ||||
| @@ -1,9 +1,19 @@ | ||||
| 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'; | ||||
| import RebootMenuItem from './RebootButton'; | ||||
| import { useControllerStore } from 'contexts/ControllerSocketProvider/useStore'; | ||||
| import { useBlinkDevice, useGetDeviceRtty } from 'hooks/Network/Devices'; | ||||
| import { useUpdateDeviceToLatest } from 'hooks/Network/Firmware'; | ||||
| @@ -22,6 +32,7 @@ interface Props { | ||||
|   onOpenConfigureModal: (serialNumber: string) => void; | ||||
|   onOpenTelemetryModal: (serialNumber: string) => void; | ||||
|   onOpenScriptModal: (device: GatewayDevice) => void; | ||||
|   onOpenRebootModal: (serialNumber: string) => void; | ||||
|   size?: 'sm' | 'md' | 'lg'; | ||||
|   isCompact?: boolean; | ||||
| } | ||||
| @@ -38,8 +49,9 @@ const DeviceActionDropdown = ({ | ||||
|   onOpenTelemetryModal, | ||||
|   onOpenConfigureModal, | ||||
|   onOpenScriptModal, | ||||
|   onOpenRebootModal, | ||||
|   size, | ||||
|   isCompact, | ||||
|   isCompact = true, | ||||
| }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
| @@ -145,11 +157,13 @@ const DeviceActionDropdown = ({ | ||||
|       }, | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const handleConnectClick = () => getRtty(); | ||||
|   const handleRebootClick = () => onOpenRebootModal(device.serialNumber); | ||||
|  | ||||
|   return ( | ||||
|     <Menu> | ||||
|       <Tooltip label={t('commands.other')}> | ||||
|       <Tooltip label={t('common.actions')}> | ||||
|         {size === undefined || isCompact ? ( | ||||
|           <MenuButton | ||||
|             as={IconButton} | ||||
| @@ -168,26 +182,28 @@ const DeviceActionDropdown = ({ | ||||
|             isDisabled={isDisabled} | ||||
|             ml={2} | ||||
|           > | ||||
|             {t('commands.other')} | ||||
|             {t('common.actions')} | ||||
|           </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={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> | ||||
|         <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> | ||||
|           <MenuItem onClick={handleRebootClick}>{t('commands.reboot')}</MenuItem> | ||||
|           <MenuItem onClick={handleOpenTelemetry}>{t('controller.telemetry.title')}</MenuItem> | ||||
|           <MenuItem onClick={handleOpenScript}>{t('script.one')}</MenuItem> | ||||
|           <MenuItem onClick={handleOpenTrace}>{t('controller.devices.trace')}</MenuItem> | ||||
|           <MenuItem onClick={handleUpdateToLatest} hidden> | ||||
|             {t('premium.toolbox.upgrade_to_latest')} | ||||
|           </MenuItem> | ||||
|           <MenuItem onClick={handleOpenScan}>{t('commands.wifiscan')}</MenuItem> | ||||
|         </MenuList> | ||||
|       </Portal> | ||||
|     </Menu> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import React from 'react'; | ||||
| import { IconButton, Button, Tooltip, useBreakpoint } from '@chakra-ui/react'; | ||||
| import { Pen } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
|  | ||||
| export interface EditButtonProps { | ||||
|   onClick: () => void; | ||||
| @@ -11,7 +12,15 @@ export interface EditButtonProps { | ||||
|   ml?: string | number; | ||||
| } | ||||
|  | ||||
| const _EditButton: React.FC<EditButtonProps> = ({ onClick, label, isDisabled, isLoading, isCompact, ...props }) => { | ||||
| const _EditButton: React.FC<EditButtonProps> = ({ | ||||
|   onClick, | ||||
|   label, | ||||
|   isDisabled, | ||||
|   isLoading, | ||||
|   isCompact = true, | ||||
|   ...props | ||||
| }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const breakpoint = useBreakpoint(); | ||||
|  | ||||
|   if (!isCompact && breakpoint !== 'base' && breakpoint !== 'sm') { | ||||
| @@ -24,12 +33,12 @@ const _EditButton: React.FC<EditButtonProps> = ({ onClick, label, isDisabled, is | ||||
|         isDisabled={isDisabled} | ||||
|         {...props} | ||||
|       > | ||||
|         {label} | ||||
|         {label ?? t('common.edit')} | ||||
|       </Button> | ||||
|     ); | ||||
|   } | ||||
|   return ( | ||||
|     <Tooltip label={label}> | ||||
|     <Tooltip label={label ?? t('common.edit')} hasArrow> | ||||
|       <IconButton | ||||
|         aria-label="edit" | ||||
|         colorScheme="gray" | ||||
|   | ||||
| @@ -17,7 +17,7 @@ const _RefreshButton: React.FC<RefreshButtonProps> = ({ | ||||
|   onClick, | ||||
|   isDisabled, | ||||
|   isFetching, | ||||
|   isCompact, | ||||
|   isCompact = true, | ||||
|   ml, | ||||
|   size, | ||||
|   ...props | ||||
|   | ||||
| @@ -15,7 +15,7 @@ const _ResponsiveButton: React.FC<ResponsiveButtonProps> = ({ | ||||
|   onClick, | ||||
|   isDisabled, | ||||
|   isLoading, | ||||
|   isCompact, | ||||
|   isCompact = true, | ||||
|   color, | ||||
|   label, | ||||
|   icon, | ||||
|   | ||||
| @@ -18,7 +18,7 @@ const _SaveButton: React.FC<SaveButtonProps> = ({ | ||||
|   onClick, | ||||
|   isDisabled, | ||||
|   isLoading, | ||||
|   isCompact, | ||||
|   isCompact = true, | ||||
|   isDirty, | ||||
|   dirtyCheck, | ||||
|   ...props | ||||
|   | ||||
| @@ -20,7 +20,7 @@ const _ToggleEditButton: React.FC<ToggleEditButtonProps> = ({ | ||||
|   isDirty, | ||||
|   isDisabled, | ||||
|   isLoading, | ||||
|   isCompact, | ||||
|   isCompact = true, | ||||
|   ml, | ||||
|   ...props | ||||
| }) => { | ||||
|   | ||||
| @@ -16,7 +16,7 @@ const _WarningButton: React.FC<WarningButtonProps> = ({ | ||||
|   onClick, | ||||
|   isDisabled, | ||||
|   isLoading, | ||||
|   isCompact, | ||||
|   isCompact = true, | ||||
|   label, | ||||
|   ...props | ||||
| }) => { | ||||
|   | ||||
| @@ -21,7 +21,7 @@ export const ColumnPicker = ({ | ||||
|   hiddenColumns, | ||||
|   setHiddenColumns, | ||||
|   size, | ||||
|   isCompact, | ||||
|   isCompact = true, | ||||
| }: ColumnPickerProps) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { getPref, setPref } = useAuth(); | ||||
|   | ||||
| @@ -44,15 +44,18 @@ const defaultProps = { | ||||
|   sortBy: [], | ||||
| }; | ||||
|  | ||||
| export type DataTableProps = { | ||||
|   columns: readonly Column<object>[]; | ||||
|   data: object[]; | ||||
| export type DataTableProps<TValue> = { | ||||
|   columns: Column<TValue>[]; | ||||
|   data: TValue[]; | ||||
|   count?: number; | ||||
|   setPageInfo?: React.Dispatch<React.SetStateAction<PageInfo | undefined>>; | ||||
|   isLoading?: boolean; | ||||
|   onRowClick?: (row: TValue) => void; | ||||
|   isRowClickable?: (row: TValue) => boolean; | ||||
|   obj?: string; | ||||
|   sortBy?: { id: string; desc: boolean }[]; | ||||
|   hiddenColumns?: string[]; | ||||
|   hideEmptyListText?: boolean; | ||||
|   hideControls?: boolean; | ||||
|   minHeight?: string | number; | ||||
|   fullScreen?: boolean; | ||||
| @@ -67,7 +70,7 @@ type TableInstanceWithHooks<T extends object> = TableInstance<T> & | ||||
|     state: UsePaginationState<T>; | ||||
|   }; | ||||
|  | ||||
| const _DataTable = ({ | ||||
| const _DataTable = <TValue extends object>({ | ||||
|   columns, | ||||
|   data, | ||||
|   isLoading, | ||||
| @@ -77,15 +80,19 @@ const _DataTable = ({ | ||||
|   sortBy, | ||||
|   hiddenColumns, | ||||
|   hideControls, | ||||
|   hideEmptyListText, | ||||
|   count, | ||||
|   setPageInfo, | ||||
|   isManual, | ||||
|   saveSettingsId, | ||||
|   showAllRows, | ||||
| }: DataTableProps) => { | ||||
|   onRowClick, | ||||
|   isRowClickable, | ||||
| }: DataTableProps<TValue>) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const breakpoint = useBreakpoint(); | ||||
|   const textColor = useColorModeValue('gray.700', 'white'); | ||||
|   const hoveredRowBg = useColorModeValue('gray.100', 'gray.600'); | ||||
|   const getPageSize = () => { | ||||
|     try { | ||||
|       if (showAllRows) return 1000000; | ||||
| @@ -140,8 +147,12 @@ const _DataTable = ({ | ||||
|     }, | ||||
|     useSortBy, | ||||
|     usePagination, | ||||
|   ) as TableInstanceWithHooks<object>; | ||||
|   ) as TableInstanceWithHooks<TValue>; | ||||
|  | ||||
|   const handleGoToPage = (newPage: number) => { | ||||
|     if (saveSettingsId) localStorage.setItem(`${saveSettingsId}.page`, String(newPage)); | ||||
|     gotoPage(newPage); | ||||
|   }; | ||||
|   const handleNextPage = () => { | ||||
|     nextPage(); | ||||
|     if (saveSettingsId) localStorage.setItem(`${saveSettingsId}.page`, String(pageIndex + 1)); | ||||
| @@ -253,10 +264,19 @@ const _DataTable = ({ | ||||
|             </Thead> | ||||
|             {data.length > 0 && ( | ||||
|               <Tbody {...getTableBodyProps()}> | ||||
|                 {page.map((row: Row) => { | ||||
|                 {page.map((row: Row<TValue>) => { | ||||
|                   prepareRow(row); | ||||
|                   const rowIsClickable = isRowClickable ? isRowClickable(row.original) : true; | ||||
|                   const onClick = rowIsClickable && onRowClick ? () => onRowClick(row.original) : undefined; | ||||
|                   return ( | ||||
|                     <Tr {...row.getRowProps()} key={uuid()}> | ||||
|                     <Tr | ||||
|                       {...row.getRowProps()} | ||||
|                       key={uuid()} | ||||
|                       _hover={{ | ||||
|                         backgroundColor: hoveredRowBg, | ||||
|                       }} | ||||
|                       onClick={onClick} | ||||
|                     > | ||||
|                       { | ||||
|                         // @ts-ignore | ||||
|                         row.cells.map((cell) => ( | ||||
| @@ -275,8 +295,26 @@ const _DataTable = ({ | ||||
|                             fontSize="14px" | ||||
|                             // @ts-ignore | ||||
|                             textAlign={cell.column.isCentered ? 'center' : undefined} | ||||
|                             // @ts-ignore | ||||
|                             fontFamily={cell.column.isMonospace ? 'monospace' : undefined} | ||||
|                             fontFamily={ | ||||
|                               // @ts-ignore | ||||
|                               cell.column.isMonospace | ||||
|                                 ? 'Inter, SFMono-Regular, Menlo, Monaco, Consolas, monospace' | ||||
|                                 : undefined | ||||
|                             } | ||||
|                             onClick={ | ||||
|                               // @ts-ignore | ||||
|                               cell.column.stopPropagation || (cell.column.id === 'actions' && onRowClick) | ||||
|                                 ? (e) => { | ||||
|                                     e.stopPropagation(); | ||||
|                                   } | ||||
|                                 : undefined | ||||
|                             } | ||||
|                             cursor={ | ||||
|                               // @ts-ignore | ||||
|                               !cell.column.stopPropagation && cell.column.id !== 'actions' && onRowClick | ||||
|                                 ? 'pointer' | ||||
|                                 : undefined | ||||
|                             } | ||||
|                           > | ||||
|                             {cell.render('Cell')} | ||||
|                           </Td> | ||||
| @@ -288,7 +326,7 @@ const _DataTable = ({ | ||||
|               </Tbody> | ||||
|             )} | ||||
|           </Table> | ||||
|           {!isLoading && data.length === 0 && ( | ||||
|           {!isLoading && data.length === 0 && !hideEmptyListText && ( | ||||
|             <Center> | ||||
|               {obj ? ( | ||||
|                 <Heading size="md" pt={12}> | ||||
| @@ -309,7 +347,7 @@ const _DataTable = ({ | ||||
|             <Tooltip label={t('table.first_page')}> | ||||
|               <IconButton | ||||
|                 aria-label="Go to first page" | ||||
|                 onClick={() => gotoPage(0)} | ||||
|                 onClick={() => handleGoToPage(0)} | ||||
|                 isDisabled={!canPreviousPage} | ||||
|                 icon={<ArrowLeftIcon h={3} w={3} />} | ||||
|                 mr={4} | ||||
| @@ -347,7 +385,7 @@ const _DataTable = ({ | ||||
|                   max={pageOptions.length} | ||||
|                   onChange={(_: unknown, numberValue: number) => { | ||||
|                     const newPage = numberValue ? numberValue - 1 : 0; | ||||
|                     gotoPage(newPage); | ||||
|                     handleGoToPage(newPage); | ||||
|                   }} | ||||
|                   defaultValue={pageIndex + 1} | ||||
|                 > | ||||
| @@ -386,7 +424,7 @@ const _DataTable = ({ | ||||
|             <Tooltip label={t('table.last_page')}> | ||||
|               <IconButton | ||||
|                 aria-label="Go to last page" | ||||
|                 onClick={() => gotoPage(pageCount - 1)} | ||||
|                 onClick={() => handleGoToPage(pageCount - 1)} | ||||
|                 isDisabled={!canNextPage} | ||||
|                 icon={<ArrowRightIcon h={3} w={3} />} | ||||
|                 ml={4} | ||||
| @@ -401,4 +439,4 @@ const _DataTable = ({ | ||||
|  | ||||
| _DataTable.defaultProps = defaultProps; | ||||
|  | ||||
| export const DataTable = React.memo(_DataTable); | ||||
| export const DataTable = React.memo(_DataTable) as unknown as typeof _DataTable; | ||||
|   | ||||
| @@ -100,6 +100,7 @@ const SortableDataTable: React.FC<Props> = ({ | ||||
| }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const breakpoint = useBreakpoint(); | ||||
|   const hoveredRowBg = useColorModeValue('gray.100', 'gray.600'); | ||||
|   const textColor = useColorModeValue('gray.700', 'white'); | ||||
|   const getPageSize = () => { | ||||
|     const saved = saveSettingsId ? localStorage.getItem(saveSettingsId) : undefined; | ||||
| @@ -223,7 +224,13 @@ const SortableDataTable: React.FC<Props> = ({ | ||||
|                 {page.map((row: Row) => { | ||||
|                   prepareRow(row); | ||||
|                   return ( | ||||
|                     <Tr {...row.getRowProps()} key={uuid()}> | ||||
|                     <Tr | ||||
|                       {...row.getRowProps()} | ||||
|                       key={uuid()} | ||||
|                       _hover={{ | ||||
|                         backgroundColor: hoveredRowBg, | ||||
|                       }} | ||||
|                     > | ||||
|                       { | ||||
|                         // @ts-ignore | ||||
|                         row.cells.map((cell) => ( | ||||
| @@ -242,8 +249,12 @@ const SortableDataTable: React.FC<Props> = ({ | ||||
|                             fontSize="14px" | ||||
|                             // @ts-ignore | ||||
|                             textAlign={cell.column.isCentered ? 'center' : undefined} | ||||
|                             // @ts-ignore | ||||
|                             fontFamily={cell.column.isMonospace ? 'monospace' : undefined} | ||||
|                             fontFamily={ | ||||
|                               // @ts-ignore | ||||
|                               cell.column.isMonospace | ||||
|                                 ? 'Inter, SFMono-Regular, Menlo, Monaco, Consolas, monospace' | ||||
|                                 : undefined | ||||
|                             } | ||||
|                           > | ||||
|                             {cell.render('Cell')} | ||||
|                           </Td> | ||||
|   | ||||
							
								
								
									
										27
									
								
								src/components/Maps/GoogleMap/Marker.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/components/Maps/GoogleMap/Marker.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import * as React from 'react'; | ||||
|  | ||||
| const _GoogleMapMarker = (options: google.maps.MarkerOptions) => { | ||||
|   const [marker, setMarker] = React.useState<google.maps.Marker>(); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     if (!marker) { | ||||
|       setMarker(new google.maps.Marker()); | ||||
|     } | ||||
|  | ||||
|     return () => { | ||||
|       if (marker) { | ||||
|         marker.setMap(null); | ||||
|       } | ||||
|     }; | ||||
|   }, [marker]); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     if (marker) { | ||||
|       marker.setOptions(options); | ||||
|     } | ||||
|   }, [marker, options]); | ||||
|  | ||||
|   return null; | ||||
| }; | ||||
|  | ||||
| export const GoogleMapMarker = React.memo(_GoogleMapMarker); | ||||
							
								
								
									
										89
									
								
								src/components/Maps/GoogleMap/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/components/Maps/GoogleMap/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| import * as React from 'react'; | ||||
| import { isLatLngLiteral } from '@googlemaps/typescript-guards'; | ||||
| import { createCustomEqual } from 'fast-equals'; | ||||
|  | ||||
| const deepCompareEqualsForMaps = createCustomEqual((deepEqual) => | ||||
|   // @ts-ignore | ||||
|   (a: number | google.maps.LatLng | google.maps.LatLngLiteral, b: number | google.maps.LatLng | google.maps.LatLngLiteral) => { | ||||
|     if ( | ||||
|       isLatLngLiteral(a) || | ||||
|       a instanceof google.maps.LatLng || | ||||
|       isLatLngLiteral(b) || | ||||
|       b instanceof google.maps.LatLng | ||||
|     ) { | ||||
|       return new google.maps.LatLng(a).equals(new google.maps.LatLng(b)); | ||||
|     } | ||||
|  | ||||
|     // @ts-ignore | ||||
|     return deepEqual(a, b); | ||||
|   }, | ||||
| ); | ||||
|  | ||||
| const useDeepCompareMemoize = (value: unknown) => { | ||||
|   const ref = React.useRef<unknown>(); | ||||
|  | ||||
|   if (!deepCompareEqualsForMaps(value, ref.current)) { | ||||
|     ref.current = value; | ||||
|   } | ||||
|  | ||||
|   return ref.current; | ||||
| }; | ||||
|  | ||||
| const useDeepCompareEffectForMaps = (callback: React.EffectCallback, dependencies: unknown[]) => { | ||||
|   React.useEffect(callback, dependencies.map(useDeepCompareMemoize)); | ||||
| }; | ||||
|  | ||||
| export interface GoogleMapProps extends google.maps.MapOptions { | ||||
|   style: { [key: string]: string }; | ||||
|   onClick?: (e: google.maps.MapMouseEvent) => void; | ||||
|   onIdle?: (map: google.maps.Map) => void; | ||||
|   children?: React.ReactNode; | ||||
| } | ||||
|  | ||||
| const _GoogleMap = ({ style, onClick, onIdle, children, ...options }: GoogleMapProps) => { | ||||
|   const ref = React.useRef<HTMLDivElement>(null); | ||||
|   const [map, setMap] = React.useState<google.maps.Map>(); | ||||
|  | ||||
|   // because React does not do deep comparisons, a custom hook is used | ||||
|   useDeepCompareEffectForMaps(() => { | ||||
|     if (map) { | ||||
|       map.setOptions(options); | ||||
|     } | ||||
|   }, [map, options]); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     if (ref.current && !map) { | ||||
|       setMap(new window.google.maps.Map(ref.current, {})); | ||||
|     } | ||||
|   }, [ref, map]); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     if (map) { | ||||
|       ['click', 'idle'].forEach((eventName) => google.maps.event.clearListeners(map, eventName)); | ||||
|  | ||||
|       if (onClick) { | ||||
|         map.addListener('click', onClick); | ||||
|       } | ||||
|  | ||||
|       if (onIdle) { | ||||
|         map.addListener('idle', () => onIdle(map)); | ||||
|       } | ||||
|     } | ||||
|   }, [map, onClick, onIdle]); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <div ref={ref} style={style} /> | ||||
|       {React.Children.map(children, (child) => { | ||||
|         if (React.isValidElement(child)) { | ||||
|           // set the map prop on the child component | ||||
|           // @ts-ignore | ||||
|           return React.cloneElement(child, { map }); | ||||
|         } | ||||
|         return null; | ||||
|       })} | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const GoogleMap = React.memo(_GoogleMap); | ||||
| @@ -5,17 +5,22 @@ import { | ||||
|   AlertIcon, | ||||
|   AlertTitle, | ||||
|   Box, | ||||
|   Button, | ||||
|   Flex, | ||||
|   FormControl, | ||||
|   FormErrorMessage, | ||||
|   FormLabel, | ||||
|   Textarea, | ||||
|   useToast, | ||||
| } from '@chakra-ui/react'; | ||||
| import { ClipboardText } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { SaveButton } from '../../Buttons/SaveButton'; | ||||
| import { Modal } from '../Modal'; | ||||
| import { FileInputButton } from 'components/Buttons/FileInputButton'; | ||||
| import { useConfigureDevice } from 'hooks/Network/Commands'; | ||||
| import { useGetDevice } from 'hooks/Network/Devices'; | ||||
| import { AxiosError } from 'models/Axios'; | ||||
|  | ||||
| export type ConfigureModalProps = { | ||||
|   serialNumber: string; | ||||
| @@ -29,11 +34,17 @@ export const ConfigureModal = ({ serialNumber, modalProps }: ConfigureModalProps | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
|   const configure = useConfigureDevice({ serialNumber }); | ||||
|   const getDevice = useGetDevice({ serialNumber }); | ||||
|  | ||||
|   const [newConfig, setNewConfig] = React.useState(''); | ||||
|  | ||||
|   const onChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => { | ||||
|     setNewConfig(e.target.value); | ||||
|   }, []); | ||||
|  | ||||
|   const onImportConfiguration = () => { | ||||
|     setNewConfig(getDevice.data?.configuration ? JSON.stringify(getDevice.data.configuration, null, 4) : ''); | ||||
|   }; | ||||
|   const isValid = React.useMemo(() => { | ||||
|     try { | ||||
|       JSON.parse(newConfig); | ||||
| @@ -60,9 +71,7 @@ export const ConfigureModal = ({ serialNumber, modalProps }: ConfigureModalProps | ||||
|           modalProps.onClose(); | ||||
|         }, | ||||
|       }); | ||||
|     } catch (e) { | ||||
|       // console.log(e); | ||||
|     } | ||||
|     } catch (e) {} | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
| @@ -79,10 +88,7 @@ export const ConfigureModal = ({ serialNumber, modalProps }: ConfigureModalProps | ||||
|             <AlertIcon /> | ||||
|             <Box> | ||||
|               <AlertTitle>{t('common.error')}</AlertTitle> | ||||
|               { | ||||
|                 // @ts-ignore | ||||
|                 <AlertDescription>{configure.error?.response?.data?.ErrorDescription}</AlertDescription> | ||||
|               } | ||||
|               <AlertDescription>{(configure.error as AxiosError)?.response?.data?.ErrorDescription}</AlertDescription> | ||||
|             </Box> | ||||
|           </Alert> | ||||
|         )} | ||||
| @@ -92,15 +98,25 @@ export const ConfigureModal = ({ serialNumber, modalProps }: ConfigureModalProps | ||||
|         </Alert> | ||||
|         <FormControl isInvalid={!isValid && newConfig.length > 0}> | ||||
|           <FormLabel>{t('configurations.one')}</FormLabel> | ||||
|           <Box mb={2} w="240px"> | ||||
|             <FileInputButton | ||||
|               value={newConfig} | ||||
|               setValue={(v) => setNewConfig(v)} | ||||
|               refreshId="1" | ||||
|               accept=".json" | ||||
|               isStringFile | ||||
|             /> | ||||
|           </Box> | ||||
|           <Flex mb={2}> | ||||
|             <Box w="240px"> | ||||
|               <FileInputButton | ||||
|                 value={newConfig} | ||||
|                 setValue={(v) => setNewConfig(v)} | ||||
|                 refreshId="1" | ||||
|                 accept=".json" | ||||
|                 isStringFile | ||||
|               /> | ||||
|             </Box> | ||||
|             <Button | ||||
|               rightIcon={<ClipboardText size={20} />} | ||||
|               onClick={onImportConfiguration} | ||||
|               hidden={!getDevice.data} | ||||
|               ml={2} | ||||
|             > | ||||
|               Current Configuration | ||||
|             </Button> | ||||
|           </Flex> | ||||
|           <Textarea height="auto" minH="600px" value={newConfig} onChange={onChange} /> | ||||
|           <FormErrorMessage>{t('controller.configure.invalid')}</FormErrorMessage> | ||||
|         </FormControl> | ||||
|   | ||||
| @@ -57,7 +57,8 @@ export const FirmwareUpgradeModal = ({ modalProps: { isOpen, onClose }, serialNu | ||||
|     upgrade({ | ||||
|       keepRedirector: isRedirector, | ||||
|       uri, | ||||
|       signature: device?.restrictedDevice ? ref.current?.values?.signature : undefined, | ||||
|       signature: | ||||
|         device?.restrictedDevice && !device?.restrictionDetails?.developer ? ref.current?.values?.signature : undefined, | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
| @@ -89,7 +90,7 @@ export const FirmwareUpgradeModal = ({ modalProps: { isOpen, onClose }, serialNu | ||||
|                 </FormLabel> | ||||
|                 <Switch isChecked={isRedirector} onChange={toggle} borderRadius="15px" size="lg" /> | ||||
|               </FormControl> | ||||
|               {device?.restrictedDevice && ( | ||||
|               {device?.restrictedDevice && !device?.restrictionDetails?.developer && ( | ||||
|                 <Formik<{ signature?: string }> | ||||
|                   innerRef={ref as Ref<FormikProps<{ signature?: string | undefined }>> | undefined} | ||||
|                   key={formKey} | ||||
|   | ||||
| @@ -1,15 +1,19 @@ | ||||
| import React from 'react'; | ||||
| import { Flex, ModalHeader as Header, Spacer } from '@chakra-ui/react'; | ||||
| import { Flex, HStack, ModalHeader as Header, Spacer } from '@chakra-ui/react'; | ||||
|  | ||||
| export interface ModalHeaderProps { | ||||
|   title: string; | ||||
|   left?: React.ReactNode; | ||||
|   right: React.ReactNode; | ||||
| } | ||||
|  | ||||
| const _ModalHeader: React.FC<ModalHeaderProps> = ({ title, right }) => ( | ||||
| const _ModalHeader: React.FC<ModalHeaderProps> = ({ title, left, right }) => ( | ||||
|   <Header> | ||||
|     <Flex justifyContent="center" alignItems="center" maxW="100%" px={1}> | ||||
|       {title} | ||||
|       <HStack spacing={2} ml={2}> | ||||
|         {left ?? null} | ||||
|       </HStack> | ||||
|       <Spacer /> | ||||
|       {right} | ||||
|     </Flex> | ||||
|   | ||||
| @@ -8,6 +8,7 @@ export type ModalProps = { | ||||
|   onClose: () => void; | ||||
|   title: string; | ||||
|   topRightButtons?: React.ReactNode; | ||||
|   tags?: React.ReactNode; | ||||
|   options?: { | ||||
|     modalSize?: 'sm' | 'md' | 'lg'; | ||||
|     maxWidth?: LayoutProps['maxWidth']; | ||||
| @@ -15,7 +16,7 @@ export type ModalProps = { | ||||
|   children: React.ReactElement; | ||||
| }; | ||||
|  | ||||
| const _Modal = ({ isOpen, onClose, title, topRightButtons, options, children }: ModalProps) => { | ||||
| const _Modal = ({ isOpen, onClose, title, topRightButtons, tags, options, children }: ModalProps) => { | ||||
|   const maxWidth = React.useMemo(() => { | ||||
|     if (options?.maxWidth) return options.maxWidth; | ||||
|     if (options?.modalSize === 'sm') return undefined; | ||||
| @@ -32,6 +33,7 @@ const _Modal = ({ isOpen, onClose, title, topRightButtons, options, children }: | ||||
|       <ModalContent maxWidth={maxWidth}> | ||||
|         <ModalHeader | ||||
|           title={title} | ||||
|           left={tags} | ||||
|           right={ | ||||
|             <HStack spacing={2}> | ||||
|               {topRightButtons} | ||||
|   | ||||
| @@ -1,40 +1,42 @@ | ||||
| import * as React from 'react'; | ||||
| import { MenuItem, useToast } from '@chakra-ui/react'; | ||||
| import { Alert, AlertIcon, Box, Button, Center, useToast } from '@chakra-ui/react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Modal } from '../Modal'; | ||||
| import { useControllerStore } from 'contexts/ControllerSocketProvider/useStore'; | ||||
| import { useRebootDevice } from 'hooks/Network/Devices'; | ||||
| import { useMutationResult } from 'hooks/useMutationResult'; | ||||
| import { AxiosError } from 'models/Axios'; | ||||
| import { GatewayDevice } from 'models/Device'; | ||||
| 
 | ||||
| type Props = { | ||||
|   device: GatewayDevice; | ||||
|   refresh: () => void; | ||||
| export type RebootModalProps = { | ||||
|   serialNumber: string; | ||||
|   modalProps: { | ||||
|     isOpen: boolean; | ||||
|     onClose: () => void; | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| const RebootMenuItem = ({ device, refresh }: Props) => { | ||||
| export const RebootModal = ({ serialNumber, modalProps }: RebootModalProps) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
|   const addEventListeners = useControllerStore((state) => state.addEventListeners); | ||||
|   const { mutateAsync: reboot } = useRebootDevice({ serialNumber: device.serialNumber }); | ||||
|   const { mutateAsync: reboot, isLoading } = useRebootDevice({ serialNumber }); | ||||
|   const { onSuccess: onRebootSuccess, onError: onRebootError } = useMutationResult({ | ||||
|     objName: t('devices.one'), | ||||
|     operationType: 'reboot', | ||||
|     refresh: () => { | ||||
|       refresh(); | ||||
|       addEventListeners([ | ||||
|         { | ||||
|           id: `device-connection-${device.serialNumber}`, | ||||
|           id: `device-connection-${serialNumber}`, | ||||
|           type: 'DEVICE_CONNECTION', | ||||
|           serialNumber: device.serialNumber, | ||||
|           serialNumber, | ||||
|           callback: () => { | ||||
|             const id = `device-connection-notification-${device.serialNumber}`; | ||||
|             const id = `device-connection-notification-${serialNumber}`; | ||||
| 
 | ||||
|             if (!toast.isActive(id)) { | ||||
|               toast({ | ||||
|                 id, | ||||
|                 title: t('common.success'), | ||||
|                 description: t('controller.devices.finished_reboot', { serialNumber: device.serialNumber }), | ||||
|                 description: t('controller.devices.finished_reboot', { serialNumber }), | ||||
|                 status: 'success', | ||||
|                 duration: 5000, | ||||
|                 isClosable: true, | ||||
| @@ -44,17 +46,17 @@ const RebootMenuItem = ({ device, refresh }: Props) => { | ||||
|           }, | ||||
|         }, | ||||
|         { | ||||
|           id: `device-disconnected-${device.serialNumber}`, | ||||
|           id: `device-disconnected-${serialNumber}`, | ||||
|           type: 'DEVICE_DISCONNECTION', | ||||
|           serialNumber: device.serialNumber, | ||||
|           serialNumber, | ||||
|           callback: () => { | ||||
|             const id = `device-disconnection-notification-${device.serialNumber}`; | ||||
|             const id = `device-disconnection-notification-${serialNumber}`; | ||||
| 
 | ||||
|             if (!toast.isActive(id)) { | ||||
|               toast({ | ||||
|                 id, | ||||
|                 title: t('common.success'), | ||||
|                 description: t('controller.devices.started_reboot', { serialNumber: device.serialNumber }), | ||||
|                 description: t('controller.devices.started_reboot', { serialNumber }), | ||||
|                 status: 'success', | ||||
|                 duration: 5000, | ||||
|                 isClosable: true, | ||||
| @@ -66,17 +68,39 @@ const RebootMenuItem = ({ device, refresh }: Props) => { | ||||
|       ]); | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   const handleRebootClick = () => | ||||
|     reboot(undefined, { | ||||
|       onSuccess: () => { | ||||
|         onRebootSuccess(); | ||||
|         modalProps.onClose(); | ||||
|       }, | ||||
|       onError: (e) => { | ||||
|         onRebootError(e as AxiosError); | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|   return <MenuItem onClick={handleRebootClick}>{t('commands.reboot')}</MenuItem>; | ||||
|   return ( | ||||
|     <Modal | ||||
|       {...modalProps} | ||||
|       title={t('commands.reboot')} | ||||
|       topRightButtons={ | ||||
|         <Button colorScheme="blue" onClick={handleRebootClick} isLoading={isLoading}> | ||||
|           {t('commands.reboot')} | ||||
|         </Button> | ||||
|       } | ||||
|       options={{ | ||||
|         modalSize: 'sm', | ||||
|       }} | ||||
|     > | ||||
|       <Box> | ||||
|         <Center mb={2}> | ||||
|           <Alert status="info" w="unset"> | ||||
|             <AlertIcon /> | ||||
|             {t('commands.reboot_description')} | ||||
|           </Alert> | ||||
|         </Center> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default RebootMenuItem; | ||||
| @@ -183,7 +183,9 @@ const CustomScriptForm = ({ | ||||
|             <> | ||||
|               <Flex> | ||||
|                 <Box> | ||||
|                   {device?.restrictedDevice && <SignatureField name="signature" isDisabled={areFieldsDisabled} />} | ||||
|                   {device?.restrictedDevice && !device?.restrictionDetails?.developer && ( | ||||
|                     <SignatureField name="signature" isDisabled={areFieldsDisabled} /> | ||||
|                   )} | ||||
|                 </Box> | ||||
|               </Flex> | ||||
|               <SelectField | ||||
|   | ||||
| @@ -60,8 +60,14 @@ export const ScriptModal = ({ device, modalProps }: ScriptModalProps) => { | ||||
|     let requestData: { | ||||
|       [k: string]: unknown; | ||||
|       serialNumber: string; | ||||
|       script?: string; | ||||
|       timeout?: number | undefined; | ||||
|     } = data; | ||||
|  | ||||
|     if (requestData.script) { | ||||
|       requestData.script = btoa(requestData.script); | ||||
|     } | ||||
|  | ||||
|     if (selectedScript === 'diagnostics') { | ||||
|       requestData = { | ||||
|         serialNumber: device?.serialNumber ?? '', | ||||
| @@ -76,7 +82,7 @@ export const ScriptModal = ({ device, modalProps }: ScriptModalProps) => { | ||||
|         when: 0, | ||||
|         deferred: data.deferred, | ||||
|         timeout: data.timeout, | ||||
|         signature: device?.restrictedDevice ? data.signature : undefined, | ||||
|         signature: device?.restrictedDevice && !device?.restrictionDetails?.developer ? data.signature : undefined, | ||||
|         uri: data.defaultUploadURI && data.defaultUploadURI?.length > 0 ? data.defaultUploadURI : undefined, | ||||
|         scriptId: selectedScript, | ||||
|         type: data.type, | ||||
| @@ -88,6 +94,19 @@ export const ScriptModal = ({ device, modalProps }: ScriptModalProps) => { | ||||
|         setValue(response.results?.status?.result ?? JSON.stringify(response.results ?? {}, null, 2)); | ||||
|         queryClient.invalidateQueries(['commands', device?.serialNumber ?? '']); | ||||
|       }, | ||||
|       onError: (e) => { | ||||
|         if (axios.isAxiosError(e) && e.response?.data?.ErrorDescription) { | ||||
|           toast({ | ||||
|             id: 'script-update-error', | ||||
|             title: t('common.error'), | ||||
|             description: e.response?.data?.ErrorDescription, | ||||
|             status: 'error', | ||||
|             duration: 5000, | ||||
|             isClosable: true, | ||||
|             position: 'top-right', | ||||
|           }); | ||||
|         } | ||||
|       }, | ||||
|     }); | ||||
|     if (!waitForResponse) { | ||||
|       toast({ | ||||
|   | ||||
| @@ -57,7 +57,7 @@ export const WifiScanModal = ({ modalProps: { isOpen, onClose }, serialNumber }: | ||||
|     if (isOpen) resetData(); | ||||
|   }, [isOpen]); | ||||
|   return ( | ||||
|     (<Modal onClose={closeModal} isOpen={isOpen} size="xl" scrollBehavior="inside"> | ||||
|     <Modal onClose={closeModal} isOpen={isOpen} size="xl" scrollBehavior="inside"> | ||||
|       <ModalOverlay /> | ||||
|       <ModalContent maxWidth={{ sm: '600px', md: '700px', lg: '800px', xl: '50%' }}> | ||||
|         <ModalHeader | ||||
| @@ -66,7 +66,7 @@ export const WifiScanModal = ({ modalProps: { isOpen, onClose }, serialNumber }: | ||||
|             <> | ||||
|               {csvData ? ( | ||||
|                 // @ts-ignore | ||||
|                 (<CSVLink | ||||
|                 <CSVLink | ||||
|                   filename={`wifi_scan_${serialNumber}_${dateForFilename(new Date().getTime() / 1000)}.csv`} | ||||
|                   data={csvData as object[]} | ||||
|                 > | ||||
| @@ -77,7 +77,7 @@ export const WifiScanModal = ({ modalProps: { isOpen, onClose }, serialNumber }: | ||||
|                     label={t('common.download')} | ||||
|                     onClick={() => {}} | ||||
|                   /> | ||||
|                 </CSVLink>) | ||||
|                 </CSVLink> | ||||
|               ) : ( | ||||
|                 <ResponsiveButton | ||||
|                   color="gray" | ||||
| @@ -118,6 +118,6 @@ export const WifiScanModal = ({ modalProps: { isOpen, onClose }, serialNumber }: | ||||
|         confirm={closeCancelAndForm} | ||||
|         cancel={closeConfirm} | ||||
|       /> | ||||
|     </Modal>) | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -149,6 +149,8 @@ export const useControllerStore = create<ControllerStoreState>((set, get) => ({ | ||||
|               connectedDevices: msg.statistics.numberOfDevices, | ||||
|               connectingDevices: msg.statistics.numberOfConnectingDevices, | ||||
|               averageConnectionTime: msg.statistics.averageConnectedTime, | ||||
|               tx: msg.statistics.tx, | ||||
|               rx: msg.statistics.rx, | ||||
|             }, | ||||
|             queryClient, | ||||
|           ); | ||||
|   | ||||
| @@ -52,6 +52,8 @@ type ConnectionStatisticsMessage = { | ||||
|       numberOfDevices: number; | ||||
|       numberOfConnectingDevices: number; | ||||
|       averageConnectedTime: number; | ||||
|       tx: number; | ||||
|       rx: number; | ||||
|     }; | ||||
|   }; | ||||
|   serialNumbers?: undefined; | ||||
| @@ -85,6 +87,8 @@ export type SocketWebSocketNotificationData = | ||||
|         numberOfDevices: number; | ||||
|         numberOfConnectingDevices: number; | ||||
|         averageConnectedTime: number; | ||||
|         rx: number; | ||||
|         tx: number; | ||||
|       }; | ||||
|       serialNumber?: undefined; | ||||
|       log?: undefined; | ||||
|   | ||||
| @@ -110,6 +110,18 @@ export const useDeleteCommand = () => { | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| export const useGetSingleCommandHistory = ({ serialNumber, commandId }: { serialNumber: string; commandId: string }) => | ||||
|   useQuery( | ||||
|     ['commands', serialNumber, commandId], | ||||
|     () => | ||||
|       axiosGw | ||||
|         .get(`command/${commandId}?serialNumber=${serialNumber}`) | ||||
|         .then((response) => response.data as DeviceCommandHistory), | ||||
|     { | ||||
|       enabled: serialNumber !== undefined && serialNumber !== '' && commandId !== undefined && commandId !== '', | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
| export type EventQueueResponse = { | ||||
|   UUID: string; | ||||
|   attachFile: number; | ||||
| @@ -245,6 +257,7 @@ export const useDeviceScript = ({ serialNumber }: { serialNumber: string }) => { | ||||
|       queryClient.invalidateQueries(['commands', serialNumber]); | ||||
|     }, | ||||
|     onError: (e) => { | ||||
|       queryClient.invalidateQueries(['commands', serialNumber]); | ||||
|       if (axios.isAxiosError(e)) { | ||||
|         toast({ | ||||
|           id: 'script-error', | ||||
| @@ -263,14 +276,44 @@ export const useDeviceScript = ({ serialNumber }: { serialNumber: string }) => { | ||||
| 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), { | ||||
| export const useDownloadScriptResult = ({ serialNumber, commandId }: { serialNumber: string; commandId: string }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
|  | ||||
|   return 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`; | ||||
|       const headerLine = | ||||
|         (response.headers['content-disposition'] as string | undefined) ?? | ||||
|         (response.headers['content-disposition'] as string | undefined); | ||||
|       const filename = headerLine?.split('filename=')[1]?.split(',')[0] ?? `Script_${commandId}.tar.gz`; | ||||
|       link.download = filename; | ||||
|       link.click(); | ||||
|     }, | ||||
|     onError: (e) => { | ||||
|       if (axios.isAxiosError(e)) { | ||||
|         const bufferResponse = e.response?.data; | ||||
|         let errorMessage = ''; | ||||
|         // If the response is a buffer, parse to JSON object | ||||
|         if (bufferResponse instanceof ArrayBuffer) { | ||||
|           const decoder = new TextDecoder('utf-8'); | ||||
|           const json = JSON.parse(decoder.decode(bufferResponse)); | ||||
|           errorMessage = json.ErrorDescription; | ||||
|         } | ||||
|  | ||||
|         toast({ | ||||
|           id: `script-download-error-${serialNumber}`, | ||||
|           title: t('common.error'), | ||||
|           description: errorMessage, | ||||
|           status: 'error', | ||||
|           duration: 5000, | ||||
|           isClosable: true, | ||||
|           position: 'top-right', | ||||
|         }); | ||||
|       } | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
|   | ||||
| @@ -11,8 +11,10 @@ export type DeviceLog = { | ||||
|   severity: number; | ||||
| }; | ||||
|  | ||||
| const getDeviceLogs = (limit: number, serialNumber?: string) => async () => | ||||
|   axiosGw.get(`device/${serialNumber}/logs?newest=true&limit=${limit}`).then((response) => response.data) as Promise<{ | ||||
| const getDeviceLogs = (limit: number, serialNumber?: string, logType?: 0 | 1) => async () => | ||||
|   axiosGw | ||||
|     .get(`device/${serialNumber}/logs?newest=true&limit=${limit}&logType=${logType}`) | ||||
|     .then((response) => response.data) as Promise<{ | ||||
|     values: DeviceLog[]; | ||||
|     serialNumber: string; | ||||
|   }>; | ||||
| @@ -21,20 +23,29 @@ export const useGetDeviceLogs = ({ | ||||
|   serialNumber, | ||||
|   limit, | ||||
|   onError, | ||||
|   logType, | ||||
| }: { | ||||
|   serialNumber?: string; | ||||
|   limit: number; | ||||
|   onError?: (e: AxiosError) => void; | ||||
|   logType?: 0 | 1; | ||||
| }) => | ||||
|   useQuery(['devicelogs', serialNumber, { limit }], getDeviceLogs(limit, serialNumber), { | ||||
|   useQuery(['devicelogs', serialNumber, { limit, logType }], getDeviceLogs(limit, serialNumber, logType ?? 0), { | ||||
|     keepPreviousData: true, | ||||
|     enabled: serialNumber !== undefined && serialNumber !== '', | ||||
|     staleTime: 30000, | ||||
|     onError, | ||||
|   }); | ||||
|  | ||||
| const deleteLogs = async ({ serialNumber, endDate }: { serialNumber: string; endDate: number }) => | ||||
|   axiosGw.delete(`device/${serialNumber}/logs?endDate=${endDate}`); | ||||
| const deleteLogs = async ({ | ||||
|   serialNumber, | ||||
|   endDate, | ||||
|   logType, | ||||
| }: { | ||||
|   serialNumber: string; | ||||
|   endDate: number; | ||||
|   logType: 0 | 1; | ||||
| }) => axiosGw.delete(`device/${serialNumber}/logs?endDate=${endDate}&logType=${logType}`); | ||||
| export const useDeleteLogs = () => { | ||||
|   const queryClient = useQueryClient(); | ||||
|  | ||||
| @@ -45,46 +56,62 @@ export const useDeleteLogs = () => { | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const getLogsBatch = (serialNumber?: string, start?: number, end?: number, limit?: number, offset?: number) => | ||||
| const getLogsBatch = ( | ||||
|   serialNumber?: string, | ||||
|   start?: number, | ||||
|   end?: number, | ||||
|   limit?: number, | ||||
|   offset?: number, | ||||
|   logType?: 0 | 1, | ||||
| ) => | ||||
|   axiosGw | ||||
|     .get(`device/${serialNumber}/logs?startDate=${start}&endDate=${end}&limit=${limit}&offset=${offset}`) | ||||
|     .get( | ||||
|       `device/${serialNumber}/logs?startDate=${start}&endDate=${end}&limit=${limit}&offset=${offset}&logType=${logType}`, | ||||
|     ) | ||||
|     .then((response) => response.data) as Promise<{ | ||||
|     values: DeviceLog[]; | ||||
|     serialNumber: string; | ||||
|   }>; | ||||
|  | ||||
| const getDeviceLogsWithTimestamps = (serialNumber?: string, start?: number, end?: number) => async () => { | ||||
|   let offset = 0; | ||||
|   const limit = 100; | ||||
|   let logs: DeviceLog[] = []; | ||||
|   let latestResponse: { | ||||
|     values: DeviceLog[]; | ||||
|     serialNumber: string; | ||||
| const getDeviceLogsWithTimestamps = | ||||
|   (serialNumber?: string, start?: number, end?: number, logType?: 0 | 1) => async () => { | ||||
|     let offset = 0; | ||||
|     const limit = 100; | ||||
|     let logs: DeviceLog[] = []; | ||||
|     let latestResponse: { | ||||
|       values: DeviceLog[]; | ||||
|       serialNumber: string; | ||||
|     }; | ||||
|     do { | ||||
|       // eslint-disable-next-line no-await-in-loop | ||||
|       latestResponse = await getLogsBatch(serialNumber, start, end, limit, offset, logType); | ||||
|       logs = logs.concat(latestResponse.values); | ||||
|       offset += limit; | ||||
|     } while (latestResponse.values.length === limit); | ||||
|     return { | ||||
|       values: logs, | ||||
|     }; | ||||
|   }; | ||||
|   do { | ||||
|     // eslint-disable-next-line no-await-in-loop | ||||
|     latestResponse = await getLogsBatch(serialNumber, start, end, limit, offset); | ||||
|     logs = logs.concat(latestResponse.values); | ||||
|     offset += limit; | ||||
|   } while (latestResponse.values.length === limit); | ||||
|   return { | ||||
|     values: logs, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export const useGetDeviceLogsWithTimestamps = ({ | ||||
|   serialNumber, | ||||
|   start, | ||||
|   end, | ||||
|   onError, | ||||
|   logType, | ||||
| }: { | ||||
|   serialNumber?: string; | ||||
|   start?: number; | ||||
|   end?: number; | ||||
|   onError?: (e: AxiosError) => void; | ||||
|   logType?: 0 | 1; | ||||
| }) => | ||||
|   useQuery(['devicelogs', serialNumber, { start, end }], getDeviceLogsWithTimestamps(serialNumber, start, end), { | ||||
|     enabled: serialNumber !== undefined && serialNumber !== '' && start !== undefined && end !== undefined, | ||||
|     staleTime: 1000 * 60, | ||||
|     onError, | ||||
|   }); | ||||
|   useQuery( | ||||
|     ['devicelogs', serialNumber, { start, end, logType }], | ||||
|     getDeviceLogsWithTimestamps(serialNumber, start, end, logType ?? 0), | ||||
|     { | ||||
|       enabled: serialNumber !== undefined && serialNumber !== '' && start !== undefined && end !== undefined, | ||||
|       staleTime: 1000 * 60, | ||||
|       onError, | ||||
|     }, | ||||
|   ); | ||||
|   | ||||
| @@ -165,7 +165,10 @@ export type DevicesStats = { | ||||
|   averageConnectionTime: number; | ||||
|   connectedDevices: number; | ||||
|   connectingDevices: number; | ||||
|   tx: number; | ||||
|   rx: number; | ||||
| }; | ||||
|  | ||||
| const getInitialStats = async () => | ||||
|   axiosGw.get(`devices?connectionStatistics=true`).then(({ data }: { data: DevicesStats }) => data); | ||||
| export const useGetDevicesStats = ({ onError }: { onError?: (e: AxiosError) => void }) => { | ||||
|   | ||||
| @@ -7,35 +7,49 @@ import { AxiosError } from 'models/Axios'; | ||||
| import { Firmware } from 'models/Firmware'; | ||||
| import { Note } from 'models/Note'; | ||||
|  | ||||
| const getAvailableFirmwareBatch = async (deviceType: string, limit: number, offset: number) => | ||||
|   axiosFms | ||||
|     .get(`firmwares?deviceType=${deviceType}&limit=${limit}&offset=${offset}`) | ||||
|     .then(({ data }: { data: { firmwares: Firmware[] } }) => data); | ||||
|  | ||||
| const getAllAvailableFirmware = async (deviceType: string) => { | ||||
|   const limit = 500; | ||||
|   let offset = 0; | ||||
|   let data: { firmwares: Firmware[] } = { firmwares: [] }; | ||||
|   let lastResponse: { firmwares: Firmware[] } = { firmwares: [] }; | ||||
|   do { | ||||
|     // eslint-disable-next-line no-await-in-loop | ||||
|     lastResponse = await getAvailableFirmwareBatch(deviceType, limit, offset); | ||||
|     data = { | ||||
|       firmwares: [...data.firmwares, ...lastResponse.firmwares], | ||||
|     }; | ||||
|     offset += 500; | ||||
|   } while (lastResponse.firmwares.length === 500); | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| export const useGetAvailableFirmware = ({ deviceType }: { deviceType: string }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
|  | ||||
|   return useQuery( | ||||
|     ['get-device-profile'], | ||||
|     () => | ||||
|       axiosFms | ||||
|         .get(`firmwares?deviceType=${deviceType}&limit=10000&offset=0`) | ||||
|         .then(({ data }: { data: { firmwares: Firmware[] } }) => data), | ||||
|     { | ||||
|       enabled: deviceType !== '', | ||||
|       onError: (e: AxiosError) => { | ||||
|         if (!toast.isActive('firmware-fetching-error')) | ||||
|           toast({ | ||||
|             id: 'firmware-fetching-error', | ||||
|             title: t('common.error'), | ||||
|             description: t('crud.error_fetching_obj', { | ||||
|               e: e?.response?.data?.ErrorDescription, | ||||
|               obj: t('analytics.firmware'), | ||||
|             }), | ||||
|             status: 'error', | ||||
|             duration: 5000, | ||||
|             isClosable: true, | ||||
|             position: 'top-right', | ||||
|           }); | ||||
|       }, | ||||
|   return useQuery(['get-device-profile'], () => getAllAvailableFirmware(deviceType), { | ||||
|     enabled: deviceType !== '', | ||||
|     onError: (e: AxiosError) => { | ||||
|       if (!toast.isActive('firmware-fetching-error')) | ||||
|         toast({ | ||||
|           id: 'firmware-fetching-error', | ||||
|           title: t('common.error'), | ||||
|           description: t('crud.error_fetching_obj', { | ||||
|             e: e?.response?.data?.ErrorDescription, | ||||
|             obj: t('analytics.firmware'), | ||||
|           }), | ||||
|           status: 'error', | ||||
|           duration: 5000, | ||||
|           isClosable: true, | ||||
|           position: 'top-right', | ||||
|         }); | ||||
|     }, | ||||
|   ); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| export const useUpdateDeviceToLatest = ({ serialNumber, compatible }: { serialNumber: string; compatible: string }) => | ||||
| @@ -56,7 +70,13 @@ export const useUpdateDeviceFirmware = ({ serialNumber, onClose }: { serialNumbe | ||||
|  | ||||
|   return useMutation( | ||||
|     ({ keepRedirector, uri, signature }: { keepRedirector: boolean; uri: string; signature?: string }) => | ||||
|       axiosGw.post(`device/${serialNumber}/upgrade`, { serialNumber, when: 0, keepRedirector, uri, signature }), | ||||
|       axiosGw.post(`device/${serialNumber}/upgrade${signature ? `?FWsignature=${signature}` : ''}`, { | ||||
|         serialNumber, | ||||
|         when: 0, | ||||
|         keepRedirector, | ||||
|         uri, | ||||
|         signature, | ||||
|       }), | ||||
|     { | ||||
|       onSuccess: () => { | ||||
|         toast({ | ||||
| @@ -222,3 +242,23 @@ export const useGetFirmwareDashboard = () => | ||||
|     keepPreviousData: true, | ||||
|     refetchInterval: 30000, | ||||
|   }); | ||||
|  | ||||
| const getLastDbUpdate = async () => | ||||
|   axiosFms.get(`firmwares?updateTimeOnly=true`).then((response) => response.data as { lastUpdateTime: number }); | ||||
| export const useGetFirmwareDbUpdate = () => | ||||
|   useQuery(['firmware', 'db'], getLastDbUpdate, { | ||||
|     keepPreviousData: true, | ||||
|     staleTime: 30 * 1000, | ||||
|   }); | ||||
|  | ||||
| const updateDb = async () => axiosFms.put(`firmwares?update=true`); | ||||
|  | ||||
| export const useUpdateFirmwareDb = () => { | ||||
|   const queryClient = useQueryClient(); | ||||
|  | ||||
|   return useMutation(updateDb, { | ||||
|     onSuccess: () => { | ||||
|       queryClient.invalidateQueries(['firmware', 'db']); | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { QueryFunctionContext, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { axiosGw } from 'constants/axiosInstances'; | ||||
| import { AxiosError } from 'models/Axios'; | ||||
|  | ||||
| @@ -89,3 +89,18 @@ export const useDeleteHealthChecks = () => { | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const getDevicesWithHealthBetween = ( | ||||
|   context: QueryFunctionContext<[string, string, { lowerLimit: number; upperLimit: number }]>, | ||||
| ) => | ||||
|   axiosGw | ||||
|     .get(`devices?health=true&lowLimit=${context.queryKey[2].lowerLimit}&highLimit=${context.queryKey[2].upperLimit}`) | ||||
|     .then((res) => res.data.serialNumbers as string[]); | ||||
|  | ||||
| export const useGetDevicesWithHealthBetween = ({ | ||||
|   lowerLimit, | ||||
|   upperLimit, | ||||
| }: { | ||||
|   lowerLimit: number; | ||||
|   upperLimit: number; | ||||
| }) => useQuery(['devices', 'health', { lowerLimit, upperLimit }], getDevicesWithHealthBetween); | ||||
|   | ||||
							
								
								
									
										83
									
								
								src/hooks/Network/Secrets.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/hooks/Network/Secrets.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | ||||
| import { QueryFunctionContext, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { axiosSec } from 'constants/axiosInstances'; | ||||
|  | ||||
| export type SecretName = 'google.maps.apikey' | string; | ||||
|  | ||||
| export type Secret = { | ||||
|   key: SecretName; | ||||
|   value: string; | ||||
| }; | ||||
|  | ||||
| export type SecretDictionaryValue = { | ||||
|   key: SecretName; | ||||
|   description: string; | ||||
| }; | ||||
|  | ||||
| const getSecret = async (context: QueryFunctionContext<string[], unknown>) => | ||||
|   axiosSec.get(`/systemSecret/${context.queryKey[1]}`).then(({ data }: { data: Secret }) => data); | ||||
|  | ||||
| export const useGetSystemSecret = ({ secret }: { secret: SecretName }) => | ||||
|   useQuery(['secrets', secret], getSecret, { | ||||
|     staleTime: 1000 * 60 * 10, | ||||
|     refetchInterval: 1000 * 60 * 10, | ||||
|   }); | ||||
|  | ||||
| const getAllSecrets = async () => | ||||
|   axiosSec.get('/systemSecret/0?all=true').then(({ data }: { data: { secrets: Secret[] } }) => data.secrets); | ||||
|  | ||||
| export const useGetAllSystemSecrets = () => { | ||||
|   const queryClient = useQueryClient(); | ||||
|  | ||||
|   return useQuery(['secrets'], getAllSecrets, { | ||||
|     staleTime: 1000 * 60 * 10, | ||||
|     refetchInterval: 1000 * 60 * 10, | ||||
|     onSuccess: (data) => { | ||||
|       for (const secret of data) { | ||||
|         queryClient.setQueryData(['secrets', secret.key], secret); | ||||
|       } | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const getSecretsDictionary = async () => | ||||
|   axiosSec | ||||
|     .get('/systemSecret/0?dictionary=true') | ||||
|     .then(({ data }: { data: { knownKeys: SecretDictionaryValue[] } }) => data.knownKeys); | ||||
|  | ||||
| export const useGetSystemSecretsDictionary = () => | ||||
|   useQuery(['secrets', 'dictionary'], getSecretsDictionary, { | ||||
|     staleTime: 1000 * 60 * 10, | ||||
|     refetchInterval: 1000 * 60 * 10, | ||||
|   }); | ||||
|  | ||||
| const updateSecret = async ({ key, value }: { key: string; value: string }) => | ||||
|   axiosSec.put(`/systemSecret/${key}?value=${value}`, { key, value }); | ||||
|  | ||||
| export const useUpdateSystemSecret = () => { | ||||
|   const queryClient = useQueryClient(); | ||||
|   return useMutation(updateSecret, { | ||||
|     onSuccess: () => { | ||||
|       queryClient.invalidateQueries(['secrets']); | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| export const useCreateSystemSecret = () => { | ||||
|   const queryClient = useQueryClient(); | ||||
|   return useMutation(updateSecret, { | ||||
|     onSuccess: () => { | ||||
|       queryClient.invalidateQueries(['secrets']); | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const deleteSecret = async (key: string) => axiosSec.delete(`/systemSecret/${key}`); | ||||
|  | ||||
| export const useDeleteSystemSecret = () => { | ||||
|   const queryClient = useQueryClient(); | ||||
|   return useMutation(deleteSecret, { | ||||
|     onSuccess: () => { | ||||
|       queryClient.invalidateQueries(['secrets']); | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { axiosGw } from 'constants/axiosInstances'; | ||||
| import { AxiosError } from 'models/Axios'; | ||||
|  | ||||
| @@ -21,6 +21,18 @@ type DeviceInterfaceStatistics = { | ||||
|     tx_errors: number; | ||||
|     tx_packets: number; | ||||
|   }; | ||||
|   'counters-aggregate'?: { | ||||
|     collisions: number; | ||||
|     multicast: number; | ||||
|     rx_bytes: number; | ||||
|     rx_dropped: number; | ||||
|     rx_errors: number; | ||||
|     rx_packets: number; | ||||
|     tx_bytes: number; | ||||
|     tx_dropped: number; | ||||
|     tx_errors: number; | ||||
|     tx_packets: number; | ||||
|   }; | ||||
|   ssids?: { | ||||
|     associations?: { | ||||
|       ack_signal: number; | ||||
| @@ -148,6 +160,11 @@ export type DeviceStatistics = { | ||||
|       }; | ||||
|     }; | ||||
|   }; | ||||
|   gps?: { | ||||
|     elevation: string; | ||||
|     latitude: string; | ||||
|     longitude: string; | ||||
|   }; | ||||
|   version?: number; | ||||
| }; | ||||
| const getLastStats = (serialNumber?: string) => | ||||
| @@ -163,7 +180,7 @@ export const useGetDeviceLastStats = ({ | ||||
|   onError?: (e: AxiosError) => void; | ||||
| }) => | ||||
|   useQuery(['device', serialNumber, 'last-statistics'], () => getLastStats(serialNumber), { | ||||
|     enabled: serialNumber !== undefined && serialNumber !== '' && false, | ||||
|     enabled: serialNumber !== undefined && serialNumber !== '', | ||||
|     staleTime: 1000 * 60, | ||||
|     onError, | ||||
|   }); | ||||
| @@ -183,24 +200,12 @@ export const useGetDeviceNewestStats = ({ | ||||
|   serialNumber?: string; | ||||
|   limit: number; | ||||
|   onError?: (e: AxiosError) => void; | ||||
| }) => { | ||||
|   const queryClient = useQueryClient(); | ||||
|  | ||||
|   return useQuery(['deviceStatistics', serialNumber, 'newest', { limit }], getNewestStats(limit, serialNumber), { | ||||
| }) => | ||||
|   useQuery(['deviceStatistics', serialNumber, 'newest', { limit }], getNewestStats(limit, serialNumber), { | ||||
|     enabled: serialNumber !== undefined && serialNumber !== '', | ||||
|     staleTime: 1000 * 60, | ||||
|     onSuccess: (response) => { | ||||
|       const entry = response.data[0]; | ||||
|       // If we have a valid entry, we prefill lastStats, if not we trigger a fetch of the last statistics | ||||
|       if (entry) { | ||||
|         queryClient.setQueryData(['device', serialNumber, 'last-statistics'], entry.data); | ||||
|       } else { | ||||
|         queryClient.fetchQuery(['device', serialNumber, 'last-statistics']); | ||||
|       } | ||||
|     }, | ||||
|     onError, | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const getOuis = (macs?: string[]) => async () => | ||||
|   axiosGw.get(`/ouis?macList=${macs?.join(',')}`).then((response) => response.data) as Promise<{ | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| 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'; | ||||
|  | ||||
| @@ -85,14 +86,44 @@ export const useTrace = ({ serialNumber, alertOnCompletion }: { serialNumber: st | ||||
| export const downloadTrace = (serialNumber: string, commandId: string) => | ||||
|   axiosGw.get(`file/${commandId}?serialNumber=${serialNumber}`, { responseType: 'arraybuffer' }); | ||||
|  | ||||
| export const useDownloadTrace = ({ serialNumber, commandId }: { serialNumber: string; commandId: string }) => | ||||
|   useQuery(['download-trace', serialNumber, commandId], () => downloadTrace(serialNumber, commandId), { | ||||
| export const useDownloadTrace = ({ serialNumber, commandId }: { serialNumber: string; commandId: string }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
|  | ||||
|   return useQuery(['download-trace', serialNumber, commandId], () => downloadTrace(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 = `Trace_${commandId}.pcap`; | ||||
|       const headerLine = | ||||
|         (response.headers['content-disposition'] as string | undefined) ?? | ||||
|         (response.headers['content-disposition'] as string | undefined); | ||||
|       const filename = headerLine?.split('filename=')[1]?.split(',')[0] ?? `Trace_${commandId}.pcap`; | ||||
|       link.download = filename; | ||||
|       link.click(); | ||||
|     }, | ||||
|     onError: (e) => { | ||||
|       if (axios.isAxiosError(e)) { | ||||
|         const bufferResponse = e.response?.data; | ||||
|         let errorMessage = ''; | ||||
|         // If the response is a buffer, parse to JSON object | ||||
|         if (bufferResponse instanceof ArrayBuffer) { | ||||
|           const decoder = new TextDecoder('utf-8'); | ||||
|           const json = JSON.parse(decoder.decode(bufferResponse)); | ||||
|           errorMessage = json.ErrorDescription; | ||||
|         } | ||||
|  | ||||
|         toast({ | ||||
|           id: `trace-download-error-${serialNumber}`, | ||||
|           title: t('common.error'), | ||||
|           description: errorMessage, | ||||
|           status: 'error', | ||||
|           duration: 5000, | ||||
|           isClosable: true, | ||||
|           position: 'top-right', | ||||
|         }); | ||||
|       } | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { useToast } from '@chakra-ui/react'; | ||||
| import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { axiosSec } from 'constants/axiosInstances'; | ||||
| import { AxiosError } from 'models/Axios'; | ||||
| @@ -58,12 +58,24 @@ export type User = { | ||||
|   waitingForEmailCheck: boolean; | ||||
| }; | ||||
|  | ||||
| const getAvatarPromises = (userList: User[]) => { | ||||
| const getAvatarPromises = (userList: User[], queryClient: QueryClient) => { | ||||
|   const promises = userList.map(async (user) => { | ||||
|     if (user.avatar !== '' && user.avatar !== '0') { | ||||
|       return axiosSec.get(`avatar/${user.id}?cache=${user.avatar}`, { | ||||
|         responseType: 'arraybuffer', | ||||
|       }); | ||||
|       // If the avatar is already in the cache, return it | ||||
|       const cachedAvatar = queryClient.getQueryData(['avatar', user.id, user.avatar]); | ||||
|       if (cachedAvatar) return cachedAvatar; | ||||
|  | ||||
|       return axiosSec | ||||
|         .get(`avatar/${user.id}?cache=${user.avatar}`, { | ||||
|           responseType: 'arraybuffer', | ||||
|         }) | ||||
|         .then((response) => { | ||||
|           queryClient.setQueryData(['avatar', user.id, user.avatar], response); | ||||
|           return response; | ||||
|         }) | ||||
|         .catch((e) => { | ||||
|           throw e; | ||||
|         }); | ||||
|     } | ||||
|     return Promise.resolve(''); | ||||
|   }); | ||||
| @@ -71,10 +83,35 @@ const getAvatarPromises = (userList: User[]) => { | ||||
|   return promises; | ||||
| }; | ||||
|  | ||||
| const getUsers = async () => { | ||||
|   const users = await axiosSec.get('users').then(({ data }) => data.users as User[]); | ||||
| const getBatchUsers = async (offset: number, limit: number) => { | ||||
|   const users = await axiosSec | ||||
|     .get(`users?offset=${offset}&limit=${limit}&withExtendedInfo=true`) | ||||
|     .then(({ data }) => data.users as User[]); | ||||
|  | ||||
|   const avatars = await Promise.allSettled(getAvatarPromises(users)).then((results) => | ||||
|   return users; | ||||
| }; | ||||
|  | ||||
| const getAllUsers = async () => { | ||||
|   let users: User[] = []; | ||||
|   let offset = 0; | ||||
|   const limit = 500; | ||||
|   let lastResponseLength = 0; | ||||
|  | ||||
|   do { | ||||
|     // eslint-disable-next-line no-await-in-loop | ||||
|     const response = await getBatchUsers(offset, limit); | ||||
|     users = [...users, ...response]; | ||||
|     offset += limit; | ||||
|     lastResponseLength = response.length; | ||||
|   } while (lastResponseLength === limit); | ||||
|  | ||||
|   return users; | ||||
| }; | ||||
|  | ||||
| const getUsers = async (queryClient: QueryClient) => { | ||||
|   const users = await getAllUsers(); | ||||
|  | ||||
|   const avatars = await Promise.allSettled(getAvatarPromises(users, queryClient)).then((results) => | ||||
|     results.map((response) => { | ||||
|       if (response.status === 'fulfilled' && response?.value !== '') { | ||||
|         const base64 = btoa( | ||||
| @@ -93,8 +130,10 @@ const getUsers = async () => { | ||||
| export const useGetUsers = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
|   const queryClient = useQueryClient(); | ||||
|  | ||||
|   return useQuery(['users'], getUsers, { | ||||
|   return useQuery(['users'], () => getUsers(queryClient), { | ||||
|     staleTime: 30 * 1000, | ||||
|     onError: (e: AxiosError) => { | ||||
|       if (!toast.isActive('users-fetching-error')) | ||||
|         toast({ | ||||
| @@ -118,7 +157,7 @@ export const useGetUser = ({ id, enabled }: { id: string; enabled: boolean }) => | ||||
|   const toast = useToast(); | ||||
|  | ||||
|   return useQuery( | ||||
|     ['get-user', id], | ||||
|     ['users', id], | ||||
|     () => axiosSec.get(`user/${id}?withExtendedInfo=true`).then(({ data }) => data as User), | ||||
|     { | ||||
|       enabled, | ||||
| @@ -173,16 +212,41 @@ export const useSendUserEmailValidation = ({ id, refresh }: { id: string; refres | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
| export const useSuspendUser = ({ id }: { id: string }) => | ||||
|   useMutation((isSuspended: boolean) => | ||||
|     axiosSec.put(`user/${id}`, { | ||||
|       suspended: isSuspended, | ||||
|     }), | ||||
|   ); | ||||
| export const useResetMfa = ({ id }: { id: string }) => useMutation(() => axiosSec.put(`user/${id}?resetMFA=true`, {})); | ||||
| export const useSuspendUser = ({ id }: { id: string }) => { | ||||
|   const queryClient = useQueryClient(); | ||||
|  | ||||
| export const useResetPassword = ({ id }: { id: string }) => | ||||
|   useMutation(() => axiosSec.put(`user/${id}?forgotPassword=true`, {})); | ||||
|   return useMutation( | ||||
|     (isSuspended: boolean) => | ||||
|       axiosSec.put(`user/${id}`, { | ||||
|         suspended: isSuspended, | ||||
|       }), | ||||
|     { | ||||
|       onSuccess: () => { | ||||
|         queryClient.invalidateQueries(['users']); | ||||
|       }, | ||||
|     }, | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const useResetMfa = ({ id }: { id: string }) => { | ||||
|   const queryClient = useQueryClient(); | ||||
|  | ||||
|   return useMutation(() => axiosSec.put(`user/${id}?resetMFA=true`, {}), { | ||||
|     onSuccess: () => { | ||||
|       queryClient.invalidateQueries(['users']); | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| export const useResetPassword = ({ id }: { id: string }) => { | ||||
|   const queryClient = useQueryClient(); | ||||
|  | ||||
|   return useMutation(() => axiosSec.put(`user/${id}?forgotPassword=true`, {}), { | ||||
|     onSuccess: () => { | ||||
|       queryClient.invalidateQueries(['users']); | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const deleteUser = async (userId: string) => axiosSec.delete(`/user/${userId}`); | ||||
| export const useDeleteUser = () => { | ||||
|   | ||||
| @@ -1,7 +1,20 @@ | ||||
| import * as React from 'react'; | ||||
| import { Flex, Heading, Tooltip, VStack } from '@chakra-ui/react'; | ||||
| import { | ||||
|   Box, | ||||
|   CircularProgress, | ||||
|   CircularProgressLabel, | ||||
|   Flex, | ||||
|   Heading, | ||||
|   Icon, | ||||
|   Text, | ||||
|   Tooltip, | ||||
|   VStack, | ||||
| } from '@chakra-ui/react'; | ||||
| import { ArrowSquareDown, ArrowSquareUp, Clock } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Card } from 'components/Containers/Card'; | ||||
| import { compactSecondsToDetailed, minimalSecondsToDetailed } from 'helpers/dateFormatting'; | ||||
| import { bytesString } from 'helpers/stringHelper'; | ||||
| import { useGetDevicesStats } from 'hooks/Network/Devices'; | ||||
|  | ||||
| const SidebarDevices = () => { | ||||
| @@ -10,18 +23,19 @@ const SidebarDevices = () => { | ||||
|   const [lastTime, setLastTime] = React.useState<Date | undefined>(); | ||||
|   const [lastUpdate, setLastUpdate] = React.useState<Date | undefined>(); | ||||
|  | ||||
|   const getTime = () => { | ||||
|   const time = React.useMemo(() => { | ||||
|     if (lastTime === undefined || lastUpdate === undefined) return null; | ||||
|  | ||||
|     const seconds = lastTime.getTime() - lastUpdate.getTime(); | ||||
|  | ||||
|     return Math.max(0, Math.floor(seconds / 1000)); | ||||
|   }; | ||||
|   }, [lastTime, lastUpdate]); | ||||
|  | ||||
|   const refresh = () => { | ||||
|     if (document.visibilityState !== 'hidden') { | ||||
|       getStats.refetch(); | ||||
|     } | ||||
|   const circleColor = () => { | ||||
|     if (time === null) return 'gray.300'; | ||||
|     if (time < 10) return 'green.300'; | ||||
|     if (time < 30) return 'yellow.300'; | ||||
|     return 'red.300'; | ||||
|   }; | ||||
|  | ||||
|   React.useEffect(() => { | ||||
| @@ -37,37 +51,60 @@ const SidebarDevices = () => { | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     document.addEventListener('visibilitychange', refresh); | ||||
|  | ||||
|     return () => { | ||||
|       document.removeEventListener('visibilitychange', refresh); | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
|   if (!getStats.data) return null; | ||||
|  | ||||
|   return ( | ||||
|     <VStack spacing={4}> | ||||
|       <Flex flexDir="column" textAlign="center"> | ||||
|         <Heading size="md">{getStats.data.connectedDevices}</Heading> | ||||
|         <Heading size="xs"> | ||||
|           {t('common.connected')} {t('devices.title')} | ||||
|         </Heading> | ||||
|         <Heading size="xs" mt={1} fontStyle="italic" fontWeight="normal" color="gray.400"> | ||||
|           ({getStats.data.connectingDevices} {t('controller.devices.connecting')}) | ||||
|         </Heading> | ||||
|         <Tooltip hasArrow label={compactSecondsToDetailed(getStats.data.averageConnectionTime, t)}> | ||||
|           <Heading size="md" textAlign="center" mt={2}> | ||||
|             {minimalSecondsToDetailed(getStats.data.averageConnectionTime, t)} | ||||
|     <Card borderWidth="2px"> | ||||
|       <Tooltip hasArrow label={t('controller.stats.seconds_ago', { s: time })}> | ||||
|         <CircularProgress | ||||
|           isIndeterminate | ||||
|           color={circleColor()} | ||||
|           position="absolute" | ||||
|           right="6px" | ||||
|           top="6px" | ||||
|           w="unset" | ||||
|           size={6} | ||||
|           thickness="14px" | ||||
|         > | ||||
|           <CircularProgressLabel fontSize="1.9em">{time}s</CircularProgressLabel> | ||||
|         </CircularProgress> | ||||
|       </Tooltip> | ||||
|       <Tooltip hasArrow label={t('controller.stats.seconds_ago', { s: time })}> | ||||
|         <Box position="absolute" right="8px" top="8px" w="unset" hidden> | ||||
|           <Clock size={16} /> | ||||
|         </Box> | ||||
|       </Tooltip> | ||||
|       <VStack mb={-1}> | ||||
|         <Flex flexDir="column" textAlign="center"> | ||||
|           <Heading size="md">{getStats.data.connectedDevices}</Heading> | ||||
|           <Heading size="xs" display="flex" justifyContent="center"> | ||||
|             <Text> | ||||
|               {t('common.connected')} {t('devices.title')}{' '} | ||||
|             </Text>{' '} | ||||
|           </Heading> | ||||
|         </Tooltip> | ||||
|         <Heading size="xs">{t('controller.devices.average_uptime')}</Heading> | ||||
|         <Heading size="xs" mt={2} fontStyle="italic" fontWeight="normal" color="gray.400"> | ||||
|           {t('controller.stats.seconds_ago', { s: getTime() })} | ||||
|         </Heading> | ||||
|       </Flex> | ||||
|     </VStack> | ||||
|           <Tooltip hasArrow label={compactSecondsToDetailed(getStats.data.averageConnectionTime, t)}> | ||||
|             <Heading size="md" textAlign="center" mt={1}> | ||||
|               {minimalSecondsToDetailed(getStats.data.averageConnectionTime, t)} | ||||
|             </Heading> | ||||
|           </Tooltip> | ||||
|           <Heading size="xs">{t('controller.devices.average_uptime')}</Heading> | ||||
|           <Flex fontSize="sm" fontWeight="bold" alignItems="center" justifyContent="center" mt={1}> | ||||
|             <Tooltip hasArrow label="Rx"> | ||||
|               <Flex alignItems="center" mr={1}> | ||||
|                 <Icon as={ArrowSquareUp} weight="bold" boxSize={5} mt="1px" color="blue.400" />{' '} | ||||
|                 {getStats.data.rx !== undefined ? bytesString(getStats.data.rx, 0) : '-'} | ||||
|               </Flex> | ||||
|             </Tooltip> | ||||
|             <Tooltip hasArrow label="Tx"> | ||||
|               <Flex alignItems="center"> | ||||
|                 <Icon as={ArrowSquareDown} weight="bold" boxSize={5} mt="1px" color="purple.400" />{' '} | ||||
|                 {getStats.data.tx !== undefined ? bytesString(getStats.data.tx, 0) : '-'} | ||||
|               </Flex> | ||||
|             </Tooltip> | ||||
|           </Flex> | ||||
|         </Flex> | ||||
|       </VStack> | ||||
|     </Card> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -20,6 +20,7 @@ export interface Column<T> { | ||||
|   alwaysShow?: boolean; | ||||
|   Footer?: string; | ||||
|   accessor?: string; | ||||
|   stopPropagation?: boolean; | ||||
|   disableSortBy?: boolean; | ||||
|   hasPopover?: boolean; | ||||
|   customMaxWidth?: string; | ||||
|   | ||||
| @@ -99,13 +99,14 @@ const DefaultConfigurationsList = () => { | ||||
|       <CardBody> | ||||
|         <Box overflowX="auto" w="100%"> | ||||
|           <LoadingOverlay isLoading={getConfigs.isFetching}> | ||||
|             <DataTable | ||||
|               columns={columns as Column<object>[]} | ||||
|             <DataTable<DefaultConfigurationResponse> | ||||
|               columns={columns} | ||||
|               saveSettingsId="firmware.table" | ||||
|               data={getConfigs.data ?? []} | ||||
|               obj={t('controller.configurations.title')} | ||||
|               minHeight="200px" | ||||
|               sortBy={[{ id: 'name', desc: true }]} | ||||
|               onRowClick={onViewDetails} | ||||
|             /> | ||||
|           </LoadingOverlay> | ||||
|         </Box> | ||||
|   | ||||
							
								
								
									
										75
									
								
								src/pages/Device/LocationDisplayButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/pages/Device/LocationDisplayButton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box, Button, Flex, FormControl, FormLabel, useDisclosure } from '@chakra-ui/react'; | ||||
| import { Wrapper } from '@googlemaps/react-wrapper'; | ||||
| import { Globe } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { GoogleMap } from 'components/Maps/GoogleMap'; | ||||
| import { GoogleMapMarker } from 'components/Maps/GoogleMap/Marker'; | ||||
| import { Modal } from 'components/Modals/Modal'; | ||||
| import { useGetSystemSecret } from 'hooks/Network/Secrets'; | ||||
| import { useGetDeviceLastStats } from 'hooks/Network/Statistics'; | ||||
|  | ||||
| type Props = { | ||||
|   serialNumber: string; | ||||
| }; | ||||
|  | ||||
| const LocationDisplayButton = ({ serialNumber }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { isOpen, onOpen, onClose } = useDisclosure(); | ||||
|   const getGoogleApiKey = useGetSystemSecret({ secret: 'google.maps.apikey' }); | ||||
|   const getLastStats = useGetDeviceLastStats({ serialNumber }); | ||||
|  | ||||
|   const location: google.maps.LatLngLiteral | undefined = React.useMemo(() => { | ||||
|     if (!getLastStats.data?.gps) return undefined; | ||||
|  | ||||
|     try { | ||||
|       return { | ||||
|         lat: Number.parseFloat(getLastStats.data.gps.latitude), | ||||
|         lng: Number.parseFloat(getLastStats.data.gps.longitude), | ||||
|       }; | ||||
|     } catch (e) { | ||||
|       return undefined; | ||||
|     } | ||||
|   }, [getLastStats.data?.gps]); | ||||
|  | ||||
|   if (!location) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Button variant="link" onClick={onOpen} rightIcon={<Globe size={20} />} colorScheme="blue"> | ||||
|         {t('locations.view_gps')} | ||||
|       </Button> | ||||
|       <Modal isOpen={isOpen} onClose={onClose} title={t('locations.one')}> | ||||
|         <Box w="100%" h="100%"> | ||||
|           <Flex mb={4}> | ||||
|             <FormControl w="unset"> | ||||
|               <FormLabel>{t('locations.lat')}</FormLabel> | ||||
|               <pre>{location.lat}</pre> | ||||
|             </FormControl> | ||||
|             <FormControl w="unset" mx={4}> | ||||
|               <FormLabel>{t('locations.longitude')}</FormLabel> | ||||
|               <pre>{location.lng}</pre> | ||||
|             </FormControl> | ||||
|             <FormControl w="unset"> | ||||
|               <FormLabel>{t('locations.elevation')}</FormLabel> | ||||
|               <pre>{getLastStats.data?.gps?.elevation}</pre> | ||||
|             </FormControl> | ||||
|           </Flex> | ||||
|           {getGoogleApiKey.data ? ( | ||||
|             <Box h="500px"> | ||||
|               <Wrapper apiKey={getGoogleApiKey.data.value}> | ||||
|                 <GoogleMap center={location} style={{ flexGrow: '1', height: '100%' }} zoom={10}> | ||||
|                   <GoogleMapMarker position={location} /> | ||||
|                 </GoogleMap> | ||||
|               </Wrapper> | ||||
|             </Box> | ||||
|           ) : null} | ||||
|         </Box> | ||||
|       </Modal> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default LocationDisplayButton; | ||||
| @@ -28,7 +28,7 @@ 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 { DeviceCommandHistory, useGetSingleCommandHistory } from 'hooks/Network/Commands'; | ||||
| import { WifiScanResult } from 'models/Device'; | ||||
|  | ||||
| type Props = { | ||||
| @@ -39,9 +39,13 @@ type Props = { | ||||
|   command?: DeviceCommandHistory; | ||||
| }; | ||||
|  | ||||
| const CommandResultModal = ({ modalProps, command }: Props) => { | ||||
| const CommandResultModal = ({ modalProps, command: initialCommandInfo }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { colorMode } = useColorMode(); | ||||
|   const { data: command } = useGetSingleCommandHistory({ | ||||
|     commandId: initialCommandInfo?.UUID ?? '', | ||||
|     serialNumber: initialCommandInfo?.serialNumber ?? '', | ||||
|   }); | ||||
|  | ||||
|   if (!command) return null; | ||||
|  | ||||
|   | ||||
| @@ -44,13 +44,13 @@ const CommandHistory = ({ serialNumber }: Props) => { | ||||
|       <Box textAlign="right" display="flex"> | ||||
|         <Spacer /> | ||||
|         <HStack> | ||||
|           <HistoryDatePickers defaults={time} setTime={setNewTime} onClear={onClear} /> | ||||
|           <ColumnPicker | ||||
|             columns={columns as Column<unknown>[]} | ||||
|             hiddenColumns={hiddenColumns} | ||||
|             setHiddenColumns={setHiddenColumns} | ||||
|             preference="gateway.device.commandshistory.hiddenColumns" | ||||
|           /> | ||||
|           <HistoryDatePickers defaults={time} setTime={setNewTime} onClear={onClear} /> | ||||
|           <RefreshButton | ||||
|             isCompact | ||||
|             isFetching={getCommands.isFetching} | ||||
|   | ||||
| @@ -99,16 +99,6 @@ const useCommandHistoryTable = ({ serialNumber, limit }: Props) => { | ||||
|   const actionCell = React.useCallback( | ||||
|     (command: DeviceCommandHistory) => ( | ||||
|       <HStack> | ||||
|         <Tooltip label={t('common.view_details')}> | ||||
|           <IconButton | ||||
|             aria-label={t('common.view_details')} | ||||
|             onClick={onOpenDetails(command)} | ||||
|             colorScheme="blue" | ||||
|             icon={<MagnifyingGlass size={20} />} | ||||
|             size="sm" | ||||
|             isLoading={loadingDeleteSerial === command.UUID} | ||||
|           /> | ||||
|         </Tooltip> | ||||
|         <Tooltip label={t('crud.delete')}> | ||||
|           <IconButton | ||||
|             aria-label={t('crud.delete')} | ||||
| @@ -119,6 +109,16 @@ const useCommandHistoryTable = ({ serialNumber, limit }: Props) => { | ||||
|             isLoading={loadingDeleteSerial === command.UUID} | ||||
|           /> | ||||
|         </Tooltip> | ||||
|         <Tooltip label={t('common.view_details')}> | ||||
|           <IconButton | ||||
|             aria-label={t('common.view_details')} | ||||
|             onClick={onOpenDetails(command)} | ||||
|             colorScheme="blue" | ||||
|             icon={<MagnifyingGlass size={20} />} | ||||
|             size="sm" | ||||
|             isLoading={loadingDeleteSerial === command.UUID} | ||||
|           /> | ||||
|         </Tooltip> | ||||
|       </HStack> | ||||
|     ), | ||||
|     [loadingDeleteSerial], | ||||
|   | ||||
							
								
								
									
										94
									
								
								src/pages/Device/LogsCard/LogHistory/CrashLogs.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								src/pages/Device/LogsCard/LogHistory/CrashLogs.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box, Button, Center, Flex, Heading, HStack, Spacer } from '@chakra-ui/react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import HistoryDatePickers from '../DatePickers'; | ||||
| import DeleteLogModal from './DeleteModal'; | ||||
| import useDeviceLogsTable from './useDeviceLogsTable'; | ||||
| import { RefreshButton } from 'components/Buttons/RefreshButton'; | ||||
| import { ColumnPicker } from 'components/DataTables/ColumnPicker'; | ||||
| import { DataTable } from 'components/DataTables/DataTable'; | ||||
| import { Column } from 'models/Table'; | ||||
|  | ||||
| type Props = { | ||||
|   serialNumber: string; | ||||
| }; | ||||
| const CrashLogs = ({ serialNumber }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const [limit, setLimit] = React.useState(25); | ||||
|   const [hiddenColumns, setHiddenColumns] = React.useState<string[]>([]); | ||||
|   const { time, setTime, getCustomLogs, getLogs, columns, modal } = useDeviceLogsTable({ | ||||
|     serialNumber, | ||||
|     limit, | ||||
|     logType: 1, | ||||
|   }); | ||||
|  | ||||
|   const setNewTime = (start: Date, end: Date) => { | ||||
|     setTime({ start, end }); | ||||
|   }; | ||||
|   const onClear = () => { | ||||
|     setTime(undefined); | ||||
|   }; | ||||
|   const raiseLimit = () => { | ||||
|     setLimit(limit + 25); | ||||
|   }; | ||||
|  | ||||
|   const noMoreAvailable = getLogs.data !== undefined && getLogs.data.values.length < limit; | ||||
|  | ||||
|   const data = React.useMemo(() => { | ||||
|     if (getCustomLogs.data) return getCustomLogs.data.values.sort((a, b) => b.recorded - a.recorded); | ||||
|     if (getLogs.data) return getLogs.data.values; | ||||
|     return []; | ||||
|   }, [getLogs.data, getCustomLogs.data]); | ||||
|  | ||||
|   return ( | ||||
|     <Box> | ||||
|       <Flex> | ||||
|         <Spacer /> | ||||
|         <HStack> | ||||
|           <HistoryDatePickers defaults={time} setTime={setNewTime} onClear={onClear} /> | ||||
|           <ColumnPicker | ||||
|             columns={columns as Column<unknown>[]} | ||||
|             hiddenColumns={hiddenColumns} | ||||
|             setHiddenColumns={setHiddenColumns} | ||||
|             preference="gateway.device.logs.hiddenColumns" | ||||
|           /> | ||||
|           <DeleteLogModal serialNumber={serialNumber} logType={0} /> | ||||
|           <RefreshButton isCompact isFetching={getLogs.isFetching} onClick={getLogs.refetch} colorScheme="blue" /> | ||||
|         </HStack> | ||||
|       </Flex> | ||||
|       <Box overflowY="auto" h="300px"> | ||||
|         <DataTable | ||||
|           columns={ | ||||
|             columns as { | ||||
|               id: string; | ||||
|               Header: string; | ||||
|               Footer: string; | ||||
|               accessor: string; | ||||
|             }[] | ||||
|           } | ||||
|           data={data} | ||||
|           isLoading={getLogs.isFetching || getCustomLogs.isFetching} | ||||
|           hiddenColumns={hiddenColumns} | ||||
|           obj={t('controller.devices.logs')} | ||||
|           // @ts-ignore | ||||
|           hideControls | ||||
|           showAllRows | ||||
|         /> | ||||
|         {getLogs.data !== undefined && ( | ||||
|           <Center mt={1} hidden={getCustomLogs.data !== undefined}> | ||||
|             {!noMoreAvailable || getLogs.isFetching ? ( | ||||
|               <Button colorScheme="blue" onClick={raiseLimit} isLoading={getLogs.isFetching}> | ||||
|                 {t('controller.devices.show_more')} | ||||
|               </Button> | ||||
|             ) : ( | ||||
|               <Heading size="sm">{t('controller.devices.no_more_available')}!</Heading> | ||||
|             )} | ||||
|           </Center> | ||||
|         )} | ||||
|       </Box> | ||||
|       {modal} | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default CrashLogs; | ||||
| @@ -16,8 +16,8 @@ const CustomInputButton = React.forwardRef( | ||||
|   ), | ||||
| ); | ||||
|  | ||||
| type Props = { serialNumber: string }; | ||||
| const DeleteLogModal = ({ serialNumber }: Props) => { | ||||
| type Props = { serialNumber: string; logType: 0 | 1 }; | ||||
| const DeleteLogModal = ({ serialNumber, logType }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
|   const modalProps = useDisclosure(); | ||||
| @@ -26,7 +26,7 @@ const DeleteLogModal = ({ serialNumber }: Props) => { | ||||
|  | ||||
|   const onDeleteClick = () => { | ||||
|     deleteLogs.mutate( | ||||
|       { endDate: Math.floor(date.getTime() / 1000), serialNumber }, | ||||
|       { endDate: Math.floor(date.getTime() / 1000), serialNumber, logType }, | ||||
|       { | ||||
|         onSuccess: () => { | ||||
|           modalProps.onClose(); | ||||
|   | ||||
| @@ -0,0 +1,55 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box, Button, Code, Heading, useClipboard } from '@chakra-ui/react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import FormattedDate from 'components/InformationDisplays/FormattedDate'; | ||||
| import { Modal } from 'components/Modals/Modal'; | ||||
| import { DeviceLog } from 'hooks/Network/DeviceLogs'; | ||||
|  | ||||
| type Props = { | ||||
|   modalProps: { | ||||
|     isOpen: boolean; | ||||
|     onClose: () => void; | ||||
|   }; | ||||
|   log?: DeviceLog; | ||||
| }; | ||||
|  | ||||
| const DetailedLogViewModal = ({ modalProps, log }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { hasCopied, onCopy, setValue } = useClipboard(JSON.stringify(log?.log ?? {}, null, 2)); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     setValue(JSON.stringify(log?.log ?? {}, null, 2)); | ||||
|   }, [log]); | ||||
|  | ||||
|   if (!log) return null; | ||||
|  | ||||
|   return ( | ||||
|     <Modal | ||||
|       isOpen={modalProps.isOpen} | ||||
|       onClose={modalProps.onClose} | ||||
|       title={t('devices.logs_one')} | ||||
|       topRightButtons={ | ||||
|         <Button onClick={onCopy} size="md" colorScheme="teal"> | ||||
|           {hasCopied ? `${t('common.copied')}!` : t('common.copy')} | ||||
|         </Button> | ||||
|       } | ||||
|     > | ||||
|       <Box> | ||||
|         <Heading size="sm"> | ||||
|           <FormattedDate date={log.recorded} /> | ||||
|         </Heading> | ||||
|         <Heading size="sm"> | ||||
|           {t('controller.devices.severity')}: {log.severity} | ||||
|         </Heading> | ||||
|         <Heading size="sm"> | ||||
|           {t('controller.devices.config_id')}: {log.UUID} | ||||
|         </Heading> | ||||
|         <Code whiteSpace="pre-line" mt={2}> | ||||
|           {log.log} | ||||
|         </Code> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default DetailedLogViewModal; | ||||
| @@ -16,7 +16,7 @@ const LogHistory = ({ serialNumber }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const [limit, setLimit] = React.useState(25); | ||||
|   const [hiddenColumns, setHiddenColumns] = React.useState<string[]>([]); | ||||
|   const { time, setTime, getCustomLogs, getLogs, columns } = useDeviceLogsTable({ serialNumber, limit }); | ||||
|   const { time, setTime, getCustomLogs, getLogs, columns } = useDeviceLogsTable({ serialNumber, limit, logType: 0 }); | ||||
|  | ||||
|   const setNewTime = (start: Date, end: Date) => { | ||||
|     setTime({ start, end }); | ||||
| @@ -48,7 +48,7 @@ const LogHistory = ({ serialNumber }: Props) => { | ||||
|             setHiddenColumns={setHiddenColumns} | ||||
|             preference="gateway.device.logs.hiddenColumns" | ||||
|           /> | ||||
|           <DeleteLogModal serialNumber={serialNumber} /> | ||||
|           <DeleteLogModal serialNumber={serialNumber} logType={0} /> | ||||
|           <RefreshButton isCompact isFetching={getLogs.isFetching} onClick={getLogs.refetch} colorScheme="blue" /> | ||||
|         </HStack> | ||||
|       </Flex> | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box } from '@chakra-ui/react'; | ||||
| import { Box, IconButton, Text, useDisclosure } from '@chakra-ui/react'; | ||||
| import { MagnifyingGlass } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import DetailedLogViewModal from './DetailedLogViewModal'; | ||||
| import FormattedDate from 'components/InformationDisplays/FormattedDate'; | ||||
| import { DeviceLog, useGetDeviceLogs, useGetDeviceLogsWithTimestamps } from 'hooks/Network/DeviceLogs'; | ||||
| import { Column } from 'models/Table'; | ||||
| @@ -8,18 +10,49 @@ import { Column } from 'models/Table'; | ||||
| type Props = { | ||||
|   serialNumber: string; | ||||
|   limit: number; | ||||
|   logType: 0 | 1; | ||||
| }; | ||||
|  | ||||
| const useDeviceLogsTable = ({ serialNumber, limit }: Props) => { | ||||
| const useDeviceLogsTable = ({ serialNumber, limit, logType }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const getLogs = useGetDeviceLogs({ serialNumber, limit }); | ||||
|   const getLogs = useGetDeviceLogs({ serialNumber, limit, logType }); | ||||
|   const modalProps = useDisclosure(); | ||||
|   const [log, setLog] = React.useState<DeviceLog | undefined>(); | ||||
|   const [time, setTime] = React.useState<{ start: Date; end: Date } | undefined>(); | ||||
|   const getCustomLogs = useGetDeviceLogsWithTimestamps({ | ||||
|     serialNumber, | ||||
|     start: time ? Math.floor(time.start.getTime() / 1000) : undefined, | ||||
|     end: time ? Math.floor(time.end.getTime() / 1000) : undefined, | ||||
|     logType, | ||||
|   }); | ||||
|  | ||||
|   const onOpen = React.useCallback((v: DeviceLog) => { | ||||
|     setLog(v); | ||||
|     modalProps.onOpen(); | ||||
|   }, []); | ||||
|  | ||||
|   const logCell = React.useCallback( | ||||
|     (v: DeviceLog) => | ||||
|       logType === 1 ? ( | ||||
|         <Box display="flex"> | ||||
|           <IconButton | ||||
|             aria-label="Open Log Details" | ||||
|             onClick={() => onOpen(v)} | ||||
|             colorScheme="blue" | ||||
|             icon={<MagnifyingGlass size={16} />} | ||||
|             size="xs" | ||||
|             mr={2} | ||||
|           /> | ||||
|           <Text my="auto" maxW="calc(20vw)" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap"> | ||||
|             {v.log} | ||||
|           </Text> | ||||
|         </Box> | ||||
|       ) : ( | ||||
|         v.log | ||||
|       ), | ||||
|     [onOpen], | ||||
|   ); | ||||
|  | ||||
|   const dateCell = React.useCallback( | ||||
|     (v: number) => ( | ||||
|       <Box> | ||||
| @@ -65,6 +98,7 @@ const useDeviceLogsTable = ({ serialNumber, limit }: Props) => { | ||||
|         Footer: '', | ||||
|         accessor: 'log', | ||||
|         customWidth: '35px', | ||||
|         Cell: (v) => logCell(v.cell.row.original), | ||||
|         disableSortBy: true, | ||||
|       }, | ||||
|       { | ||||
| @@ -85,6 +119,7 @@ const useDeviceLogsTable = ({ serialNumber, limit }: Props) => { | ||||
|     getCustomLogs, | ||||
|     time, | ||||
|     setTime, | ||||
|     modal: <DetailedLogViewModal modalProps={modalProps} log={log} />, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; | ||||
| import CommandHistory from './CommandHistory'; | ||||
| import HealthCheckHistory from './HealthCheckHistory'; | ||||
| import LogHistory from './LogHistory'; | ||||
| import CrashLogs from './LogHistory/CrashLogs'; | ||||
| import { Card } from 'components/Containers/Card'; | ||||
| import { CardBody } from 'components/Containers/Card/CardBody'; | ||||
|  | ||||
| @@ -32,6 +33,9 @@ const DeviceLogsCard = ({ serialNumber }: Props) => { | ||||
|             <Tab fontSize="lg" fontWeight="bold"> | ||||
|               {t('controller.devices.logs')} | ||||
|             </Tab> | ||||
|             <Tab fontSize="lg" fontWeight="bold"> | ||||
|               {t('devices.crash_logs')} | ||||
|             </Tab> | ||||
|           </TabList> | ||||
|           <TabPanels> | ||||
|             <TabPanel p={0}> | ||||
| @@ -51,10 +55,12 @@ const DeviceLogsCard = ({ serialNumber }: Props) => { | ||||
|             <TabPanel> | ||||
|               <HealthCheckHistory serialNumber={serialNumber} /> | ||||
|             </TabPanel> | ||||
|  | ||||
|             <TabPanel> | ||||
|               <LogHistory serialNumber={serialNumber} /> | ||||
|             </TabPanel> | ||||
|             <TabPanel> | ||||
|               <CrashLogs serialNumber={serialNumber} /> | ||||
|             </TabPanel> | ||||
|           </TabPanels> | ||||
|         </Tabs> | ||||
|       </CardBody> | ||||
|   | ||||
| @@ -1,5 +1,16 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box, Flex, Heading, ListItem, Text, UnorderedList } from '@chakra-ui/react'; | ||||
| import { | ||||
|   Box, | ||||
|   Flex, | ||||
|   Heading, | ||||
|   ListItem, | ||||
|   Tag, | ||||
|   TagLabel, | ||||
|   TagLeftIcon, | ||||
|   Text, | ||||
|   Tooltip, | ||||
|   UnorderedList, | ||||
| } from '@chakra-ui/react'; | ||||
| import { LockSimple, LockSimpleOpen } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Card } from 'components/Containers/Card'; | ||||
| @@ -20,7 +31,7 @@ const RestrictionsCard = ({ serialNumber }: Props) => { | ||||
|     ssh: 'SSH', | ||||
|     rtty: 'RTTY', | ||||
|     tty: t('restrictions.tty'), | ||||
|     developer: t('restrictions.developer'), | ||||
|     // developer: t('restrictions.developer'), | ||||
|     upgrade: t('restrictions.signed_upgrade'), | ||||
|     commands: t('restrictions.gw_commands'), | ||||
|   } as { [key: string]: string }; | ||||
| @@ -38,27 +49,52 @@ const RestrictionsCard = ({ serialNumber }: Props) => { | ||||
|     return restrictedKeys.map(([k]) => <ListItem key={k}>{LABELS[k]}</ListItem>); | ||||
|   }; | ||||
|  | ||||
|   const isMissingSigningInfo = | ||||
|     !restrictions.key_info || | ||||
|     (!restrictions.key_info.algo && !restrictions.key_info.vendor) || | ||||
|     (restrictions.key_info.algo.length === 0 && restrictions.key_info.vendor.length === 0); | ||||
|  | ||||
|   return ( | ||||
|     <Card mb={4}> | ||||
|       <CardHeader> | ||||
|         <Heading size="md">{t('restrictions.title')}</Heading> | ||||
|         <Heading size="md" my="auto" mr={2}> | ||||
|           {t('restrictions.title')} | ||||
|         </Heading> | ||||
|         {getDevice.data?.restrictionDetails?.developer ? ( | ||||
|           <Tooltip label={t('devices.restricted_overriden')} hasArrow> | ||||
|             <Tag size="lg" colorScheme="green"> | ||||
|               <TagLeftIcon boxSize="18px" as={LockSimpleOpen} /> | ||||
|               <TagLabel>{t('devices.restrictions_overriden_title')}</TagLabel> | ||||
|             </Tag> | ||||
|           </Tooltip> | ||||
|         ) : null} | ||||
|       </CardHeader> | ||||
|       <CardBody p={0} display="block"> | ||||
|         <Flex mt={2}> | ||||
|           <Heading size="sm" mr={2}> | ||||
|           <Heading size="sm" mr={2} my="auto"> | ||||
|             {t('restrictions.countries')}: | ||||
|           </Heading> | ||||
|           <Text>{restrictions.country.join(', ')}</Text> | ||||
|           <Text my="auto"> | ||||
|             {restrictions.country?.length === 0 ? t('common.all') : restrictions.country.join(', ')} | ||||
|           </Text> | ||||
|         </Flex> | ||||
|         <Heading size="sm" mt={2}> | ||||
|           {t('restrictions.key_verification')} | ||||
|         </Heading> | ||||
|         <UnorderedList> | ||||
|         <Flex mt={2}> | ||||
|           <Heading size="sm" mt={2} my="auto"> | ||||
|             {t('restrictions.key_verification')} {isMissingSigningInfo ? ':' : ''} | ||||
|           </Heading> | ||||
|           {isMissingSigningInfo ? ( | ||||
|             <Text my="auto" ml={2}> | ||||
|               {t('common.none')} | ||||
|             </Text> | ||||
|           ) : null} | ||||
|         </Flex> | ||||
|         <UnorderedList hidden={isMissingSigningInfo}> | ||||
|           <ListItem> | ||||
|             {t('controller.wifi.vendor')}: {restrictions.key_info?.vendor} | ||||
|             {t('controller.wifi.vendor')}:{' '} | ||||
|             {restrictions.key_info?.vendor?.length > 0 ? restrictions.key_info?.vendor : '-'} | ||||
|           </ListItem> | ||||
|           <ListItem> | ||||
|             {t('restrictions.algo')}: {restrictions.key_info?.algo} | ||||
|             {t('restrictions.algo')}: {restrictions.key_info?.algo?.length > 0 ? restrictions.key_info?.algo : '-'} | ||||
|           </ListItem> | ||||
|         </UnorderedList> | ||||
|         <Flex mt={2}> | ||||
|   | ||||
| @@ -9,10 +9,12 @@ import { | ||||
|   Title, | ||||
|   Tooltip, | ||||
|   Legend, | ||||
|   Filler, | ||||
|   ChartData, | ||||
| } from 'chart.js'; | ||||
| import { Line } from 'react-chartjs-2'; | ||||
|  | ||||
| ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); | ||||
| ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler); | ||||
|  | ||||
| const getDivisionFactor = (maxBytes: number) => { | ||||
|   if (maxBytes < 1024) { | ||||
| @@ -42,24 +44,30 @@ const InterfaceChart = ({ data }: Props) => { | ||||
|  | ||||
|   const { factor, unit } = getDivisionFactor(data.maxTx); | ||||
|  | ||||
|   const points = { | ||||
|   const points: ChartData<'line', string[], string> = { | ||||
|     labels: data.recorded.map((recorded) => new Date(recorded * 1000).toLocaleTimeString()), | ||||
|     datasets: [ | ||||
|         { | ||||
|           // 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 | ||||
|         }, | ||||
|       ], | ||||
|       { | ||||
|         // Real 'Tx', but shown as 'Rx' | ||||
|         label: 'Tx', | ||||
|         data: data.rx.map((tx) => (Math.floor((tx / factor) * 100) / 100).toFixed(2)), | ||||
|         borderColor: colorMode === 'light' ? 'rgba(99, 179, 237, 1)' : 'rgba(190, 227, 248, 1)', // blue-300 - blue-100 | ||||
|         backgroundColor: colorMode === 'light' ? 'rgba(99, 179, 237, 0.3)' : 'rgba(190, 227, 248, 0.3)', // blue-300 - blue-100 | ||||
|         tension: 0.5, | ||||
|         pointRadius: 0, | ||||
|         fill: 'start', | ||||
|       }, | ||||
|       { | ||||
|         // Real 'Rx', but shown as 'Tx' | ||||
|         label: 'Rx', | ||||
|         data: data.tx.map((rx) => (Math.floor((rx / factor) * 100) / 100).toFixed(2)), | ||||
|         borderColor: colorMode === 'light' ? 'rgba(72, 187, 120, 1)' : 'rgba(154, 230, 180, 1)', // green-400 - green-200 | ||||
|         backgroundColor: colorMode === 'light' ? 'rgba(72, 187, 120, 0.3)' : 'rgba(154, 230, 180, 0.3)', // green-400 - green-200 | ||||
|         tension: 0.5, | ||||
|         pointRadius: 0, | ||||
|         fill: 'start', | ||||
|       }, | ||||
|     ], | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|   | ||||
| @@ -34,20 +34,29 @@ const DeviceMemoryChart = ({ data }: Props) => { | ||||
|       { | ||||
|         label: 'Free', | ||||
|         data: data.free.map((free) => Math.floor(free / 1024 / 1024)), | ||||
|         borderColor: colorMode === 'light' ? '#63B3ED' : '#BEE3F8', // blue-300 - blue-100 | ||||
|         backgroundColor: colorMode === 'light' ? '#63B3ED' : '#BEE3F8', // blue-300 - blue-100 | ||||
|       }, | ||||
|       { | ||||
|         label: 'Buffered', | ||||
|         data: data.buffered.map((buffered) => Math.floor(buffered / 1024 / 1024)), | ||||
|         borderColor: colorMode === 'light' ? '#ECC94B' : '#FAF089', // yellow-400 - yellow-200 | ||||
|         backgroundColor: colorMode === 'light' ? '#ECC94B' : '#FAF089', // yellow-400 - yellow-200 | ||||
|         borderColor: colorMode === 'light' ? 'rgb(99, 179, 237, 1)' : 'rgb(190, 227, 248, 1)', // blue-300 - blue-100 | ||||
|         backgroundColor: colorMode === 'light' ? 'rgb(99, 179, 237, 0.3)' : 'rgb(190, 227, 248, 0.3)', // blue-300 - blue-100 | ||||
|         tension: 0.5, | ||||
|         pointRadius: 0, | ||||
|         fill: '+1', | ||||
|       }, | ||||
|       { | ||||
|         label: 'Cached', | ||||
|         data: data.cached.map((cached) => Math.floor(cached / 1024 / 1024)), | ||||
|         borderColor: colorMode === 'light' ? '#ED64A6' : '#FBB6CE', // pink-400 - pink-200 | ||||
|         backgroundColor: colorMode === 'light' ? '#ED64A6' : '#FBB6CE', // pink-400 - pink-200 | ||||
|         borderColor: colorMode === 'light' ? 'rgb(237, 100, 166, 1)' : 'rgb(251, 182, 206, 1)', // pink-400 - pink-200 | ||||
|         backgroundColor: colorMode === 'light' ? 'rgb(237, 100, 166, 0.3)' : 'rgb(251, 182, 206, 0.3)', // pink-400 - pink-200 | ||||
|         tension: 0.5, | ||||
|         pointRadius: 0, | ||||
|         fill: '+1', | ||||
|       }, | ||||
|       { | ||||
|         label: 'Buffered', | ||||
|         data: data.buffered.map((buffered) => Math.floor(buffered / 1024 / 1024)), | ||||
|         borderColor: colorMode === 'light' ? 'rgb(255, 240, 31, 1)' : 'rgb(250, 240, 137, 1)', // yellow-400 - yellow-200 | ||||
|         backgroundColor: colorMode === 'light' ? 'rgb(255, 240, 31, 0.3)' : 'rgb(250, 240, 137, 0.3)', // yellow-400 - yellow-200 | ||||
|         tension: 0.5, | ||||
|         pointRadius: 0, | ||||
|         fill: 'origin', | ||||
|       }, | ||||
|     ], | ||||
|   }; | ||||
|   | ||||
| @@ -38,7 +38,7 @@ const ViewLastStatsModal = ({ serialNumber }: Props) => { | ||||
|     if (getLastStats.data) { | ||||
|       setValue(JSON.stringify(getLastStats.data, null, 2)); | ||||
|     } | ||||
|   }, [getLastStats.data]); | ||||
|   }, [getLastStats.data, isOpen]); | ||||
|   return ( | ||||
|     <> | ||||
|       <Tooltip label={t('statistics.last_stats')}> | ||||
|   | ||||
| @@ -52,8 +52,6 @@ const DeviceStatisticsCard = ({ serialNumber }: Props) => { | ||||
|           <Heading size="md">{t('configurations.statistics')}</Heading> | ||||
|           <Spacer /> | ||||
|           <HStack> | ||||
|             <ViewLastStatsModal serialNumber={serialNumber} /> | ||||
|             <StatisticsCardDatePickers defaults={time} setTime={setNewTime} onClear={onClear} /> | ||||
|             <Select value={selected} onChange={onSelectInterface}> | ||||
|               {parsedData?.interfaces | ||||
|                 ? Object.keys(parsedData.interfaces).map((v) => ( | ||||
| @@ -64,6 +62,8 @@ const DeviceStatisticsCard = ({ serialNumber }: Props) => { | ||||
|                 : null} | ||||
|               <option value="memory">{t('statistics.memory')}</option> | ||||
|             </Select> | ||||
|             <StatisticsCardDatePickers defaults={time} setTime={setNewTime} onClear={onClear} /> | ||||
|             <ViewLastStatsModal serialNumber={serialNumber} /> | ||||
|             <RefreshButton | ||||
|               size="sm" | ||||
|               onClick={refresh} | ||||
|   | ||||
| @@ -16,6 +16,7 @@ type Props = { | ||||
| export const useStatisticsCard = ({ serialNumber }: Props) => { | ||||
|   const [selected, setSelected] = React.useState('memory'); | ||||
|   const [progress, setProgress] = React.useState(0); | ||||
|   const [hasSelectedNew, setHasSelectedNew] = React.useState(false); | ||||
|   const [time, setTime] = React.useState<{ start: Date; end: Date } | undefined>(); | ||||
|   const onProgressChange = React.useCallback((newProgress: number) => { | ||||
|     setProgress(newProgress); | ||||
| @@ -29,13 +30,17 @@ export const useStatisticsCard = ({ serialNumber }: Props) => { | ||||
|   }); | ||||
|  | ||||
|   const onSelectInterface = (event: React.ChangeEvent<HTMLSelectElement>) => { | ||||
|     setHasSelectedNew(true); | ||||
|     setSelected(event.target.value); | ||||
|   }; | ||||
|  | ||||
|   const parsedData = React.useMemo(() => { | ||||
|     if (!getStats.data && !getCustomStats.data) return undefined; | ||||
|  | ||||
|     const data: Record<string, { tx: number[]; rx: number[]; recorded: number[]; maxRx: number; maxTx: number }> = {}; | ||||
|     const data: Record< | ||||
|       string, | ||||
|       { tx: number[]; rx: number[]; recorded: number[]; maxRx: number; maxTx: number; removed?: boolean } | ||||
|     > = {}; | ||||
|     const memoryData = { | ||||
|       used: [] as number[], | ||||
|       buffered: [] as number[], | ||||
| @@ -56,7 +61,7 @@ export const useStatisticsCard = ({ serialNumber }: Props) => { | ||||
|       if (index === 0) { | ||||
|         let updated = false; | ||||
|         for (const inter of stat.data.interfaces ?? []) { | ||||
|           if (!updated && selected === 'memory') { | ||||
|           if (!hasSelectedNew && !updated && selected === 'memory') { | ||||
|             updated = true; | ||||
|             setSelected(inter.name); | ||||
|           } | ||||
| @@ -77,7 +82,10 @@ export const useStatisticsCard = ({ serialNumber }: Props) => { | ||||
|           let rx = inter.counters?.rx_bytes ?? 0; | ||||
|           let tx = inter.counters?.tx_bytes ?? 0; | ||||
|  | ||||
|           if (isInterUpstream) { | ||||
|           if (inter['counters-aggregate']) { | ||||
|             rx = inter['counters-aggregate'].rx_bytes; | ||||
|             tx = inter['counters-aggregate'].tx_bytes; | ||||
|           } else if (isInterUpstream) { | ||||
|             for (const ssid of inter.ssids ?? []) { | ||||
|               rx += ssid.counters?.rx_bytes ?? 0; | ||||
|               tx += ssid.counters?.tx_bytes ?? 0; | ||||
| @@ -97,6 +105,18 @@ export const useStatisticsCard = ({ serialNumber }: Props) => { | ||||
|               maxRx: rxDelta, | ||||
|             }; | ||||
|           else { | ||||
|             if (data[inter.name] && !data[inter.name]?.removed && data[inter.name]?.recorded.length === 1) { | ||||
|               data[inter.name]?.tx.shift(); | ||||
|               data[inter.name]?.rx.shift(); | ||||
|               data[inter.name]?.recorded.shift(); | ||||
|               // @ts-ignore | ||||
|               data[inter.name].maxRx = rxDelta; | ||||
|               // @ts-ignore | ||||
|               data[inter.name].maxTx = txDelta; | ||||
|               // @ts-ignore | ||||
|               data[inter.name].removed = true; | ||||
|             } | ||||
|  | ||||
|             data[inter.name]?.rx.push(rxDelta); | ||||
|             data[inter.name]?.tx.push(txDelta); | ||||
|             data[inter.name]?.recorded.push(stat.recorded); | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import * as React from 'react'; | ||||
| import { Flex, Grid, GridItem, Heading, Image, Tag } from '@chakra-ui/react'; | ||||
| import { Box, Flex, Grid, GridItem, Heading, Image, Tag } from '@chakra-ui/react'; | ||||
| import ReactCountryFlag from 'react-country-flag'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import LocationDisplayButton from './LocationDisplayButton'; | ||||
| import { Card } from 'components/Containers/Card'; | ||||
| import { CardBody } from 'components/Containers/Card/CardBody'; | ||||
| import FormattedDate from 'components/InformationDisplays/FormattedDate'; | ||||
| @@ -90,11 +91,12 @@ const DeviceSummary = ({ serialNumber }: Props) => { | ||||
|               {!getDevice.data?.locale || getDevice.data?.locale === '' ? ( | ||||
|                 '-' | ||||
|               ) : ( | ||||
|                 <> | ||||
|                 <Box mr={2}> | ||||
|                   <ReactCountryFlag style={ICON_STYLE} countryCode={getDevice.data.locale} svg /> | ||||
|                   {COUNTRY_LIST.find(({ value }) => value === getDevice.data.locale)?.label} | ||||
|                 </> | ||||
|                 </Box> | ||||
|               )} | ||||
|               <LocationDisplayButton serialNumber={serialNumber} /> | ||||
|             </GridItem> | ||||
|             <GridItem colSpan={1} alignContent="center" alignItems="center"> | ||||
|               <Heading size="sm">{t('analytics.last_contact')}:</Heading> | ||||
|   | ||||
| @@ -9,12 +9,15 @@ import { | ||||
|   Button, | ||||
|   Center, | ||||
|   Heading, | ||||
|   IconButton, | ||||
|   Spinner, | ||||
|   Tooltip, | ||||
|   useClipboard, | ||||
|   useColorMode, | ||||
|   useDisclosure, | ||||
| } from '@chakra-ui/react'; | ||||
| import { JsonViewer } from '@textea/json-viewer'; | ||||
| import { ListDashes } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { RefreshButton } from 'components/Buttons/RefreshButton'; | ||||
| import FormattedDate from 'components/InformationDisplays/FormattedDate'; | ||||
| @@ -43,9 +46,15 @@ const ViewCapabilitiesModal = ({ serialNumber }: Props) => { | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Button onClick={onOpen} colorScheme="pink" mr={2}> | ||||
|         {t('controller.devices.capabilities')} | ||||
|       </Button> | ||||
|       <Tooltip label={t('controller.devices.capabilities')} hasArrow> | ||||
|         <IconButton | ||||
|           aria-label={t('controller.devices.capabilities')} | ||||
|           icon={<ListDashes size={20} />} | ||||
|           onClick={onOpen} | ||||
|           colorScheme="pink" | ||||
|           mr={2} | ||||
|         /> | ||||
|       </Tooltip> | ||||
|       <Modal | ||||
|         isOpen={isOpen} | ||||
|         title={t('controller.devices.capabilities')} | ||||
|   | ||||
| @@ -7,11 +7,14 @@ import { | ||||
|   AccordionPanel, | ||||
|   Box, | ||||
|   Button, | ||||
|   IconButton, | ||||
|   Tooltip, | ||||
|   useClipboard, | ||||
|   useColorMode, | ||||
|   useDisclosure, | ||||
| } from '@chakra-ui/react'; | ||||
| import { JsonViewer } from '@textea/json-viewer'; | ||||
| import { Barcode } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Modal } from 'components/Modals/Modal'; | ||||
| import { DeviceConfiguration } from 'models/Device'; | ||||
| @@ -30,9 +33,14 @@ const ViewConfigurationModal = ({ configuration }: { configuration?: DeviceConfi | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Button onClick={onOpen} isDisabled={!configuration} colorScheme="purple"> | ||||
|         {t('configurations.one')} | ||||
|       </Button> | ||||
|       <Tooltip label={t('configurations.one')} hasArrow> | ||||
|         <IconButton | ||||
|           aria-label={t('configurations.one')} | ||||
|           icon={<Barcode size={20} />} | ||||
|           onClick={onOpen} | ||||
|           colorScheme="purple" | ||||
|         /> | ||||
|       </Tooltip> | ||||
|       <Modal | ||||
|         isOpen={isOpen} | ||||
|         title={t('configurations.one')} | ||||
|   | ||||
| @@ -1,6 +1,13 @@ | ||||
| import * as React from 'react'; | ||||
| import { | ||||
|   AlertDialog, | ||||
|   AlertDialogBody, | ||||
|   AlertDialogContent, | ||||
|   AlertDialogFooter, | ||||
|   AlertDialogHeader, | ||||
|   AlertDialogOverlay, | ||||
|   Box, | ||||
|   Button, | ||||
|   Heading, | ||||
|   HStack, | ||||
|   Portal, | ||||
| @@ -12,10 +19,13 @@ import { | ||||
|   useBreakpoint, | ||||
|   useColorModeValue, | ||||
|   useDisclosure, | ||||
|   useToast, | ||||
| } from '@chakra-ui/react'; | ||||
| import { Heart, HeartBreak, LockSimple, WifiHigh, WifiSlash } from 'phosphor-react'; | ||||
| import axios from 'axios'; | ||||
| import { Heart, HeartBreak, LockSimple, LockSimpleOpen, WifiHigh, WifiSlash } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import Masonry from 'react-masonry-css'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
| import DeviceDetails from './Details'; | ||||
| import DeviceLogsCard from './LogsCard'; | ||||
| import DeviceNotes from './Notes'; | ||||
| @@ -23,6 +33,7 @@ import RestrictionsCard from './RestrictionsCard'; | ||||
| import DeviceStatisticsCard from './StatisticsCard'; | ||||
| import DeviceSummary from './Summary'; | ||||
| import WifiAnalysisCard from './WifiAnalysis'; | ||||
| import { DeleteButton } from 'components/Buttons/DeleteButton'; | ||||
| import DeviceActionDropdown from 'components/Buttons/DeviceActionDropdown'; | ||||
| import { RefreshButton } from 'components/Buttons/RefreshButton'; | ||||
| import { Card } from 'components/Containers/Card'; | ||||
| @@ -33,11 +44,12 @@ 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 { RebootModal } from 'components/Modals/RebootModal'; | ||||
| 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'; | ||||
| import { useGetDevice, useGetDeviceHealthChecks, useGetDeviceStatus } from 'hooks/Network/Devices'; | ||||
| import { useDeleteDevice, useGetDevice, useGetDeviceHealthChecks, useGetDeviceStatus } from 'hooks/Network/Devices'; | ||||
|  | ||||
| type Props = { | ||||
|   serialNumber: string; | ||||
| @@ -45,10 +57,17 @@ type Props = { | ||||
|  | ||||
| const DevicePageWrapper = ({ serialNumber }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
|   const breakpoint = useBreakpoint(); | ||||
|   const cancelRef = React.useRef(null); | ||||
|   const navigate = useNavigate(); | ||||
|   const { mutateAsync: deleteDevice, isLoading: isDeleting } = useDeleteDevice({ | ||||
|     serialNumber, | ||||
|   }); | ||||
|   const getDevice = useGetDevice({ serialNumber }); | ||||
|   const getStatus = useGetDeviceStatus({ serialNumber }); | ||||
|   const getHealth = useGetDeviceHealthChecks({ serialNumber, limit: 1 }); | ||||
|   const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure(); | ||||
|   const scanModalProps = useDisclosure(); | ||||
|   const resetModalProps = useDisclosure(); | ||||
|   const eventQueueProps = useDisclosure(); | ||||
| @@ -56,7 +75,40 @@ const DevicePageWrapper = ({ serialNumber }: Props) => { | ||||
|   const upgradeModalProps = useDisclosure(); | ||||
|   const telemetryModalProps = useDisclosure(); | ||||
|   const traceModalProps = useDisclosure(); | ||||
|   const rebootModalProps = useDisclosure(); | ||||
|   const scriptModal = useScriptModal(); | ||||
|   // Sticky-top styles | ||||
|   const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md'; | ||||
|   const boxShadow = useColorModeValue('0px 7px 23px rgba(0, 0, 0, 0.05)', 'none'); | ||||
|  | ||||
|   const handleDeleteClick = () => | ||||
|     deleteDevice(serialNumber, { | ||||
|       onSuccess: () => { | ||||
|         toast({ | ||||
|           id: `delete-device-success-${serialNumber}`, | ||||
|           title: t('common.success'), | ||||
|           status: 'success', | ||||
|           duration: 5000, | ||||
|           isClosable: true, | ||||
|           position: 'top-right', | ||||
|         }); | ||||
|         navigate('/devices'); | ||||
|       }, | ||||
|       onError: (e) => { | ||||
|         if (axios.isAxiosError(e)) { | ||||
|           toast({ | ||||
|             id: `delete-device-error-${serialNumber}`, | ||||
|             title: t('common.error'), | ||||
|             description: e.response?.data?.ErrorDescription, | ||||
|             status: 'error', | ||||
|             duration: 5000, | ||||
|             isClosable: true, | ||||
|             position: 'top-right', | ||||
|           }); | ||||
|         } | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|   const connectedTag = React.useMemo(() => { | ||||
|     if (!getStatus.data) return null; | ||||
|  | ||||
| @@ -100,9 +152,28 @@ const DevicePageWrapper = ({ serialNumber }: Props) => { | ||||
|     ); | ||||
|   }, [getStatus.data, getHealth.data]); | ||||
|  | ||||
|   // Sticky-top styles | ||||
|   const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md'; | ||||
|   const boxShadow = useColorModeValue('0px 7px 23px rgba(0, 0, 0, 0.05)', 'none'); | ||||
|   const restrictedTag = React.useMemo(() => { | ||||
|     if (!getDevice.data || !getDevice.data.restrictedDevice) return null; | ||||
|  | ||||
|     if (getDevice.data.restrictionDetails?.developer) | ||||
|       return ( | ||||
|         <Tooltip label={t('devices.restricted_overriden')} hasArrow> | ||||
|           <Tag size="lg" colorScheme="green"> | ||||
|             <TagLeftIcon boxSize="18px" as={LockSimpleOpen} /> | ||||
|             <TagLabel> | ||||
|               {t('devices.restricted')} {isCompact ? '' : '(Dev Mode)'} | ||||
|             </TagLabel> | ||||
|           </Tag> | ||||
|         </Tooltip> | ||||
|       ); | ||||
|  | ||||
|     return ( | ||||
|       <Tag size="lg" colorScheme="red"> | ||||
|         <TagLeftIcon boxSize="18px" as={LockSimple} /> | ||||
|         <TagLabel>{t('devices.restricted')}</TagLabel> | ||||
|       </Tag> | ||||
|     ); | ||||
|   }, [getDevice.data, isCompact]); | ||||
|  | ||||
|   const refresh = () => { | ||||
|     getDevice.refetch(); | ||||
| @@ -119,16 +190,12 @@ const DevicePageWrapper = ({ serialNumber }: Props) => { | ||||
|               <Heading size="md">{serialNumber}</Heading> | ||||
|               {connectedTag} | ||||
|               {healthTag} | ||||
|               {getDevice.data?.restrictedDevice && ( | ||||
|                 <Tag size="lg" colorScheme="gray"> | ||||
|                   <TagLeftIcon boxSize="18px" as={LockSimple} /> | ||||
|                   <TagLabel>{t('devices.restricted')}</TagLabel> | ||||
|                 </Tag> | ||||
|               )} | ||||
|               {restrictedTag} | ||||
|             </HStack> | ||||
|             <Spacer /> | ||||
|             <HStack spacing={2}> | ||||
|               {breakpoint !== 'base' && breakpoint !== 'md' && <DeviceSearchBar />} | ||||
|               <DeleteButton isCompact onClick={onDeleteOpen} /> | ||||
|               {getDevice?.data && ( | ||||
|                 <DeviceActionDropdown | ||||
|                   // @ts-ignore | ||||
| @@ -142,6 +209,7 @@ const DevicePageWrapper = ({ serialNumber }: Props) => { | ||||
|                   onOpenConfigureModal={configureModalProps.onOpen} | ||||
|                   onOpenTelemetryModal={telemetryModalProps.onOpen} | ||||
|                   onOpenScriptModal={scriptModal.openModal} | ||||
|                   onOpenRebootModal={rebootModalProps.onOpen} | ||||
|                   size="md" | ||||
|                   isCompact | ||||
|                 /> | ||||
| @@ -172,16 +240,12 @@ const DevicePageWrapper = ({ serialNumber }: Props) => { | ||||
|                 <Heading size="md">{serialNumber}</Heading> | ||||
|                 {connectedTag} | ||||
|                 {healthTag} | ||||
|                 {getDevice.data?.restrictedDevice && ( | ||||
|                   <Tag size="lg" colorScheme="gray"> | ||||
|                     <TagLeftIcon boxSize="18px" as={LockSimple} /> | ||||
|                     <TagLabel>{t('devices.restricted')}</TagLabel> | ||||
|                   </Tag> | ||||
|                 )} | ||||
|                 {restrictedTag} | ||||
|               </HStack> | ||||
|               <Spacer /> | ||||
|               <HStack spacing={2}> | ||||
|                 <DeviceSearchBar /> | ||||
|                 <DeleteButton isCompact onClick={onDeleteOpen} /> | ||||
|                 {getDevice?.data && ( | ||||
|                   <DeviceActionDropdown | ||||
|                     // @ts-ignore | ||||
| @@ -194,8 +258,10 @@ const DevicePageWrapper = ({ serialNumber }: Props) => { | ||||
|                     onOpenEventQueue={eventQueueProps.onOpen} | ||||
|                     onOpenConfigureModal={configureModalProps.onOpen} | ||||
|                     onOpenTelemetryModal={telemetryModalProps.onOpen} | ||||
|                     onOpenRebootModal={rebootModalProps.onOpen} | ||||
|                     onOpenScriptModal={scriptModal.openModal} | ||||
|                     size="md" | ||||
|                     isCompact | ||||
|                   /> | ||||
|                 )} | ||||
|                 <RefreshButton | ||||
| @@ -210,6 +276,24 @@ const DevicePageWrapper = ({ serialNumber }: Props) => { | ||||
|           </Card> | ||||
|         </Portal> | ||||
|       )} | ||||
|       <AlertDialog isOpen={isDeleteOpen} leastDestructiveRef={cancelRef} onClose={onDeleteClose}> | ||||
|         <AlertDialogOverlay> | ||||
|           <AlertDialogContent> | ||||
|             <AlertDialogHeader fontSize="lg" fontWeight="bold"> | ||||
|               {t('crud.delete')} {serialNumber} | ||||
|             </AlertDialogHeader> | ||||
|             <AlertDialogBody>{t('crud.delete_confirm', { obj: t('devices.one') })}</AlertDialogBody> | ||||
|             <AlertDialogFooter> | ||||
|               <Button colorScheme="gray" mr="1" onClick={onDeleteClose} ref={cancelRef}> | ||||
|                 {t('common.cancel')} | ||||
|               </Button> | ||||
|               <Button colorScheme="red" ml="1" onClick={handleDeleteClick} isLoading={isDeleting}> | ||||
|                 {t('common.yes')} | ||||
|               </Button> | ||||
|             </AlertDialogFooter> | ||||
|           </AlertDialogContent> | ||||
|         </AlertDialogOverlay> | ||||
|       </AlertDialog> | ||||
|       <WifiScanModal modalProps={scanModalProps} serialNumber={serialNumber} /> | ||||
|       <FirmwareUpgradeModal modalProps={upgradeModalProps} serialNumber={serialNumber} /> | ||||
|       <FactoryResetModal modalProps={resetModalProps} serialNumber={serialNumber} /> | ||||
| @@ -217,6 +301,7 @@ const DevicePageWrapper = ({ serialNumber }: Props) => { | ||||
|       <EventQueueModal serialNumber={serialNumber} modalProps={eventQueueProps} /> | ||||
|       <ConfigureModal serialNumber={serialNumber} modalProps={configureModalProps} /> | ||||
|       <TelemetryModal serialNumber={serialNumber} modalProps={telemetryModalProps} /> | ||||
|       <RebootModal serialNumber={serialNumber} modalProps={rebootModalProps} /> | ||||
|       {scriptModal.modal} | ||||
|       <Box mt={isCompact ? '0px' : '68px'}> | ||||
|         <Masonry | ||||
|   | ||||
| @@ -1,27 +1,16 @@ | ||||
| import * as React from 'react'; | ||||
| import { | ||||
|   Alert, | ||||
|   AlertDescription, | ||||
|   AlertIcon, | ||||
|   AlertTitle, | ||||
|   Box, | ||||
|   FormControl, | ||||
|   FormErrorMessage, | ||||
|   FormLabel, | ||||
|   Input, | ||||
|   Textarea, | ||||
|   useDisclosure, | ||||
|   useToast, | ||||
| } from '@chakra-ui/react'; | ||||
| import { FormControl, FormErrorMessage, FormLabel, Input, Textarea, useDisclosure, useToast } from '@chakra-ui/react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { CreateButton } from 'components/Buttons/CreateButton'; | ||||
| import { SaveButton } from 'components/Buttons/SaveButton'; | ||||
| import { Modal } from 'components/Modals/Modal'; | ||||
| import { useCreateBlacklist } from 'hooks/Network/Blacklist'; | ||||
| import { AxiosError } from 'models/Axios'; | ||||
|  | ||||
| const CreateBlacklistModal = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
|   const initialRef = React.useRef<HTMLInputElement>(null); | ||||
|   const modalProps = useDisclosure(); | ||||
|   const createDevice = useCreateBlacklist(); | ||||
|   const [serialNumber, setSerialNumber] = React.useState<string>(''); | ||||
| @@ -43,41 +32,50 @@ const CreateBlacklistModal = () => { | ||||
|           }); | ||||
|           modalProps.onClose(); | ||||
|         }, | ||||
|         onError: (e) => { | ||||
|           toast({ | ||||
|             id: 'add-blacklist-error', | ||||
|             title: t('common.error'), | ||||
|             description: (e as AxiosError)?.response?.data?.ErrorDescription, | ||||
|             status: 'error', | ||||
|             duration: 5000, | ||||
|             isClosable: true, | ||||
|             position: 'top-right', | ||||
|           }); | ||||
|         }, | ||||
|       }, | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const isSerialValid = serialNumber.length === 12 && serialNumber.match('^[a-fA-F0-9]+$') !== null; | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|   const onOpen = () => { | ||||
|     setSerialNumber(''); | ||||
|     setReason(''); | ||||
|   }, [modalProps.isOpen]); | ||||
|     modalProps.onOpen(); | ||||
|     setTimeout(() => { | ||||
|       initialRef.current?.focus(); | ||||
|     }, 200); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <CreateButton onClick={modalProps.onOpen} isCompact ml={2} /> | ||||
|       <CreateButton onClick={onOpen} isCompact ml={2} /> | ||||
|       <Modal | ||||
|         {...modalProps} | ||||
|         title={t('controller.devices.add_blacklist')} | ||||
|         topRightButtons={<SaveButton onClick={onSave} isLoading={createDevice.isLoading} isCompact />} | ||||
|       > | ||||
|         <> | ||||
|           {createDevice.error && ( | ||||
|             <Alert status="error" mb={4}> | ||||
|               <AlertIcon /> | ||||
|               <Box> | ||||
|                 <AlertTitle>{t('common.error')}</AlertTitle> | ||||
|                 { | ||||
|                   // @ts-ignore | ||||
|                   <AlertDescription>{createDevice.error?.response?.data?.ErrorDescription}</AlertDescription> | ||||
|                 } | ||||
|               </Box> | ||||
|             </Alert> | ||||
|           )} | ||||
|           <FormControl isInvalid={!isSerialValid} mb={2}> | ||||
|             <FormLabel>{t('inventory.serial_number')}</FormLabel> | ||||
|             <Input type="text" onChange={(e) => setSerialNumber(e.target.value)} value={serialNumber} w="140px" /> | ||||
|             <Input | ||||
|               type="text" | ||||
|               onChange={(e) => setSerialNumber(e.target.value)} | ||||
|               value={serialNumber} | ||||
|               w="140px" | ||||
|               ref={initialRef} | ||||
|             /> | ||||
|             <FormErrorMessage>{t('inventory.invalid_serial_number')}</FormErrorMessage> | ||||
|           </FormControl> | ||||
|           <FormControl> | ||||
|   | ||||
| @@ -1,5 +1,22 @@ | ||||
| import * as React from 'react'; | ||||
| import { useColorMode } from '@chakra-ui/react'; | ||||
| import { CopyIcon } from '@chakra-ui/icons'; | ||||
| import { | ||||
|   useColorMode, | ||||
|   Alert, | ||||
|   AlertDescription, | ||||
|   AlertTitle, | ||||
|   Box, | ||||
|   Center, | ||||
|   Heading, | ||||
|   IconButton, | ||||
|   Link, | ||||
|   ListItem, | ||||
|   Spinner, | ||||
|   UnorderedList, | ||||
|   useClipboard, | ||||
|   Tooltip as ChakraTooltip, | ||||
|   useDisclosure, | ||||
| } from '@chakra-ui/react'; | ||||
| import { | ||||
|   Chart as ChartJS, | ||||
|   CategoryScale, | ||||
| @@ -11,20 +28,57 @@ import { | ||||
|   Legend, | ||||
|   ChartData, | ||||
|   ArcElement, | ||||
|   ChartTypeRegistry, | ||||
|   ScatterDataPoint, | ||||
|   BubbleDataPoint, | ||||
| } from 'chart.js'; | ||||
| import { Pie } from 'react-chartjs-2'; | ||||
| import { Pie, getElementAtEvent } from 'react-chartjs-2'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import GraphStatDisplay from 'components/Containers/GraphStatDisplay'; | ||||
| import { Modal } from 'components/Modals/Modal'; | ||||
| import { ControllerDashboardHealth } from 'hooks/Network/Controller'; | ||||
| import { useGetDevicesWithHealthBetween } from 'hooks/Network/HealthChecks'; | ||||
| import { AxiosError } from 'models/Axios'; | ||||
|  | ||||
| const LABEL_TO_LIMITS = { | ||||
|   '100%': { lowerLimit: 100, upperLimit: 100, label: 'With 100% Health' }, | ||||
|   '>90%': { lowerLimit: 90, upperLimit: 99, label: 'Between 90% and 99%' }, | ||||
|   '>60%': { lowerLimit: 60, upperLimit: 89, label: 'Between 60% and 89%' }, | ||||
|   '<=60%': { lowerLimit: 0, upperLimit: 59, label: 'Between 0% and 59%' }, | ||||
| } as const; | ||||
|  | ||||
| ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, ArcElement); | ||||
|  | ||||
| type Props = { | ||||
|   data: ControllerDashboardHealth[]; | ||||
| }; | ||||
|  | ||||
| const OverallHealthPieChart = ({ data }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { colorMode } = useColorMode(); | ||||
|   const { hasCopied, onCopy, setValue } = useClipboard(''); | ||||
|   const modalProps = useDisclosure(); | ||||
|   const [deviceCategory, setDeviceCategory] = React.useState<{ lowerLimit: number; upperLimit: number; label: string }>( | ||||
|     LABEL_TO_LIMITS['100%'], | ||||
|   ); | ||||
|   const serialNumbersFromCategory = useGetDevicesWithHealthBetween(deviceCategory); | ||||
|   const chartRef = | ||||
|     React.useRef<ChartJS<keyof ChartTypeRegistry, (number | ScatterDataPoint | BubbleDataPoint | null)[], unknown>>( | ||||
|       null, | ||||
|     ); | ||||
|  | ||||
|   const onClick = (event: React.MouseEvent<HTMLCanvasElement>) => { | ||||
|     if (chartRef.current) { | ||||
|       const element = getElementAtEvent(chartRef.current, event)?.[0]; | ||||
|       if (element && element.index !== undefined) { | ||||
|         const label = chartRef.current?.data?.labels?.[element.index] as keyof typeof LABEL_TO_LIMITS | undefined; | ||||
|         if (label && LABEL_TO_LIMITS[label]) { | ||||
|           setDeviceCategory(LABEL_TO_LIMITS[label]); | ||||
|           modalProps.onOpen(); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const parsedData: ChartData<'pie', number[], unknown> = React.useMemo(() => { | ||||
|     const totalDevices = data.reduce( | ||||
| @@ -85,7 +139,7 @@ const OverallHealthPieChart = ({ data }: Props) => { | ||||
|     } | ||||
|     if (totalDevices['<60%'] > 0) { | ||||
|       newData.push(totalDevices['<60%']); | ||||
|       labels.push('<60%'); | ||||
|       labels.push('<=60%'); | ||||
|       const color = colorMode === 'light' ? '#FC8181' : '#FC8181'; | ||||
|       backgroundColor.push(color); | ||||
|       borderColor.push(color); | ||||
| @@ -105,38 +159,108 @@ const OverallHealthPieChart = ({ data }: Props) => { | ||||
|     }; | ||||
|   }, [data, colorMode]); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     if (serialNumbersFromCategory.data) setValue(serialNumbersFromCategory.data.join(',')); | ||||
|   }, [serialNumbersFromCategory.data]); | ||||
|  | ||||
|   return ( | ||||
|     <GraphStatDisplay | ||||
|       title={t('controller.dashboard.overall_health')} | ||||
|       explanation={t('controller.dashboard.overall_health_explanation_pie')} | ||||
|       chart={ | ||||
|         <Pie | ||||
|           data={parsedData} | ||||
|           options={{ | ||||
|             plugins: { | ||||
|               legend: { | ||||
|                 position: 'top' as const, | ||||
|                 labels: { | ||||
|                   color: colorMode === 'dark' ? 'white' : undefined, | ||||
|     <> | ||||
|       <GraphStatDisplay | ||||
|         title={t('controller.dashboard.overall_health')} | ||||
|         explanation={t('controller.dashboard.overall_health_explanation_pie')} | ||||
|         chart={ | ||||
|           <Pie | ||||
|             // @ts-ignore | ||||
|             ref={chartRef} | ||||
|             data={parsedData} | ||||
|             onClick={onClick} | ||||
|             options={{ | ||||
|               onHover: (e, elements) => { | ||||
|                 const element = e.native?.target as unknown as { style: { cursor: string } }; | ||||
|                 if (element && elements.length > 0) { | ||||
|                   element.style.cursor = 'pointer'; | ||||
|                 } else if (element) { | ||||
|                   element.style.cursor = 'default'; | ||||
|                 } | ||||
|               }, | ||||
|               plugins: { | ||||
|                 legend: { | ||||
|                   position: 'top' as const, | ||||
|                   labels: { | ||||
|                     color: colorMode === 'dark' ? 'white' : undefined, | ||||
|                   }, | ||||
|                 }, | ||||
|                 title: { | ||||
|                   display: false, | ||||
|                 }, | ||||
|                 tooltip: { | ||||
|                   callbacks: { | ||||
|                     label: (context) => | ||||
|                       `${context.label}: ${context.formattedValue} (${Math.round( | ||||
|                         // @ts-ignore | ||||
|                         (context.raw / context.dataset.data.reduce((acc, curr) => acc + curr, 0)) * 100, | ||||
|                       )}%)`, | ||||
|                   }, | ||||
|                 }, | ||||
|               }, | ||||
|               title: { | ||||
|                 display: false, | ||||
|               }, | ||||
|               tooltip: { | ||||
|                 callbacks: { | ||||
|                   label: (context) => | ||||
|                     `${context.label}: ${context.formattedValue} (${Math.round( | ||||
|                       // @ts-ignore | ||||
|                       (context.raw / context.dataset.data.reduce((acc, curr) => acc + curr, 0)) * 100, | ||||
|                     )}%)`, | ||||
|                 }, | ||||
|               }, | ||||
|             }, | ||||
|           }} | ||||
|         /> | ||||
|       } | ||||
|     /> | ||||
|             }} | ||||
|           /> | ||||
|         } | ||||
|       /> | ||||
|       <Modal | ||||
|         title={t('controller.dashboard.overall_health')} | ||||
|         {...modalProps} | ||||
|         options={{ | ||||
|           modalSize: 'sm', | ||||
|         }} | ||||
|         topRightButtons={ | ||||
|           <ChakraTooltip label={hasCopied ? `${t('common.copied')}!` : t('common.copy')} hasArrow closeOnClick={false}> | ||||
|             <IconButton | ||||
|               aria-label={t('common.copy')} | ||||
|               icon={<CopyIcon h={5} w={5} />} | ||||
|               onClick={onCopy} | ||||
|               colorScheme="teal" | ||||
|               hidden={!serialNumbersFromCategory.data || serialNumbersFromCategory.data.length === 0} | ||||
|             /> | ||||
|           </ChakraTooltip> | ||||
|         } | ||||
|       > | ||||
|         {serialNumbersFromCategory.isFetching ? ( | ||||
|           <Center my={8}> | ||||
|             <Spinner size="xl" /> | ||||
|           </Center> | ||||
|         ) : ( | ||||
|           <Box> | ||||
|             {serialNumbersFromCategory.error ? ( | ||||
|               <Alert mb={4} status="error"> | ||||
|                 <AlertTitle>{t('common.error')}</AlertTitle> | ||||
|                 <AlertDescription> | ||||
|                   {(serialNumbersFromCategory.error as AxiosError).response?.data.ErrorDescription} | ||||
|                 </AlertDescription> | ||||
|               </Alert> | ||||
|             ) : null} | ||||
|             {serialNumbersFromCategory.data ? ( | ||||
|               <Box> | ||||
|                 <Heading size="md" mb={4}> | ||||
|                   {serialNumbersFromCategory.data.length} {t('devices.title')} {deviceCategory.label} | ||||
|                 </Heading> | ||||
|                 <Box maxH="70vh" overflowY="auto" overflowX="hidden"> | ||||
|                   <UnorderedList pl={2}> | ||||
|                     {serialNumbersFromCategory.data | ||||
|                       .sort((a, b) => a.localeCompare(b)) | ||||
|                       .map((device) => ( | ||||
|                         <ListItem key={device} fontFamily="mono"> | ||||
|                           <Link href={`#/devices/${device}`}>{device}</Link> | ||||
|                         </ListItem> | ||||
|                       ))} | ||||
|                   </UnorderedList> | ||||
|                 </Box> | ||||
|               </Box> | ||||
|             ) : null} | ||||
|           </Box> | ||||
|         )} | ||||
|       </Modal> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -34,6 +34,7 @@ interface Props { | ||||
|   onOpenConfigureModal: (serialNumber: string) => void; | ||||
|   onOpenTelemetryModal: (serialNumber: string) => void; | ||||
|   onOpenScriptModal: (device: GatewayDevice) => void; | ||||
|   onOpenRebootModal: (serialNumber: string) => void; | ||||
| } | ||||
|  | ||||
| const Actions: React.FC<Props> = ({ | ||||
| @@ -47,6 +48,7 @@ const Actions: React.FC<Props> = ({ | ||||
|   onOpenConfigureModal, | ||||
|   onOpenTelemetryModal, | ||||
|   onOpenScriptModal, | ||||
|   onOpenRebootModal, | ||||
| }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { isOpen, onOpen, onClose } = useDisclosure(); | ||||
| @@ -102,6 +104,7 @@ const Actions: React.FC<Props> = ({ | ||||
|         onOpenConfigureModal={onOpenConfigureModal} | ||||
|         onOpenTelemetryModal={onOpenTelemetryModal} | ||||
|         onOpenScriptModal={onOpenScriptModal} | ||||
|         onOpenRebootModal={onOpenRebootModal} | ||||
|       /> | ||||
|       <Tooltip hasArrow label={t('common.view_details')} placement="top"> | ||||
|         <Link href={`#/devices/${device.serialNumber}`}> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box, Button, Heading, Image, Spacer, Tooltip, useDisclosure } from '@chakra-ui/react'; | ||||
| import { Box, Heading, Image, Link, Spacer, Tooltip, useDisclosure } from '@chakra-ui/react'; | ||||
| import { LockSimple } from 'phosphor-react'; | ||||
| import ReactCountryFlag from 'react-country-flag'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| @@ -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 { RebootModal } from 'components/Modals/RebootModal'; | ||||
| import { useScriptModal } from 'components/Modals/ScriptModal/useScriptModal'; | ||||
| import { TelemetryModal } from 'components/Modals/TelemetryModal'; | ||||
| import { TraceModal } from 'components/Modals/TraceModal'; | ||||
| @@ -60,6 +61,7 @@ const DeviceListCard = () => { | ||||
|   const eventQueueProps = useDisclosure(); | ||||
|   const telemetryModalProps = useDisclosure(); | ||||
|   const configureModalProps = useDisclosure(); | ||||
|   const rebootModalProps = useDisclosure(); | ||||
|   const scriptModal = useScriptModal(); | ||||
|   const getCount = useGetDeviceCount({ enabled: true }); | ||||
|   const getDevices = useGetDevices({ | ||||
| @@ -98,9 +100,9 @@ const DeviceListCard = () => { | ||||
|     setSerialNumber(serial); | ||||
|     configureModalProps.onOpen(); | ||||
|   }; | ||||
|  | ||||
|   const goToSerial = (serial: string) => () => { | ||||
|     navigate(`/devices/${serial}`); | ||||
|   const onOpenReboot = (serial: string) => { | ||||
|     setSerialNumber(serial); | ||||
|     rebootModalProps.onOpen(); | ||||
|   }; | ||||
|  | ||||
|   const badgeCell = React.useCallback( | ||||
| @@ -160,9 +162,9 @@ const DeviceListCard = () => { | ||||
|  | ||||
|   const serialCell = React.useCallback( | ||||
|     (device: DeviceWithStatus) => ( | ||||
|       <Button variant="link" onClick={goToSerial(device.serialNumber)} fontSize="sm"> | ||||
|       <Link href={`#/devices/${device.serialNumber}`} fontSize="sm" my="auto" pt={1}> | ||||
|         <pre>{device.serialNumber}</pre> | ||||
|       </Button> | ||||
|       </Link> | ||||
|     ), | ||||
|     [], | ||||
|   ); | ||||
| @@ -216,6 +218,7 @@ const DeviceListCard = () => { | ||||
|         onOpenConfigureModal={onOpenConfigure} | ||||
|         onOpenTelemetryModal={onOpenTelemetry} | ||||
|         onOpenScriptModal={scriptModal.openModal} | ||||
|         onOpenRebootModal={onOpenReboot} | ||||
|       /> | ||||
|     ), | ||||
|     [], | ||||
| @@ -251,6 +254,7 @@ const DeviceListCard = () => { | ||||
|         Footer: '', | ||||
|         accessor: 'firmware', | ||||
|         Cell: (v) => firmwareCell(v.cell.row.original), | ||||
|         stopPropagation: true, | ||||
|         customWidth: '50px', | ||||
|         disableSortBy: true, | ||||
|       }, | ||||
| @@ -388,7 +392,7 @@ const DeviceListCard = () => { | ||||
|       </CardHeader> | ||||
|       <CardBody p={4}> | ||||
|         <Box overflowX="auto" w="100%"> | ||||
|           <DataTable | ||||
|           <DataTable<DeviceWithStatus> | ||||
|             columns={ | ||||
|               columns.filter(({ id }) => !hiddenColumns.find((hidden) => hidden === id)) as { | ||||
|                 id: string; | ||||
| @@ -406,7 +410,8 @@ const DeviceListCard = () => { | ||||
|             // @ts-ignore | ||||
|             setPageInfo={setPageInfo} | ||||
|             saveSettingsId="gateway.devices.table" | ||||
|             minHeight="600px" | ||||
|             onRowClick={(device) => navigate(`devices/${device.serialNumber}`)} | ||||
|             isRowClickable={() => true} | ||||
|           /> | ||||
|         </Box> | ||||
|       </CardBody> | ||||
| @@ -417,6 +422,7 @@ const DeviceListCard = () => { | ||||
|       <EventQueueModal modalProps={eventQueueProps} serialNumber={serialNumber} /> | ||||
|       <ConfigureModal modalProps={configureModalProps} serialNumber={serialNumber} /> | ||||
|       <TelemetryModal modalProps={telemetryModalProps} serialNumber={serialNumber} /> | ||||
|       <RebootModal modalProps={rebootModalProps} serialNumber={serialNumber} /> | ||||
|       {scriptModal.modal} | ||||
|     </> | ||||
|   ); | ||||
|   | ||||
| @@ -191,6 +191,7 @@ const FirmwareDetailsModal = ({ modalProps, firmware }: Props) => { | ||||
|               ml={2} | ||||
|             /> | ||||
|             {isEditingDescription && ( | ||||
|               // @ts-ignore | ||||
|               <SaveButton onClick={onSaveDescription} ml={2} isCompact size="sm" isLoading={updateFirmware.isLoading} /> | ||||
|             )} | ||||
|           </FormLabel> | ||||
| @@ -202,48 +203,51 @@ const FirmwareDetailsModal = ({ modalProps, firmware }: Props) => { | ||||
|             isDisabled={!isEditingDescription} | ||||
|           /> | ||||
|         </FormControl> | ||||
|         <FormControl> | ||||
|           <FormLabel> | ||||
|             {t('common.notes')}{' '} | ||||
|             <Popover trigger="click" placement="auto"> | ||||
|               {({ onClose }) => ( | ||||
|                 <> | ||||
|                   <PopoverTrigger> | ||||
|                     <IconButton | ||||
|                       aria-label={`${t('crud.add')} ${t('common.note')}`} | ||||
|                       size="sm" | ||||
|                       icon={<Plus size={20} />} | ||||
|                     /> | ||||
|                   </PopoverTrigger> | ||||
|                   <PopoverContent w={breakpoint === 'base' ? 'calc(80vw)' : '500px'}> | ||||
|                     <PopoverArrow /> | ||||
|                     <PopoverCloseButton alignContent="center" mt={1} /> | ||||
|                     <PopoverHeader display="flex">{t('profile.add_new_note')}</PopoverHeader> | ||||
|                     <PopoverBody> | ||||
|                       <Box> | ||||
|                         <Textarea h="100px" placeholder="Your new note" value={newNote} onChange={onNoteChange} /> | ||||
|                       </Box> | ||||
|                       <Center mt={2}> | ||||
|                         <Button | ||||
|                           colorScheme="blue" | ||||
|                           isDisabled={newNote.length === 0} | ||||
|                           onClick={onNoteSubmit(onClose)} | ||||
|                           isLoading={updateFirmware.isLoading} | ||||
|                         > | ||||
|                           {t('crud.add')} | ||||
|                         </Button> | ||||
|                       </Center> | ||||
|                     </PopoverBody> | ||||
|                   </PopoverContent> | ||||
|                 </> | ||||
|               )} | ||||
|             </Popover> | ||||
|           </FormLabel> | ||||
|           <Box overflowX="auto" overflowY="auto" maxH="400px"> | ||||
|             <DataTable columns={columns as Column<object>[]} data={notes} obj={t('common.notes')} minHeight="200px" /> | ||||
|           </Box> | ||||
|         </FormControl> | ||||
|       </SimpleGrid> | ||||
|       <FormControl mt={2}> | ||||
|         <FormLabel> | ||||
|           {t('common.notes')}{' '} | ||||
|           <Popover trigger="click" placement="auto"> | ||||
|             {({ onClose }) => ( | ||||
|               <> | ||||
|                 <PopoverTrigger> | ||||
|                   <IconButton aria-label={`${t('crud.add')} ${t('common.note')}`} size="sm" icon={<Plus size={20} />} /> | ||||
|                 </PopoverTrigger> | ||||
|                 <PopoverContent w={breakpoint === 'base' ? 'calc(80vw)' : '500px'}> | ||||
|                   <PopoverArrow /> | ||||
|                   <PopoverCloseButton alignContent="center" mt={1} /> | ||||
|                   <PopoverHeader display="flex">{t('profile.add_new_note')}</PopoverHeader> | ||||
|                   <PopoverBody> | ||||
|                     <Box> | ||||
|                       <Textarea h="100px" placeholder="Your new note" value={newNote} onChange={onNoteChange} /> | ||||
|                     </Box> | ||||
|                     <Center mt={2}> | ||||
|                       <Button | ||||
|                         colorScheme="blue" | ||||
|                         isDisabled={newNote.length === 0} | ||||
|                         onClick={onNoteSubmit(onClose)} | ||||
|                         isLoading={updateFirmware.isLoading} | ||||
|                       > | ||||
|                         {t('crud.add')} | ||||
|                       </Button> | ||||
|                     </Center> | ||||
|                   </PopoverBody> | ||||
|                 </PopoverContent> | ||||
|               </> | ||||
|             )} | ||||
|           </Popover> | ||||
|         </FormLabel> | ||||
|       </FormControl> | ||||
|       <Box overflowX="auto" overflowY="auto" maxH="400px" mb={4}> | ||||
|         <DataTable | ||||
|           columns={columns as Column<object>[]} | ||||
|           data={notes} | ||||
|           obj={t('common.notes')} | ||||
|           minHeight="200px" | ||||
|           showAllRows | ||||
|           hideControls | ||||
|         /> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										108
									
								
								src/pages/Firmware/List/UpdateDbButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/pages/Firmware/List/UpdateDbButton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | ||||
| import * as React from 'react'; | ||||
| import { | ||||
|   Alert, | ||||
|   AlertDescription, | ||||
|   AlertIcon, | ||||
|   AlertTitle, | ||||
|   Box, | ||||
|   Button, | ||||
|   Center, | ||||
|   IconButton, | ||||
|   Tag, | ||||
|   TagLabel, | ||||
|   Text, | ||||
|   Tooltip, | ||||
|   useDisclosure, | ||||
|   useToast, | ||||
| } from '@chakra-ui/react'; | ||||
| import axios from 'axios'; | ||||
| import { Database } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import FormattedDate from 'components/InformationDisplays/FormattedDate'; | ||||
| import { Modal } from 'components/Modals/Modal'; | ||||
| import { useGetFirmwareDbUpdate, useUpdateFirmwareDb } from 'hooks/Network/Firmware'; | ||||
|  | ||||
| const UpdateDbButton = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
|   const { isOpen, onOpen, onClose } = useDisclosure(); | ||||
|   const updateDb = useUpdateFirmwareDb(); | ||||
|   const getLastUpdate = useGetFirmwareDbUpdate(); | ||||
|  | ||||
|   const onUpdateClick = async () => { | ||||
|     updateDb.mutate(undefined, { | ||||
|       onSuccess: () => { | ||||
|         toast({ | ||||
|           id: `firmware-db-update-success`, | ||||
|           title: t('common.success'), | ||||
|           description: t('firmware.started_db_update'), | ||||
|           status: 'success', | ||||
|           duration: 5000, | ||||
|           isClosable: true, | ||||
|           position: 'top-right', | ||||
|         }); | ||||
|       }, | ||||
|       onError: (e) => { | ||||
|         if (axios.isAxiosError(e)) { | ||||
|           toast({ | ||||
|             id: `firmware-db-update-error`, | ||||
|             title: t('common.error'), | ||||
|             description: e?.response?.data?.ErrorDescription, | ||||
|             status: 'error', | ||||
|             duration: 5000, | ||||
|             isClosable: true, | ||||
|             position: 'top-right', | ||||
|           }); | ||||
|         } | ||||
|       }, | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Tooltip label={t('firmware.last_db_update_title')}> | ||||
|         <IconButton | ||||
|           aria-label={t('firmware.last_db_update_title')} | ||||
|           colorScheme="teal" | ||||
|           icon={<Database size={20} />} | ||||
|           onClick={onOpen} | ||||
|         /> | ||||
|       </Tooltip> | ||||
|       <Modal | ||||
|         isOpen={isOpen} | ||||
|         onClose={onClose} | ||||
|         title={t('firmware.last_db_update_modal')} | ||||
|         tags={ | ||||
|           <Tag colorScheme="blue" size="lg"> | ||||
|             <TagLabel display="flex"> | ||||
|               <Text mr={1}>Last Update:</Text> | ||||
|               <FormattedDate date={getLastUpdate.data?.lastUpdateTime} /> | ||||
|             </TagLabel> | ||||
|           </Tag> | ||||
|         } | ||||
|       > | ||||
|         <Box> | ||||
|           <Alert status="warning"> | ||||
|             <AlertIcon /> | ||||
|             <Box> | ||||
|               <AlertTitle>{t('common.warning')}</AlertTitle> | ||||
|               <AlertDescription>{t('firmware.db_update_warning')}</AlertDescription> | ||||
|             </Box> | ||||
|           </Alert> | ||||
|           <Center my={4}> | ||||
|             <Button | ||||
|               colorScheme="red" | ||||
|               leftIcon={<Database size={20} />} | ||||
|               onClick={onUpdateClick} | ||||
|               isLoading={updateDb.isLoading} | ||||
|             > | ||||
|               {t('firmware.start_db_update')} | ||||
|             </Button> | ||||
|           </Center> | ||||
|         </Box> | ||||
|       </Modal> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default UpdateDbButton; | ||||
| @@ -11,7 +11,15 @@ const UriCell = ({ uri }: Props) => { | ||||
|  | ||||
|   return ( | ||||
|     <Box display="flex"> | ||||
|       <Button onClick={copy.onCopy} size="xs" colorScheme="teal" mr={2}> | ||||
|       <Button | ||||
|         onClick={(e) => { | ||||
|           copy.onCopy(); | ||||
|           e.stopPropagation(); | ||||
|         }} | ||||
|         size="xs" | ||||
|         colorScheme="teal" | ||||
|         mr={2} | ||||
|       > | ||||
|         {copy.hasCopied ? `${t('common.copied')}!` : t('common.copy')} | ||||
|       </Button> | ||||
|       <Text my="auto">{uri}</Text> | ||||
|   | ||||
| @@ -16,6 +16,7 @@ import { MagnifyingGlass } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import FirmwareDetailsModal from './Modal'; | ||||
| import UpdateDbButton from './UpdateDbButton'; | ||||
| import UriCell from './UriCell'; | ||||
| import { RefreshButton } from 'components/Buttons/RefreshButton'; | ||||
| import { CardBody } from 'components/Containers/Card/CardBody'; | ||||
| @@ -143,6 +144,7 @@ const FirmwareListTable = () => { | ||||
|           </Box> | ||||
|           <Text>{t('controller.firmware.show_dev_releases')}</Text> | ||||
|           <Switch isChecked={showDevFirmware} onChange={toggle} size="lg" /> | ||||
|           <UpdateDbButton /> | ||||
|           <RefreshButton | ||||
|             onClick={() => { | ||||
|               getDeviceTypes.refetch(); | ||||
| @@ -156,13 +158,14 @@ const FirmwareListTable = () => { | ||||
|       <CardBody p={4}> | ||||
|         <Box overflowX="auto" w="100%"> | ||||
|           <LoadingOverlay isLoading={getDeviceTypes.isFetching || getFirmware.isFetching}> | ||||
|             <DataTable | ||||
|               columns={columns as Column<object>[]} | ||||
|             <DataTable<Firmware> | ||||
|               columns={columns} | ||||
|               saveSettingsId="firmware.table" | ||||
|               data={getFirmware.data?.filter((firmw) => showDevFirmware || !firmw.revision.includes('devel')) ?? []} | ||||
|               obj={t('analytics.firmware')} | ||||
|               minHeight="200px" | ||||
|               sortBy={[{ id: 'imageDate', desc: true }]} | ||||
|               onRowClick={(firmw) => handleViewDetailsClick(firmw)()} | ||||
|             /> | ||||
|           </LoadingOverlay> | ||||
|         </Box> | ||||
|   | ||||
| @@ -48,7 +48,10 @@ const _LoginForm: React.FC<_LoginFormProps> = ({ setActiveForm }) => { | ||||
|   const displayError = useMemo(() => { | ||||
|     const loginError: AxiosError = error as AxiosError; | ||||
|  | ||||
|     if (loginError?.response?.data?.ErrorCode === 4) return t('login.waiting_for_email_verification'); | ||||
|     if (loginError?.response?.data?.ErrorCode === 5) return t('login.waiting_for_email_verification'); | ||||
|     if (loginError?.response?.data?.ErrorCode === 15) { | ||||
|       return t('login.suspended_error'); | ||||
|     } | ||||
|     return t('login.invalid_credentials'); | ||||
|   }, [t, error]); | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react'; | ||||
| import { Box, Flex, HStack, IconButton, Select, Spacer, Table, Text, Th, Thead, Tooltip, Tr } from '@chakra-ui/react'; | ||||
| import { Download } from 'phosphor-react'; | ||||
| import { CSVLink } from 'react-csv'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| @@ -127,9 +127,9 @@ const LogsCard = () => { | ||||
|             filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`} | ||||
|             data={downloadableLogs as object[]} | ||||
|           > | ||||
|             <Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}> | ||||
|               {t('logs.export')} | ||||
|             </Button> | ||||
|             <Tooltip label={t('logs.export')} hasArrow> | ||||
|               <IconButton aria-label={t('logs.export')} icon={<Download />} colorScheme="blue" /> | ||||
|             </Tooltip> | ||||
|           </CSVLink> | ||||
|         </HStack> | ||||
|       </CardHeader> | ||||
|   | ||||
| @@ -1,5 +1,19 @@ | ||||
| import * as React from 'react'; | ||||
| import { Badge, Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react'; | ||||
| import { | ||||
|   Badge, | ||||
|   Box, | ||||
|   Flex, | ||||
|   HStack, | ||||
|   IconButton, | ||||
|   Select, | ||||
|   Spacer, | ||||
|   Table, | ||||
|   Text, | ||||
|   Th, | ||||
|   Thead, | ||||
|   Tooltip, | ||||
|   Tr, | ||||
| } from '@chakra-ui/react'; | ||||
| import { Download } from 'phosphor-react'; | ||||
| import { CSVLink } from 'react-csv'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| @@ -128,9 +142,9 @@ const FmsLogsCard = () => { | ||||
|             filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`} | ||||
|             data={downloadableLogs as object[]} | ||||
|           > | ||||
|             <Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}> | ||||
|               {t('logs.export')} | ||||
|             </Button> | ||||
|             <Tooltip label={t('logs.export')} hasArrow> | ||||
|               <IconButton aria-label={t('logs.export')} icon={<Download />} colorScheme="blue" /> | ||||
|             </Tooltip> | ||||
|           </CSVLink> | ||||
|         </HStack> | ||||
|       </CardHeader> | ||||
|   | ||||
| @@ -1,5 +1,19 @@ | ||||
| import * as React from 'react'; | ||||
| import { Badge, Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react'; | ||||
| import { | ||||
|   Badge, | ||||
|   Box, | ||||
|   Flex, | ||||
|   HStack, | ||||
|   IconButton, | ||||
|   Select, | ||||
|   Spacer, | ||||
|   Table, | ||||
|   Text, | ||||
|   Th, | ||||
|   Thead, | ||||
|   Tooltip, | ||||
|   Tr, | ||||
| } from '@chakra-ui/react'; | ||||
| import { Download } from 'phosphor-react'; | ||||
| import { CSVLink } from 'react-csv'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| @@ -128,9 +142,9 @@ const GeneralLogsCard = () => { | ||||
|             filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`} | ||||
|             data={downloadableLogs as object[]} | ||||
|           > | ||||
|             <Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}> | ||||
|               {t('logs.export')} | ||||
|             </Button> | ||||
|             <Tooltip label={t('logs.export')} hasArrow> | ||||
|               <IconButton aria-label={t('logs.export')} icon={<Download />} colorScheme="blue" /> | ||||
|             </Tooltip> | ||||
|           </CSVLink> | ||||
|         </HStack> | ||||
|       </CardHeader> | ||||
|   | ||||
| @@ -1,5 +1,19 @@ | ||||
| import * as React from 'react'; | ||||
| import { Badge, Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react'; | ||||
| import { | ||||
|   Badge, | ||||
|   Box, | ||||
|   Flex, | ||||
|   HStack, | ||||
|   IconButton, | ||||
|   Select, | ||||
|   Spacer, | ||||
|   Table, | ||||
|   Text, | ||||
|   Th, | ||||
|   Thead, | ||||
|   Tooltip, | ||||
|   Tr, | ||||
| } from '@chakra-ui/react'; | ||||
| import { Download } from 'phosphor-react'; | ||||
| import { CSVLink } from 'react-csv'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| @@ -128,9 +142,9 @@ const SecLogsCard = () => { | ||||
|             filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`} | ||||
|             data={downloadableLogs as object[]} | ||||
|           > | ||||
|             <Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}> | ||||
|               {t('logs.export')} | ||||
|             </Button> | ||||
|             <Tooltip label={t('logs.export')} hasArrow> | ||||
|               <IconButton aria-label={t('logs.export')} icon={<Download />} colorScheme="blue" /> | ||||
|             </Tooltip> | ||||
|           </CSVLink> | ||||
|         </HStack> | ||||
|       </CardHeader> | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box } from '@chakra-ui/react'; | ||||
| import ApiKeyTable from './Table'; | ||||
| import { Card } from 'components/Containers/Card'; | ||||
| import { CardBody } from 'components/Containers/Card/CardBody'; | ||||
| @@ -10,7 +11,9 @@ const ApiKeysCard = () => { | ||||
|   return ( | ||||
|     <Card p={4}> | ||||
|       <CardBody> | ||||
|         <ApiKeyTable userId={user?.id ?? ''} /> | ||||
|         <Box w="100%"> | ||||
|           <ApiKeyTable userId={user?.id ?? ''} /> | ||||
|         </Box> | ||||
|       </CardBody> | ||||
|     </Card> | ||||
|   ); | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box, Button, Heading, HStack, Spacer } from '@chakra-ui/react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { useParams } from 'react-router-dom'; | ||||
| import ScriptTableActions from './Actions'; | ||||
| import CreateScriptButton from './CreateButton'; | ||||
| import useScriptsTable from './useScriptsTable'; | ||||
| @@ -21,6 +22,7 @@ type Props = { | ||||
| const ScriptTableCard = ({ onIdSelect }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { query, hiddenColumns } = useScriptsTable(); | ||||
|   const { id } = useParams(); | ||||
|  | ||||
|   const dateCell = React.useCallback((date: number) => <FormattedDate date={date} />, []); | ||||
|   const actionCell = React.useCallback( | ||||
| @@ -108,8 +110,8 @@ const ScriptTableCard = ({ onIdSelect }: Props) => { | ||||
|       </CardHeader> | ||||
|       <CardBody> | ||||
|         <Box w="100%" h="300px" overflowY="auto"> | ||||
|           <DataTable | ||||
|             columns={columns as Column<object>[]} | ||||
|           <DataTable<Script> | ||||
|             columns={columns} | ||||
|             saveSettingsId="apiKeys.profile.table" | ||||
|             data={query.data ?? []} | ||||
|             obj={t('script.other')} | ||||
| @@ -118,6 +120,8 @@ const ScriptTableCard = ({ onIdSelect }: Props) => { | ||||
|             hiddenColumns={hiddenColumns[0]} | ||||
|             showAllRows | ||||
|             hideControls | ||||
|             onRowClick={(script) => onIdSelect(script.id)} | ||||
|             isRowClickable={(script) => script.id !== id} | ||||
|           /> | ||||
|         </Box> | ||||
|       </CardBody> | ||||
|   | ||||
							
								
								
									
										139
									
								
								src/pages/SystemPage/SystemSecrets/Actions.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								src/pages/SystemPage/SystemSecrets/Actions.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,139 @@ | ||||
| import React from 'react'; | ||||
| import { CopyIcon } from '@chakra-ui/icons'; | ||||
| import { | ||||
|   IconButton, | ||||
|   Tooltip, | ||||
|   Popover, | ||||
|   PopoverArrow, | ||||
|   PopoverBody, | ||||
|   PopoverCloseButton, | ||||
|   PopoverContent, | ||||
|   PopoverFooter, | ||||
|   PopoverHeader, | ||||
|   PopoverTrigger, | ||||
|   Center, | ||||
|   Box, | ||||
|   Button, | ||||
|   useDisclosure, | ||||
|   HStack, | ||||
|   Text, | ||||
|   useClipboard, | ||||
| } from '@chakra-ui/react'; | ||||
| import { Eye, Trash } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import EditSecretButton from './EditButton'; | ||||
| import { Secret, useDeleteSystemSecret } from 'hooks/Network/Secrets'; | ||||
|  | ||||
| interface Props { | ||||
|   secret: Secret; | ||||
|   isDisabled?: boolean; | ||||
| } | ||||
|  | ||||
| const SystemSecretActions = ({ secret, isDisabled }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { isOpen, onOpen, onClose } = useDisclosure(); | ||||
|   const deleteSecret = useDeleteSystemSecret(); | ||||
|   const { hasCopied, onCopy } = useClipboard(secret.value); | ||||
|  | ||||
|   const handleDeleteClick = React.useCallback(() => { | ||||
|     deleteSecret.mutate(secret.key, { | ||||
|       onSuccess: () => { | ||||
|         onClose(); | ||||
|       }, | ||||
|     }); | ||||
|   }, []); | ||||
|  | ||||
|   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-device" | ||||
|                 colorScheme="red" | ||||
|                 icon={<Trash size={20} />} | ||||
|                 size="sm" | ||||
|                 isDisabled={isDisabled} | ||||
|               /> | ||||
|             </PopoverTrigger> | ||||
|           </Box> | ||||
|         </Tooltip> | ||||
|         <PopoverContent w="340px"> | ||||
|           <PopoverArrow /> | ||||
|           <PopoverCloseButton /> | ||||
|           <PopoverHeader> | ||||
|             {t('crud.delete')} {secret.key} | ||||
|           </PopoverHeader> | ||||
|           <PopoverBody> | ||||
|             <Text whiteSpace="break-spaces">{t('crud.delete_confirm', { obj: t('system.secrets_one') })}</Text> | ||||
|           </PopoverBody> | ||||
|           <PopoverFooter> | ||||
|             <Center> | ||||
|               <Button colorScheme="gray" mr="1" onClick={onClose}> | ||||
|                 {t('common.cancel')} | ||||
|               </Button> | ||||
|               <Button colorScheme="red" ml="1" onClick={handleDeleteClick} isLoading={deleteSecret.isLoading}> | ||||
|                 {t('common.yes')} | ||||
|               </Button> | ||||
|             </Center> | ||||
|           </PopoverFooter> | ||||
|         </PopoverContent> | ||||
|       </Popover> | ||||
|       <Tooltip | ||||
|         label={hasCopied ? `${t('common.copied')}!` : `${t('common.copy')} ${t('system.secrets_one')}`} | ||||
|         hasArrow | ||||
|         closeOnClick={false} | ||||
|       > | ||||
|         <IconButton | ||||
|           aria-label={t('common.copy')} | ||||
|           icon={<CopyIcon h={5} w={5} />} | ||||
|           onClick={onCopy} | ||||
|           size="sm" | ||||
|           colorScheme="teal" | ||||
|           mr={2} | ||||
|         /> | ||||
|       </Tooltip> | ||||
|       <EditSecretButton secret={secret} /> | ||||
|       <Popover> | ||||
|         <Tooltip label={`${t('common.view')} ${t('system.secrets_one')}`} hasArrow closeOnClick={false}> | ||||
|           <Box> | ||||
|             <PopoverTrigger> | ||||
|               <IconButton aria-label={t('common.view')} icon={<Eye size={20} />} size="sm" colorScheme="purple" /> | ||||
|             </PopoverTrigger> | ||||
|           </Box> | ||||
|         </Tooltip> | ||||
|         <PopoverContent w="560px"> | ||||
|           <PopoverArrow /> | ||||
|           <PopoverCloseButton /> | ||||
|           <PopoverHeader> | ||||
|             {t('common.view')} {secret.key} | ||||
|             <Tooltip | ||||
|               label={hasCopied ? `${t('common.copied')}!` : `${t('common.copy')} ${t('system.secrets_one')}`} | ||||
|               hasArrow | ||||
|               closeOnClick={false} | ||||
|             > | ||||
|               <IconButton | ||||
|                 aria-label={t('common.copy')} | ||||
|                 icon={<CopyIcon h={4} w={4} />} | ||||
|                 onClick={onCopy} | ||||
|                 size="xs" | ||||
|                 colorScheme="teal" | ||||
|                 ml={2} | ||||
|               /> | ||||
|             </Tooltip> | ||||
|           </PopoverHeader> | ||||
|           <PopoverBody> | ||||
|             <Text whiteSpace="break-spaces"> | ||||
|               <Center> | ||||
|                 <pre style={{ fontFamily: 'monospace' }}>{secret.value}</pre> | ||||
|               </Center> | ||||
|             </Text> | ||||
|           </PopoverBody> | ||||
|         </PopoverContent> | ||||
|       </Popover> | ||||
|     </HStack> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default SystemSecretActions; | ||||
							
								
								
									
										118
									
								
								src/pages/SystemPage/SystemSecrets/CreateButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								src/pages/SystemPage/SystemSecrets/CreateButton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,118 @@ | ||||
| import * as React from 'react'; | ||||
| import { | ||||
|   Box, | ||||
|   FormControl, | ||||
|   FormErrorMessage, | ||||
|   FormLabel, | ||||
|   Input, | ||||
|   Textarea, | ||||
|   useDisclosure, | ||||
|   useToast, | ||||
| } from '@chakra-ui/react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { CreateButton } from '../../../components/Buttons/CreateButton'; | ||||
| import { SaveButton } from '../../../components/Buttons/SaveButton'; | ||||
| import { Modal } from '../../../components/Modals/Modal'; | ||||
| import { useCreateSystemSecret } from 'hooks/Network/Secrets'; | ||||
| import { AxiosError } from 'models/Axios'; | ||||
|  | ||||
| type FormValues = { | ||||
|   key: string; | ||||
|   value: string; | ||||
| }; | ||||
|  | ||||
| const DEFAULT_FORM_VALUES: FormValues = { | ||||
|   key: '', | ||||
|   value: '', | ||||
| }; | ||||
|  | ||||
| const SystemSecretCreateButton = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
|   const { isOpen, onOpen, onClose } = useDisclosure(); | ||||
|   const [form, setForm] = React.useState<FormValues>(DEFAULT_FORM_VALUES); | ||||
|   const [isNameChanged, setIsNameChanged] = React.useState(false); | ||||
|   const [isValueChanged, setIsValueChanged] = React.useState(false); | ||||
|   const create = useCreateSystemSecret(); | ||||
|  | ||||
|   const onKeyChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     setForm({ ...form, key: e.target.value }); | ||||
|     if (!isNameChanged) setIsNameChanged(true); | ||||
|   }; | ||||
|   const onValueChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { | ||||
|     setForm({ ...form, value: e.target.value }); | ||||
|     if (!isValueChanged) setIsValueChanged(true); | ||||
|   }; | ||||
|  | ||||
|   const isNameError = form.key.length === 0; | ||||
|   const isValueError = form.value.length === 0; | ||||
|  | ||||
|   const onSubmit = () => { | ||||
|     create.mutate(form, { | ||||
|       onSuccess: () => { | ||||
|         toast({ | ||||
|           id: 'create-system-secret-success', | ||||
|           title: t('common.success'), | ||||
|           description: t('crud.success_update_obj', { | ||||
|             obj: t('system.secrets_one'), | ||||
|           }), | ||||
|           status: 'success', | ||||
|           duration: 5000, | ||||
|           isClosable: true, | ||||
|           position: 'top-right', | ||||
|         }); | ||||
|         onClose(); | ||||
|       }, | ||||
|       onError: (e) => { | ||||
|         toast({ | ||||
|           id: 'create-system-secret-error', | ||||
|           title: t('common.error'), | ||||
|           description: (e as AxiosError)?.response?.data?.ErrorDescription, | ||||
|           status: 'error', | ||||
|           duration: 5000, | ||||
|           isClosable: true, | ||||
|           position: 'top-right', | ||||
|         }); | ||||
|       }, | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const handleOpenClick = () => { | ||||
|     setIsNameChanged(false); | ||||
|     setIsValueChanged(false); | ||||
|     setForm(DEFAULT_FORM_VALUES); | ||||
|     onOpen(); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <CreateButton onClick={handleOpenClick} isCompact /> | ||||
|       <Modal | ||||
|         isOpen={isOpen} | ||||
|         onClose={onClose} | ||||
|         title={t('system.secrets_create')} | ||||
|         topRightButtons={ | ||||
|           <SaveButton onClick={onSubmit} isDisabled={isNameError || isValueError} isLoading={create.isLoading} /> | ||||
|         } | ||||
|         options={{ | ||||
|           modalSize: 'sm', | ||||
|         }} | ||||
|       > | ||||
|         <Box> | ||||
|           <FormControl mb={2} isInvalid={isNameError && isNameChanged}> | ||||
|             <FormLabel>{t('common.name')}</FormLabel> | ||||
|             <Input value={form.key} onChange={onKeyChange} /> | ||||
|             <FormErrorMessage>{t('form.required')}</FormErrorMessage> | ||||
|           </FormControl> | ||||
|           <FormControl mb={2} isInvalid={isValueError && isValueChanged}> | ||||
|             <FormLabel>{t('common.value')}</FormLabel> | ||||
|             <Textarea value={form.value} onChange={onValueChange} rows={2} /> | ||||
|             <FormErrorMessage>{t('form.required')}</FormErrorMessage> | ||||
|           </FormControl> | ||||
|         </Box> | ||||
|       </Modal> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default SystemSecretCreateButton; | ||||
							
								
								
									
										146
									
								
								src/pages/SystemPage/SystemSecrets/EditButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								src/pages/SystemPage/SystemSecrets/EditButton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,146 @@ | ||||
| import * as React from 'react'; | ||||
| import { | ||||
|   Box, | ||||
|   Button, | ||||
|   Center, | ||||
|   FormControl, | ||||
|   FormErrorMessage, | ||||
|   FormLabel, | ||||
|   IconButton, | ||||
|   Input, | ||||
|   Popover, | ||||
|   PopoverArrow, | ||||
|   PopoverBody, | ||||
|   PopoverCloseButton, | ||||
|   PopoverContent, | ||||
|   PopoverFooter, | ||||
|   PopoverHeader, | ||||
|   PopoverTrigger, | ||||
|   Text, | ||||
|   Textarea, | ||||
|   Tooltip, | ||||
|   useDisclosure, | ||||
|   useToast, | ||||
| } from '@chakra-ui/react'; | ||||
| import { Pencil } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Secret, useUpdateSystemSecret } from 'hooks/Network/Secrets'; | ||||
| import { AxiosError } from 'models/Axios'; | ||||
|  | ||||
| type FormValues = { | ||||
|   key: string; | ||||
|   value: string; | ||||
| }; | ||||
|  | ||||
| type Props = { | ||||
|   secret: Secret; | ||||
| }; | ||||
|  | ||||
| const EditSecretButton = ({ secret }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
|   const { isOpen, onOpen, onClose } = useDisclosure(); | ||||
|   const [form, setForm] = React.useState<FormValues>({ | ||||
|     key: secret.key, | ||||
|     value: secret.value, | ||||
|   }); | ||||
|   const [isNameChanged, setIsNameChanged] = React.useState(false); | ||||
|   const [isValueChanged, setIsValueChanged] = React.useState(false); | ||||
|   const update = useUpdateSystemSecret(); | ||||
|  | ||||
|   const onKeyChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     setForm({ ...form, key: e.target.value }); | ||||
|     if (!isNameChanged) setIsNameChanged(true); | ||||
|   }; | ||||
|   const onValueChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { | ||||
|     setForm({ ...form, value: e.target.value }); | ||||
|     if (!isValueChanged) setIsValueChanged(true); | ||||
|   }; | ||||
|  | ||||
|   const isNameError = form.key.length === 0; | ||||
|   const isValueError = form.value.length === 0; | ||||
|  | ||||
|   const onSubmit = () => { | ||||
|     update.mutate(form, { | ||||
|       onSuccess: () => { | ||||
|         toast({ | ||||
|           id: 'create-system-secret-success', | ||||
|           title: t('common.success'), | ||||
|           status: 'success', | ||||
|           duration: 5000, | ||||
|           isClosable: true, | ||||
|           position: 'top-right', | ||||
|         }); | ||||
|         onClose(); | ||||
|       }, | ||||
|       onError: (e) => { | ||||
|         toast({ | ||||
|           id: 'create-system-secret-error', | ||||
|           title: t('common.error'), | ||||
|           description: (e as AxiosError)?.response?.data?.ErrorDescription, | ||||
|           status: 'error', | ||||
|           duration: 5000, | ||||
|           isClosable: true, | ||||
|           position: 'top-right', | ||||
|         }); | ||||
|       }, | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const handleOpenClick = () => { | ||||
|     setIsNameChanged(false); | ||||
|     setIsValueChanged(false); | ||||
|     setForm({ | ||||
|       key: secret.key, | ||||
|       value: secret.value, | ||||
|     }); | ||||
|     onOpen(); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Popover isOpen={isOpen} onOpen={handleOpenClick} onClose={onClose}> | ||||
|       <Tooltip hasArrow label={t('crud.edit')} placement="top" isDisabled={isOpen}> | ||||
|         <Box> | ||||
|           <PopoverTrigger> | ||||
|             <IconButton aria-label="delete-device" colorScheme="blue" icon={<Pencil size={20} />} size="sm" /> | ||||
|           </PopoverTrigger> | ||||
|         </Box> | ||||
|       </Tooltip> | ||||
|       <PopoverContent w="340px"> | ||||
|         <PopoverArrow /> | ||||
|         <PopoverCloseButton /> | ||||
|         <PopoverHeader> | ||||
|           {t('crud.edit')} {secret.key} | ||||
|         </PopoverHeader> | ||||
|         <PopoverBody> | ||||
|           <Text whiteSpace="break-spaces"> | ||||
|             <Box> | ||||
|               <FormControl mb={2} isInvalid={isNameError && isNameChanged}> | ||||
|                 <FormLabel>{t('common.name')}</FormLabel> | ||||
|                 <Input value={form.key} onChange={onKeyChange} /> | ||||
|                 <FormErrorMessage>{t('form.required')}</FormErrorMessage> | ||||
|               </FormControl> | ||||
|               <FormControl mb={2} isInvalid={isValueError && isValueChanged}> | ||||
|                 <FormLabel>{t('common.value')}</FormLabel> | ||||
|                 <Textarea value={form.value} onChange={onValueChange} rows={2} /> | ||||
|                 <FormErrorMessage>{t('form.required')}</FormErrorMessage> | ||||
|               </FormControl> | ||||
|             </Box> | ||||
|           </Text> | ||||
|         </PopoverBody> | ||||
|         <PopoverFooter> | ||||
|           <Center> | ||||
|             <Button colorScheme="gray" mr="1" onClick={onClose}> | ||||
|               {t('common.cancel')} | ||||
|             </Button> | ||||
|             <Button colorScheme="blue" ml="1" onClick={onSubmit} isLoading={update.isLoading}> | ||||
|               {t('common.save')} | ||||
|             </Button> | ||||
|           </Center> | ||||
|         </PopoverFooter> | ||||
|       </PopoverContent> | ||||
|     </Popover> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default EditSecretButton; | ||||
							
								
								
									
										70
									
								
								src/pages/SystemPage/SystemSecrets/Table.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								src/pages/SystemPage/SystemSecrets/Table.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| import * as React from 'react'; | ||||
| import { Box } from '@chakra-ui/react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { DataTable } from '../../../components/DataTables/DataTable'; | ||||
| import SystemSecretActions from './Actions'; | ||||
| import { Secret, useGetAllSystemSecrets, useGetSystemSecretsDictionary } from 'hooks/Network/Secrets'; | ||||
| import { Column } from 'models/Table'; | ||||
|  | ||||
| const SystemSecretsTable = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const getSecrets = useGetAllSystemSecrets(); | ||||
|   const getDictionary = useGetSystemSecretsDictionary(); | ||||
|  | ||||
|   const descriptionCell = React.useCallback( | ||||
|     (secret: Secret) => { | ||||
|       if (!getDictionary.data) return '-'; | ||||
|  | ||||
|       return getDictionary.data.find((d) => d.key === secret.key)?.description ?? '-'; | ||||
|     }, | ||||
|     [getDictionary.data], | ||||
|   ); | ||||
|  | ||||
|   const actionCell = React.useCallback((secret: Secret) => <SystemSecretActions secret={secret} />, []); | ||||
|  | ||||
|   const columns = React.useMemo( | ||||
|     (): Column<Secret>[] => [ | ||||
|       { | ||||
|         id: 'key', | ||||
|         Header: t('common.name'), | ||||
|         Footer: '', | ||||
|         accessor: 'key', | ||||
|         alwaysShow: true, | ||||
|         customWidth: '120px', | ||||
|       }, | ||||
|       { | ||||
|         id: 'description', | ||||
|         Header: t('common.description'), | ||||
|         Footer: '', | ||||
|         Cell: ({ cell }) => descriptionCell(cell.row.original), | ||||
|         accessor: 'description', | ||||
|         hasPopover: true, | ||||
|       }, | ||||
|       { | ||||
|         id: 'actions', | ||||
|         Header: t('common.actions'), | ||||
|         Footer: '', | ||||
|         Cell: (v) => actionCell(v.cell.row.original), | ||||
|         disableSortBy: true, | ||||
|         customWidth: '120px', | ||||
|         alwaysShow: true, | ||||
|       }, | ||||
|     ], | ||||
|     [t, descriptionCell], | ||||
|   ); | ||||
|   return ( | ||||
|     <Box w="100%"> | ||||
|       <DataTable | ||||
|         columns={columns as Column<object>[]} | ||||
|         saveSettingsId="system.secrets.table" | ||||
|         data={getSecrets.data ?? []} | ||||
|         obj={t('keys.other')} | ||||
|         sortBy={[{ id: 'key', desc: false }]} | ||||
|         showAllRows | ||||
|         hideControls | ||||
|       /> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default SystemSecretsTable; | ||||
							
								
								
									
										53
									
								
								src/pages/SystemPage/SystemSecrets/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/pages/SystemPage/SystemSecrets/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| import * as React from 'react'; | ||||
| import { | ||||
|   BackgroundProps, | ||||
|   Box, | ||||
|   EffectProps, | ||||
|   Heading, | ||||
|   InteractivityProps, | ||||
|   LayoutProps, | ||||
|   PositionProps, | ||||
|   SpaceProps, | ||||
|   Spacer, | ||||
| } from '@chakra-ui/react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import SystemSecretCreateButton from './CreateButton'; | ||||
| import SystemSecretsTable from './Table'; | ||||
| import { Card } from 'components/Containers/Card'; | ||||
| import { CardBody } from 'components/Containers/Card/CardBody'; | ||||
| import { CardHeader } from 'components/Containers/Card/CardHeader'; | ||||
| import { useAuth } from 'contexts/AuthProvider'; | ||||
|  | ||||
| export interface SystemSecretsCardProps | ||||
|   extends LayoutProps, | ||||
|     SpaceProps, | ||||
|     BackgroundProps, | ||||
|     InteractivityProps, | ||||
|     PositionProps, | ||||
|     EffectProps {} | ||||
|  | ||||
| export const SystemSecretsCard = ({ ...props }: SystemSecretsCardProps) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { user } = useAuth(); | ||||
|  | ||||
|   if (!user || user.userRole !== 'root') { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Box px={4} py={4}> | ||||
|       <Card variant="widget" {...props}> | ||||
|         <CardHeader> | ||||
|           <Heading size="md" my="auto"> | ||||
|             {t('system.secrets')} | ||||
|           </Heading> | ||||
|           <Spacer /> | ||||
|           <SystemSecretCreateButton /> | ||||
|         </CardHeader> | ||||
|         <CardBody p={4}> | ||||
|           <SystemSecretsTable /> | ||||
|         </CardBody> | ||||
|       </Card> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
| @@ -21,8 +21,8 @@ import axios from 'axios'; | ||||
| import { FloppyDisk } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import { Modal } from '../../../../components/Modals/Modal'; | ||||
| import { LoadingOverlay } from 'components/LoadingOverlay'; | ||||
| import { Modal } from 'components/Modals/Modal'; | ||||
| import { useGetSystemLogLevelNames, useGetSystemLogLevels, useUpdateSystemLogLevels } from 'hooks/Network/System'; | ||||
|  | ||||
| type Props = { | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import * as React from 'react'; | ||||
| import { Button, useDisclosure } from '@chakra-ui/react'; | ||||
| import { IconButton, Tooltip, useDisclosure } from '@chakra-ui/react'; | ||||
| import { Article } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import SystemLoggingModal from './Modal'; | ||||
| import { EndpointApiResponse } from 'hooks/Network/Endpoints'; | ||||
| @@ -15,9 +16,17 @@ const SystemLoggingButton = ({ endpoint, token }: Props) => { | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Button colorScheme="teal" onClick={modalProps.onOpen} mr={2} my="auto"> | ||||
|         {t('system.logging')} | ||||
|       </Button> | ||||
|       <Tooltip label={t('system.logging')} hasArrow> | ||||
|         <IconButton | ||||
|           aria-label={t('system.logging')} | ||||
|           colorScheme="teal" | ||||
|           type="button" | ||||
|           my="auto" | ||||
|           onClick={modalProps.onOpen} | ||||
|           icon={<Article size={20} />} | ||||
|           mr={2} | ||||
|         /> | ||||
|       </Tooltip> | ||||
|       <SystemLoggingModal modalProps={modalProps} endpoint={endpoint.uri} token={token} /> | ||||
|     </> | ||||
|   ); | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import React, { useCallback } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { DataTable } from 'components/DataTables/DataTable'; | ||||
| import { DataTable } from '../../../components/DataTables/DataTable'; | ||||
| import { compactDate } from 'helpers/dateFormatting'; | ||||
| import { Column } from 'models/Table'; | ||||
|  | ||||
|   | ||||
| @@ -19,11 +19,12 @@ import { | ||||
| import { MultiValue, Select } from 'chakra-react-select'; | ||||
| import { ArrowsClockwise } from 'phosphor-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import FormattedDate from '../../../components/InformationDisplays/FormattedDate'; | ||||
| import SystemLoggingButton from './LoggingButton'; | ||||
| import SystemCertificatesTable from './SystemCertificatesTable'; | ||||
| import { RefreshButton } from 'components/Buttons/RefreshButton'; | ||||
| import { Card } from 'components/Containers/Card'; | ||||
| import { CardBody } from 'components/Containers/Card/CardBody'; | ||||
| import FormattedDate from 'components/InformationDisplays/FormattedDate'; | ||||
| import { compactSecondsToDetailed } from 'helpers/dateFormatting'; | ||||
| import { EndpointApiResponse } from 'hooks/Network/Endpoints'; | ||||
| import { useGetSubsystems, useGetSystemInfo, useReloadSubsystems } from 'hooks/Network/System'; | ||||
| @@ -65,21 +66,12 @@ const SystemTile = ({ endpoint, token }: Props) => { | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Card> | ||||
|       <Card variant="widget"> | ||||
|         <Box display="flex" mb={2}> | ||||
|           <Heading pt={0}>{endpoint.type}</Heading> | ||||
|           <Spacer /> | ||||
|           <SystemLoggingButton endpoint={endpoint} token={token} /> | ||||
|           <Button | ||||
|             mt={1} | ||||
|             minWidth="112px" | ||||
|             colorScheme="gray" | ||||
|             rightIcon={<ArrowsClockwise />} | ||||
|             onClick={refresh} | ||||
|             isLoading={isFetchingSystem || isFetchingSubsystems} | ||||
|           > | ||||
|             {t('common.refresh')} | ||||
|           </Button> | ||||
|           <RefreshButton onClick={refresh} isFetching={isFetchingSystem || isFetchingSubsystems} /> | ||||
|         </Box> | ||||
|         <CardBody> | ||||
|           <VStack w="100%"> | ||||
| @@ -179,7 +171,7 @@ const SystemTile = ({ endpoint, token }: Props) => { | ||||
|                   ml={2} | ||||
|                   onClick={handleReloadClick} | ||||
|                   icon={<ArrowsClockwise size={20} />} | ||||
|                   colorScheme="gray" | ||||
|                   colorScheme="blue" | ||||
|                   isLoading={isReloading} | ||||
|                   isDisabled={subs.length === 0} | ||||
|                 /> | ||||
|   | ||||
| @@ -1,27 +1,47 @@ | ||||
| import React from 'react'; | ||||
| import { Heading, SimpleGrid, Spacer } from '@chakra-ui/react'; | ||||
| import { Box, SimpleGrid, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import { RefreshButton } from '../../components/Buttons/RefreshButton'; | ||||
| import { SystemSecretsCard } from './SystemSecrets'; | ||||
| import SystemTile from './SystemTile'; | ||||
| import { RefreshButton } from 'components/Buttons/RefreshButton'; | ||||
| import { Card } from 'components/Containers/Card'; | ||||
| import { CardHeader } from 'components/Containers/Card/CardHeader'; | ||||
| import { axiosSec } from 'constants/axiosInstances'; | ||||
| import { useAuth } from 'contexts/AuthProvider'; | ||||
| import { useGetEndpoints } from 'hooks/Network/Endpoints'; | ||||
|  | ||||
| const SystemPage = () => { | ||||
| const getDefaultTabIndex = () => { | ||||
|   const index = localStorage.getItem('system-tab-index') || '0'; | ||||
|   try { | ||||
|     return parseInt(index, 10); | ||||
|   } catch { | ||||
|     return 0; | ||||
|   } | ||||
| }; | ||||
| type Props = { | ||||
|   isOnlySec?: boolean; | ||||
| }; | ||||
|  | ||||
| const SystemPage = ({ isOnlySec }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { token } = useAuth(); | ||||
|   const { token, user } = useAuth(); | ||||
|   const { data: endpoints, refetch, isFetching } = useGetEndpoints({ onSuccess: () => {} }); | ||||
|   const [tabIndex, setTabIndex] = React.useState(getDefaultTabIndex()); | ||||
|   const handleTabChange = (index: number) => { | ||||
|     setTabIndex(index); | ||||
|     localStorage.setItem('system-tab-index', index.toString()); | ||||
|   }; | ||||
|  | ||||
|   const isRoot = user && user.userRole === 'root'; | ||||
|  | ||||
|   const endpointsList = React.useMemo(() => { | ||||
|     if (!endpoints || !token) return null; | ||||
|     if (!token || (!isOnlySec && !endpoints)) return null; | ||||
|  | ||||
|     const endpointList = [...endpoints]; | ||||
|     const endpointList = endpoints ? [...endpoints] : []; | ||||
|     endpointList.push({ | ||||
|       uri: axiosSec.defaults.baseURL?.split('/api/v1')[0] ?? '', | ||||
|       type: 'owsec', | ||||
|       type: isOnlySec ? '' : 'owsec', | ||||
|       id: 0, | ||||
|       vendor: 'owsec', | ||||
|       authenticationType: '', | ||||
| @@ -37,20 +57,51 @@ const SystemPage = () => { | ||||
|   }, [endpoints, token]); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Card mb={4} py={2} px={4}> | ||||
|         <CardHeader> | ||||
|           <Heading size="md" my="auto"> | ||||
|             {t('controller.firmware.endpoints')} | ||||
|           </Heading> | ||||
|           <Spacer /> | ||||
|           <RefreshButton onClick={refetch} isFetching={isFetching} /> | ||||
|         </CardHeader> | ||||
|       </Card> | ||||
|       <SimpleGrid minChildWidth="500px" spacing="20px" mb={3}> | ||||
|         {endpointsList} | ||||
|       </SimpleGrid> | ||||
|     </> | ||||
|     <Card p={0}> | ||||
|       <Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy> | ||||
|         <TabList> | ||||
|           <CardHeader> | ||||
|             <Tab>{t('system.services')}</Tab> | ||||
|             <Tab hidden={!isRoot}>{t('system.configuration')}</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" | ||||
|             > | ||||
|               {!isOnlySec && ( | ||||
|                 <CardHeader px={4} pt={4}> | ||||
|                   <Spacer /> | ||||
|                   <RefreshButton onClick={refetch} isFetching={isFetching} /> | ||||
|                 </CardHeader> | ||||
|               )} | ||||
|               <SimpleGrid minChildWidth="500px" spacing="20px" p={4}> | ||||
|                 {endpointsList} | ||||
|               </SimpleGrid> | ||||
|             </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" | ||||
|             > | ||||
|               <SystemSecretsCard /> | ||||
|             </Box> | ||||
|           </TabPanel> | ||||
|         </TabPanels> | ||||
|       </Tabs> | ||||
|     </Card> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default SystemPage; | ||||
|   | ||||
| @@ -12,9 +12,11 @@ interface Props { | ||||
|   isSuspended: boolean; | ||||
|   isWaitingForCheck: boolean; | ||||
|   refresh: () => void; | ||||
|   isDisabled?: boolean; | ||||
|   size?: 'sm' | 'md' | 'lg'; | ||||
| } | ||||
|  | ||||
| const UserActions: React.FC<Props> = ({ id, isSuspended, isWaitingForCheck, refresh }) => { | ||||
| const UserActions = ({ id, isSuspended, isWaitingForCheck, refresh, size = 'sm', isDisabled }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const toast = useToast(); | ||||
|   const { mutateAsync: sendValidation } = useSendUserEmailValidation({ id, refresh }); | ||||
| @@ -75,10 +77,17 @@ const UserActions: React.FC<Props> = ({ id, isSuspended, isWaitingForCheck, refr | ||||
|  | ||||
|   return ( | ||||
|     <Menu> | ||||
|       <Tooltip label={t('commands.other')}> | ||||
|         <MenuButton as={IconButton} aria-label="Commands" icon={<Wrench size={20} />} size="sm" ml={2} /> | ||||
|       <Tooltip label={t('common.actions')}> | ||||
|         <MenuButton | ||||
|           as={IconButton} | ||||
|           aria-label="Commands" | ||||
|           icon={<Wrench size={20} />} | ||||
|           size={size} | ||||
|           ml={2} | ||||
|           isDisabled={isDisabled} | ||||
|         /> | ||||
|       </Tooltip> | ||||
|       <MenuList> | ||||
|       <MenuList fontSize="md"> | ||||
|         <MenuItem onClick={handleSuspendClick}> | ||||
|           {isSuspended ? t('users.reactivate_user') : t('users.suspend')} | ||||
|         </MenuItem> | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import * as React from 'react'; | ||||
| import { AddIcon } from '@chakra-ui/icons'; | ||||
| import { Button, useDisclosure } from '@chakra-ui/react'; | ||||
| import { useDisclosure } from '@chakra-ui/react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { SaveButton } from '../../../../components/Buttons/SaveButton'; | ||||
| import { ConfirmCloseAlertModal } from '../../../../components/Modals/ConfirmCloseAlert'; | ||||
| import { Modal } from '../../../../components/Modals/Modal'; | ||||
| import CreateUserForm, { CreateUserFormValues } from './Form'; | ||||
| import { CreateButton } from 'components/Buttons/CreateButton'; | ||||
| import { useAuth } from 'contexts/AuthProvider'; | ||||
| import { useFormRef } from 'hooks/useFormRef'; | ||||
|  | ||||
| @@ -25,16 +25,7 @@ const CreateUserModal = () => { | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Button | ||||
|         hidden={user?.userRole === 'csr'} | ||||
|         alignItems="center" | ||||
|         colorScheme="blue" | ||||
|         rightIcon={<AddIcon />} | ||||
|         onClick={onOpen} | ||||
|         ml={2} | ||||
|       > | ||||
|         {t('crud.create')} | ||||
|       </Button> | ||||
|       {user?.userRole === 'CSR' ? null : <CreateButton onClick={onOpen} ml={2} />} | ||||
|       <Modal | ||||
|         isOpen={isOpen} | ||||
|         onClose={closeModal} | ||||
|   | ||||
| @@ -62,7 +62,7 @@ const UpdateUserForm = ({ editing, isOpen, onClose, selectedUser, formRef }: Pro | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setFormKey(uuid()); | ||||
|   }, [isOpen]); | ||||
|   }, [isOpen, editing]); | ||||
|  | ||||
|   return ( | ||||
|     <Formik | ||||
|   | ||||
| @@ -1,12 +1,14 @@ | ||||
| import * as React from 'react'; | ||||
| import { useEffect } from 'react'; | ||||
| import { Spinner, Center, useDisclosure, useBoolean } from '@chakra-ui/react'; | ||||
| import { Spinner, Center, useDisclosure, useBoolean, Tag } from '@chakra-ui/react'; | ||||
| import { useQueryClient } from '@tanstack/react-query'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { EditButton } from '../../../../components/Buttons/EditButton'; | ||||
| import { SaveButton } from '../../../../components/Buttons/SaveButton'; | ||||
| import { ConfirmCloseAlertModal } from '../../../../components/Modals/ConfirmCloseAlert'; | ||||
| import { Modal } from '../../../../components/Modals/Modal'; | ||||
| import ActionsDropdown from '../ActionsDropdown'; | ||||
| import UpdateUserForm from './Form'; | ||||
| import { ToggleEditButton } from 'components/Buttons/ToggleEditButton'; | ||||
| import { useGetUser, User } from 'hooks/Network/Users'; | ||||
| import { useFormRef } from 'hooks/useFormRef'; | ||||
|  | ||||
| @@ -19,10 +21,11 @@ type Props = { | ||||
| const EditUserModal = ({ isOpen, onClose, userId }: Props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const [editing, setEditing] = useBoolean(); | ||||
|   const queryClient = useQueryClient(); | ||||
|   const { isOpen: showConfirm, onOpen: openConfirm, onClose: closeConfirm } = useDisclosure(); | ||||
|   const { form, formRef } = useFormRef<User>(); | ||||
|   const canFetchUser = userId !== '' && isOpen; | ||||
|   const { data: user, isFetching } = useGetUser({ id: userId ?? '', enabled: canFetchUser }); | ||||
|   const { data: user, isFetching, refetch } = useGetUser({ id: userId ?? '', enabled: canFetchUser }); | ||||
|  | ||||
|   const closeModal = () => (form.dirty ? openConfirm() : onClose()); | ||||
|  | ||||
| @@ -31,6 +34,11 @@ const EditUserModal = ({ isOpen, onClose, userId }: Props) => { | ||||
|     onClose(); | ||||
|   }; | ||||
|  | ||||
|   const refresh = () => { | ||||
|     refetch(); | ||||
|     queryClient.invalidateQueries(['users']); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (isOpen) setEditing.off(); | ||||
|   }, [isOpen]); | ||||
| @@ -40,15 +48,40 @@ const EditUserModal = ({ isOpen, onClose, userId }: Props) => { | ||||
|       <Modal | ||||
|         isOpen={isOpen} | ||||
|         onClose={closeModal} | ||||
|         title={t('crud.edit_obj', { obj: t('user.title') })} | ||||
|         title={user?.name ?? t('crud.edit_obj', { obj: t('user.title') })} | ||||
|         tags={ | ||||
|           <> | ||||
|             {user?.suspended ? ( | ||||
|               <Tag colorScheme="yellow" size="lg"> | ||||
|                 {t('user.suspended')} | ||||
|               </Tag> | ||||
|             ) : null} | ||||
|             {user?.waitingForEmailCheck ? ( | ||||
|               <Tag colorScheme="blue" size="lg"> | ||||
|                 {t('user.email_not_validated')} | ||||
|               </Tag> | ||||
|             ) : null} | ||||
|           </> | ||||
|         } | ||||
|         topRightButtons={ | ||||
|           <> | ||||
|             <SaveButton | ||||
|               onClick={form.submitForm} | ||||
|               isLoading={form.isSubmitting} | ||||
|               isDisabled={!editing || !form.isValid || !form.dirty} | ||||
|               hidden={!editing} | ||||
|             /> | ||||
|             <EditButton ml={2} isDisabled={editing} onClick={setEditing.toggle} isCompact /> | ||||
|             <ToggleEditButton ml={2} isEditing={editing} toggleEdit={setEditing.toggle} isDirty={form.dirty} /> | ||||
|             {user ? ( | ||||
|               <ActionsDropdown | ||||
|                 id={user?.id} | ||||
|                 isSuspended={user?.suspended} | ||||
|                 isWaitingForCheck={user?.waitingForEmailCheck} | ||||
|                 refresh={refresh} | ||||
|                 size="md" | ||||
|                 isDisabled={editing} | ||||
|               /> | ||||
|             ) : null} | ||||
|           </> | ||||
|         } | ||||
|       > | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import React, { useCallback, useState } from 'react'; | ||||
| import { Avatar, Box, Button, Flex, useDisclosure } from '@chakra-ui/react'; | ||||
| import { ArrowsClockwise } from 'phosphor-react'; | ||||
| import { Avatar, Box, Flex, useDisclosure } from '@chakra-ui/react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import { ColumnPicker } from '../../../components/DataTables/ColumnPicker'; | ||||
| @@ -9,6 +8,7 @@ import FormattedDate from '../../../components/InformationDisplays/FormattedDate | ||||
| import CreateUserModal from './CreateUserModal'; | ||||
| import EditUserModal from './EditUserModal'; | ||||
| import UserActions from './UserActions'; | ||||
| 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'; | ||||
| @@ -25,10 +25,10 @@ const UserTable = () => { | ||||
|   const { isOpen: editOpen, onOpen: openEdit, onClose: closeEdit } = useDisclosure(); | ||||
|   const { data: users, refetch: refreshUsers, isFetching } = useGetUsers(); | ||||
|  | ||||
|   const openEditModal = (editUser: User) => { | ||||
|   const openEditModal = React.useCallback((editUser: User) => { | ||||
|     setEditId(editUser.id); | ||||
|     openEdit(); | ||||
|   }; | ||||
|   }, []); | ||||
|  | ||||
|   const memoizedActions = useCallback( | ||||
|     (userActions: User) => ( | ||||
| @@ -99,7 +99,7 @@ const UserTable = () => { | ||||
|     ]; | ||||
|     if (user?.userRole !== 'csr') | ||||
|       baseColumns.push({ | ||||
|         id: 'user', | ||||
|         id: 'actions', | ||||
|         Header: t('common.actions'), | ||||
|         Footer: '', | ||||
|         accessor: 'Id', | ||||
| @@ -125,28 +125,21 @@ const UserTable = () => { | ||||
|                 preference="provisioning.userTable.hiddenColumns" | ||||
|               /> | ||||
|               <CreateUserModal /> | ||||
|               <Button | ||||
|                 colorScheme="gray" | ||||
|                 onClick={() => refreshUsers()} | ||||
|                 rightIcon={<ArrowsClockwise />} | ||||
|                 ml={2} | ||||
|                 isLoading={isFetching} | ||||
|               > | ||||
|                 {t('common.refresh')} | ||||
|               </Button> | ||||
|               <RefreshButton onClick={refreshUsers} isFetching={isFetching} ml={2} /> | ||||
|             </Box> | ||||
|           </Flex> | ||||
|         </CardHeader> | ||||
|         <CardBody> | ||||
|           <Box overflowX="auto" w="100%"> | ||||
|             <DataTable | ||||
|               columns={columns as Column<object>[]} | ||||
|               data={users?.filter((curr) => curr.email !== user?.email) ?? []} | ||||
|             <DataTable<User> | ||||
|               columns={columns} | ||||
|               data={users ?? []} | ||||
|               isLoading={isFetching} | ||||
|               obj={t('users.title')} | ||||
|               sortBy={[{ id: 'email', desc: false }]} | ||||
|               hiddenColumns={hiddenColumns} | ||||
|               fullScreen | ||||
|               onRowClick={openEditModal} | ||||
|             /> | ||||
|           </Box> | ||||
|         </CardBody> | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { extendTheme, type ThemeConfig } from '@chakra-ui/react'; | ||||
| import { extendTheme, Tooltip, type ThemeConfig } from '@chakra-ui/react'; | ||||
| import CardComponent from './additions/card/Card'; | ||||
| import CardBodyComponent from './additions/card/CardBody'; | ||||
| import CardHeaderComponent from './additions/card/CardHeader'; | ||||
| @@ -37,4 +37,6 @@ const theme = extendTheme({ | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| Tooltip.defaultProps = { ...Tooltip.defaultProps, hasArrow: true }; | ||||
|  | ||||
| export default theme; | ||||
|   | ||||
| @@ -1,49 +1,9 @@ | ||||
| import { defineConfig } from 'vite'; | ||||
| import tsconfigPaths from 'vite-tsconfig-paths'; | ||||
| import { VitePWA } from 'vite-plugin-pwa'; | ||||
| import react from '@vitejs/plugin-react'; | ||||
|  | ||||
| export default defineConfig({ | ||||
|   plugins: [ | ||||
|     tsconfigPaths(), | ||||
|     react(), | ||||
|     VitePWA({ | ||||
|       registerType: 'autoUpdate', | ||||
|       devOptions: { | ||||
|         enabled: true, | ||||
|         /* other options */ | ||||
|       }, | ||||
|       manifest: { | ||||
|         name: 'OpenWiFi Controller App', | ||||
|         short_name: 'OpenWiFiController', | ||||
|         description: 'OpenWiFi Controller App', | ||||
|         theme_color: '#000000', | ||||
|         icons: [ | ||||
|           { | ||||
|             src: 'android-chrome-192x192.png', | ||||
|             sizes: '192x192', | ||||
|             type: 'image/png', | ||||
|           }, | ||||
|           { | ||||
|             src: 'android-chrome-384x384.png', | ||||
|             sizes: '384x384', | ||||
|             type: 'image/png', | ||||
|           }, | ||||
|           { | ||||
|             src: 'android-chrome-512x512.png', | ||||
|             sizes: '512x512', | ||||
|             type: 'image/png', | ||||
|           }, | ||||
|           { | ||||
|             src: 'android-chrome-512x512.png', | ||||
|             sizes: '512x512', | ||||
|             type: 'image/png', | ||||
|             purpose: 'any maskable', | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }), | ||||
|   ], | ||||
|   plugins: [tsconfigPaths(), react()], | ||||
|   build: { | ||||
|     outDir: './build', | ||||
|     chunkSizeWarningLimit: 1000, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user