[WIFI-12917] Monitoring page, new "Live Data" view, better Venue page UI with new Monitoring section

Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
Charles
2023-09-01 15:55:21 +02:00
parent e2001ae17f
commit 88276292e5
155 changed files with 6857 additions and 3495 deletions

333
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "wlan-cloud-owprov-ui",
"version": "2.10.0(12)",
"version": "2.11.0(29)",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "wlan-cloud-owprov-ui",
"version": "2.10.0(12)",
"version": "2.11.0(29)",
"license": "ISC",
"dependencies": {
"@chakra-ui/anatomy": "^2.1.1",
@@ -17,6 +17,8 @@
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/inter": "^4.5.15",
"@googlemaps/react-wrapper": "^1.1.35",
"@googlemaps/typescript-guards": "^2.0.3",
"@hello-pangea/dnd": "^16.2.0",
"@nivo/circle-packing": "^0.80.0",
"@nivo/core": "^0.80.0",
@@ -27,10 +29,12 @@
"axios": "^1.3.5",
"buffer": "^6.0.3",
"chakra-react-select": "^4.6.0",
"chart.js": "^4.4.0",
"cronstrue": "2.26.0",
"currency-codes": "^2.1.0",
"dagre": "^0.8.5",
"dotenv": "^16.0.3",
"fast-equals": "^5.0.1",
"formik": "^2.2.9",
"framer-motion": "^10.12.3",
"i18next": "^22.4.14",
@@ -40,8 +44,10 @@
"lodash.debounce": "^4.0.8",
"papaparse": "^5.4.1",
"prop-types": "^15.8.1",
"rc-tree": "^5.7.9",
"react": "^18.2.0",
"react-app-polyfill": "^3.0.0",
"react-chartjs-2": "^5.2.0",
"react-country-flag": "^3.1.0",
"react-csv": "^2.2.2",
"react-datepicker": "^4.11.0",
@@ -64,6 +70,7 @@
"zustand": "^4.3.7"
},
"devDependencies": {
"@types/google.maps": "^3.52.5",
"@types/lodash.debounce": "^4.0.7",
"@types/node": "^18.15.11",
"@types/react": "^18.0.37",
@@ -3534,6 +3541,30 @@
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-4.5.15.tgz",
"integrity": "sha512-FzleM9AxZQK2nqsTDtBiY0PMEVWvnKnuu2i09+p6DHvrHsuucoV2j0tmw+kAT3L4hvsLdAIDv6MdGehsPIdT+Q=="
},
"node_modules/@googlemaps/js-api-loader": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.2.tgz",
"integrity": "sha512-psGw5u0QM6humao48Hn4lrChOM2/rA43ZCm3tKK9qQsEj1/VzqkCqnvGfEOshDbBQflydfaRovbKwZMF4AyqbA==",
"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/@hello-pangea/dnd": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-16.2.0.tgz",
@@ -3643,6 +3674,11 @@
"@jridgewell/sourcemap-codec": "1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
},
"node_modules/@nivo/circle-packing": {
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@nivo/circle-packing/-/circle-packing-0.80.0.tgz",
@@ -4414,6 +4450,12 @@
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA=="
},
"node_modules/@types/google.maps": {
"version": "3.54.0",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.54.0.tgz",
"integrity": "sha512-b1MBy2eGrZoEFLnzq1RrlHbfzuWHz+Nitgqbb5N+MFA0kAUv0kYPmAXtczpb4dHlFZyu58EYzcKXtWNqSInyXg==",
"dev": true
},
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
@@ -4723,9 +4765,9 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": {
"version": "7.3.7",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
"integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
@@ -4839,9 +4881,9 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/semver": {
"version": "7.3.7",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
"integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
@@ -4978,9 +5020,9 @@
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/semver": {
"version": "7.3.7",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
"integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
@@ -5554,6 +5596,17 @@
"node": ">=0.8.0"
}
},
"node_modules/chart.js": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.0.tgz",
"integrity": "sha512-vQEj6d+z0dcsKLlQvbKIMYFHd3t8W/7L2vfJIbYcfyPcRx92CsHqECpueN8qVGNlKyDcr5wBrYAYKnfu/9Q1hQ==",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=7"
}
},
"node_modules/classcat": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.3.tgz",
@@ -7094,8 +7147,7 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-diff": {
"version": "1.2.0",
@@ -7103,6 +7155,14 @@
"integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
"dev": true
},
"node_modules/fast-equals": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz",
"integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.2.12",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
@@ -9395,6 +9455,85 @@
"safe-buffer": "^5.1.0"
}
},
"node_modules/rc-motion": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.8.0.tgz",
"integrity": "sha512-9gWWzlPvx/IJANj+t+ArqLCQ43rCWYLpOUe6+WJSAGb+b+fqBcfx81qPhg6b+ewa6g3mGNDhkTpBrVrCC4gcXA==",
"dependencies": {
"@babel/runtime": "^7.11.1",
"classnames": "^2.2.1",
"rc-util": "^5.21.0"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/rc-resize-observer": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.3.1.tgz",
"integrity": "sha512-iFUdt3NNhflbY3mwySv5CA1TC06zdJ+pfo0oc27xpf4PIOvfZwZGtD9Kz41wGYqC4SLio93RVAirSSpYlV/uYg==",
"dependencies": {
"@babel/runtime": "^7.20.7",
"classnames": "^2.2.1",
"rc-util": "^5.27.0",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/rc-tree": {
"version": "5.7.10",
"resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.7.10.tgz",
"integrity": "sha512-n4UkMQY3bzvJUNnbw6e3YI7sy2kE9c9vAYbSt94qAhcPKtMOThONNr1LIaFB/M5XeFYYrWVbvRVoT8k38eFuSQ==",
"dependencies": {
"@babel/runtime": "^7.10.1",
"classnames": "2.x",
"rc-motion": "^2.0.1",
"rc-util": "^5.16.1",
"rc-virtual-list": "^3.5.1"
},
"engines": {
"node": ">=10.x"
},
"peerDependencies": {
"react": "*",
"react-dom": "*"
}
},
"node_modules/rc-util": {
"version": "5.37.0",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.37.0.tgz",
"integrity": "sha512-cPMV8DzaHI1KDaS7XPRXAf4J7mtBqjvjikLpQieaeOO7+cEbqY2j7Kso/T0R0OiEZTNcLS/8Zl9YrlXiO9UbjQ==",
"dependencies": {
"@babel/runtime": "^7.18.3",
"react-is": "^16.12.0"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/rc-virtual-list": {
"version": "3.10.5",
"resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.10.5.tgz",
"integrity": "sha512-Vc89TL3JHfRlLVQXVj5Hmv0dIflgwmHDcbjt9lrZjOG3wNUDkTF5zci8kFDU/CzdmmqgKu+CUktEpT10VUKYSQ==",
"dependencies": {
"@babel/runtime": "^7.20.0",
"classnames": "^2.2.6",
"rc-resize-observer": "^1.0.0",
"rc-util": "^5.36.0"
},
"engines": {
"node": ">=8.x"
},
"peerDependencies": {
"react": "*",
"react-dom": "*"
}
},
"node_modules/react": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
@@ -9422,6 +9561,15 @@
"node": ">=14"
}
},
"node_modules/react-chartjs-2": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz",
"integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-clientside-effect": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz",
@@ -9977,6 +10125,11 @@
"node": ">=0.10.0"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"node_modules/resolve": {
"version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
@@ -10130,9 +10283,9 @@
}
},
"node_modules/semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
@@ -11182,9 +11335,9 @@
}
},
"node_modules/word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
@@ -14194,6 +14347,27 @@
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-4.5.15.tgz",
"integrity": "sha512-FzleM9AxZQK2nqsTDtBiY0PMEVWvnKnuu2i09+p6DHvrHsuucoV2j0tmw+kAT3L4hvsLdAIDv6MdGehsPIdT+Q=="
},
"@googlemaps/js-api-loader": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.2.tgz",
"integrity": "sha512-psGw5u0QM6humao48Hn4lrChOM2/rA43ZCm3tKK9qQsEj1/VzqkCqnvGfEOshDbBQflydfaRovbKwZMF4AyqbA==",
"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=="
},
"@hello-pangea/dnd": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-16.2.0.tgz",
@@ -14280,6 +14454,11 @@
"@jridgewell/sourcemap-codec": "1.4.14"
}
},
"@kurkle/color": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
},
"@nivo/circle-packing": {
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@nivo/circle-packing/-/circle-packing-0.80.0.tgz",
@@ -14888,6 +15067,12 @@
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA=="
},
"@types/google.maps": {
"version": "3.54.0",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.54.0.tgz",
"integrity": "sha512-b1MBy2eGrZoEFLnzq1RrlHbfzuWHz+Nitgqbb5N+MFA0kAUv0kYPmAXtczpb4dHlFZyu58EYzcKXtWNqSInyXg==",
"dev": true
},
"@types/hoist-non-react-statics": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
@@ -15138,9 +15323,9 @@
}
},
"semver": {
"version": "7.3.7",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
"integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dev": true,
"requires": {
"lru-cache": "^6.0.0"
@@ -15202,9 +15387,9 @@
}
},
"semver": {
"version": "7.3.7",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
"integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dev": true,
"requires": {
"lru-cache": "^6.0.0"
@@ -15279,9 +15464,9 @@
}
},
"semver": {
"version": "7.3.7",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
"integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dev": true,
"requires": {
"lru-cache": "^6.0.0"
@@ -15663,6 +15848,14 @@
}
}
},
"chart.js": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.0.tgz",
"integrity": "sha512-vQEj6d+z0dcsKLlQvbKIMYFHd3t8W/7L2vfJIbYcfyPcRx92CsHqECpueN8qVGNlKyDcr5wBrYAYKnfu/9Q1hQ==",
"requires": {
"@kurkle/color": "^0.3.0"
}
},
"classcat": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.3.tgz",
@@ -16828,8 +17021,7 @@
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-diff": {
"version": "1.2.0",
@@ -16837,6 +17029,11 @@
"integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
"dev": true
},
"fast-equals": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz",
"integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ=="
},
"fast-glob": {
"version": "3.2.12",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
@@ -18477,6 +18674,59 @@
"safe-buffer": "^5.1.0"
}
},
"rc-motion": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.8.0.tgz",
"integrity": "sha512-9gWWzlPvx/IJANj+t+ArqLCQ43rCWYLpOUe6+WJSAGb+b+fqBcfx81qPhg6b+ewa6g3mGNDhkTpBrVrCC4gcXA==",
"requires": {
"@babel/runtime": "^7.11.1",
"classnames": "^2.2.1",
"rc-util": "^5.21.0"
}
},
"rc-resize-observer": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.3.1.tgz",
"integrity": "sha512-iFUdt3NNhflbY3mwySv5CA1TC06zdJ+pfo0oc27xpf4PIOvfZwZGtD9Kz41wGYqC4SLio93RVAirSSpYlV/uYg==",
"requires": {
"@babel/runtime": "^7.20.7",
"classnames": "^2.2.1",
"rc-util": "^5.27.0",
"resize-observer-polyfill": "^1.5.1"
}
},
"rc-tree": {
"version": "5.7.10",
"resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.7.10.tgz",
"integrity": "sha512-n4UkMQY3bzvJUNnbw6e3YI7sy2kE9c9vAYbSt94qAhcPKtMOThONNr1LIaFB/M5XeFYYrWVbvRVoT8k38eFuSQ==",
"requires": {
"@babel/runtime": "^7.10.1",
"classnames": "2.x",
"rc-motion": "^2.0.1",
"rc-util": "^5.16.1",
"rc-virtual-list": "^3.5.1"
}
},
"rc-util": {
"version": "5.37.0",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.37.0.tgz",
"integrity": "sha512-cPMV8DzaHI1KDaS7XPRXAf4J7mtBqjvjikLpQieaeOO7+cEbqY2j7Kso/T0R0OiEZTNcLS/8Zl9YrlXiO9UbjQ==",
"requires": {
"@babel/runtime": "^7.18.3",
"react-is": "^16.12.0"
}
},
"rc-virtual-list": {
"version": "3.10.5",
"resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.10.5.tgz",
"integrity": "sha512-Vc89TL3JHfRlLVQXVj5Hmv0dIflgwmHDcbjt9lrZjOG3wNUDkTF5zci8kFDU/CzdmmqgKu+CUktEpT10VUKYSQ==",
"requires": {
"@babel/runtime": "^7.20.0",
"classnames": "^2.2.6",
"rc-resize-observer": "^1.0.0",
"rc-util": "^5.36.0"
}
},
"react": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
@@ -18498,6 +18748,12 @@
"whatwg-fetch": "^3.6.2"
}
},
"react-chartjs-2": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz",
"integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==",
"requires": {}
},
"react-clientside-effect": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz",
@@ -18856,6 +19112,11 @@
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true
},
"resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"resolve": {
"version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
@@ -18959,9 +19220,9 @@
}
},
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true
},
"serialize-javascript": {
@@ -19680,9 +19941,9 @@
}
},
"word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true
},
"workbox-background-sync": {

View File

@@ -1,6 +1,6 @@
{
"name": "wlan-cloud-owprov-ui",
"version": "2.10.0(12)",
"version": "2.11.0(29)",
"description": "",
"main": "index.tsx",
"scripts": {
@@ -22,6 +22,8 @@
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/inter": "^4.5.15",
"@googlemaps/react-wrapper": "^1.1.35",
"@googlemaps/typescript-guards": "^2.0.3",
"@hello-pangea/dnd": "^16.2.0",
"@nivo/circle-packing": "^0.80.0",
"@nivo/core": "^0.80.0",
@@ -32,10 +34,12 @@
"axios": "^1.3.5",
"buffer": "^6.0.3",
"chakra-react-select": "^4.6.0",
"chart.js": "^4.4.0",
"cronstrue": "2.26.0",
"currency-codes": "^2.1.0",
"dagre": "^0.8.5",
"dotenv": "^16.0.3",
"fast-equals": "^5.0.1",
"formik": "^2.2.9",
"framer-motion": "^10.12.3",
"i18next": "^22.4.14",
@@ -45,8 +49,10 @@
"lodash.debounce": "^4.0.8",
"papaparse": "^5.4.1",
"prop-types": "^15.8.1",
"rc-tree": "^5.7.9",
"react": "^18.2.0",
"react-app-polyfill": "^3.0.0",
"react-chartjs-2": "^5.2.0",
"react-country-flag": "^3.1.0",
"react-csv": "^2.2.2",
"react-datepicker": "^4.11.0",
@@ -69,6 +75,7 @@
"zustand": "^4.3.7"
},
"devDependencies": {
"@types/google.maps": "^3.52.5",
"@types/lodash.debounce": "^4.0.7",
"@types/node": "^18.15.11",
"@types/react": "^18.0.37",

View File

@@ -223,6 +223,7 @@
"day": "Tag",
"days": "Tage",
"default": "Standard",
"defaults": "Standardeinstellungen",
"description": "Beschreibung",
"details": "Einzelheiten",
"device_details": "Gerätedetails",
@@ -268,6 +269,7 @@
"map": "Karte",
"max": "Max",
"min": "MINDEST",
"miscellaneous": "Verschiedenes",
"mode": "Modus",
"model": "Modell",
"modified": "Geändert",
@@ -644,6 +646,7 @@
"notifications": "Gerätebenachrichtigungen",
"one": "Gerät",
"reassign_already_owned": "Geräte neu zuweisen, die bereits vorhanden sind und einem anderen Unternehmen/Veranstaltungsort/Abonnenten gehören?",
"reboot_logs": "Neustartprotokolle",
"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",
@@ -701,11 +704,32 @@
"venues_under_root": "Veranstaltungsorte können nicht direkt unter der Root-Entität erstellt werden"
},
"firmware": {
"confirm_default_data": "Bitte bestätigen Sie die untenstehenden Informationen und klicken Sie auf „Bestätigen“, sobald Sie bereit sind, den Vorgang zu starten",
"create_success": "Neue Standard-Firmware-Einstellungen erstellt!",
"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",
"default_created_error_one": "{{count}} Fehler beim Versuch, eine neue Einstellung zu erstellen",
"default_created_error_other": "{{count}} Fehler beim Versuch, eine neue Einstellung zu erstellen",
"default_created_one": "{{count}} Standard-Firmware-Einstellung erstellt",
"default_created_other": "{{count}} Standard-Firmware-Einstellungen erstellt",
"default_found_one": "Für den Gerätetyp {{count}} wurde eine gültige Revision gefunden",
"default_found_other": "Gültige Revisionen für {{count}} Gerätetypen gefunden",
"default_mass_delete_success_one": " {{count}} Standard-Firmware-Einstellung gelöscht!",
"default_mass_delete_success_other": " {{count}} Standard-Firmware-Einstellungen gelöscht!",
"default_not_found_one": "Keine gültigen Firmware-Versionen für den Gerätetyp {{count}} ",
"default_not_found_other": "Keine gültigen Firmware-Versionen für {{count}} Gerätetypen",
"default_title": "",
"default_update_success": "Standard-Firmware für {{deviceType}}aktualisiert!",
"delete_success": "Standard-Firmware-Einstellung gelöscht!",
"edit_default_title": "Dies ist die aktuelle Firmware, die als Mindestversion für neue APs vom Typ {{deviceType}}verwendet wird. Wenn ein neuer {{deviceType}} AP eine Verbindung zum Gateway herstellt, wird er automatisch auf diese Version aktualisiert.",
"fetching_defaults": "Alle verfügbaren Firmware für ausgewählte Gerätetypen werden abgerufen...",
"last_db_update_modal": "Firmware-Datenbank",
"last_db_update_title": "Datenbank",
"one": "Firmware",
"select_default_device_types": "Bitte wählen Sie alle Gerätetypen aus, auf die Sie diese neue Standard-Firmware-Regel anwenden möchten. Wenn Sie den gewünschten Gerätetyp nicht finden können, bedeutet dies, dass bereits eine Regel angewendet wurde.",
"select_default_revision": "Sie können jetzt die Mindestversion auswählen, auf die Ihre Gerätetypen abzielen sollen",
"start_db_update": "Datenbankaktualisierung starten",
"started_db_update": "Datenbankaktualisierung gestartet, dieser Vorgang sollte bis zu 25 Minuten dauern"
"started_db_update": "Datenbankaktualisierung gestartet, dieser Vorgang sollte bis zu 25 Minuten dauern",
"update_success": "Standard-Firmware-Informationen gespeichert!"
},
"footer": {
"powered_by": "Unterstützt von",
@@ -1007,6 +1031,9 @@
"current_live_devices": "Aktuelle Live-Geräte",
"currently_running_one": "Derzeit wird {{count}} Simulation ausgeführt",
"currently_running_other": "Derzeit laufen {{count}} Simulationen",
"delete_devices_confirm": "Sind Sie sicher, dass Sie alle Geräte und deren Statistiken vom Gateway entfernen möchten? Diese Aktion ist nicht rückgängig zu machen",
"delete_devices_loading": "Dieser Vorgang kann bis zu 5 Minuten dauern",
"delete_simulation_devices": "Geräte löschen",
"delete_success": "Gelöschte Simulation!",
"duration": "Dauer",
"error_devices": "Fehler Geräte",

View File

@@ -223,6 +223,7 @@
"day": "Day",
"days": "Days",
"default": "Default",
"defaults": "Defaults",
"description": "Description",
"details": "Details",
"device_details": "Device Details",
@@ -268,6 +269,7 @@
"map": "Map",
"max": "Max",
"min": "Min",
"miscellaneous": "Miscellaneous",
"mode": "Mode",
"model": "Model",
"modified": "Modified",
@@ -644,6 +646,7 @@
"notifications": "Device Notifications",
"one": "Device",
"reassign_already_owned": "Reassign devices which already exist and are owned by another entity/venue/subscriber?",
"reboot_logs": "Reboot Logs",
"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",
@@ -701,11 +704,32 @@
"venues_under_root": "Venues cannot be created directly under the root entity"
},
"firmware": {
"confirm_default_data": "Please confirm the information below and click 'Confirm' once you are ready to start the process",
"create_success": "Created new default firmware settings!",
"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",
"default_created_error_one": "{{count}} error while trying to create new setting",
"default_created_error_other": "{{count}} errors while trying to create new setting",
"default_created_one": "{{count}} default firmware setting created",
"default_created_other": "{{count}} default firmware settings created",
"default_found_one": "Found valid revision for {{count}} device type",
"default_found_other": "Found valid revisions for {{count}} device types",
"default_mass_delete_success_one": "Deleted {{count}} default firmware setting!",
"default_mass_delete_success_other": "Deleted {{count}} default firmware settings!",
"default_not_found_one": "No valid firmware versions for {{count}} device type",
"default_not_found_other": "No valid firmware versions for {{count}} device types",
"default_title": "Default Firmware",
"default_update_success": "Updated default firmware for {{deviceType}}!",
"delete_success": "Deleted default firmware setting!",
"edit_default_title": "This is the current firmware that is used as the minimum version for new APs of type {{deviceType}}. If a new {{deviceType}} AP connects to the gateway, it will be automatically upgraded to this version.",
"fetching_defaults": "Fetching all available firmware for selected device types...",
"last_db_update_modal": "Firmware Database",
"last_db_update_title": "Database",
"one": "Firmware",
"select_default_device_types": "Please select all device types that you want to target with this new default firmware rule. If you cannot find your desired device type, it means they already have an applied rule.",
"select_default_revision": "You can now select the minimum revision you want your device types to target",
"start_db_update": "Start Database Update",
"started_db_update": "Started database update, this operation should take up to 25 minutes to complete"
"started_db_update": "Started database update, this operation should take up to 25 minutes to complete",
"update_success": "Saved default firmware information!"
},
"footer": {
"powered_by": "Powered By",
@@ -1007,6 +1031,9 @@
"current_live_devices": "Current Live Devices",
"currently_running_one": "There is currently {{count}} simulation running",
"currently_running_other": "There are currently {{count}} simulations running",
"delete_devices_confirm": "Are you sure you want to remove all devices and their statistics from the gateway? This action is not reversible",
"delete_devices_loading": "This process may take up to 5 minutes",
"delete_simulation_devices": "Delete Devices",
"delete_success": "Deleted Simulation!",
"duration": "Duration",
"error_devices": "Error Devices",

View File

@@ -223,6 +223,7 @@
"day": "Día",
"days": "días",
"default": "Defecto",
"defaults": "Valores predeterminados",
"description": "Descripción",
"details": "Detalles",
"device_details": "Detalles del dispositivo",
@@ -268,6 +269,7 @@
"map": "Mapa",
"max": "Max",
"min": "Min",
"miscellaneous": "Diverso",
"mode": "Modo",
"model": "Modelo",
"modified": "Modificado",
@@ -644,6 +646,7 @@
"notifications": "notificaciones de dispositivos",
"one": "Dispositivo",
"reassign_already_owned": "¿Reasignar dispositivos que ya existen y son propiedad de otra entidad/lugar/suscriptor?",
"reboot_logs": "Reiniciar registros",
"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",
@@ -701,11 +704,32 @@
"venues_under_root": "Los lugares no se pueden crear directamente bajo la entidad raíz"
},
"firmware": {
"confirm_default_data": "Confirme la información a continuación y haga clic en 'Confirmar' una vez que esté listo para comenzar el proceso",
"create_success": "¡Se crearon nuevas configuraciones de firmware predeterminadas!",
"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",
"default_created_error_one": "{{count}} error al intentar crear una nueva configuración",
"default_created_error_other": "{{count}} errores al intentar crear una nueva configuración",
"default_created_one": "{{count}} configuración de firmware predeterminada creada",
"default_created_other": "{{count}} ajustes de firmware predeterminados creados",
"default_found_one": "Se encontró una revisión válida para el tipo de dispositivo {{count}} ",
"default_found_other": "Se encontraron revisiones válidas para {{count}} tipos de dispositivos",
"default_mass_delete_success_one": "¡Se eliminó {{count}} configuración de firmware predeterminada!",
"default_mass_delete_success_other": "¡Se eliminaron {{count}} configuraciones de firmware predeterminadas!",
"default_not_found_one": "No hay versiones de firmware válidas para el tipo de dispositivo {{count}} ",
"default_not_found_other": "No hay versiones de firmware válidas para {{count}} tipos de dispositivos",
"default_title": "",
"default_update_success": "¡Firmware predeterminado actualizado para {{deviceType}}!",
"delete_success": "¡Configuración de firmware predeterminada eliminada!",
"edit_default_title": "Este es el firmware actual que se utiliza como versión mínima para los nuevos AP de tipo {{deviceType}}. Si un nuevo AP {{deviceType}} se conecta a la puerta de enlace, se actualizará automáticamente a esta versión.",
"fetching_defaults": "Obteniendo todo el firmware disponible para los tipos de dispositivos seleccionados...",
"last_db_update_modal": "Base de datos de firmware",
"last_db_update_title": "Base de datos",
"one": "Firmware",
"select_default_device_types": "Seleccione todos los tipos de dispositivos a los que desea apuntar con esta nueva regla de firmware predeterminada. Si no puede encontrar el tipo de dispositivo deseado, significa que ya tienen una regla aplicada.",
"select_default_revision": "Ahora puede seleccionar la revisión mínima a la que desea que se dirijan sus tipos de dispositivos",
"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"
"started_db_update": "Actualización de la base de datos iniciada, esta operación debería tardar hasta 25 minutos en completarse",
"update_success": "¡Información de firmware predeterminada guardada!"
},
"footer": {
"powered_by": "energizado por",
@@ -1007,6 +1031,9 @@
"current_live_devices": "Dispositivos activos actuales",
"currently_running_one": "Actualmente hay {{count}} simulación en ejecución",
"currently_running_other": "Actualmente hay {{count}} simulaciones ejecutándose",
"delete_devices_confirm": "¿Está seguro de que desea eliminar todos los dispositivos y sus estadísticas de la puerta de enlace? Esta acción no es reversible",
"delete_devices_loading": "Este proceso puede tardar hasta 5 minutos.",
"delete_simulation_devices": "BORRAR DISPOSITIVOS",
"delete_success": "¡Simulación eliminada!",
"duration": "Duración",
"error_devices": "Dispositivos de error",

View File

@@ -223,6 +223,7 @@
"day": "journée",
"days": "Journées",
"default": "Défaut",
"defaults": "Valeurs par défaut",
"description": "La description",
"details": "Détails",
"device_details": "Détails de l'appareil",
@@ -268,6 +269,7 @@
"map": "Carte",
"max": "Max",
"min": "Min",
"miscellaneous": "Divers",
"mode": "Mode",
"model": "Modèle",
"modified": "Modifié",
@@ -644,6 +646,7 @@
"notifications": "notifications de l'appareil",
"one": "Dispositif",
"reassign_already_owned": "Réattribuer des appareils qui existent déjà et qui appartiennent à une autre entité/salle/abonné ?",
"reboot_logs": "Journaux de redémarrage",
"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",
@@ -701,11 +704,32 @@
"venues_under_root": "Les lieux ne peuvent pas être créés directement sous l'entité racine"
},
"firmware": {
"confirm_default_data": "Veuillez confirmer les informations ci-dessous et cliquez sur \"Confirmer\" une fois que vous êtes prêt à démarrer le processus",
"create_success": "Création de nouveaux paramètres de firmware par défaut !",
"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",
"default_created_error_one": "{{count}} erreur lors de la tentative de création d'un nouveau paramètre",
"default_created_error_other": "{{count}} erreurs lors de la tentative de création d'un nouveau paramètre",
"default_created_one": "{{count}} paramètre de micrologiciel par défaut créé",
"default_created_other": "{{count}} paramètres de micrologiciel par défaut créés",
"default_found_one": "Révision valide trouvée pour le type d'appareil {{count}} ",
"default_found_other": "Révisions valides trouvées pour {{count}} types d'appareils",
"default_mass_delete_success_one": "Paramètre de micrologiciel par défaut {{count}} supprimé !",
"default_mass_delete_success_other": " {{count}} paramètres de micrologiciel par défaut supprimés !",
"default_not_found_one": "Aucune version de micrologiciel valide pour le type d'appareil {{count}} ",
"default_not_found_other": "Aucune version de micrologiciel valide pour {{count}} types d'appareils",
"default_title": "",
"default_update_success": "Firmware par défaut mis à jour pour {{deviceType}} !",
"delete_success": "Paramètre de micrologiciel par défaut supprimé !",
"edit_default_title": "Il s'agit du micrologiciel actuel utilisé comme version minimale pour les nouveaux points d'accès de type {{deviceType}}. Si un nouveau point d'accès {{deviceType}} se connecte à la passerelle, il sera automatiquement mis à niveau vers cette version.",
"fetching_defaults": "Récupération de tous les micrologiciels disponibles pour les types d'appareils sélectionnés...",
"last_db_update_modal": "Base de données du micrologiciel",
"last_db_update_title": "Base de données",
"one": "Micrologiciel",
"select_default_device_types": "Veuillez sélectionner tous les types d'appareils que vous souhaitez cibler avec cette nouvelle règle de micrologiciel par défaut. Si vous ne trouvez pas le type d'appareil souhaité, cela signifie qu'une règle est déjà appliquée.",
"select_default_revision": "Vous pouvez maintenant sélectionner la révision minimale que vous souhaitez que vos types d'appareils ciblent",
"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"
"started_db_update": "Mise à jour de la base de données démarrée, cette opération devrait prendre jusqu'à 25 minutes",
"update_success": "Informations sur le micrologiciel par défaut enregistrées !"
},
"footer": {
"powered_by": "Alimenté par",
@@ -1007,6 +1031,9 @@
"current_live_devices": "Appareils en direct actuels",
"currently_running_one": "Il y a actuellement {{count}} simulation en cours",
"currently_running_other": "Il y a actuellement {{count}} simulations en cours d'exécution",
"delete_devices_confirm": "Voulez-vous vraiment supprimer tous les appareils et leurs statistiques de la passerelle ? Cette action n'est pas réversible",
"delete_devices_loading": "Ce processus peut prendre jusqu'à 5 minutes",
"delete_simulation_devices": "Supprimer des appareils",
"delete_success": "Simulation supprimée !",
"duration": "Durée",
"error_devices": "Périphériques d'erreur",

View File

@@ -223,6 +223,7 @@
"day": "Dia",
"days": "Dias",
"default": "Padrão",
"defaults": "Predefinições",
"description": "Descrição",
"details": "Detalhes",
"device_details": "Detalhes do dispositivo",
@@ -268,6 +269,7 @@
"map": "Mapa",
"max": "máximo",
"min": "minuto",
"miscellaneous": "Diversos",
"mode": "Modo",
"model": "Modelo",
"modified": "Modificado",
@@ -644,6 +646,7 @@
"notifications": "Notificações do dispositivo",
"one": "Dispositivo",
"reassign_already_owned": "Reatribuir dispositivos que já existem e são de propriedade de outra entidade/local/assinante?",
"reboot_logs": "Registros de reinicialização",
"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",
@@ -701,11 +704,32 @@
"venues_under_root": "Os locais não podem ser criados diretamente na entidade raiz"
},
"firmware": {
"confirm_default_data": "Confirme as informações abaixo e clique em 'Confirmar' quando estiver pronto para iniciar o processo",
"create_success": "Criou novas configurações de firmware padrão!",
"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",
"default_created_error_one": "{{count}} erro ao tentar criar uma nova configuração",
"default_created_error_other": "{{count}} erros ao tentar criar uma nova configuração",
"default_created_one": "{{count}} configuração de firmware padrão criada",
"default_created_other": "{{count}} configurações de firmware padrão criadas",
"default_found_one": "Revisão válida encontrada para {{count}} tipo de dispositivo",
"default_found_other": "Foram encontradas revisões válidas para {{count}} tipos de dispositivo",
"default_mass_delete_success_one": "Configuração de firmware padrão {{count}} excluída!",
"default_mass_delete_success_other": "Excluídas {{count}} configurações de firmware padrão!",
"default_not_found_one": "Nenhuma versão de firmware válida para {{count}} tipo de dispositivo",
"default_not_found_other": "Nenhuma versão de firmware válida para {{count}} tipos de dispositivo",
"default_title": "",
"default_update_success": "Firmware padrão atualizado para {{deviceType}}!",
"delete_success": "Configuração de firmware padrão excluída!",
"edit_default_title": "Este é o firmware atual usado como versão mínima para novos APs do tipo {{deviceType}}. Se um novo AP {{deviceType}} se conectar ao gateway, ele será atualizado automaticamente para esta versão.",
"fetching_defaults": "Buscando todo o firmware disponível para os tipos de dispositivos selecionados...",
"last_db_update_modal": "banco de dados de firmware",
"last_db_update_title": "base de dados",
"one": "Firmware",
"select_default_device_types": "Selecione todos os tipos de dispositivos que deseja segmentar com esta nova regra de firmware padrão. Se você não conseguir encontrar o tipo de dispositivo desejado, significa que eles já têm uma regra aplicada.",
"select_default_revision": "Agora você pode selecionar a revisão mínima para a qual deseja que seus tipos de dispositivo sejam direcionados",
"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"
"started_db_update": "Atualização do banco de dados iniciada, esta operação deve levar até 25 minutos para ser concluída",
"update_success": "Informações de firmware padrão salvas!"
},
"footer": {
"powered_by": "Distribuído por",
@@ -1007,6 +1031,9 @@
"current_live_devices": "Dispositivos ativos atuais",
"currently_running_one": "Atualmente, há {{count}} simulação em execução",
"currently_running_other": "Existem atualmente {{count}} simulações em execução",
"delete_devices_confirm": "Tem certeza de que deseja remover todos os dispositivos e suas estatísticas do gateway? Esta ação não é reversível",
"delete_devices_loading": "Este processo pode levar até 5 minutos",
"delete_simulation_devices": "Apagar dispositivos",
"delete_success": "Simulação excluída!",
"duration": "Duração",
"error_devices": "Dispositivos de Erro",

View File

@@ -3,6 +3,7 @@ import { Spinner } from '@chakra-ui/react';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { HashRouter } from 'react-router-dom';
import { AuthProvider } from 'contexts/AuthProvider';
import { FavoritesProvider } from 'contexts/FavoritesProvider';
import { FirmwareSocketProvider } from 'contexts/FirmwareSocketProvider';
import { ProvisioningSocketProvider } from 'contexts/ProvisioningSocketProvider';
import { SecuritySocketProvider } from 'contexts/SecuritySocketProvider';
@@ -25,6 +26,7 @@ const App = () => {
<HashRouter>
<Suspense fallback={<Spinner />}>
<AuthProvider token={storageToken !== null ? storageToken : undefined}>
<FavoritesProvider>
<SecuritySocketProvider>
<FirmwareSocketProvider>
<ProvisioningSocketProvider>
@@ -32,6 +34,7 @@ const App = () => {
</ProvisioningSocketProvider>
</FirmwareSocketProvider>
</SecuritySocketProvider>
</FavoritesProvider>
</AuthProvider>
</Suspense>
</HashRouter>

View File

@@ -36,7 +36,6 @@ const Form = ({ name }) => {
<CreatableSelectField name={`${name}.phones`} label={t('contacts.phones')} placeholder="+1(202)555-0103" />
<CreatableSelectField name={`${name}.mobiles`} label={t('contacts.mobiles')} placeholder="+1(202)555-0103" />
</SimpleGrid>
<AddressSearchField placeholder={t('common.address_search_autofill')} namePrefix={name} maxWidth="600px" mb={2} />
<SimpleGrid minChildWidth="300px" spacing="20px" mb={8}>
<StringField name={`${name}.addressLineOne`} label={t('locations.address_line_one')} isRequired />

View File

@@ -1,8 +1,9 @@
import React, { useEffect } from 'react';
import { CloseButton, Modal, ModalBody, ModalContent, ModalOverlay, SimpleGrid, useDisclosure } from '@chakra-ui/react';
import { Modal, ModalBody, ModalContent, ModalOverlay, SimpleGrid, useDisclosure } from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import CloseButton from 'components/Buttons/CloseButton';
import SaveButton from 'components/Buttons/SaveButton';
import AddressSearchField from 'components/CustomFields/AddressSearchField';
import CreatableSelectField from 'components/FormFields/CreatableSelectField';
@@ -70,7 +71,7 @@ const LocationPickerCreatorModal = ({ setLocation, reset }) => {
city: '',
state: '',
postal: '',
country: '',
country: 'US',
buildingName: '',
mobiles: [],
phones: [],
@@ -136,7 +137,6 @@ const LocationPickerCreatorModal = ({ setLocation, reset }) => {
<CreatableSelectField name="phones" label={t('contacts.phones')} placeholder="+1(202)555-0103" />
<CreatableSelectField name="mobiles" label={t('contacts.mobiles')} placeholder="+1(202)555-0103" />
</SimpleGrid>
<AddressSearchField placeholder={t('common.address_search_autofill')} maxWidth="600px" mb={2} />
<SimpleGrid minChildWidth="300px" spacing="20px" mb={8}>
<StringField name="addressLineOne" label={t('locations.address_line_one')} isRequired />

View File

@@ -16,15 +16,25 @@ const propTypes = {
isModal: PropTypes.bool,
venueId: PropTypes.string,
entityId: PropTypes.string,
hideLabel: PropTypes.bool,
};
const defaultProps = {
isModal: false,
entityId: null,
venueId: null,
hideLabel: false,
};
const LocationPickerCreator = ({ locationName, createLocationName, editing, isModal, entityId, venueId }) => {
const LocationPickerCreator = ({
locationName,
createLocationName,
editing,
isModal,
entityId,
venueId,
hideLabel,
}) => {
const { t } = useTranslation();
const toast = useToast();
const [{ value: location }, , { setValue: setLocation }] = useField(locationName);
@@ -43,7 +53,7 @@ const LocationPickerCreator = ({ locationName, createLocationName, editing, isMo
};
const getCreateLabel = () => {
if (!newLocation) return t('common.create_new');
if (!newLocation) return 'New Location';
return newLocation?.name;
};
@@ -95,9 +105,11 @@ const LocationPickerCreator = ({ locationName, createLocationName, editing, isMo
options={[
{ value: '', label: t('common.none') },
{ value: 'CREATE_NEW', label: getCreateLabel() },
{ value: 'none', label: '──────────────────', isDisabled: true },
...getOptions(),
]}
w="unset"
isLabelHidden={hideLabel}
/>
{location === 'CREATE_NEW' && newLocation && !isModal && <Form name={createLocationName} />}
{location === 'CREATE_NEW' && isModal && (

View File

@@ -0,0 +1,40 @@
import * as React from 'react';
import { Button, useDisclosure } from '@chakra-ui/react';
import { Plus } from '@phosphor-icons/react';
import CreateVenueModal from 'components/Tables/VenueTable/CreateVenueModal';
type Props = {
id: string;
type: 'venue' | 'entity';
};
const CreateVenueButton = ({ id, type }: Props) => {
const modalProps = useDisclosure();
return (
<>
<Button
colorScheme="purple"
onClick={modalProps.onOpen}
leftIcon={
<Plus
size={18}
weight="bold"
style={{
marginTop: '-2px',
}}
/>
}
>
Venue
</Button>
<CreateVenueModal
{...modalProps}
parentId={type === 'venue' ? id : undefined}
entityId={type === 'entity' ? id : undefined}
/>
</>
);
};
export default CreateVenueButton;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Box, ListItem, UnorderedList } from '@chakra-ui/react';
import { Box, Button, Heading, ListItem, UnorderedList } from '@chakra-ui/react';
import { useField } from 'formik';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
@@ -50,6 +50,14 @@ const IpDetectionModalField = ({ name, isDisabled, isRequired }) => {
const { t } = useTranslation();
const [{ value }, { error }, { setValue }] = useField(name);
const openButton = React.useCallback(
(onOpen) => (
<Button colorScheme="teal" onClick={onOpen}>
{isDisabled ? `View IPs (${value?.length ?? 0})` : `Edit IPs (${value?.length ?? 0})`}
</Button>
),
[isDisabled, value?.length],
);
return (
<ListInputModalField
initialValue={value}
@@ -59,6 +67,11 @@ const IpDetectionModalField = ({ name, isDisabled, isRequired }) => {
buttonLabel={value.length === 0 ? t('entities.add_ips') : value.join(',')}
title={t('entities.ip_detection')}
explanation={
isDisabled ? (
<Box>
<Heading size="sm">Current IPs ({value?.length ?? 0})</Heading>
</Box>
) : (
<Box>
<b>{t('entities.add_ips_explanation')}</b>
<UnorderedList>
@@ -68,7 +81,9 @@ const IpDetectionModalField = ({ name, isDisabled, isRequired }) => {
<ListItem>{t('entities.ip_cidr')}</ListItem>
</UnorderedList>
</Box>
)
}
button={openButton}
error={error}
placeholder={t('entities.enter_ips')}
isDisabled={isDisabled}

View File

@@ -36,18 +36,11 @@ const RrmFormField = ({ namePrefix = 'deviceRules', isDisabled }: Props) => {
}, [value]);
return (
<FormControl isInvalid={isError} isRequired isDisabled={isDisabled}>
<FormControl isInvalid={isError} isDisabled={isDisabled}>
<FormLabel ms="4px" fontSize="md" fontWeight="normal" _disabled={{ opacity: 0.8 }}>
RRM
</FormLabel>
<Button
variant="link"
onClick={modalProps.onOpen}
colorScheme="blue"
mt={2}
ml={1}
isLoading={rrm.getProviders.isFetching}
>
<Button onClick={modalProps.onOpen} colorScheme="teal" isLoading={rrm.getProviders.isFetching}>
{displayedValue}
</Button>
<FormErrorMessage>{error}</FormErrorMessage>

View File

@@ -50,7 +50,18 @@ const DataGridControls = <T extends object>({ table, isDisabled }: Props<T>) =>
</Flex>
<Flex alignItems="center">
{isCompact ? null : (
{isCompact ? (
<Text flexShrink={0} mr={8}>
{t('table.page')}{' '}
<Text fontWeight="bold" as="span">
{table.getState().pagination.pageIndex + 1}
</Text>{' '}
{t('common.of')}{' '}
<Text fontWeight="bold" as="span">
{table.getPageCount()}
</Text>
</Text>
) : (
<>
<Text flexShrink={0} mr={8}>
{t('table.page')}{' '}

View File

@@ -149,7 +149,7 @@ export const DataGrid = <TValue extends object>({
...tableOptions,
});
if (isLoading && data.length === 0) {
if (isLoading && !options.showAsCard && data.length === 0) {
return (
<Center>
<Spinner size="xl" />

View File

@@ -0,0 +1,118 @@
import * as React from 'react';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Menu,
MenuButton,
MenuItem,
MenuList,
Text,
useColorModeValue,
useDisclosure,
} from '@chakra-ui/react';
import { TreeEntity, TreeVenue, useGetEntityTree } from 'hooks/Network/Entity';
const traverseTreeToFindId = (tree: TreeEntity, desiredId: string) => {
const traverse: (node: TreeEntity | TreeVenue) => null | (TreeEntity | TreeVenue)[] = (node) => {
if (node.uuid === desiredId) {
return [node];
}
for (const child of node.children) {
const result = traverse(child);
if (result) {
return [node, ...result];
}
}
for (const child of node.venues ?? []) {
const result = traverse(child);
if (result) {
return [node, ...result];
}
}
return null;
};
return traverse(tree);
};
type Props = {
id: string;
};
const EntityBreadcrumb = ({ id }: Props) => {
const menuProps = useDisclosure();
const getEntityTree = useGetEntityTree();
const pathToEntity = React.useMemo(() => {
if (getEntityTree.data) {
const path = traverseTreeToFindId(getEntityTree.data, id);
if (path) {
return path.filter(({ uuid }) => uuid !== '0000-0000-0000');
}
}
return [];
}, [getEntityTree.data, id]);
const lastEntry = pathToEntity[pathToEntity.length - 1];
const lastEntryChildren = [...(lastEntry?.children ?? []), ...(lastEntry?.venues ?? [])].sort((a, b) =>
a.name.localeCompare(b.name),
);
let timeout: NodeJS.Timeout;
const onMouseEnter = () => {
if (timeout) {
clearTimeout(timeout);
}
menuProps.onOpen();
};
const onMouseLeave = () => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => menuProps.onClose(), 100);
};
return (
<Breadcrumb separator="/" spacing={1}>
{pathToEntity.map((entity) => (
<BreadcrumbItem key={entity.uuid} isCurrentPage={entity.uuid === id}>
<BreadcrumbLink href={`#/${entity.type}/${entity.uuid}`} fontWeight={entity.uuid === id ? 'bold' : undefined}>
{entity.name}
</BreadcrumbLink>
</BreadcrumbItem>
))}
<BreadcrumbItem>
<Menu {...menuProps} gutter={0}>
<MenuButton
py={2}
transition="all 0.3s"
_focus={{ boxShadow: 'none' }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<Text>...</Text>
</MenuButton>
<MenuList
bg={useColorModeValue('white', 'gray.900')}
borderColor={useColorModeValue('gray.200', 'gray.700')}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{lastEntryChildren.map((child) => (
<MenuItem as="a" w="100%" key={child.uuid} href={`#/${child.type}/${child.uuid}`}>
{child.name}
</MenuItem>
))}
{lastEntryChildren.length === 0 && <MenuItem isDisabled>No children</MenuItem>}
</MenuList>
</Menu>
</BreadcrumbItem>
</Breadcrumb>
);
};
export default EntityBreadcrumb;

View File

@@ -0,0 +1,32 @@
import * as React from 'react';
import { StarIcon } from '@chakra-ui/icons';
import { Box, Tooltip } from '@chakra-ui/react';
import { useEntityFavorite } from 'hooks/useEntityFavorite';
type Props = {
id: string;
type: 'venue' | 'entity';
};
const EntityFavoritesButton = ({ id, type }: Props) => {
const { isFavorite, onFavoriteClick, isLoading } = useEntityFavorite({
id,
type,
});
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
onFavoriteClick();
};
return (
<Tooltip label={!isFavorite ? 'Add to favorites' : 'Remove from favorites'}>
<Box onClick={isLoading ? undefined : onClick} ml={1} cursor="pointer">
<StarIcon boxSize={6} color={isFavorite ? 'yellow.300' : 'gray.200'} />
</Box>
</Tooltip>
);
};
export default EntityFavoritesButton;

View File

@@ -0,0 +1,128 @@
import * as React from 'react';
import { Box, Center, Heading, IconButton, Progress, Tooltip, useDisclosure } from '@chakra-ui/react';
import { Download, Export } from '@phosphor-icons/react';
import { CSVLink } from 'react-csv';
import { useTranslation } from 'react-i18next';
import { ExportedDeviceInfo, getAllExportedDevicesInfo, getSelectExportedDevicesInfo } from './utils';
import ResponsiveButton from 'components/Buttons/ResponsiveButton';
import { Modal } from 'components/Modals/Modal';
import { dateForFilename } from 'utils/dateFormatting';
const HEADER_MAPPING: { key: keyof ExportedDeviceInfo; label: string }[] = [
{ key: 'serialNumber', label: 'Serial Number' },
{ key: 'deviceType', label: 'Device Type' },
{ key: 'name', label: 'Name' },
{ key: 'entity', label: 'Entity' },
{ key: 'venue', label: 'Venue' },
{ key: 'created', label: 'Created' },
{ key: 'modified', label: 'Modified' },
{ key: 'description', label: 'Description' },
{ key: 'devClass', label: 'Device Class' },
{ key: 'firmwareUpgrade', label: 'Firmware Upgrade' },
{ key: 'rcOnly', label: 'Release Candidates Only' },
{ key: 'rrm', label: 'RRM' },
{ key: 'id', label: 'ID' },
{ key: 'locale', label: 'Locale' },
];
type Status = {
progress: number;
status: 'loading-all' | 'loading-select' | 'success' | 'error' | 'idle';
error?: string;
lastResults?: ExportedDeviceInfo[];
};
type Props = {
serialNumbers?: string[];
};
const ExportDevicesTableButton = ({ serialNumbers }: Props) => {
const { t } = useTranslation();
const modalProps = useDisclosure();
const [status, setStatus] = React.useState<Status>({
progress: 0,
status: 'idle',
});
const setProgress = (progress: number) => {
setStatus((prev) => ({ ...prev, progress }));
};
const onOpen = () => {
if (!serialNumbers) {
setStatus((prev) => ({ ...prev, error: undefined, lastResults: undefined, status: 'loading-all', progress: 0 }));
getAllExportedDevicesInfo(setProgress)
.then((result) => {
setStatus((prev) => ({ ...prev, status: 'success', lastResults: result }));
})
.catch((error) => {
setStatus((prev) => ({ ...prev, status: 'error', error }));
});
} else {
setStatus((prev) => ({
...prev,
error: undefined,
lastResults: undefined,
status: 'loading-select',
progress: 0,
}));
getSelectExportedDevicesInfo(serialNumbers, setProgress)
.then((result) => {
setStatus((prev) => ({ ...prev, status: 'success', lastResults: result }));
})
.catch((error) => {
setStatus((prev) => ({ ...prev, status: 'error', error }));
});
}
modalProps.onOpen();
};
return (
<>
<Tooltip label={t('common.export')}>
<IconButton aria-label={t('common.export')} icon={<Export size={20} />} colorScheme="purple" onClick={onOpen} />
</Tooltip>
<Modal {...modalProps} title={t('common.export')}>
<Box>
{status.status.includes('loading') || status.status === 'success' ? (
<Box>
<Center>
<Heading size="sm">{Math.round(status.progress)}%</Heading>
</Center>
<Box px={8}>
<Progress
isIndeterminate={status.progress === 0}
value={status.progress}
colorScheme={status.progress !== 100 ? 'blue' : 'green'}
hasStripe={status.progress !== 100}
isAnimated={status.progress !== 100}
/>
</Box>
<Center my={8} hidden={!status.lastResults}>
<CSVLink
filename={`devices_export_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={status.lastResults ?? []}
headers={HEADER_MAPPING}
>
<ResponsiveButton
color="blue"
icon={<Download size={20} />}
isCompact={false}
label={t('common.download')}
onClick={() => {}}
/>
</CSVLink>
</Center>
</Box>
) : null}
{status.status.includes('error') ? (
<Center my={12}>
<Heading size="sm">{JSON.stringify(status.error, null, 2)}</Heading>
</Center>
) : null}
</Box>
</Modal>
</>
);
};
export default ExportDevicesTableButton;

View File

@@ -0,0 +1,142 @@
import { InventoryTagApiResponse } from 'models/Inventory';
import { axiosProv } from 'utils/axiosInstances';
export type ExportedDeviceInfo = {
serialNumber: string;
name: string;
created: string;
modified: string;
description: string;
devClass: string;
deviceType: string;
firmwareUpgrade: string;
rcOnly: string;
rrm: string;
entity: string;
venue: string;
id: string;
locale: string;
};
const getProvisioningInfo = (limit: number, offset: number) =>
axiosProv
.get(`inventory?withExtendedInfo=true&limit=${limit}&offset=${offset}`)
.then((response) => response.data) as Promise<{ taglist: InventoryTagApiResponse[] }>;
const getAllProvisioningInfo = async (
count: number,
initialProgress: number,
setProgress: (progress: number) => void,
) => {
const progressStep = (90 - initialProgress) / Math.ceil(count / 100);
let newProgress = initialProgress;
let offset = 0;
let devices: InventoryTagApiResponse[] = [];
let devicesResponse: { taglist: InventoryTagApiResponse[] };
do {
// eslint-disable-next-line no-await-in-loop
devicesResponse = await getProvisioningInfo(100, offset);
devices = devices.concat(devicesResponse.taglist);
setProgress((newProgress += progressStep));
offset += 100;
} while (devicesResponse.taglist.length === 100);
return devices;
};
export const getAllExportedDevicesInfo = async (setProgress: (progress: number) => void) => {
// Base Setup
setProgress(0);
const devicesCount = await axiosProv
.get('inventory?countOnly=true')
.then((response) => response.data.count as number);
setProgress(10);
if (devicesCount === 0) {
setProgress(100);
return [];
}
// Get Devices Info
const devices = await getAllProvisioningInfo(devicesCount, 10, setProgress);
setProgress(95);
const unixToStr = (unixValue: number) => {
try {
return new Date(unixValue * 1000).toISOString();
} catch (e) {
return '';
}
};
const exportedDevicesInfo: ExportedDeviceInfo[] = devices.map((device) => ({
serialNumber: device.serialNumber,
name: device.name,
created: unixToStr(device.created),
modified: unixToStr(device.modified),
description: device.description,
devClass: device.devClass,
deviceType: device.deviceType,
firmwareUpgrade: device.deviceRules.firmwareUpgrade,
rcOnly: device.deviceRules.rcOnly,
rrm: device.deviceRules.rrm,
entity: device.extendedInfo?.entity?.name ?? '',
venue: device.extendedInfo?.venue?.name ?? '',
id: device.id,
locale: device.locale,
}));
setProgress(100);
return exportedDevicesInfo;
};
const getProvisioningInfoSelect = (serialNumbers: string[]) =>
axiosProv
.get(`inventory?withExtendedInfo=true&select=${serialNumbers.join(',')}`)
.then((response) => response.data) as Promise<{ taglist: InventoryTagApiResponse[] }>;
export const getSelectExportedDevicesInfo = async (
serialNumbers: string[],
setProgress: (progress: number) => void,
) => {
// Base Setup
setProgress(0);
const devicesCount = serialNumbers.length;
setProgress(10);
if (devicesCount === 0) {
setProgress(100);
return [];
}
// Get Devices Info
const devices = (await getProvisioningInfoSelect(serialNumbers)).taglist;
setProgress(95);
const unixToStr = (unixValue: number) => {
try {
return new Date(unixValue * 1000).toISOString();
} catch (e) {
return '';
}
};
const exportedDevicesInfo: ExportedDeviceInfo[] = devices.map((device) => ({
serialNumber: device.serialNumber,
name: device.name,
created: unixToStr(device.created),
modified: unixToStr(device.modified),
description: device.description,
devClass: device.devClass,
deviceType: device.deviceType,
firmwareUpgrade: device.deviceRules.firmwareUpgrade,
rcOnly: device.deviceRules.rcOnly,
rrm: device.deviceRules.rrm,
entity: device.extendedInfo?.entity?.name ?? '',
venue: device.extendedInfo?.venue?.name ?? '',
id: device.id,
locale: device.locale,
}));
setProgress(100);
return exportedDevicesInfo;
};

View File

@@ -14,7 +14,6 @@ import {
InputGroup,
Input,
InputRightElement,
Text,
IconButton,
Tooltip,
} from '@chakra-ui/react';
@@ -38,6 +37,7 @@ const propTypes = {
validation: PropTypes.func,
isDisabled: PropTypes.bool,
isRequired: PropTypes.bool,
button: PropTypes.func,
};
const defaultProps = {
@@ -60,6 +60,7 @@ const ListInputModalField = ({
error,
isDisabled,
isRequired,
button,
}) => {
const { t } = useTranslation();
const [entry, setEntry] = useState('');
@@ -85,9 +86,12 @@ const ListInputModalField = ({
return (
<>
<FormControl isInvalid={error} isRequired={isRequired} isDisabled={isDisabled}>
<FormLabel ms="4px" fontSize="md" fontWeight="normal">
<FormLabel ms="4px" fontSize="md" fontWeight="normal" _disabled={{ opacity: 0.8 }}>
{label}
</FormLabel>
{button ? (
button(onOpen)
) : (
<Button
mt={3}
alignItems="center"
@@ -99,6 +103,7 @@ const ListInputModalField = ({
>
{buttonLabel}
</Button>
)}
<FormErrorMessage>{error}</FormErrorMessage>
</FormControl>
<Modal onClose={onClose} isOpen={isOpen} size="md" initialFocusRef={initialRef}>
@@ -108,14 +113,14 @@ const ListInputModalField = ({
title={title}
right={
<>
<SaveButton onClick={save} />
<SaveButton onClick={save} hidden={isDisabled} />
<CloseButton ml={2} onClick={onClose} ref={initialRef} />
</>
}
/>
<ModalBody>
{explanation}
<InputGroup size="md" my={6}>
<InputGroup size="md" mt={6} hidden={isDisabled}>
<Input
borderRadius="15px"
fontSize="sm"
@@ -135,11 +140,6 @@ const ListInputModalField = ({
</Button>
</InputRightElement>
</InputGroup>
{localValue.length === 0 ? (
<Text mb={6}>
<b>{t('common.no_items_yet')}</b>
</Text>
) : (
<UnorderedList>
{localValue.map((val) => (
<ListItem key={uuid()}>
@@ -152,12 +152,12 @@ const ListInputModalField = ({
type="button"
onClick={() => deleteEntry(val)}
icon={<Trash size={16} />}
hidden={isDisabled}
/>
</Tooltip>
</ListItem>
))}
</UnorderedList>
)}
</ModalBody>
</ModalContent>
</Modal>

View File

@@ -37,7 +37,7 @@ interface Props extends FieldInputProps<object[]> {
isHidden: boolean;
hideLabel: boolean;
fields: React.ReactNode;
columns: Column<unknown>[];
columns: Column<object[]>[];
options: ObjectArrayFieldModalOptions;
schema: (t: (e: string) => string, useDefault?: boolean) => object;
}
@@ -90,6 +90,13 @@ const ObjectArrayFieldInput: React.FC<Props> = ({
[tempValue],
);
const computedButtonLabel = () => {
if (options?.buttonLabel) return options.buttonLabel;
return `${t('common.manage')} ${variableName} (${value?.length ?? 0}
${t('common.entries', { count: value?.length ?? 0 }).toLowerCase()})`;
};
useEffect(() => {
if (!isOpen) {
setTempValue(value ?? []);
@@ -104,8 +111,7 @@ const ObjectArrayFieldInput: React.FC<Props> = ({
</FormLabel>
<Text ml={1} fontSize="sm">
<Button colorScheme="blue" onClick={onOpen}>
{options?.buttonLabel ?? `${t('common.manage')} ${variableName} `} ({value?.length ?? 0}{' '}
{t('common.entries', { count: value?.length ?? 0 }).toLowerCase()})
{computedButtonLabel()}
</Button>
</Text>
<FormErrorMessage>{error}</FormErrorMessage>

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { InfoIcon } from '@chakra-ui/icons';
import {
Button,
FormControl,
@@ -7,22 +8,24 @@ import {
Input,
InputGroup,
InputRightElement,
LayoutProps,
Textarea,
Tooltip,
useBoolean,
} from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import ConfigurationFieldExplanation from '../ConfigurationFieldExplanation';
import { FieldInputProps } from 'models/Form';
interface Props extends FieldInputProps<string | undefined | string[]> {
interface StringInputProps extends FieldInputProps<string | undefined | string[]>, LayoutProps {
isError: boolean;
hideButton: boolean;
isArea: boolean;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => void;
explanation?: string;
}
const StringInput = (
{
const StringInput: React.FC<StringInputProps> = ({
label,
value,
onChange,
@@ -35,9 +38,10 @@ const StringInput = (
isArea,
isDisabled,
definitionKey,
explanation,
h,
...props
}: Props
) => {
}) => {
const { t } = useTranslation();
const [show, setShow] = useBoolean();
@@ -46,6 +50,11 @@ const StringInput = (
<FormControl isInvalid={isError} isRequired={isRequired} isDisabled={isDisabled}>
<FormLabel ms="4px" fontSize="md" fontWeight="normal" _disabled={{ opacity: 0.8 }}>
{label}
{explanation ? (
<Tooltip hasArrow label={explanation}>
<InfoIcon ml={2} mb="2px" />
</Tooltip>
) : null}
</FormLabel>
{element ?? (
<InputGroup size="md">
@@ -55,7 +64,7 @@ const StringInput = (
onBlur={onBlur}
borderRadius="15px"
fontSize="sm"
h="360px"
h={h ?? '360px'}
_disabled={{ opacity: 0.8, cursor: 'not-allowed' }}
/>
</InputGroup>
@@ -69,6 +78,11 @@ const StringInput = (
<FormControl isInvalid={isError} isRequired={isRequired} isDisabled={isDisabled} {...props}>
<FormLabel ms="4px" fontSize="md" fontWeight="normal" _disabled={{ opacity: 0.8 }}>
{label}
{explanation ? (
<Tooltip hasArrow label={explanation}>
<InfoIcon ml={2} mb="2px" />
</Tooltip>
) : null}
<ConfigurationFieldExplanation definitionKey={definitionKey} />
</FormLabel>
{element ?? (

View File

@@ -4,22 +4,22 @@ import StringInput from './StringInput';
import useFastField from 'hooks/useFastField';
import { FieldProps } from 'models/Form';
interface Props extends FieldProps, LayoutProps {
formatValue?: (value: string) => string;
interface StringFieldProps extends FieldProps, LayoutProps {
hideButton?: boolean;
explanation?: string;
}
const StringField: React.FC<Props> = ({
const StringField: React.FC<StringFieldProps> = ({
name,
isDisabled = false,
label,
hideButton = false,
isRequired = false,
element,
formatValue,
isArea = false,
emptyIsUndefined = false,
definitionKey,
explanation,
...props
}) => {
const { value, error, isError, onChange, onBlur } = useFastField<string | undefined>({ name });
@@ -27,7 +27,7 @@ const StringField: React.FC<Props> = ({
const onFieldChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
if (emptyIsUndefined && e.target.value.length === 0) onChange(undefined);
else onChange(formatValue ? formatValue(e.target.value) : e.target.value);
else onChange(e.target.value);
},
[onChange],
);
@@ -46,6 +46,7 @@ const StringField: React.FC<Props> = ({
isArea={isArea}
isDisabled={isDisabled}
definitionKey={definitionKey}
explanation={explanation}
{...props}
/>
);

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

View File

@@ -0,0 +1,90 @@
import * as React from 'react';
import { isLatLngLiteral } from '@googlemaps/typescript-guards';
import { createCustomEqual } from 'fast-equals';
// @ts-ignore
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);

View File

@@ -1,14 +1,17 @@
import React, { useMemo } from 'react';
import { Box, BoxProps } from '@chakra-ui/react';
import { bytesString } from 'utils/stringHelper';
const DataCell = ({ bytes }: { bytes?: number }) => {
type Props = { bytes?: number; showZerosAs?: string; boxProps?: BoxProps };
const DataCell = ({ bytes, showZerosAs, boxProps }: Props) => {
const data = useMemo(() => {
if (bytes === undefined) return '-';
if (showZerosAs && bytes === 0) return showZerosAs;
return bytesString(bytes);
}, [bytes]);
return <div>{data}</div>;
return <Box {...boxProps}>{data}</Box>;
};
export default React.memo(DataCell);

View File

@@ -225,6 +225,19 @@ const EditTagForm = ({
isClosable: true,
position: 'top-right',
});
if (tag.entity.length > 0) {
queryClient.invalidateQueries(['get-entity', tag.entity]);
}
if (tag.venue.length > 0) {
queryClient.invalidateQueries(['get-venue', tag.venue]);
}
if (params.entity.length > 0) {
queryClient.invalidateQueries(['get-entity', params.entity]);
}
if (params.venue.length > 0) {
queryClient.invalidateQueries(['get-venue', params.venue]);
}
queryClient.invalidateQueries(['get-inventory-tag', tag.serialNumber]);
queryClient.invalidateQueries(['get-configuration']);
queryClient.invalidateQueries(['configurationOverrides', tag.serialNumber]);
@@ -291,18 +304,22 @@ const EditTagForm = ({
{
label: t('entities.title'),
options:
entities?.map((ent) => ({
entities
?.map((ent) => ({
value: `ent:${ent.id}`,
label: `${ent.name}${ent.description ? `: ${ent.description}` : ''}`,
})) ?? [],
}))
.sort((a, b) => a.label.localeCompare(b.label)) ?? [],
},
{
label: t('venues.title'),
options:
venues?.map((ven) => ({
venues
?.map((ven) => ({
value: `ven:${ven.id}`,
label: `${ven.name}${ven.description ? `: ${ven.description}` : ''}`,
})) ?? [],
}))
.sort((a, b) => a.label.localeCompare(b.label)) ?? [],
},
]}
/>

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { useAuth } from 'contexts/AuthProvider';
const SETTING_NAME = 'global.favorites';
export type EntityFavorite = {
id: string;
type: 'venue' | 'entity';
};
export type SettingValue = {
entityFavorites: EntityFavorite[];
};
export interface FavoritesProviderReturn {
entityFavorites: {
favorites: EntityFavorite[];
add: (entityFavorite: EntityFavorite) => Promise<void>;
remove: (entityFavorite: EntityFavorite) => Promise<void>;
};
}
const FavoritesContext = React.createContext<FavoritesProviderReturn>({
entityFavorites: {
favorites: [],
add: async () => {},
remove: async () => {},
},
});
export const FavoritesProvider = ({ children }: { children: React.ReactElement }) => {
const authContext = useAuth();
const [favorites, setFavorites] = React.useState<SettingValue>({
entityFavorites: [],
});
const fetchSetting = () => {
const newFavorites = authContext.getPref(SETTING_NAME);
if (newFavorites) {
try {
setFavorites(JSON.parse(newFavorites));
} catch (e) {
authContext.deletePref(SETTING_NAME);
setFavorites({ entityFavorites: [] });
}
}
};
const addEntityFavorite = async (entityFavorite: EntityFavorite) => {
const newEntityFavorites = [...favorites.entityFavorites, entityFavorite];
setFavorites({ entityFavorites: newEntityFavorites });
await authContext.setPref({
preference: SETTING_NAME,
value: JSON.stringify({ entityFavorites: newEntityFavorites }),
});
};
const removeEntityFavorite = async (entityFavorite: EntityFavorite) => {
const newEntityFavorites = favorites.entityFavorites.filter(
(favorite) => favorite.id !== entityFavorite.id || favorite.type !== entityFavorite.type,
);
setFavorites({ entityFavorites: newEntityFavorites });
await authContext.setPref({
preference: SETTING_NAME,
value: JSON.stringify({ entityFavorites: newEntityFavorites }),
});
};
const value = React.useMemo(
() => ({
entityFavorites: {
favorites: favorites.entityFavorites,
add: addEntityFavorite,
remove: removeEntityFavorite,
},
}),
[favorites.entityFavorites],
);
React.useEffect(() => {
if (authContext.isUserLoaded) {
fetchSetting();
}
}, [authContext.isUserLoaded]);
return <FavoritesContext.Provider value={value}>{children}</FavoritesContext.Provider>;
};
export const useFavorites: () => FavoritesProviderReturn = () => React.useContext(FavoritesContext);

View File

@@ -2,7 +2,7 @@ import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { AxiosError } from 'models/Axios';
import { ContactObj } from 'models/Contact';
import { ContactObj, CreateContactObj } from 'models/Contact';
import { PageInfo } from 'models/Table';
import { axiosProv } from 'utils/axiosInstances';
@@ -10,7 +10,10 @@ export const useGetContactCount = ({ enabled }: { enabled: boolean }) => {
const { t } = useTranslation();
const toast = useToast();
return useQuery(['get-contact-count'], () => axiosProv.get('contact?countOnly=true').then(({ data }) => data.count), {
return useQuery(
['get-contact-count'],
() => axiosProv.get('contact?countOnly=true').then(({ data }) => data.count as number),
{
enabled,
staleTime: 30000,
onError: (e: AxiosError) => {
@@ -28,7 +31,8 @@ export const useGetContactCount = ({ enabled }: { enabled: boolean }) => {
position: 'top-right',
});
},
});
},
);
};
export const useGetContacts = ({
@@ -48,7 +52,7 @@ export const useGetContacts = ({
() =>
axiosProv
.get(`contact?withExtendedInfo=true&limit=${pageInfo.limit}&offset=${pageInfo.limit * pageInfo.index}`)
.then(({ data }) => data.contacts),
.then(({ data }) => data.contacts as ContactObj[]),
{
keepPreviousData: true,
enabled,
@@ -80,8 +84,10 @@ export const useGetSelectContacts = ({ select }: { select: string[] }) => {
['get-contacts-select', select],
() =>
select.length === 0
? []
: axiosProv.get(`contact?withExtendedInfo=true&select=${select}`).then(({ data }) => data.contacts),
? ([] as ContactObj[])
: axiosProv
.get(`contact?withExtendedInfo=true&select=${select}`)
.then(({ data }) => data.contacts as ContactObj[]),
{
staleTime: 100 * 1000,
onError: (e: AxiosError) => {
@@ -150,7 +156,7 @@ export const useGetContact = ({ enabled, id }: { enabled: boolean; id: string })
const { t } = useTranslation();
const toast = useToast();
return useQuery(['get-contact', id], () => axiosProv.get(`contact/${id}`).then(({ data }) => data), {
return useQuery(['get-contact', id], () => axiosProv.get(`contact/${id}`).then(({ data }) => data as ContactObj), {
enabled,
onError: (e: AxiosError) => {
if (!toast.isActive('contact-fetching-error'))
@@ -170,10 +176,17 @@ export const useGetContact = ({ enabled, id }: { enabled: boolean; id: string })
});
};
export const useCreateContact = () => useMutation((newContact) => axiosProv.post('contact/0', newContact));
export const useCreateContact = () =>
useMutation((newContact: CreateContactObj) =>
axiosProv.post('contact/0', newContact).then(({ data }) => data as ContactObj),
);
export const useUpdateContact = ({ id }: { id: string }) =>
useMutation((newContact) => axiosProv.put(`contact/${id}`, newContact));
useMutation((newContact: CreateContactObj) =>
axiosProv.put(`contact/${id}`, newContact).then(({ data }) => data as ContactObj),
);
export const useDeleteContact = ({ id }: { id: string }) => useMutation(() => axiosProv.delete(`contact/${id}`));
const claimContacts = async (contactIds: string[], entity: string, venue: string) => {
const addPromises = contactIds.map(async (id) =>

View File

@@ -6,11 +6,30 @@ import useDefaultPage from '../useDefaultPage';
import { AxiosError } from 'models/Axios';
import { axiosProv, axiosSec } from 'utils/axiosInstances';
export type TreeVenue = {
name: string;
uuid: string;
type: 'venue';
children: TreeVenue[];
venues?: undefined;
};
export type TreeEntity = {
name: string;
uuid: string;
type: 'entity';
children: TreeEntity[];
venues: TreeVenue[];
};
export const useGetEntityTree = () => {
const { t } = useTranslation();
const toast = useToast();
return useQuery(['get-entity-tree'], () => axiosProv.get('entity?getTree=true').then(({ data }) => data), {
return useQuery(
['get-entity-tree'],
() => axiosProv.get('entity?getTree=true').then(({ data }) => data as TreeEntity),
{
enabled: axiosProv.defaults.baseURL !== axiosSec.defaults.baseURL,
staleTime: Infinity,
keepPreviousData: true,
@@ -29,7 +48,8 @@ export const useGetEntityTree = () => {
position: 'top-right',
});
},
});
},
);
};
const getEntitiesBatch = async (limit: number, offset: number) =>
@@ -116,7 +136,7 @@ export const useGetEntity = ({ id }: { id?: string }) => {
() => axiosProv.get(`entity/${id}?withExtendedInfo=true`).then(({ data }) => data as Entity),
{
keepPreviousData: true,
staleTime: 1000 * 60 * 5,
staleTime: 1000 * 5,
enabled: id !== undefined && id !== null && id !== '',
onError: (e: AxiosError) => {
if (!toast.isActive('entity-fetching-error'))

View File

@@ -45,7 +45,7 @@ export const useGetInventoryCount = ({
id: 'inventory-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('inventory.tags'),
obj: t('inventory.one'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',

View File

@@ -1,7 +1,8 @@
import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { AxiosError } from 'models/Axios';
import { CreateLocation, Location } from 'models/Location';
import { PageInfo } from 'models/Table';
import { axiosProv } from 'utils/axiosInstances';
@@ -11,7 +12,7 @@ export const useGetLocationCount = ({ enabled }: { enabled: boolean }) => {
return useQuery(
['get-location-count'],
() => axiosProv.get('location?countOnly=true').then(({ data }) => data.count),
() => axiosProv.get('location?countOnly=true').then(({ data }) => data.count as number),
{
enabled,
staleTime: 30000,
@@ -51,7 +52,7 @@ export const useGetLocations = ({
() =>
axiosProv
.get(`location?withExtendedInfo=true&limit=${pageInfo.limit}&offset=${pageInfo.limit * pageInfo.index}`)
.then(({ data }) => data.locations),
.then(({ data }) => data.locations as Location[]),
{
keepPreviousData: true,
enabled,
@@ -84,7 +85,9 @@ export const useGetSelectLocations = ({ select, enabled = true }: { select: stri
() =>
select.length === 0
? []
: axiosProv.get(`location?withExtendedInfo=true&select=${select}`).then(({ data }) => data.locations),
: axiosProv
.get(`location?withExtendedInfo=true&select=${select}`)
.then(({ data }) => data.locations as Location[]),
{
enabled,
staleTime: 100 * 1000,
@@ -156,12 +159,44 @@ export const useGetAllLocations = ({ venueId }: { venueId?: string }) => {
});
};
export const useGetAllVenueLocations = ({ venueId }: { venueId: string }) => {
const { t } = useTranslation();
const toast = useToast();
return useQuery(
['get-all-locations', venueId],
() =>
axiosProv
.get(`venue?locationsForVenue=${venueId}`)
.then(({ data }) => data.locations as { uuid: string; name: string }[]),
{
staleTime: 1000 * 1000,
onError: (e: AxiosError) => {
if (!toast.isActive('locations-fetching-error'))
toast({
id: 'locations-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('locations.other'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
},
);
};
export const useGetLocation = ({ enabled, id }: { enabled: boolean; id: string }) => {
const { t } = useTranslation();
const toast = useToast();
return useQuery(['get-location', id], () => axiosProv.get(`location/${id}`).then(({ data }) => data), {
return useQuery(['get-location', id], () => axiosProv.get(`location/${id}`).then(({ data }) => data as Location), {
enabled,
staleTime: Infinity,
onError: (e: AxiosError) => {
if (!toast.isActive('location-fetching-error'))
toast({
@@ -180,7 +215,20 @@ export const useGetLocation = ({ enabled, id }: { enabled: boolean; id: string }
});
};
export const useCreateLocation = () => useMutation((newLocation) => axiosProv.post('location/0', newLocation));
export const useCreateLocation = () =>
useMutation((newLocation: CreateLocation) =>
axiosProv.post('location/0', newLocation).then(({ data }) => data as Location),
);
export const useUpdateLocation = ({ id }: { id: string }) =>
useMutation((newLocation) => axiosProv.put(`location/${id}`, newLocation));
export const useUpdateLocation = ({ id }: { id: string }) => {
const queryClient = useQueryClient();
return useMutation(
(newLocation: CreateLocation) => axiosProv.put(`location/${id}`, newLocation).then(({ data }) => data as Location),
{
onSuccess: (data) => {
queryClient.setQueryData(['get-location', id], data);
},
},
);
};

View File

@@ -2,6 +2,7 @@ import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as axios from 'axios';
import { useTranslation } from 'react-i18next';
import { AxiosError } from 'models/Axios';
type System = {
UI?: string;
@@ -103,7 +104,7 @@ export const useReloadSubsystems = ({
});
resetSubs();
},
onError: (e) => {
onError: (e: AxiosError) => {
toast({
id: 'system-fetching-error',
title: t('common.error'),
@@ -198,3 +199,36 @@ export const useUpdateSystemLogLevels = ({ endpoint, token }: { endpoint: string
},
});
};
export type SystemResources = {
currRealMem: number;
currVirtMem: number;
numberOfFileDescriptors: number;
peakRealMem: number;
peakVirtMem: number;
};
export const useGetSystemResources = ({
endpoint,
token,
onSuccess,
}: {
endpoint: string;
token: string;
onSuccess?: (data: SystemResources) => void;
}) =>
useQuery(
['systemResources', endpoint],
() =>
axiosInstance
.get(`${endpoint}/api/v1/system?command=resources`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then(({ data }: { data: SystemResources }) => data),
{
refetchInterval: 5 * 1000,
onSuccess,
},
);

View File

@@ -91,6 +91,8 @@ export const useGetVenue = ({ id }: { id?: string }) => {
() => axiosProv.get(`venue/${id}?withExtendedInfo=true`).then(({ data }: { data: VenueApiResponse }) => data),
{
enabled: id !== undefined && id !== '',
keepPreviousData: true,
staleTime: 1000 * 5,
onError: (e: AxiosError) => {
if (!toast.isActive('venue-fetching-error'))
toast({
@@ -237,22 +239,23 @@ export const useUpgradeVenueDevices = () => {
export const useDeleteVenue = () => useMutation((id) => axiosProv.delete(`venue/${id}`));
export const useAddVenueContact = ({ id, originalContacts = [] }: { id: string; originalContacts?: string[] }) =>
useMutation((newContact: string) =>
export const useAddVenueContact = ({ id, originalContacts = [] }: { id: string; originalContacts?: string[] }) => {
const queryClient = useQueryClient();
return useMutation(
(newContact: string) =>
axiosProv.put(`venue/${id}`, {
contacts: [...originalContacts, newContact],
}),
{
onSuccess: () => {
queryClient.invalidateQueries(['get-venue', id]);
},
},
);
export const useRemoveVenueContact = ({
id,
originalContacts = [],
refresh,
}: {
id: string;
originalContacts?: string[];
refresh: () => void;
}) => {
};
export const useRemoveVenueContact = ({ id, originalContacts = [] }: { id: string; originalContacts?: string[] }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const toast = useToast();
return useMutation(
@@ -262,7 +265,7 @@ export const useRemoveVenueContact = ({
}),
{
onSuccess: () => {
refresh();
queryClient.invalidateQueries(['get-venue', id]);
toast({
id: `contact-remove-success`,
title: t('common.success'),
@@ -305,5 +308,7 @@ const getVenueUpgradeAvailableFirmware = (id: string) =>
}) => res.data,
);
export const useGetVenueUpgradeAvailableFirmware = ({ id }: { id: string }) =>
useQuery(['venue', id, 'availableFirmware'], () => getVenueUpgradeAvailableFirmware(id));
export const useGetVenueUpgradeAvailableFirmware = ({ id, enabled }: { id: string; enabled?: boolean }) =>
useQuery(['venue', id, 'availableFirmware'], () => getVenueUpgradeAvailableFirmware(id), {
enabled,
});

View File

@@ -0,0 +1,30 @@
import * as React from 'react';
import { useFavorites } from 'contexts/FavoritesProvider';
export type UseEntityFavoriteProps = {
id: string;
type: 'venue' | 'entity';
};
export const useEntityFavorite = ({ id, type }: UseEntityFavoriteProps) => {
const favoriteContext = useFavorites();
const [isLoading, setIsLoading] = React.useState(false);
const isFavorite = favoriteContext.entityFavorites.favorites.some(({ id: entityId }) => entityId === id);
const onFavoriteClick = async () => {
setIsLoading(true);
if (isFavorite) {
await favoriteContext.entityFavorites.remove({ id, type });
} else {
await favoriteContext.entityFavorites.add({ id, type });
}
setIsLoading(false);
};
return {
isFavorite,
onFavoriteClick,
isLoading,
};
};

View File

@@ -5,6 +5,7 @@ import App from 'App';
import 'i18n';
import theme from 'theme/theme';
import 'index.css';
import 'rc-tree/assets/index.css';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
ReactDOM.createRoot(document.getElementById('root')!).render(

View File

@@ -0,0 +1,77 @@
import * as React from 'react';
import { StarIcon } from '@chakra-ui/icons';
import { IconButton, Menu, MenuButton, MenuItem, MenuList, Tooltip } from '@chakra-ui/react';
import { useNavigate } from 'react-router-dom';
import { useFavorites } from 'contexts/FavoritesProvider';
import { useGetEntities } from 'hooks/Network/Entity';
import { useGetVenues } from 'hooks/Network/Venues';
const FavoritesDropdown = () => {
const context = useFavorites();
const getEntities = useGetEntities();
const getVenues = useGetVenues();
const navigate = useNavigate();
const allFavorites = React.useMemo(() => {
if (!context.entityFavorites.favorites) return [];
if (!getEntities.data || !getVenues.data) return [];
const availableFavorites: {
label: string;
destinationPath: string;
}[] = [];
for (const favorite of context.entityFavorites.favorites) {
if (favorite.type === 'entity') {
const found = getEntities.data.find((entity) => entity.id === favorite.id);
if (found) {
availableFavorites.push({
label: found.name,
destinationPath: `entity/${found.id}`,
});
}
} else {
const found = getVenues.data.find((venue) => venue.id === favorite.id);
if (found) {
availableFavorites.push({
label: found.name,
destinationPath: `venue/${found.id}`,
});
}
}
}
return availableFavorites.sort((a, b) => a.label.localeCompare(b.label));
}, [context.entityFavorites.favorites, getEntities.data, getVenues.data]);
const navigateTo = (destinationPath: string) => () => {
navigate(destinationPath);
};
return (
<Menu>
<Tooltip label={allFavorites.length === 0 ? 'No Favorites' : 'Favorites'}>
<MenuButton
background="transparent"
variant="ghost"
as={IconButton}
aria-label="Commands"
icon={<StarIcon boxSize={5} />}
size="sm"
isDisabled={allFavorites.length === 0}
/>
</Tooltip>
<MenuList>
{allFavorites.map((favorite) => (
<MenuItem key={favorite.destinationPath} onClick={navigateTo(favorite.destinationPath)}>
{favorite.label}
</MenuItem>
))}
</MenuList>
</Menu>
);
};
export default FavoritesDropdown;

View File

@@ -17,8 +17,9 @@ import {
Tooltip,
useBreakpoint,
Portal,
useDisclosure,
} from '@chakra-ui/react';
import { ArrowCircleLeft, MapTrifold } from '@phosphor-icons/react';
import { ArrowCircleLeft } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuth } from 'contexts/AuthProvider';
@@ -27,11 +28,20 @@ export type NavbarProps = {
toggleSidebar: () => void;
activeRoute?: string;
languageSwitcher?: React.ReactNode;
favoritesButton?: React.ReactNode;
rightElements?: React.ReactNode;
};
export const Navbar = ({ toggleSidebar, activeRoute, languageSwitcher }: NavbarProps) => {
export const Navbar = ({
toggleSidebar,
activeRoute,
languageSwitcher,
favoritesButton,
rightElements = null,
}: NavbarProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const menuProps = useDisclosure();
const [scrolled, setScrolled] = useState(false);
const breakpoint = useBreakpoint();
const { colorMode, toggleColorMode } = useColorMode();
@@ -73,10 +83,23 @@ export const Navbar = ({ toggleSidebar, activeRoute, languageSwitcher }: NavbarP
};
const goToProfile = () => navigate('/account');
const goToMap = () => navigate('/map');
window.addEventListener('scroll', changeNavbar);
let timeout: NodeJS.Timeout;
const onMouseEnter = () => {
if (timeout) {
clearTimeout(timeout);
}
menuProps.onOpen();
};
const onMouseLeave = () => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => menuProps.onClose(), 100);
};
return (
<Portal>
<Flex
@@ -110,11 +133,14 @@ export const Navbar = ({ toggleSidebar, activeRoute, languageSwitcher }: NavbarP
justifyContent="center"
>
{isCompact && <HamburgerIcon w="24px" h="24px" onClick={toggleSidebar} mr={10} mt={1} />}
<Heading size="lg">{activeRoute}</Heading>
{activeRoute && activeRoute.length > 0 ? (
<Heading size="lg" mr={4}>
{activeRoute}
</Heading>
) : null}
<Tooltip label={t('common.go_back')}>
<IconButton
mt={1}
ml={4}
colorScheme="blue"
aria-label={t('common.go_back')}
onClick={goBack}
@@ -124,14 +150,8 @@ export const Navbar = ({ toggleSidebar, activeRoute, languageSwitcher }: NavbarP
</Tooltip>
<Box ms="auto" w={{ base: 'unset' }}>
<Flex alignItems="center" flexDirection="row">
<Tooltip hasArrow label={t('common.go_to_map')}>
<IconButton
aria-label={t('common.go_to_map')}
variant="ghost"
icon={<MapTrifold size={24} />}
onClick={goToMap}
/>
</Tooltip>
{rightElements}
{favoritesButton}
<Tooltip hasArrow label={t('common.theme')}>
<IconButton
aria-label={t('common.theme')}
@@ -141,27 +161,33 @@ export const Navbar = ({ toggleSidebar, activeRoute, languageSwitcher }: NavbarP
/>
</Tooltip>
{languageSwitcher}
<HStack spacing={{ base: '0', md: '6' }} ml={1} mr={4}>
<Menu>
<MenuButton py={2} transition="all 0.3s" _focus={{ boxShadow: 'none' }}>
<Box ml={1} mr={4}>
<Menu {...menuProps} gutter={0}>
<MenuButton
py={2}
transition="all 0.3s"
_focus={{ boxShadow: 'none' }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<HStack>
{!isCompact && <Text fontWeight="bold">{user?.name}</Text>}
<Avatar h="40px" w="40px" fontSize="0.8rem" lineHeight="2rem" src={avatar} name={user?.name} />
</HStack>
</MenuButton>
<Portal>
<MenuList
bg={useColorModeValue('white', 'gray.900')}
borderColor={useColorModeValue('gray.200', 'gray.700')}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<MenuItem onClick={goToProfile} w="100%">
{t('account.title')}
</MenuItem>
<MenuItem onClick={logout}>{t('common.logout')}</MenuItem>
</MenuList>
</Portal>
</Menu>
</HStack>
</Box>
</Flex>
</Box>
</Flex>

View File

@@ -1,105 +0,0 @@
import React from 'react';
import { Button, Flex, Text, useColorModeValue, useDisclosure } from '@chakra-ui/react';
import { ArrowCircleRight } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import EntityPopover from './EntityPopover';
import IconBox from 'components/IconBox';
import { Route } from 'models/Routes';
const variantChange = '0.2s linear';
interface Props {
isActive: boolean;
route: Route;
toggleSidebar: () => void;
}
const EntityNavButton = ({ isActive, route, toggleSidebar }: Props) => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const activeArrowColor = useColorModeValue('var(--chakra-colors-gray-700)', 'white');
const inactiveArrowColor = useColorModeValue('var(--chakra-colors-gray-600)', 'var(--chakra-colors-gray-200)');
const activeTextColor = useColorModeValue('gray.700', 'white');
const inactiveTextColor = useColorModeValue('gray.600', 'gray.200');
const hoverBg = useColorModeValue('blue.100', 'blue.800');
return (
<EntityPopover isOpen={isOpen} onClose={onClose} toggleSidebar={toggleSidebar}>
{isActive ? (
<Button
onClick={onOpen}
boxSize="initial"
justifyContent="flex-start"
alignItems="center"
boxShadow="none"
bg="transparent"
transition={variantChange}
mx="auto"
px={1}
py="12px"
borderRadius="15px"
w="100%"
_active={{
bg: 'inherit',
transform: 'none',
borderColor: 'transparent',
}}
_focus={{
boxShadow: '0px 7px 11px rgba(0, 0, 0, 0.04)',
}}
_hover={{
bg: hoverBg,
}}
borderWidth="0px"
rightIcon={<ArrowCircleRight size={24} color={activeArrowColor} />}
>
<Flex alignItems="center" w="100%">
<IconBox color="blue.300" h="30px" w="30px" me="6px" transition={variantChange} fontWeight="bold">
{route.icon(false)}
</IconBox>
<Text color={activeTextColor} fontSize="md" fontWeight="bold">
{t(route.name)}
</Text>
</Flex>
</Button>
) : (
<Button
onClick={onOpen}
boxSize="initial"
justifyContent="flex-start"
alignItems="center"
bg="transparent"
mx="auto"
py="12px"
ps={1}
borderRadius="15px"
w="100%"
_active={{
bg: 'inherit',
transform: 'none',
borderColor: 'transparent',
}}
_focus={{
boxShadow: 'none',
}}
_hover={{
bg: hoverBg,
}}
borderWidth="0px"
rightIcon={<ArrowCircleRight size={20} color={inactiveArrowColor} />}
>
<Flex alignItems="center" w="100%">
<IconBox color="blue.300" h="30px" w="30px" me="6px" transition={variantChange} fontWeight="bold">
{route.icon(false)}
</IconBox>
<Text color={inactiveTextColor} fontSize="md" fontWeight="bold">
{t(route.name)}
</Text>
</Flex>
</Button>
)}
</EntityPopover>
);
};
export default React.memo(EntityNavButton);

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
import { Box, UseDisclosureReturn } from '@chakra-ui/react';
import EntityNavigationTree from './Tree';
import { Modal } from 'components/Modals/Modal';
import { useGetEntityTree } from 'hooks/Network/Entity';
type Props = {
modalProps: UseDisclosureReturn;
navigateTo: (id: string, type: 'venue' | 'entity') => void;
};
const EntityNavigationModal = ({ modalProps, navigateTo }: Props) => {
const getEntityTree = useGetEntityTree();
return (
<Modal {...modalProps} title="Entity and Venue Navigation">
<Box>
{getEntityTree.data ? (
<EntityNavigationTree isModalOpen={modalProps.isOpen} treeRoot={getEntityTree.data} navigateTo={navigateTo} />
) : null}
</Box>
</Modal>
);
};
export default EntityNavigationModal;

View File

@@ -0,0 +1,32 @@
import * as React from 'react';
import { StarIcon } from '@chakra-ui/icons';
import { Box, Tooltip } from '@chakra-ui/react';
import { useEntityFavorite } from 'hooks/useEntityFavorite';
type Props = {
id: string;
type: 'venue' | 'entity';
};
const EntityFavoritesButton = ({ id, type }: Props) => {
const { isFavorite, onFavoriteClick, isLoading } = useEntityFavorite({
id,
type,
});
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
onFavoriteClick();
};
return (
<Tooltip label={!isFavorite ? 'Add to favorites' : 'Remove from favorites'}>
<Box onClick={isLoading ? undefined : onClick} ml={1} mt={-1}>
<StarIcon color={isFavorite ? 'yellow.300' : 'gray.200'} />
</Box>
</Tooltip>
);
};
export default EntityFavoritesButton;

View File

@@ -0,0 +1,131 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import * as React from 'react';
import { Box, Flex, Text } from '@chakra-ui/react';
import { Buildings, CaretDown, CaretRight, TreeStructure } from '@phosphor-icons/react';
import EntityFavoritesButton from './EntityFavoritesButton';
import { TreeEntity, TreeVenue } from 'hooks/Network/Entity';
const expandIcon = (childrenLength: number, isExpanded: boolean) => {
if (childrenLength === 0) return null;
return isExpanded ? <CaretDown size={16} weight="fill" /> : <CaretRight size={16} weight="fill" />;
};
type Props = {
node: TreeEntity | TreeVenue;
level: number;
expandedIds: {
[key: string]: boolean;
};
onExpand: (id: string) => void;
onCollapse: (id: string) => void;
navigateTo: (id: string, type: 'venue' | 'entity') => void;
onHoverParent: () => void;
onLeaveParent: () => void;
};
const EntityTreeNode = ({
node,
level,
expandedIds,
onExpand,
onCollapse,
navigateTo,
onHoverParent,
onLeaveParent,
}: Props) => {
const [isHovered, setIsHovered] = React.useState(false);
const childrenLevel = level + 1;
const isExpanded = expandedIds[node.uuid] === true;
const mergedAndSortedChildren = [...node.children, ...(node.venues ?? [])].sort((a, b) =>
(a.name as string).localeCompare(b.name as string),
);
const onExpandClick = () => {
if (isExpanded) {
onCollapse(node.uuid);
} else {
onExpand(node.uuid);
}
};
const onNavigate: React.MouseEventHandler<HTMLButtonElement | HTMLDivElement> = (e) => {
navigateTo(node.uuid, node.type);
e.stopPropagation();
e.preventDefault();
};
const onMouseEnter = () => {
setIsHovered(true);
onHoverParent();
};
const onMouseLeave = () => {
setIsHovered(false);
onLeaveParent();
};
const onChildHover = () => {
setIsHovered(true);
onHoverParent();
};
const onChildLeave = () => {
setIsHovered(false);
onLeaveParent();
};
return (
<Box>
<Flex alignItems="center">
<span
style={{
width: '16px',
cursor: 'pointer',
paddingRight: '4px',
}}
onClick={mergedAndSortedChildren.length > 0 ? onExpandClick : undefined}
>
{expandIcon(mergedAndSortedChildren.length, isExpanded)}
</span>
<Flex
onClick={onNavigate}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
cursor="pointer"
w="100%"
alignItems="center"
>
<span
style={{
marginRight: '4px',
}}
>
{node.type === 'entity' ? <TreeStructure size={16} /> : <Buildings size={16} />}
</span>
<Text fontWeight={isHovered ? 'bold' : 'normal'}>{node.name}</Text>
{isHovered ? <EntityFavoritesButton id={node.uuid} type={node.type} /> : null}
</Flex>
</Flex>
{isExpanded ? (
<Box paddingLeft={`${childrenLevel * 6}px`}>
{mergedAndSortedChildren.map((child) => (
<EntityTreeNode
key={child.uuid}
node={child}
level={childrenLevel}
expandedIds={expandedIds}
onExpand={onExpand}
onCollapse={onCollapse}
navigateTo={navigateTo}
onHoverParent={onChildHover}
onLeaveParent={onChildLeave}
/>
))}
</Box>
) : null}
</Box>
);
};
export default React.memo(EntityTreeNode);

View File

@@ -0,0 +1,234 @@
import * as React from 'react';
import { Box, Button, Flex, Heading, Spacer, useColorMode } from '@chakra-ui/react';
import { ChakraStylesConfig, GroupBase, Select } from 'chakra-react-select';
import EntityTreeNode from './EntityTreeNode';
import { getExpandedKeys, setExpandedKeys } from './utils.entityTree';
import { TreeEntity, TreeVenue, useGetEntities } from 'hooks/Network/Entity';
import { useGetVenues } from 'hooks/Network/Venues';
const groupStyles = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
};
const groupBadgeStyles: React.CSSProperties = {
backgroundColor: '#EBECF0',
borderRadius: '2em',
color: '#172B4D',
display: 'inline-block',
fontSize: 12,
fontWeight: 'normal',
lineHeight: '1',
minWidth: 1,
padding: '0.16666666666667em 0.5em',
textAlign: 'center',
};
type Option = { value: string; label: string; type: 'venue' | 'entity' };
const chakraStyles: (colorMode: 'light' | 'dark') => ChakraStylesConfig<Option, false, GroupBase<Option>> = (
colorMode,
) => ({
dropdownIndicator: (provided) => ({
...provided,
width: '32px',
}),
placeholder: (provided) => ({
...provided,
lineHeight: '1',
pointerEvents: 'none',
userSelect: 'none',
MozUserSelect: 'none',
WebkitUserSelect: 'none',
msUserSelect: 'none',
}),
container: (provided) => ({
...provided,
width: '300px',
backgroundColor: colorMode === 'light' ? 'white' : 'gray.600',
borderRadius: '15px',
}),
input: (provided) => ({
...provided,
gridArea: '1 / 2 / 4 / 4 !important',
}),
});
const formatGroupLabel = (data: GroupBase<Option>) => (
<div style={groupStyles}>
<span>{data.label}</span>
<span style={groupBadgeStyles}>{data.options.length}</span>
</div>
);
const getTreeIds = (tree: TreeEntity | TreeVenue): string[] => [
tree.uuid,
...tree.children.flatMap((child) => getTreeIds(child)),
...(tree.venues ?? []).flatMap((venue) => getTreeIds(venue)),
];
type Props = {
isModalOpen: boolean;
treeRoot: TreeEntity;
navigateTo: (id: string, type: 'venue' | 'entity') => void;
};
const EntityNavigationTree = ({ isModalOpen, treeRoot, navigateTo }: Props) => {
const { colorMode } = useColorMode();
const [expandedIds, setExpandedIds] = React.useState<{
[key: string]: boolean;
}>({});
const getEntities = useGetEntities();
const getVenues = useGetVenues();
const [rawInput, setRawInput] = React.useState('');
const onExpand = React.useCallback(
(id: string) => {
const newExpandedIds = { ...expandedIds };
newExpandedIds[id] = true;
setExpandedIds(newExpandedIds);
setExpandedKeys(newExpandedIds);
},
[expandedIds],
);
const onCollapse = React.useCallback(
(id: string) => {
const newExpandedIds = { ...expandedIds };
newExpandedIds[id] = false;
setExpandedIds(newExpandedIds);
setExpandedKeys(newExpandedIds);
},
[expandedIds],
);
const onExpandAll = () => {
const treeIds = getTreeIds(treeRoot);
const newExpandedIds: { [key: string]: boolean } = {};
treeIds.forEach((id) => {
newExpandedIds[id] = true;
});
setExpandedIds({ ...newExpandedIds });
setExpandedKeys(newExpandedIds);
};
const onCollapseAll = () => {
setExpandedIds({});
setExpandedKeys({});
};
const mappedAndSortedEntities: Option[] = React.useMemo(() => {
if (!getEntities.data) return [];
return getEntities.data
.sort((a, b) => a.name.localeCompare(b.name))
.map((entity) => ({
label: entity.name,
value: entity.id,
type: 'entity',
}));
}, [getEntities.data]);
const mappedAndSortedVenues: Option[] = React.useMemo(() => {
if (!getVenues.data) return [];
return getVenues.data
.sort((a, b) => a.name.localeCompare(b.name))
.map((venue) => ({
label: venue.name,
value: venue.id,
type: 'venue',
}));
}, [getVenues.data]);
const filteredEntities: Option[] = React.useMemo(() => {
if (rawInput.length === 0) return mappedAndSortedEntities;
return mappedAndSortedEntities?.filter((entity) => entity.label.toLowerCase().includes(rawInput.toLowerCase()));
}, [mappedAndSortedEntities, rawInput]);
const filteredVenues: Option[] = React.useMemo(() => {
if (rawInput.length === 0) return mappedAndSortedVenues;
return mappedAndSortedVenues?.filter((venue) => venue.label.toLowerCase().includes(rawInput.toLowerCase()));
}, [mappedAndSortedVenues, rawInput]);
const NoOptionsMessage = React.useCallback(
() => (
<Heading size="xs" textAlign="left" px={2}>
No results found
</Heading>
),
[],
);
const onOptionChoice = (v: Option) => {
navigateTo(v.value, v.type);
};
const onChange = (v: string) => {
setRawInput(v);
};
React.useEffect(() => {
if (isModalOpen) {
setRawInput('');
}
}, [isModalOpen]);
React.useEffect(() => {
const settings = getExpandedKeys();
const newExpandedIds: { [key: string]: boolean } = {};
settings.forEach((id) => {
newExpandedIds[id] = true;
});
setExpandedIds({ ...newExpandedIds });
}, [treeRoot]);
return (
<Box>
<Flex alignItems="center" mb={2}>
<Select<Option, false, GroupBase<Option>>
chakraStyles={chakraStyles(colorMode)}
formatGroupLabel={formatGroupLabel}
components={{ NoOptionsMessage }}
options={[
{
label: 'Entities',
options: filteredEntities,
},
{
label: 'Venues',
options: filteredVenues,
},
]}
filterOption={() => true}
inputValue={rawInput}
// @ts-ignore
value={rawInput}
placeholder="Search for an entity or venue"
onInputChange={onChange}
// @ts-ignore
onChange={onOptionChoice}
menuPlacement="top"
/>
<Spacer />
<Button onClick={onExpandAll} colorScheme="blue" size="sm">
Expand
</Button>
<Button ml={2} onClick={onCollapseAll} colorScheme="gray" size="sm">
Collapse
</Button>
</Flex>
<EntityTreeNode
node={treeRoot}
level={0}
expandedIds={expandedIds}
onExpand={onExpand}
onCollapse={onCollapse}
navigateTo={navigateTo}
onHoverParent={() => {}}
onLeaveParent={() => {}}
/>
</Box>
);
};
export default EntityNavigationTree;

View File

@@ -0,0 +1,18 @@
const PREFERENCE = 'provisioning.entityTree.expandedKeys';
export const getExpandedKeys = (): string[] => {
const expandedKeys = localStorage.getItem(PREFERENCE);
if (expandedKeys) {
try {
return JSON.parse(expandedKeys);
} catch (e) {
return [];
}
}
return [];
};
export const setExpandedKeys = (keys: { [key: string]: boolean }) => {
const arr = Object.keys(keys).filter((key) => keys[key] === true);
localStorage.setItem(PREFERENCE, JSON.stringify(arr));
};

View File

@@ -0,0 +1,75 @@
import * as React from 'react';
import { Button, Flex, Text, useBreakpoint, useColorModeValue, useDisclosure } from '@chakra-ui/react';
import { ArrowCircleRight } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import EntityNavigationModal from './Modal';
import IconBox from 'components/IconBox';
import { Route } from 'models/Routes';
const variantChange = '0.2s linear';
type Props = {
route: Route;
toggleSidebar: () => void;
};
const EntityNavigationButton = ({ route, toggleSidebar }: Props) => {
const { t } = useTranslation();
const breakpoint = useBreakpoint();
const navigate = useNavigate();
const modalProps = useDisclosure();
const inactiveArrowColor = useColorModeValue('var(--chakra-colors-gray-600)', 'var(--chakra-colors-gray-200)');
const inactiveTextColor = useColorModeValue('gray.600', 'gray.200');
const hoverBg = useColorModeValue('blue.100', 'blue.800');
const navigateTo = React.useCallback(
(id: string, type: 'venue' | 'entity') => {
navigate(`/${type}/${id}`);
modalProps.onClose();
if (breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md') toggleSidebar();
},
[breakpoint],
);
return (
<>
<EntityNavigationModal modalProps={modalProps} navigateTo={navigateTo} />
<Button
onClick={modalProps.onOpen}
boxSize="initial"
justifyContent="flex-start"
alignItems="center"
bg="transparent"
mx="auto"
py="12px"
ps={1}
borderRadius="15px"
w="100%"
_active={{
bg: 'inherit',
transform: 'none',
borderColor: 'transparent',
}}
_focus={{
boxShadow: 'none',
}}
_hover={{
bg: hoverBg,
}}
borderWidth="0px"
rightIcon={<ArrowCircleRight size={20} color={inactiveArrowColor} />}
>
<Flex alignItems="center" w="100%">
<IconBox color="blue.300" h="30px" w="30px" me="6px" transition={variantChange} fontWeight="bold">
{route.icon(false)}
</IconBox>
<Text color={inactiveTextColor} fontSize="md" fontWeight="bold">
{t(route.name)}
</Text>
</Flex>
</Button>
</>
);
};
export default EntityNavigationButton;

View File

@@ -1,212 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
Button,
Center,
Heading,
IconButton,
ListItem,
Popover,
PopoverAnchor,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverHeader,
Portal,
Spacer,
Spinner,
UnorderedList,
useBreakpoint,
} from '@chakra-ui/react';
import { FocusableElement } from '@chakra-ui/utils';
import { TreeStructure, Buildings, X } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { v4 as uuid } from 'uuid';
import { useGetEntityTree } from 'hooks/Network/Entity';
interface Tree {
uuid: string;
name: string;
type: string;
children?: Tree[];
venues?: Tree[];
}
const renderList = (tree: Tree | Tree[], depth: number, goTo: (uuid: string, type: string) => void) => {
if (!Array.isArray(tree)) {
if (tree.children && tree.children.length > 0) {
return (
<UnorderedList ml={depth} styleType="none">
<ListItem>
<Button
colorScheme="blue"
variant="link"
onClick={() => goTo(tree.uuid, tree.type)}
leftIcon={tree.type === 'entity' ? <TreeStructure size={16} /> : <Buildings size={16} />}
>
{tree.name}
</Button>
<UnorderedList styleType="none">{renderList(tree.children, depth + 2, goTo)}</UnorderedList>
</ListItem>
</UnorderedList>
);
}
return (
<UnorderedList ml={depth}>
<ListItem>
<Button
colorScheme="blue"
variant="link"
onClick={() => goTo(tree.uuid, tree.type)}
leftIcon={tree.type === 'entity' ? <TreeStructure size={16} /> : <Buildings size={16} />}
>
{tree.name}
</Button>
</ListItem>
</UnorderedList>
);
}
return tree.map((obj) => {
const childrenLength = obj?.children?.length ?? 0;
const venuesLength = obj?.venues?.length ?? 0;
if (childrenLength === 0 && venuesLength === 0)
return (
<ListItem key={uuid()}>
<Button
colorScheme="blue"
variant="link"
onClick={() => goTo(obj.uuid, obj.type)}
leftIcon={obj.type === 'entity' ? <TreeStructure size={16} /> : <Buildings size={16} />}
>
{obj.name}
</Button>
</ListItem>
);
return (
<ListItem key={uuid()}>
<Button
colorScheme="blue"
variant="link"
onClick={() => goTo(obj.uuid, obj.type)}
leftIcon={obj.type === 'entity' ? <TreeStructure size={16} /> : <Buildings size={16} />}
>
{obj.name}
</Button>
<UnorderedList ml={depth} styleType="none">
{childrenLength > 0 ? renderList(obj.children ?? [], depth + 2, goTo) : null}
{venuesLength > 0 ? renderList(obj.venues ?? [], depth + 2, goTo) : null}
</UnorderedList>
</ListItem>
);
});
};
interface Props {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
toggleSidebar: () => void;
}
const EntityPopover = ({ isOpen, onClose, children, toggleSidebar }: Props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const breakpoint = useBreakpoint();
const [closeOnBlur, setCloseOnBlur] = useState(false);
const { data: tree, isFetching } = useGetEntityTree();
const initRef = React.useRef<HTMLButtonElement>();
const goTo = useCallback(
(id, type) => {
navigate(`/${type}/${id}`);
onClose();
if (breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md') toggleSidebar();
},
[breakpoint],
);
useEffect(() => {
if (isOpen) {
setTimeout(() => {
setCloseOnBlur(true);
}, 200);
} else {
setCloseOnBlur(false);
}
}, [isOpen]);
return (
<Popover
offset={[0, -100]}
isLazy
returnFocusOnClose={false}
isOpen={isOpen}
onClose={onClose}
placement="right"
closeOnBlur={closeOnBlur}
initialFocusRef={initRef as React.RefObject<FocusableElement>}
>
{
// @ts-ignore
<PopoverAnchor>{children}</PopoverAnchor>
}
{breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md' ? (
<PopoverContent maxW={{ base: 'calc(60vw)' }}>
<PopoverHeader fontWeight="semibold" display="flex" alignItems="center">
<Heading size="md">{t('entities.title')}</Heading>
<Spacer />
<IconButton
aria-label="Close"
ref={initRef as React.RefObject<HTMLButtonElement>}
colorScheme="gray"
onClick={onClose}
icon={<X size={20} />}
ms="auto"
/>
</PopoverHeader>
<PopoverArrow />
<PopoverBody overflowX="auto" overflowY="auto" maxH="80vh">
{tree && !isFetching ? (
renderList(tree, 0, goTo)
) : (
<Center>
<Spinner size="lg" />
</Center>
)}
</PopoverBody>
</PopoverContent>
) : (
<Portal>
<PopoverContent>
<PopoverHeader fontWeight="semibold" display="flex" alignItems="center">
<Heading size="md">{t('entities.title')}</Heading>
<Spacer />
<IconButton
aria-label="Close"
ref={initRef as React.RefObject<HTMLButtonElement>}
colorScheme="gray"
onClick={onClose}
icon={<X size={20} />}
ms="auto"
/>
</PopoverHeader>
<PopoverArrow />
<PopoverBody overflowY="auto" maxH="80vh">
{tree && !isFetching ? (
renderList(tree, 0, goTo)
) : (
<Center>
<Spinner size="lg" />
</Center>
)}
</PopoverBody>
</PopoverContent>
</Portal>
)}
</Popover>
);
};
export default EntityPopover;

View File

@@ -89,7 +89,7 @@ export const Sidebar = ({ routes, isOpen, toggle, logo, version, topNav, childre
</Box>
</>
),
[user?.userRole, location],
[user?.userRole, location, topNav],
);
return (

View File

@@ -1,23 +1,29 @@
import React from 'react';
import { useBoolean, useColorMode } from '@chakra-ui/react';
import { IconButton, Tooltip, useBoolean, useBreakpoint, useColorMode } from '@chakra-ui/react';
import { MapTrifold } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import { Route, Routes, useLocation } from 'react-router-dom';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { v4 as uuid } from 'uuid';
import FavoritesDropdown from './FavoritesDropdown';
import { Navbar } from './Navbar';
import { PageContainer } from './PageContainer';
import { Sidebar } from './Sidebar';
import darkLogo from 'assets/Logo_Dark_Mode.svg';
import lightLogo from 'assets/Logo_Light_Mode.svg';
import LanguageSwitcher from 'components/LanguageSwitcher';
import { useAuth } from 'contexts/AuthProvider';
import { RouteName } from 'models/Routes';
import NotFoundPage from 'pages/NotFound';
import routes from 'router/routes';
const Layout = () => {
const { t } = useTranslation();
const authContext = useAuth();
const navigate = useNavigate();
const location = useLocation();
const { colorMode } = useColorMode();
const [isSidebarOpen, { toggle: toggleSidebar }] = useBoolean(false);
const breakpoint = useBreakpoint('xl');
const [isSidebarOpen, { toggle: toggleSidebar }] = useBoolean(breakpoint !== 'base' && breakpoint !== 'sm');
document.documentElement.dir = 'ltr';
const activeRoute = React.useMemo(() => {
@@ -73,6 +79,8 @@ const Layout = () => {
return instances;
}, []);
const goToMap = () => navigate('/map');
return (
<>
<Sidebar
@@ -93,7 +101,22 @@ const Layout = () => {
/>
}
/>
<Navbar toggleSidebar={toggleSidebar} languageSwitcher={<LanguageSwitcher />} activeRoute={activeRoute} />
<Navbar
toggleSidebar={toggleSidebar}
languageSwitcher={<LanguageSwitcher />}
favoritesButton={authContext.isUserLoaded ? <FavoritesDropdown /> : null}
rightElements={
<Tooltip label="Map" aria-label={t('common.go_to_map')}>
<IconButton
aria-label={t('common.go_to_map')}
variant="ghost"
icon={<MapTrifold size={24} />}
onClick={goToMap}
/>
</Tooltip>
}
activeRoute={activeRoute}
/>
<PageContainer waitForUser>
<Routes>{[...routeInstances, <Route path="*" element={<NotFoundPage />} key={uuid()} />]}</Routes>
</PageContainer>

View File

@@ -8,6 +8,7 @@ export interface Configuration {
notes: Note[];
entity: string;
venue: string;
deviceTypes: string[];
}
interface ConfigurationNestedForm {

View File

@@ -1,10 +1,41 @@
import { Note } from './Note';
export interface CreateLocation {
name: string;
description?: string;
id?: string;
notes?: Note[];
type: 'SERVICE' | 'EQUIPMENT' | 'AUTO' | 'MANUAL' | 'SPECIAL' | 'UNKNOWN' | 'CORPORATE';
buildingName: string;
addressLines: string[];
city: string;
state: string;
postal: string;
country: string;
phones: string[];
mobiles: string[];
inUse?: string[];
entity: string;
geoCode: string;
}
export interface Location {
name: string;
description: string;
id: string;
notes: Note[];
type: 'SERVICE' | 'EQUIPMENT' | 'AUTO' | 'MANUAL' | 'SPECIAL' | 'UNKNOWN' | 'CORPORATE';
buildingName: string;
addressLines: string[];
city: string;
state: string;
postal: string;
country: string;
phones: string[];
mobiles: string[];
inUse: string[];
entity: string;
geoCode: string;
}
export interface AddressValue {

View File

@@ -0,0 +1,171 @@
import React, { useMemo } from 'react';
import { HStack } from '@chakra-ui/react';
import { INTERFACE_ETHERNET_SCHEMA } from '../interfacesConstants';
import MultiSelectField from 'components/FormFields/MultiSelectField';
import ObjectArrayFieldModal from 'components/FormFields/ObjectArrayFieldModal';
import SelectField from 'components/FormFields/SelectField';
import StringField from 'components/FormFields/StringField';
import ToggleField from 'components/FormFields/ToggleField';
const selectPortsOptions = [
{
value: '*',
label: 'All',
},
{
value: 'WAN*',
label: 'WAN*',
},
{
value: 'LAN*',
label: 'LAN*',
},
{
value: 'LAN1',
label: 'LAN1',
},
{
value: 'LAN2',
label: 'LAN2',
},
{
value: 'LAN3',
label: 'LAN3',
},
{
value: 'LAN4',
label: 'LAN4',
},
{
value: 'LAN5',
label: 'LAN5',
},
{
value: 'LAN6',
label: 'LAN6',
},
{
value: 'LAN7',
label: 'LAN7',
},
{
value: 'LAN8',
label: 'LAN8',
},
{
value: 'LAN9',
label: 'LAN9',
},
{
value: 'LAN10',
label: 'LAN10',
},
{
value: 'LAN11',
label: 'LAN11',
},
{
value: 'LAN12',
label: 'LAN12',
},
];
const boolOrUndefined = (defaultVal: boolean, value: boolean | undefined) => {
if (value === undefined) {
return defaultVal ? 'Yes' : 'No';
}
return value ? 'Yes' : 'No';
};
const tableCols = [
{
id: 'select-ports',
Header: 'Ports',
Footer: '',
accessor: 'select-ports',
Cell: ({ cell }) => cell.row.original['select-ports']?.join(',') ?? 'None',
},
{
id: 'macaddr',
Header: 'Mac Address',
Footer: '',
accessor: 'macaddr',
},
{
id: 'multicast',
Header: 'Multicast',
Footer: '',
accessor: 'multicast',
Cell: ({ cell }) => boolOrUndefined(true, cell.row.original.multicast),
},
{
id: 'learning',
Header: 'Learning',
Footer: '',
accessor: 'learning',
Cell: ({ cell }) => boolOrUndefined(true, cell.row.original.learning),
},
{
id: 'reverse-path',
Header: 'Reverse Path',
Footer: '',
accessor: 'reverse-path',
Cell: ({ cell }) => boolOrUndefined(false, cell.row.original.learning),
},
{
id: 'vlan-tag',
Header: 'Vlan Tag',
Footer: '',
accessor: 'vlan-tag',
Cell: ({ cell }) => cell.row.original['vlan-tag'] ?? 'Auto',
},
];
interface Props {
isDisabled?: boolean;
name: string;
}
const InterfaceSelectPortsField = ({ name, isDisabled = false }: Props) => {
const fields = useMemo(
() => (
<>
<MultiSelectField name="select-ports" label="Ports" options={selectPortsOptions} isRequired />
<StringField name="macaddr" label="Mac Address" w="200px" emptyIsUndefined />
<HStack spacing={4}>
<ToggleField name="multicast" label="Multicast" />
<ToggleField name="learning" label="Learning" />
<ToggleField name="reverse-path" label="Reverse Path" />
<SelectField
name="vlan-tag"
label="Vlan Tag"
options={[
{ label: 'Auto', value: 'auto' },
{ label: 'Tagged', value: 'tagged' },
{ label: 'Un-tagged', value: 'un-tagged' },
]}
/>
</HStack>
</>
),
[],
);
return (
<ObjectArrayFieldModal
name={name}
label="ethernet"
fields={fields}
columns={tableCols}
schema={INTERFACE_ETHERNET_SCHEMA}
isDisabled={isDisabled}
isRequired
options={{
buttonLabel: 'Manage Ethernet Ports',
}}
/>
);
};
export default InterfaceSelectPortsField;

View File

@@ -3,13 +3,13 @@ import { Box, Flex, Heading, SimpleGrid, Spacer } from '@chakra-ui/react';
import { FieldArray } from 'formik';
import { useTranslation } from 'react-i18next';
import Captive from './Captive';
import InterfaceSelectPortsField from './InterfaceSelectPortsField';
import IpV4 from './IpV4';
import IpV6 from './IpV6';
import SsidList from './SsidList';
import Tunnel from './Tunnel';
import Vlan from './Vlan';
import DeleteButton from 'components/Buttons/DeleteButton';
import ConfigurationSelectPortsField from 'components/CustomFields/ConfigurationSelectPortsField';
import CreatableSelectField from 'components/FormFields/CreatableSelectField';
import MultiSelectField from 'components/FormFields/MultiSelectField';
import SelectField from 'components/FormFields/SelectField';
@@ -62,10 +62,7 @@ const SingleInterface: React.FC<Props> = ({ editing, index, remove }) => {
isRequired
options={roleOpts}
/>
<ConfigurationSelectPortsField
name={`configuration[${index}].ethernet[0].select-ports`}
isDisabled={!editing}
/>
<InterfaceSelectPortsField name={`configuration[${index}].ethernet`} isDisabled={!editing} />
<ToggleField
name={`configuration[${index}].isolate-hosts`}
label="isolate-hosts"

View File

@@ -7,7 +7,6 @@ import {
testLeaseTime,
testLength,
testRegex,
testSelectPorts,
testUcMac,
} from 'constants/formTests';
import { testStaticIpv4ClassD, testStaticIpv4ClassE } from 'utils/formatTests';
@@ -449,7 +448,7 @@ export const INTERFACE_IPV4_DHCP_SCHEMA = (t, useDefault = false) => {
const shape = object()
.shape({
'lease-first': number().required(t('form.required')).positive().integer().default(1),
'lease-count': number().required(t('form.required')).positive().integer().default(1),
'lease-count': number().required(t('form.required')).positive().integer().default(128),
'lease-time': string()
.required(t('form.required'))
.test('ipv4_dhcp.lease-time', t('form.invalid_lease_time'), testLeaseTime)
@@ -458,7 +457,7 @@ export const INTERFACE_IPV4_DHCP_SCHEMA = (t, useDefault = false) => {
})
.default({
'lease-first': 1,
'lease-count': 1,
'lease-count': 128,
'lease-time': '6h',
});
@@ -701,6 +700,32 @@ export const INTERFACE_TUNNEL_SCHEMA = (t, useDefault = false) => {
return useDefault ? shape : shape.nullable().default(undefined);
};
export const INTERFACE_ETHERNET_SCHEMA = (t, useDefault = false) => {
const shape = object().shape({
'select-ports': array().of(string()).min(1, t('form.required')).default([]),
multicast: bool().default(true),
learning: bool().default(true),
isolate: bool().default(false),
macaddr: string()
.test('interface.ethernet.mac.length', t('form.invalid_mac_uc'), (v) => (v === undefined ? true : testUcMac(v)))
.default(undefined),
'reverse-path': bool().default(false),
'vlan-tag': string().default('auto'),
});
return useDefault
? shape
: shape.nullable().default({
'select-ports': [],
multicast: true,
learning: true,
isolate: false,
macaddr: undefined,
'reverse-path': false,
'vlan-tag': 'auto',
});
};
export const SINGLE_INTERFACE_SCHEMA = (
t,
useDefault = false,
@@ -721,28 +746,10 @@ export const SINGLE_INTERFACE_SCHEMA = (
'isolate-hosts': bool().default(undefined),
services: array().of(string()).default(undefined),
ethernet: array()
.of(
object().shape({
'select-ports': array()
.of(string())
.test('select-ports-test', t('form.invalid_select_ports'), (v, { from }) => {
const rootConfig = from[from.length - 1];
const portStuff = [];
for (const conf of rootConfig.value.configuration) {
portStuff.push({
ports: conf.ethernet && conf.ethernet && conf.ethernet[0] ? conf.ethernet[0]['select-ports'] : [],
vlan: conf.vlan?.id ?? 'None',
});
}
return testSelectPorts(portStuff);
})
.default([]),
}),
)
.of(INTERFACE_ETHERNET_SCHEMA(t, useDefault))
.required(t('form.required'))
.min(1, t('form.required'))
.default([{ 'select-ports': [] }]),
.default([]),
vlan: initialCreation
? object().shape().nullable().default(undefined)
: object().shape({

View File

@@ -0,0 +1,36 @@
import * as React from 'react';
import { Button, useDisclosure } from '@chakra-ui/react';
import { Plus } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import CreateEntityModal from './CreateEntityModal';
type Props = {
id: string;
};
const CreateEntityButton = ({ id }: Props) => {
const { t } = useTranslation();
const modalProps = useDisclosure();
return (
<>
<Button
colorScheme="pink"
onClick={modalProps.onOpen}
leftIcon={
<Plus
size={18}
weight="bold"
style={{
marginTop: '-2px',
}}
/>
}
>
{t('entities.one')}
</Button>
<CreateEntityModal {...modalProps} parentId={id} />
</>
);
};
export default CreateEntityButton;

View File

@@ -1,12 +1,14 @@
import React, { useEffect, useState } from 'react';
import { useToast, SimpleGrid } from '@chakra-ui/react';
import { useToast, SimpleGrid, Heading, Box } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { Formik, Form } from 'formik';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { v4 as uuid } from 'uuid';
import DeviceRulesField from 'components/CustomFields/DeviceRulesField';
import IpDetectionModalField from 'components/CustomFields/IpDetectionModalField';
import RrmFormField from 'components/CustomFields/RrmFormField';
import SelectField from 'components/FormFields/SelectField';
import StringField from 'components/FormFields/StringField';
import { EntitySchema } from 'constants/formSchemas';
import { useCreateEntity } from 'hooks/Network/Entity';
@@ -26,12 +28,19 @@ const CreateEntityForm = ({ isOpen, onClose, formRef, parentId }) => {
const [formKey, setFormKey] = useState(uuid());
const create = useCreateEntity();
const createParameters = ({ name, description, note, deviceRules }) => ({
const options = [
{ value: 'yes', label: t('common.yes') },
{ value: 'no', label: t('common.no') },
{ value: 'inherit', label: t('common.inherit') },
];
const createParameters = ({ name, description, note, deviceRules, sourceIP }) => ({
name,
deviceRules,
description,
notes: note.length > 0 ? [{ note }] : undefined,
parent: parentId,
sourceIP,
});
useEffect(() => {
@@ -50,6 +59,7 @@ const CreateEntityForm = ({ isOpen, onClose, formRef, parentId }) => {
rcOnly: 'inherit',
firmwareUpgrade: 'inherit',
},
sourceIP: [],
note: '',
}}
validationSchema={EntitySchema(t)}
@@ -57,7 +67,6 @@ const CreateEntityForm = ({ isOpen, onClose, formRef, parentId }) => {
create.mutateAsync(createParameters(formData), {
onSuccess: (data) => {
queryClient.invalidateQueries(['get-entity-tree']);
queryClient.invalidateQueries(['get-entity', parentId]);
setSubmitting(false);
resetForm();
toast({
@@ -92,16 +101,33 @@ const CreateEntityForm = ({ isOpen, onClose, formRef, parentId }) => {
})
}
>
{({ errors, touched }) => (
<Form>
<SimpleGrid minChildWidth="300px" spacing="20px" mb={6}>
<StringField name="name" label={t('common.name')} errors={errors} touched={touched} isRequired />
<DeviceRulesField />
<StringField name="description" label={t('common.description')} errors={errors} touched={touched} />
<StringField name="note" label={t('common.note')} errors={errors} touched={touched} />
<Heading size="md">{t('common.details')}</Heading>
<StringField w="240px" name="name" label={t('common.name')} isRequired mr={4} />
<StringField name="description" isArea h="80px" label={t('common.description')} />
<StringField name="note" label={t('common.note')} />
<Heading size="md" mt={6} mb={4}>
Behaviors
</Heading>
<SimpleGrid minChildWidth="200px">
<IpDetectionModalField name="sourceIP" />
<Box>
<RrmFormField namePrefix="deviceRules" />
</Box>
<Box w="200px">
<SelectField name="deviceRules.rcOnly" label={t('configurations.rc_only')} options={options} w="100px" />
</Box>
<Box w="200px">
<SelectField
name="deviceRules.firmwareUpgrade"
label={t('configurations.firmware_upgrade')}
options={options}
w="100px"
/>
</Box>
</SimpleGrid>
</Form>
)}
</Formik>
);
};

View File

@@ -1,9 +1,8 @@
import React from 'react';
import { useDisclosure, Modal, ModalOverlay, ModalContent, ModalBody } from '@chakra-ui/react';
import { useDisclosure, Modal, ModalOverlay, ModalContent, ModalBody, CloseButton } from '@chakra-ui/react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import CreateEntityForm from './Form';
import CloseButton from 'components/Buttons/CloseButton';
import SaveButton from 'components/Buttons/SaveButton';
import ConfirmCloseAlert from 'components/Modals/Actions/ConfirmCloseAlert';
import ModalHeader from 'components/Modals/ModalHeader';

View File

@@ -1,77 +0,0 @@
import * as React from 'react';
import {
Button,
IconButton,
Menu,
MenuButton,
MenuDivider,
MenuItem,
MenuList,
Tooltip,
useBreakpoint,
useDisclosure,
} from '@chakra-ui/react';
import { TreeStructure } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import CreateEntityModal from './CreateEntityModal';
import { useGetEntity, useGetSelectEntities } from 'hooks/Network/Entity';
import { Entity } from 'models/Entity';
type Props = {
id: string;
};
const EntityDropdown = ({ id }: Props) => {
const { t } = useTranslation();
const breakpoint = useBreakpoint();
const navigate = useNavigate();
const getEntity = useGetEntity({ id });
const getChildren = useGetSelectEntities({ select: getEntity.data?.children ?? [] });
const { isOpen, onOpen, onClose } = useDisclosure();
const goToEntity = (entityId: string) => () => navigate(`/entity/${entityId}`);
const isCompact = breakpoint === 'base' || breakpoint === 'sm';
return (
<>
<Menu>
<Tooltip label={`${t('entities.sub_other')} (${getEntity.data?.children.length ?? 0})`}>
{isCompact ? (
<MenuButton
as={IconButton}
icon={<TreeStructure size={20} />}
aria-label={`${t('entities.sub_other')} (${getEntity.data?.children.length ?? 0})`}
colorScheme="pink"
isDisabled={!getEntity.data}
mx={2}
/>
) : (
<MenuButton
as={Button}
aria-label={`${t('entities.sub_other')} (${getEntity.data?.children.length ?? 0})`}
colorScheme="pink"
isDisabled={!getEntity.data}
mx={2}
>{`${t('entities.sub_other')} (${getEntity.data?.children.length ?? 0})`}</MenuButton>
)}
</Tooltip>
<MenuList>
<MenuItem onClick={onOpen}>{t('common.create')}</MenuItem>
<MenuDivider />
{getChildren.data
?.sort((a: Entity, b: Entity) => a.name.localeCompare(b.name))
.map(({ id: entityId, name }: Entity) => (
<MenuItem key={entityId} onClick={goToEntity(entityId)}>
{name}
</MenuItem>
)) ?? []}
</MenuList>
</Menu>
<CreateEntityModal isOpen={isOpen} onClose={onClose} parentId={getEntity.data?.id ?? ''} />
</>
);
};
export default EntityDropdown;

View File

@@ -1,12 +1,14 @@
import * as React from 'react';
import { HStack, Heading, Icon, Spacer } from '@chakra-ui/react';
import { Box, HStack, Heading, Icon, Spacer, VStack } from '@chakra-ui/react';
import { TreeStructure } from '@phosphor-icons/react';
import EntityFavoritesButton from '../../components/EntityFavoritesButton';
import CreateEntityButton from './CreateEntityButton';
import DeleteEntityPopover from './DeleteEntityPopover';
import EntityDropdown from './EntityDropdown';
import VenueDropdown from './VenueDropdown';
import RefreshButton from 'components/Buttons/RefreshButton';
import Card from 'components/Card';
import CardHeader from 'components/Card/CardHeader';
import CreateVenueButton from 'components/CreateVenueButton';
import EntityBreadcrumb from 'components/EntityBreadcrumb';
import { useGetEntity } from 'hooks/Network/Entity';
type Props = {
@@ -17,19 +19,27 @@ const EntityPageHeader = ({ id }: Props) => {
const getEntity = useGetEntity({ id });
return (
<Card mb={4}>
<CardHeader py={2} px={4} variant="unstyled" display="flex">
<HStack spacing={2}>
<Icon my="auto" as={TreeStructure} color="inherit" boxSize="24px" mr={2} />
<Card mb={4} py={2}>
<CardHeader px={4} variant="unstyled" display="flex">
<HStack spacing={2} alignItems="start">
<VStack alignItems="start">
<HStack marginRight="auto" spacing={2} alignItems="start">
<Icon my="auto" as={TreeStructure} color="inherit" boxSize="24px" />
<Heading my="auto" size="md">
{getEntity.data?.name}
</Heading>
<EntityDropdown id={id} />
<VenueDropdown id={id} />
<Box pt={-1}>
<EntityFavoritesButton id={id} type="entity" />
</Box>
</HStack>
<EntityBreadcrumb id={id} />
</VStack>
</HStack>
<Spacer />
<HStack spacing={2}>
<DeleteEntityPopover entity={getEntity.data} isDisabled={getEntity.isFetching || !getEntity.data} />
<CreateEntityButton id={id} />
<CreateVenueButton id={id} type="entity" />
<RefreshButton onClick={getEntity.refetch} isFetching={getEntity.isFetching} isCompact />
</HStack>
</CardHeader>

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { Box, Wrap, WrapItem } from '@chakra-ui/react';
import { Form } from 'formik';
import { useTranslation } from 'react-i18next';
import IpDetectionModalField from 'components/CustomFields/IpDetectionModalField';
import RrmFormField from 'components/CustomFields/RrmFormField';
import SelectField from 'components/FormFields/SelectField';
type Props = {
isDisabled: boolean;
};
const EntityDetailsForm = ({ isDisabled }: Props) => {
const { t } = useTranslation();
const options = [
{ value: 'yes', label: t('common.yes') },
{ value: 'no', label: t('common.no') },
{ value: 'inherit', label: t('common.inherit') },
];
return (
<Form>
<Wrap>
<WrapItem>
<IpDetectionModalField name="sourceIP" isDisabled={isDisabled} />
</WrapItem>
<WrapItem>
<Box>
<RrmFormField namePrefix="deviceRules" isDisabled={isDisabled} />
</Box>
</WrapItem>
<WrapItem>
<Box w="200px">
<SelectField
name="deviceRules.rcOnly"
label={t('configurations.rc_only')}
isDisabled={isDisabled}
options={options}
w="100px"
/>
</Box>
</WrapItem>
<WrapItem>
<Box w="200px">
<SelectField
name="deviceRules.firmwareUpgrade"
label={t('configurations.firmware_upgrade')}
isDisabled={isDisabled}
options={options}
w="100px"
/>
</Box>
</WrapItem>
</Wrap>
</Form>
);
};
export default EntityDetailsForm;

View File

@@ -0,0 +1,131 @@
import * as React from 'react';
import { Box, Center, HStack, Heading, Spacer, Spinner, useBoolean, useToast } from '@chakra-ui/react';
import { Formik } from 'formik';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import EntityDetailsForm from './Form';
import SaveButton from 'components/Buttons/SaveButton';
import ToggleEditButton from 'components/Buttons/ToggleEditButton';
import Card from 'components/Card';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import { EntitySchema } from 'constants/formSchemas';
import { useGetEntity, useUpdateEntity } from 'hooks/Network/Entity';
import useFormModal from 'hooks/useFormModal';
import useFormRef from 'hooks/useFormRef';
import { AxiosError } from 'models/Axios';
import { Entity } from 'models/Entity';
import { VenueApiResponse } from 'models/Venue';
type Props = {
id: string;
};
const EntityBehaviorsCard = ({ id }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const [formKey, setFormKey] = React.useState(uuid());
const { form, formRef } = useFormRef<VenueApiResponse & { __createLocation?: unknown }>();
const updateEntity = useUpdateEntity({ id });
const [isEditing, setEditing] = useBoolean();
const modalInfo = useFormModal({
isDirty: form.dirty,
});
const getEntity = useGetEntity({ id });
React.useEffect(() => {
setFormKey(uuid());
}, [isEditing]);
React.useEffect(() => {
if (!modalInfo.isOpen) {
setEditing.off();
}
}, [modalInfo.isOpen]);
return (
<Card>
<CardHeader>
<Heading size="md">Behaviors</Heading>
<Spacer />
<HStack>
<SaveButton
onClick={form.submitForm}
isLoading={form.isSubmitting}
isCompact
isDisabled={!isEditing || !form.isValid || !form.dirty}
hidden={!isEditing}
/>
<ToggleEditButton
isEditing={isEditing}
toggleEdit={setEditing.toggle}
isDisabled={getEntity.isFetching}
isDirty={form.dirty}
/>
</HStack>
</CardHeader>
<CardBody>
<Box w="100%">
{getEntity.data ? (
<Formik
innerRef={formRef}
enableReinitialize
key={formKey}
initialValues={getEntity.data as Entity}
validationSchema={EntitySchema(t)}
onSubmit={({ deviceRules, sourceIP }, { setSubmitting, resetForm }) =>
updateEntity.mutateAsync(
{
deviceRules,
sourceIP,
},
{
onSuccess: () => {
setSubmitting(false);
toast({
id: 'entity-update-success',
title: t('common.success'),
description: t('crud.success_update_obj', {
obj: t('entities.one'),
}),
status: 'success',
duration: 5000,
isClosable: true,
position: 'top-right',
});
resetForm();
setEditing.off();
},
onError: (e) => {
toast({
id: uuid(),
title: t('common.error'),
description: t('crud.error_update_obj', {
obj: t('entities.one'),
e: (e as AxiosError)?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
setSubmitting(false);
},
},
)
}
>
<EntityDetailsForm isDisabled={!isEditing || getEntity.isFetching || updateEntity.isLoading} />
</Formik>
) : (
<Center my={12}>
<Spinner size="xl" />
</Center>
)}
</Box>
</CardBody>
</Card>
);
};
export default EntityBehaviorsCard;

View File

@@ -50,7 +50,7 @@ const EntityResources = ({ id }: Props) => {
<CreateResourceModal refresh={getEntity.refetch} entityId={getEntity.data?.id ?? ''} />
</Flex>
<CardBody p={4}>
<Box w="100%" overflowX="auto">
<Box w="100%">
<ResourcesTable
select={getEntity.data?.variables ?? []}
actions={actions}

View File

@@ -1,9 +1,10 @@
import * as React from 'react';
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs, useColorMode } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import EntityConfigurations from './EntityConfigurations';
import EntityResources from './EntityResources';
import Card from 'components/Card';
import CardHeader from 'components/Card/CardHeader';
type Props = {
id: string;
@@ -11,13 +12,59 @@ type Props = {
const ConfigurationCard = ({ id }: Props) => {
const { t } = useTranslation();
const { colorMode } = useColorMode();
const isLight = colorMode === 'light';
return (
<Card p={0}>
<Tabs variant="enclosed" isLazy>
<TabList>
<Tab>{t('configurations.title')}</Tab>
<Tab>{t('resources.title')}</Tab>
<TabList mt={0} px={0}>
<CardHeader>
<Tab
mb="-14px"
style={{
// borderBottom: '0px',
borderWidth: '1px',
}}
_selected={{
background: isLight ? 'white' : 'var(--chakra-colors-gray-700)',
borderColor: 'unset',
textColor: isLight ? 'var(--chakra-colors-blue-600)' : 'var(--chakra-colors-blue-300)',
fontWeight: 'semibold',
// borderTopRadius: '15px',
borderTopColor: isLight ? 'black' : 'white',
borderLeftColor: isLight ? 'black' : 'white',
borderRightColor: isLight ? 'black' : 'white',
borderWidth: '0.5px',
borderBottom: '2px solid',
borderBottomColor: isLight ? 'white' : 'gray.800',
}}
>
{t('configurations.title')}
</Tab>
<Tab
mb="-14px"
style={{
// borderBottom: '0px',
borderWidth: '1px',
}}
_selected={{
background: isLight ? 'white' : 'var(--chakra-colors-gray-700)',
borderColor: 'unset',
textColor: isLight ? 'var(--chakra-colors-blue-600)' : 'var(--chakra-colors-blue-300)',
fontWeight: 'semibold',
// borderTopRadius: '15px',
borderTopColor: isLight ? 'black' : 'white',
borderLeftColor: isLight ? 'black' : 'white',
borderRightColor: isLight ? 'black' : 'white',
borderWidth: '0.5px',
borderBottom: '2px solid',
borderBottomColor: isLight ? 'white' : 'gray.800',
}}
>
{t('resources.title')}
</Tab>
</CardHeader>
</TabList>
<TabPanels>
<TabPanel p={0}>

View File

@@ -1,38 +0,0 @@
import * as React from 'react';
import { Heading, Spacer } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import CreateEntityModal from '../CreateEntityModal';
import EntityChildrenActions from './EntityChildrenActions';
import Card from 'components/Card';
import CardHeader from 'components/Card/CardHeader';
import EntityTable from 'components/Tables/EntityTable';
import { useGetEntity } from 'hooks/Network/Entity';
import { Entity } from 'models/Entity';
type Props = {
id: string;
};
const EntityChildren = ({ id }: Props) => {
const { t } = useTranslation();
const getEntity = useGetEntity({ id });
const actions = React.useCallback(
(cell: { row: { original: Entity } }) => <EntityChildrenActions entity={cell.row.original} isVenue />,
[],
);
return (
<Card>
<CardHeader>
<Heading size="md" my="auto">
{t('entities.sub_other')}
</Heading>
<Spacer />
<CreateEntityModal parentId={getEntity.data?.id ?? ''} />
</CardHeader>
<EntityTable select={getEntity.data?.children ?? []} actions={actions} />
</Card>
);
};
export default EntityChildren;

View File

@@ -2,13 +2,11 @@ import * as React from 'react';
import {
Box,
Center,
Flex,
FormControl,
FormLabel,
Grid,
GridItem,
HStack,
Heading,
SimpleGrid,
Spacer,
Spinner,
useBoolean,
@@ -22,8 +20,6 @@ import ToggleEditButton from 'components/Buttons/ToggleEditButton';
import Card from 'components/Card';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import DeviceRulesField from 'components/CustomFields/DeviceRulesField';
import IpDetectionModalField from 'components/CustomFields/IpDetectionModalField';
import FormattedDate from 'components/FormattedDate';
import StringField from 'components/FormFields/StringField';
import { EntitySchema } from 'constants/formSchemas';
@@ -82,13 +78,11 @@ const EntityDetails = ({ id }: Props) => {
key={formKey}
initialValues={getEntity.data}
validationSchema={EntitySchema(t)}
onSubmit={({ name, description, sourceIP, deviceRules }, { setSubmitting, resetForm }) =>
onSubmit={({ name, description }, { setSubmitting, resetForm }) =>
updateEntity.mutateAsync(
{
name,
description,
deviceRules,
sourceIP,
},
{
onSuccess: () => {
@@ -127,24 +121,16 @@ const EntityDetails = ({ id }: Props) => {
}
>
<Form>
<Grid templateRows="repeat(1, 1fr)" templateColumns="repeat(3, 1fr)" gap={4}>
<GridItem colSpan={1}>
<StringField name="name" label={t('common.name')} isDisabled={!editing} isRequired />
</GridItem>
<GridItem colSpan={2}>
<StringField name="description" label={t('common.description')} isDisabled={!editing} />
</GridItem>
</Grid>
<SimpleGrid minChildWidth="200px" spacing={4} mt={2}>
<IpDetectionModalField name="sourceIP" isDisabled={!editing} />
<DeviceRulesField isDisabled={!editing} />
<FormControl>
<Flex>
<StringField name="name" label={t('common.name')} isDisabled={!editing} isRequired w="240px" />
<FormControl ml={4} w="200px">
<FormLabel>{t('common.modified')}</FormLabel>
<Box pt={1.5}>
<Box pt={2}>
<FormattedDate date={getEntity.data?.modified} />
</Box>
</FormControl>
</SimpleGrid>
</Flex>
<StringField name="description" label={t('common.description')} isDisabled={!editing} isArea h="80px" />
</Form>
</Formik>
) : (

View File

@@ -17,8 +17,8 @@ import {
useDisclosure,
useToast,
} from '@chakra-ui/react';
import { useMutation } from '@tanstack/react-query';
import { MagnifyingGlass, Trash } from '@phosphor-icons/react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { AxiosError } from 'models/Axios';

View File

@@ -42,7 +42,7 @@ const EntityContacts = ({ id }: Props) => {
<CreateContactModal refresh={getEntity.refetch} entityId={getEntity.data?.id ?? ''} />
</Flex>
<CardBody p={4}>
<Box w="100%" overflowX="auto">
<Box w="100%">
<ContactTable
select={getEntity.data?.contacts ?? []}
actions={actions}

View File

@@ -42,7 +42,7 @@ const EntityLocations = ({ id }: Props) => {
<CreateLocationModal refresh={getEntity.refetch} entityId={getEntity.data?.id ?? ''} />
</Flex>
<CardBody p={4}>
<Box w="100%" overflowX="auto">
<Box w="100%">
<LocationTable
select={getEntity.data?.locations ?? []}
actions={actions}

View File

@@ -17,8 +17,8 @@ import {
useDisclosure,
useToast,
} from '@chakra-ui/react';
import { useMutation } from '@tanstack/react-query';
import { MagnifyingGlass, Trash } from '@phosphor-icons/react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { AxiosError } from 'models/Axios';

View File

@@ -1,9 +1,10 @@
import * as React from 'react';
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import { Box, Tab, TabList, TabPanel, TabPanels, Tabs, useColorMode } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import EntityContacts from './EntityContacts';
import EntityLocations from './EntityLocations';
import Card from 'components/Card';
import CardHeader from 'components/Card/CardHeader';
type Props = {
id: string;
@@ -11,13 +12,59 @@ type Props = {
const EntityLocationContactsCard = ({ id }: Props) => {
const { t } = useTranslation();
const { colorMode } = useColorMode();
const isLight = colorMode === 'light';
return (
<Card p={0}>
<Tabs variant="enclosed" isLazy>
<TabList>
<Tab>{t('locations.title')}</Tab>
<Tab>{t('contacts.other')}</Tab>
<TabList mt={0} px={0}>
<CardHeader>
<Tab
mb="-14px"
style={{
// borderBottom: '0px',
borderWidth: '1px',
}}
_selected={{
background: isLight ? 'white' : 'var(--chakra-colors-gray-700)',
borderColor: 'unset',
textColor: isLight ? 'var(--chakra-colors-blue-600)' : 'var(--chakra-colors-blue-300)',
fontWeight: 'semibold',
// borderTopRadius: '15px',
borderTopColor: isLight ? 'black' : 'white',
borderLeftColor: isLight ? 'black' : 'white',
borderRightColor: isLight ? 'black' : 'white',
borderWidth: '0.5px',
borderBottom: '2px solid',
borderBottomColor: isLight ? 'white' : 'gray.800',
}}
>
{t('locations.title')}
</Tab>
<Tab
mb="-14px"
style={{
// borderBottom: '0px',
borderWidth: '1px',
}}
_selected={{
background: isLight ? 'white' : 'var(--chakra-colors-gray-700)',
borderColor: 'unset',
textColor: isLight ? 'var(--chakra-colors-blue-600)' : 'var(--chakra-colors-blue-300)',
fontWeight: 'semibold',
// borderTopRadius: '15px',
borderTopColor: isLight ? 'black' : 'white',
borderLeftColor: isLight ? 'black' : 'white',
borderRightColor: isLight ? 'black' : 'white',
borderWidth: '0.5px',
borderBottom: '2px solid',
borderBottomColor: isLight ? 'white' : 'gray.800',
}}
>
{t('contacts.other')}
</Tab>
</CardHeader>
</TabList>
<TabPanels>
<TabPanel p={0}>

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import EntityInventoryActions from './Actions';
import Card from 'components/Card';
import CardHeader from 'components/Card/CardHeader';
import ExportDevicesTableButton from 'components/ExportInventoryButton';
import FactoryResetModal from 'components/Modals/SubscriberDevice/FactoryResetModal';
import FirmwareUpgradeModal from 'components/Modals/SubscriberDevice/FirmwareUpgradeModal';
import WifiScanModal from 'components/Modals/SubscriberDevice/WifiScanModal';
@@ -76,6 +77,7 @@ const EntityInventoryCard = ({ id }: Props) => {
{t('inventory.title')}
</Heading>
<Spacer />
<ExportDevicesTableButton serialNumbers={getEntity.data?.devices ?? []} />
<ImportDeviceCsvModal
refresh={getEntity.refetch}
parent={{ entity: getEntity.data?.id }}

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import Masonry from 'react-masonry-css';
import EntityBehaviorsCard from './BehaviorsCard';
import ConfigurationCard from './ConfigurationCard';
import EntityDetails from './EntityDetails';
import EntityLocationContactsCard from './EntityLocationContactsCard';
@@ -21,6 +22,7 @@ const EntityPageLayout = ({ id }: Props) => (
columnClassName="my-masonry-grid_column"
>
<EntityDetails id={id} />
<EntityBehaviorsCard id={id} />
<EntityInventoryCard id={id} />
<ConfigurationCard id={id} />
<EntityLocationContactsCard id={id} />

View File

@@ -6,6 +6,7 @@ import { v4 as uuid } from 'uuid';
import Actions from './Actions';
import { DataGrid } from 'components/DataGrid';
import { DataGridColumn, useDataGrid } from 'components/DataGrid/useDataGrid';
import ExportDevicesTableButton from 'components/ExportInventoryButton';
import FormattedDate from 'components/FormattedDate';
import FactoryResetModal from 'components/Modals/SubscriberDevice/FactoryResetModal';
import FirmwareUpgradeModal from 'components/Modals/SubscriberDevice/FirmwareUpgradeModal';
@@ -237,12 +238,20 @@ const InventoryTable = () => {
title: `${t('devices.title')} ${count ? `(${count})` : ''}`,
objectListed: t('devices.title'),
otherButtons: (
<>
<FormControl display="flex" w="unset" alignItems="center" mr={2}>
<FormLabel htmlFor="unassigned-switch" mb="0">
{t('devices.unassigned_only')}
</FormLabel>
<Switch id="unassigned-switch" defaultChecked={onlyUnassigned} onChange={onUnassignedToggle} size="lg" />
<Switch
id="unassigned-switch"
defaultChecked={onlyUnassigned}
onChange={onUnassignedToggle}
size="lg"
/>
</FormControl>
<ExportDevicesTableButton />
</>
),
addButton: <CreateConfigurationModal refresh={refetchCount} />,
leftContent: <DeviceSearchBar onClick={onSearchClick} />,

View File

@@ -0,0 +1,291 @@
import * as React from 'react';
import { Box, Center, Divider, Flex, Heading, useBreakpoint, useColorMode } from '@chakra-ui/react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
CoreChartOptions,
ElementChartOptions,
PluginChartOptions,
DatasetChartOptions,
ScaleChartOptions,
LineControllerChartOptions,
} from 'chart.js';
import { _DeepPartialObject } from 'chart.js/types/utils';
import { Line } from 'react-chartjs-2';
import Card from 'components/Card';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import { EndpointApiResponse } from 'hooks/Network/Endpoints';
import { SystemResources, useGetSystemResources } from 'hooks/Network/System';
import { bytesString } from 'utils/stringHelper';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
const getDivisionFactor = (maxBytes: number) => {
if (maxBytes < 1024) {
return { factor: 1, unit: 'B' };
}
if (maxBytes < 1024 * 1024) {
return { factor: 1024, unit: 'KB' };
}
if (maxBytes < 1024 * 1024 * 1024) {
return { factor: 1024 * 1024, unit: 'MB' };
}
return { factor: 1024 * 1024 * 1024, unit: 'GB' };
};
interface Props {
endpoint: EndpointApiResponse;
token: string;
}
const MonitoringSystemCard = ({ endpoint, token }: Props) => {
const { colorMode } = useColorMode();
const [cumulativeData, setCumulativeData] = React.useState<(SystemResources & { timestamp: Date })[]>([]);
const breakpoint = useBreakpoint();
const isVertical = React.useMemo(
() => breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md',
[breakpoint],
);
const onNewData = (data: SystemResources) => {
if (cumulativeData.length > 100) {
setCumulativeData((prev) => [...prev.slice(1), { ...data, timestamp: new Date() }]);
} else {
setCumulativeData((prev) => [...prev, { ...data, timestamp: new Date() }]);
}
};
const getResources = useGetSystemResources({ endpoint: endpoint.uri, token, onSuccess: onNewData });
const data = React.useMemo(() => {
const labels = [] as string[];
const currentRealMemory = [] as string[];
const peakRealMemory = [] as string[];
const currentVirtualMemory = [] as string[];
const peakVirtualMemory = [] as string[];
const numberOfFileDescriptors = [] as number[];
let highestRealMem = 0;
let highestVirtualMem = 0;
for (const curr of cumulativeData) {
if (curr.currRealMem > highestRealMem) highestRealMem = curr.currRealMem;
if (curr.currVirtMem > highestVirtualMem) highestVirtualMem = curr.currVirtMem;
}
const realMemFactor = getDivisionFactor(highestRealMem);
const virtualMemFactor = getDivisionFactor(highestVirtualMem);
for (const curr of cumulativeData) {
labels.push(curr.timestamp.toLocaleTimeString());
currentRealMemory.push((Math.floor((curr.currRealMem / realMemFactor.factor) * 100) / 100).toFixed(2));
peakRealMemory.push((Math.floor((curr.peakRealMem / realMemFactor.factor) * 100) / 100).toFixed(2));
currentVirtualMemory.push((Math.floor((curr.currVirtMem / virtualMemFactor.factor) * 100) / 100).toFixed(2));
peakVirtualMemory.push((Math.floor((curr.peakVirtMem / virtualMemFactor.factor) * 100) / 100).toFixed(2));
numberOfFileDescriptors.push(curr.numberOfFileDescriptors);
}
const datasets = [
{
label: 'Curr. Real Mem.',
data: currentRealMemory,
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.5)',
pointRadius: 0,
},
{
label: 'Curr. Virt. Mem.',
data: currentVirtualMemory,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.5)',
pointRadius: 0,
},
{
label: 'File Descriptors',
data: numberOfFileDescriptors,
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
pointRadius: 0,
},
] as const;
const newData = {
labels,
realMemFactor,
virtualMemFactor,
dataTick: (value: number) => {
try {
const temp = String(value);
if (temp.includes('.')) {
return Number(temp).toFixed(1);
}
return temp;
} catch (e) {
return value;
}
},
datasets,
};
return newData;
}, [cumulativeData]);
const options: (
factor?: number,
unit?: string,
) => _DeepPartialObject<
CoreChartOptions<'line'> &
ElementChartOptions<'line'> &
PluginChartOptions<'line'> &
DatasetChartOptions<'line'> &
ScaleChartOptions<'line'> &
LineControllerChartOptions
> = React.useMemo(
() => (_?: number, unit?: string) => ({
maintainAspectRatio: false,
responsive: true,
plugins: {
legend: {
display: false,
},
title: {
display: false,
},
tooltip: {
mode: 'index',
position: 'nearest',
intersect: false,
callbacks: {
label: (context) => `${context.dataset.label}: ${context.formattedValue} ${unit ?? ''}`,
},
},
},
scales: {
x: {
grid: {
color: colorMode === 'dark' ? 'white' : undefined,
},
ticks: {
color: colorMode === 'dark' ? 'white' : undefined,
maxRotation: 10,
autoSkip: true,
maxTicksLimit: 10,
},
},
y: {
grid: {
color: colorMode === 'dark' ? 'white' : undefined,
},
ticks: {
color: colorMode === 'dark' ? 'white' : undefined,
callback: (tickValue) => (unit ? `${data.dataTick(tickValue as number)} ${unit}` : tickValue),
},
beginAtZero: true,
},
},
elements: {
line: {
tension: 0.4,
},
},
hover: {
mode: 'nearest',
intersect: true,
},
}),
[colorMode, data],
);
if (getResources.error || getResources.isLoading) return null;
return (
<Card>
<CardHeader>
<Heading size="md" pt={0}>
{endpoint.type}
</Heading>
</CardHeader>
<CardBody>
{isVertical ? (
<Box w="100%" display="block">
<Box mb={4}>
<Heading size="sm">Real Memory (Peak: {bytesString(getResources.data?.peakRealMem ?? 0)})</Heading>
<Box position="relative" w="100%">
<Line
options={options(data.realMemFactor.factor, data.realMemFactor.unit)}
data={{ ...data, datasets: [data.datasets[0]] }}
/>
</Box>
</Box>
<Box>
<Heading size="sm">Virtual Memory (Peak: {bytesString(getResources.data?.peakVirtMem ?? 0)})</Heading>
<Box position="relative" w="100%">
<Line
options={options(data.virtualMemFactor.factor, data.virtualMemFactor.unit)}
data={{ ...data, datasets: [data.datasets[1]] }}
/>
</Box>
</Box>
<Box>
<Heading size="sm">File Descriptors</Heading>
<Box position="relative" w="100%">
<Line options={options()} data={{ ...data, datasets: [data.datasets[2]] }} height={180} />
</Box>
</Box>
</Box>
) : (
<Box w="100%" display="block">
<Flex w="100%">
<Box w="100%">
<Center>
<Heading size="sm">Real Memory (Peak: {bytesString(getResources.data?.peakRealMem ?? 0)})</Heading>
</Center>
<Box position="relative" w="100%">
<Line
options={options(data.realMemFactor.factor, data.realMemFactor.unit)}
data={{ ...data, datasets: [data.datasets[0]] }}
height={180}
/>
</Box>
</Box>
<Divider height="180px" mx={4} orientation="vertical" />
<Box w="100%">
<Center>
<Heading size="sm">Virtual Memory (Peak: {bytesString(getResources.data?.peakVirtMem ?? 0)})</Heading>
</Center>
<Box position="relative" w="100%">
<Line
options={options(data.virtualMemFactor.factor, data.virtualMemFactor.unit)}
data={{ ...data, datasets: [data.datasets[1]] }}
height={180}
/>
</Box>
</Box>
<Divider height="180px" mx={4} orientation="vertical" />
<Box w="100%">
<Center>
<Heading size="sm">File Descriptors</Heading>
</Center>
<Box position="relative" w="100%">
<Line options={options()} data={{ ...data, datasets: [data.datasets[2]] }} height={180} />
</Box>
</Box>
</Flex>
</Box>
)}
</CardBody>
</Card>
);
};
export default MonitoringSystemCard;

View File

@@ -0,0 +1,56 @@
import * as React from 'react';
import { SimpleGrid, Spacer } from '@chakra-ui/react';
import { v4 as uuid } from 'uuid';
import SystemTile from './MonitoringSystemCard';
import RefreshButton from 'components/Buttons/RefreshButton';
import Card from 'components/Card';
import CardHeader from 'components/Card/CardHeader';
import { useAuth } from 'contexts/AuthProvider';
import { useGetEndpoints } from 'hooks/Network/Endpoints';
import { axiosSec } from 'utils/axiosInstances';
type MonitoringPageProps = {
isOnlySec?: boolean;
};
const MonitoringPage = ({ isOnlySec }: MonitoringPageProps) => {
const { token } = useAuth();
const { data: endpoints, refetch, isFetching } = useGetEndpoints({ onSuccess: () => {} });
const endpointsList = React.useMemo(() => {
if (!token || (!isOnlySec && !endpoints)) return null;
const endpointList = endpoints ? [...endpoints] : [];
endpointList.push({
uri: axiosSec.defaults.baseURL?.split('/api/v1')[0] ?? '',
type: isOnlySec ? '' : 'owsec',
id: 0,
vendor: 'owsec',
authenticationType: '',
});
return endpointList
.sort((a, b) => {
if (a.type < b.type) return -1;
if (a.type > b.type) return 1;
return 0;
})
.map((endpoint) => <SystemTile key={uuid()} endpoint={endpoint} token={token} />);
}, [endpoints, token]);
return (
<>
<Card mb="20px">
<CardHeader variant="unstyled" px={4} py={2}>
<Spacer />
<RefreshButton onClick={refetch} isFetching={isFetching} />
</CardHeader>
</Card>
<SimpleGrid minChildWidth="1000px" spacing="20px">
{endpointsList}
</SimpleGrid>
</>
);
};
export default MonitoringPage;

View File

@@ -28,7 +28,7 @@ const VenueActions = ({ venueId, isDisabled }: Props) => {
<>
<Menu>
<Tooltip label={t('common.actions')} hasArrow>
<MenuButton as={IconButton} icon={<Wrench size={20} />} ml={2} isDisabled={isDisabled}>
<MenuButton as={IconButton} icon={<Wrench size={20} />} isDisabled={isDisabled}>
{t('common.actions')}
</MenuButton>
</Tooltip>

View File

@@ -103,7 +103,7 @@ const DeleteVenuePopover = ({ venue, isDisabled }) => {
<Popover isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
<PopoverAnchor>
<span>
<DeleteButton onClick={onOpen} isDisabled={isDisabled} ml={2} />
<DeleteButton onClick={onOpen} isDisabled={isDisabled} />
</span>
</PopoverAnchor>
<PopoverContent>

View File

@@ -1,129 +0,0 @@
import React from 'react';
import {
Box,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverHeader,
PopoverTrigger,
Portal,
Table,
Tag,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
} from '@chakra-ui/react';
import { ComputedDatum } from '@nivo/circle-packing';
import { Interpolation, SpringValue, animated } from '@react-spring/web';
import { WifiHigh } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import { AssociationCircle } from '../utils';
import { useCircleGraph } from 'contexts/CircleGraphProvider';
import { bytesString, formatNumberToScientificBasedOnMax } from 'utils/stringHelper';
const AssociationCirclePack = ({
node,
style,
handleClicks,
}: {
node: ComputedDatum<AssociationCircle>;
style: {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
};
handleClicks: {
onClick: (e: React.MouseEvent<SVGCircleElement>) => void;
};
}) => {
const { t } = useTranslation();
const context = useCircleGraph();
return (
<Popover isLazy trigger="hover" placement="auto">
<PopoverTrigger>
<animated.circle
key={node.id}
cx={style.x}
cy={style.y}
r={style.radius}
fill={node.fill}
stroke="black"
cursor="pointer"
strokeWidth="1px"
opacity={style.opacity}
onClick={handleClicks.onClick}
/>
</PopoverTrigger>
<Portal containerRef={context?.popoverRef}>
<PopoverContent>
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
<PopoverHeader display="flex">
<WifiHigh weight="bold" size={24} />
<Text ml={2}>
{node?.data?.name.split('/')[0]}
<Tag ml={2} colorScheme={node.data.details.tagColor} size="md">
<b>({node.data.details.rssi} db)</b>
</Tag>
</Text>
</PopoverHeader>
<PopoverBody px={0}>
<Box px={0} fontWeight="bold">
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th />
<Th>TX</Th>
<Th>RX</Th>
</Tr>
</Thead>
<Tbody>
<Tr>
<Td>{t('analytics.total_data')}</Td>
<Td>{bytesString(node.data.details.tx_bytes)}</Td>
<Td>{bytesString(node.data.details.rx_bytes)}</Td>
</Tr>
<Tr>
<Td>{t('analytics.delta')}</Td>
<Td>{bytesString(node.data.details.tx_bytes_delta)}</Td>
<Td>{bytesString(node.data.details.rx_bytes_delta)}</Td>
</Tr>
<Tr>
<Td>{t('analytics.bandwidth')}</Td>
<Td>{bytesString(node.data.details.tx_bytes_bw)}</Td>
<Td>{bytesString(node.data.details.rx_bytes_bw)}</Td>
</Tr>
<Tr>
<Td>{t('analytics.packets')} /s</Td>
<Td>{formatNumberToScientificBasedOnMax(node.data.details.tx_packets_bw)}</Td>
<Td>{formatNumberToScientificBasedOnMax(node.data.details.rx_packets_bw)}</Td>
</Tr>
<Tr>
<Td>MCS</Td>
<Td>{node.data.details.tx_rate.mcs}</Td>
<Td>{node.data.details.rx_rate.mcs}</Td>
</Tr>
<Tr>
<Td>NSS</Td>
<Td>{node.data.details.tx_rate.nss}</Td>
<Td>{node.data.details.rx_rate.nss}</Td>
</Tr>
</Tbody>
</Table>
</Box>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
};
export default AssociationCirclePack;

View File

@@ -1,152 +0,0 @@
import React, { useMemo } from 'react';
import {
IconButton,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverHeader,
PopoverTrigger,
Portal,
Text,
Tooltip,
Table,
Tbody,
Td,
Tr,
Box,
Flex,
Tag as TagDisplay,
} from '@chakra-ui/react';
import { ComputedDatum } from '@nivo/circle-packing';
import { Interpolation, SpringValue, animated } from '@react-spring/web';
import { ArrowSquareOut, Tag } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import { DeviceCircleInfo } from '../utils';
import FormattedDate from 'components/FormattedDate';
import { useCircleGraph } from 'contexts/CircleGraphProvider';
import { useGetGatewayUi } from 'hooks/Network/Endpoints';
import { bytesString } from 'utils/stringHelper';
const DeviceCirclePack = ({
node,
style,
handleClicks,
}: {
node: ComputedDatum<DeviceCircleInfo>;
style: {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
};
handleClicks: {
onClick: (e: React.MouseEvent<SVGCircleElement>) => void;
};
}) => {
const { t } = useTranslation();
const { data: gwUi } = useGetGatewayUi();
const context = useCircleGraph();
const handleOpenInGateway = useMemo(
() => () => window.open(`${gwUi}/#/devices/${node.data.details.deviceInfo.serialNumber}`, '_blank'),
[gwUi],
);
return (
<Popover isLazy trigger="hover" placement="auto">
<PopoverTrigger>
<animated.circle
key={node.id}
cx={style.x}
cy={style.y}
r={style.radius}
fill={node.data.details.color}
stroke="black"
strokeWidth="1px"
cursor="pointer"
opacity={style.opacity}
onClick={handleClicks.onClick}
/>
</PopoverTrigger>
<Portal containerRef={context?.popoverRef}>
<PopoverContent w="580px">
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
<PopoverHeader display="flex">
<Tag size={24} weight="fill" />
<Text ml={2}>{node?.data?.name.split('/')[0]}</Text>
<Tooltip hasArrow label={t('common.view_in_gateway')} placement="top">
<IconButton
aria-label={t('common.view_in_gateway')}
ml={2}
colorScheme="blue"
icon={<ArrowSquareOut size={20} />}
size="xs"
onClick={handleOpenInGateway}
/>
</Tooltip>
</PopoverHeader>
<PopoverBody>
<Box px={0} fontWeight="bold" w="100%">
<Table variant="simple" size="sm">
<Tbody>
<Tr>
<Td w="130px">{t('common.type')}</Td>
<Td>
{node.data.details.deviceInfo.deviceType === ''
? t('common.unknown')
: node.data.details.deviceInfo.deviceType}
</Td>
<Td w="150px">TX {t('analytics.delta')}</Td>
<Td>{bytesString(node.data.details.apData.tx_bytes_delta)}</Td>
</Tr>
<Tr>
<Td w="130px">{t('analytics.firmware')}</Td>
<Td>{node.data.details.deviceInfo.lastFirmware?.split('/')[1] ?? t('common.unknown')}</Td>
<Td w="150px">RX {t('analytics.delta')}</Td>
<Td>{bytesString(node.data.details.apData.rx_bytes_delta)}</Td>
</Tr>
<Tr>
<Td w="130px">SSIDs</Td>
<Td>{node.data.children.length}</Td>
<Td w="150px">2G {t('analytics.associations')}</Td>
<Td>{node.data.details.deviceInfo.associations_2g}</Td>
</Tr>
<Tr>
<Td w="130px">{t('analytics.health')}</Td>
<Td>
<TagDisplay ml={-2} colorScheme={node.data.details.tagColor} size="md">
<b>{node.data.details.deviceInfo.health}%</b>
</TagDisplay>
</Td>
<Td w="150px">5G {t('analytics.associations')}</Td>
<Td>{node.data.details.deviceInfo.associations_5g}</Td>
</Tr>
<Tr>
<Td w="130px">{t('analytics.memory_used')}</Td>
<Td>{Math.floor(node.data.details.deviceInfo.memory)}%</Td>
<Td w="150px">6G {t('analytics.associations')}</Td>
<Td>{node.data.details.deviceInfo.associations_6g}</Td>
</Tr>
</Tbody>
</Table>
{node.data.details.deviceInfo.lastDisconnection !== 0 && (
<Flex ml={4}>
<Text mr={1}>{t('analytics.last_disconnection')}</Text>
<Text>
<FormattedDate date={node.data.details.deviceInfo.lastDisconnection} />
</Text>
</Flex>
)}
</Box>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
};
export default DeviceCirclePack;

View File

@@ -1,118 +0,0 @@
import React from 'react';
import {
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverHeader,
PopoverTrigger,
Portal,
Text,
Table,
Tbody,
Td,
Tr,
Box,
Tag,
} from '@chakra-ui/react';
import { ComputedDatum } from '@nivo/circle-packing';
import { Interpolation, SpringValue, animated } from '@react-spring/web';
import { Radio } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import { RadioCircle } from '../utils';
import { useCircleGraph } from 'contexts/CircleGraphProvider';
const RadioCirclePack = ({
node,
style,
handleClicks,
}: {
node: ComputedDatum<RadioCircle>;
style: {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
};
handleClicks: {
onClick: (e: React.MouseEvent<SVGCircleElement>) => void;
};
}) => {
const { t } = useTranslation();
const context = useCircleGraph();
return (
<Popover isLazy trigger="hover" placement="auto">
<PopoverTrigger>
<animated.circle
key={node.id}
cx={style.x}
cy={style.y}
r={style.radius}
fill={node.data.details.color}
stroke="blue"
cursor="pointer"
strokeWidth="1px"
opacity={style.opacity}
onClick={handleClicks.onClick}
/>
</PopoverTrigger>
<Portal containerRef={context?.popoverRef}>
<PopoverContent>
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
<PopoverHeader display="flex">
<Radio size={24} weight="fill" />
<Text ml={2} mt="2px">
{node.data.details.band}G
</Text>
</PopoverHeader>
<PopoverBody>
<Box px={0} fontWeight="bold">
<Table variant="simple" size="sm">
<Tbody>
<Tr>
<Td w="100px">{t('analytics.noise')}</Td>
<Td>{node.data.details.noise} db</Td>
</Tr>
<Tr>
<Td w="100px">{t('analytics.channel')}</Td>
<Td>{node.data.details.channel}</Td>
</Tr>
<Tr>
<Td w="100px">{t('analytics.airtime')}</Td>
<Td>
<Tag ml={-2} colorScheme={node.data.details.tagColor} size="md">
<b>{node.data.details.transmit_pct.toFixed(2)}%</b>
</Tag>
</Td>
</Tr>
<Tr>
<Td w="100px">{t('analytics.active')}</Td>
<Td>{node.data.details.active_pct.toFixed(2)}%</Td>
</Tr>
<Tr>
<Td w="100px">{t('analytics.busy')}</Td>
<Td>{node.data.details.busy_pct.toFixed(2)}%</Td>
</Tr>
<Tr>
<Td w="100px">{t('analytics.receive')}</Td>
<Td>{node.data.details.receive_pct.toFixed(2)}%</Td>
</Tr>
<Tr>
<Td w="100px">{t('analytics.temperature')}</Td>
<Td>{node.data.details.temperature}&#8451;</Td>
</Tr>
</Tbody>
</Table>
</Box>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
};
export default RadioCirclePack;

View File

@@ -1,134 +0,0 @@
import React from 'react';
import {
Heading,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverHeader,
PopoverTrigger,
Portal,
Text,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
Box,
Tag,
} from '@chakra-ui/react';
import { ComputedDatum } from '@nivo/circle-packing';
import { Interpolation, SpringValue, animated } from '@react-spring/web';
import { Broadcast } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import { SsidCircle } from '../utils';
import { useCircleGraph } from 'contexts/CircleGraphProvider';
import { bytesString } from 'utils/stringHelper';
const SsidCirclePack = ({
node,
style,
handleClicks,
}: {
node: ComputedDatum<SsidCircle>;
style: {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
};
handleClicks: {
onClick: (e: React.MouseEvent<SVGCircleElement>) => void;
};
}) => {
const { t } = useTranslation();
const context = useCircleGraph();
return (
<Popover isLazy trigger="hover" placement="auto">
<PopoverTrigger>
<animated.circle
key={node.id}
cx={style.x}
cy={style.y}
r={style.radius}
fill={node.data.details.color}
stroke="black"
strokeWidth="2px"
cursor="pointer"
strokeDasharray="4"
opacity={style.opacity}
onClick={handleClicks.onClick}
/>
</PopoverTrigger>
<Portal containerRef={context?.popoverRef}>
<PopoverContent w="400px">
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
<PopoverHeader display="flex">
<Broadcast size={24} weight="fill" />
<Text ml={2}>
{node.data.details.band}G - {node?.data?.name.split('/')[0]}{' '}
{node.data.children.length === 0 ? undefined : (
<Tag colorScheme={node.data.details.tagColor} size="md">
<b>({node.data.details.avgRssi} avg db)</b>
</Tag>
)}
</Text>
</PopoverHeader>
<PopoverBody px={0}>
<Heading size="sm" pl={4}>
BSSID: {node.data.details.bssid}
</Heading>
<Heading size="sm" pl={4}>
{t('analytics.associations')}: {node.data.children.length}
</Heading>
<Box px={0} fontWeight="bold">
<Table variant="simple" size="sm">
<Thead>
<Tr>
<Th w="150px" />
<Th>{t('common.avg')}</Th>
<Th>{t('common.min')}</Th>
<Th>{t('common.max')}</Th>
</Tr>
</Thead>
<Tbody>
<Tr>
<Td w="150px">TX {t('analytics.bandwidth')}</Td>
<Td>{bytesString(node.data.details.tx_bytes_bw.avg)}</Td>
<Td>{bytesString(node.data.details.tx_bytes_bw.min)}</Td>
<Td>{bytesString(node.data.details.tx_bytes_bw.max)}</Td>
</Tr>
<Tr>
<Td w="150px">TX {t('analytics.packets')} /s</Td>
<Td>{node.data.details.tx_packets_bw.avg.toFixed(2)}</Td>
<Td>{node.data.details.tx_packets_bw.min.toFixed(2)}</Td>
<Td>{node.data.details.tx_packets_bw.max.toFixed(2)}</Td>
</Tr>
<Tr>
<Td w="150px">RX {t('analytics.bandwidth')}</Td>
<Td>{bytesString(node.data.details.rx_bytes_bw.avg)}</Td>
<Td>{bytesString(node.data.details.rx_bytes_bw.min)}</Td>
<Td>{bytesString(node.data.details.rx_bytes_bw.max)}</Td>
</Tr>
<Tr>
<Td w="150px">RX {t('analytics.packets')} /s</Td>
<Td>{node.data.details.rx_packets_bw.avg.toFixed(2)}</Td>
<Td>{node.data.details.rx_packets_bw.min.toFixed(2)}</Td>
<Td>{node.data.details.rx_packets_bw.max.toFixed(2)}</Td>
</Tr>
</Tbody>
</Table>
</Box>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
};
export default SsidCirclePack;

View File

@@ -1,84 +0,0 @@
import React from 'react';
import {
Heading,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverHeader,
PopoverTrigger,
Portal,
Tag,
Text,
} from '@chakra-ui/react';
import { ComputedDatum } from '@nivo/circle-packing';
import { Interpolation, SpringValue, animated } from '@react-spring/web';
import { Buildings } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import { CirclePackRoot } from '../utils';
import { useCircleGraph } from 'contexts/CircleGraphProvider';
const VenueCirclePack = ({
node,
style,
handleClicks,
}: {
node: ComputedDatum<CirclePackRoot>;
style: {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
};
handleClicks: {
onClick: (e: React.MouseEvent<SVGCircleElement>) => void;
};
}) => {
const { t } = useTranslation();
const context = useCircleGraph();
return (
<Popover isLazy trigger="hover" placement="auto">
<PopoverTrigger>
<animated.circle
key={node.id}
cx={style.x}
cy={style.y}
r={style.radius}
cursor="pointer"
fill={node.data.details.color}
stroke="black"
strokeWidth="3px"
opacity={style.opacity}
onClick={handleClicks.onClick}
/>
</PopoverTrigger>
<Portal containerRef={context?.popoverRef}>
<PopoverContent>
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
<PopoverHeader display="flex">
<Buildings weight="fill" size={24} />
<Text ml={2}>{node?.data?.name.split('/')[0]}</Text>
</PopoverHeader>
<PopoverBody>
<Heading size="sm">
{node.data.children.length} {t('devices.title')}
</Heading>
<Heading size="sm">
<Tag colorScheme={node.data.details.tagColor} size="md">
<b>
{node.data.details.avgHealth}% {t('analytics.average_health')}
</b>
</Tag>
</Heading>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
};
export default VenueCirclePack;

View File

@@ -1,110 +0,0 @@
import React, { useMemo } from 'react';
import { ComputedDatum, CircleComponent as CircleComponentT, CircleProps } from '@nivo/circle-packing';
import { Interpolation, SpringValue } from '@react-spring/web';
import { AssociationCircle, CirclePackRoot, DeviceCircleInfo, RadioCircle, SsidCircle } from '../utils';
import AssociationCirclePack from './AssociationCirclePack';
import DeviceCirclePack from './DeviceCirclePack';
import RadioCirclePack from './RadioCirclePack';
import SsidCirclePack from './SsidCirclePack';
import VenueCirclePack from './VenueCirclePack';
const CircleComponent: CircleComponentT<
CirclePackRoot | SsidCircle | RadioCircle | AssociationCircle | DeviceCircleInfo
> = ({
node,
style,
onClick,
}: CircleProps<CirclePackRoot | SsidCircle | RadioCircle | AssociationCircle | DeviceCircleInfo>) => {
const handleClicks = useMemo(
() => ({
onClick: (e: React.MouseEvent<SVGCircleElement>) => {
if (onClick) onClick(node, e);
},
}),
[onClick, node],
);
if (node.data.type === 'association')
return (
<AssociationCirclePack
node={node as ComputedDatum<AssociationCircle>}
style={
style as unknown as {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
}
}
handleClicks={handleClicks}
/>
);
if (node.data.type === 'ssid')
return (
<SsidCirclePack
node={node as ComputedDatum<SsidCircle>}
style={
style as unknown as {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
}
}
handleClicks={handleClicks}
/>
);
if (node.data.type === 'radio')
return (
<RadioCirclePack
node={node as ComputedDatum<RadioCircle>}
style={
style as unknown as {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
}
}
handleClicks={handleClicks}
/>
);
if (node.data.type === 'device')
return (
<DeviceCirclePack
node={node as ComputedDatum<DeviceCircleInfo>}
style={
style as unknown as {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
}
}
handleClicks={handleClicks}
/>
);
if (node.data.type === 'venue')
return (
<VenueCirclePack
node={node as ComputedDatum<CirclePackRoot>}
style={
style as unknown as {
x: SpringValue<number>;
y: SpringValue<number>;
radius: Interpolation<number>;
textColor: SpringValue<string>;
opacity: SpringValue<number>;
}
}
handleClicks={handleClicks}
/>
);
return null;
};
export default CircleComponent;

View File

@@ -1,23 +0,0 @@
import React from 'react';
import { LabelComponent, LabelProps } from '@nivo/circle-packing';
import { animated } from '@react-spring/web';
import { CirclePackRoot } from './utils';
const CircleLabel: LabelComponent<CirclePackRoot> = ({ label, node, style }: LabelProps<CirclePackRoot>) => (
<animated.text
key={node.id}
x={style.x}
y={style.y}
textAnchor="middle"
dominantBaseline="central"
style={{
fill: style.textColor,
opacity: style.opacity,
pointerEvents: 'none',
}}
>
{typeof label === 'string' ? label.split('/')[0] : label}
</animated.text>
);
export default CircleLabel;

View File

@@ -1,56 +0,0 @@
import React, { useMemo, useState } from 'react';
import { Box, Center, Heading, Slider, SliderFilledTrack, SliderThumb, SliderTrack, Tooltip } from '@chakra-ui/react';
import { Clock } from '@phosphor-icons/react';
import { AnalyticsTimePointApiResponse } from 'models/Analytics';
import { compactDate } from 'utils/dateFormatting';
type Props = {
index: number;
setIndex: (index: number) => void;
points: AnalyticsTimePointApiResponse[][];
};
const CirclePackSlider = ({ index, setIndex, points }: Props) => {
const [showTooltip, setShowTooltip] = useState(false);
const onMouseEnter = () => setShowTooltip(true);
const onMouseLeave = () => setShowTooltip(false);
const stepsDetails = useMemo(
() => ({
steps: points.length,
allTimestamps: points.map((point) => (!point[0] ? '-' : compactDate(point[0].timestamp))),
}),
[points],
);
const currTimestamp = points[index]?.[0]?.timestamp;
return (
<>
<Slider
id="slider"
value={index}
min={0}
max={stepsDetails.steps - 1}
colorScheme="teal"
onChange={setIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<SliderTrack h="6px" borderRadius="4px">
<SliderFilledTrack />
</SliderTrack>
<Tooltip hasArrow placement="top" isOpen={showTooltip} label={stepsDetails.allTimestamps[index]}>
<SliderThumb boxSize={6}>
<Box textColor="black" as={Clock} />
</SliderThumb>
</Tooltip>
</Slider>
<Center>
<Heading size="lg">{currTimestamp ? compactDate(currTimestamp) : ''}</Heading>
</Center>
</>
);
};
export default React.memo(CirclePackSlider);

View File

@@ -1,89 +0,0 @@
import * as React from 'react';
import { Box, Center, Heading, useColorMode } from '@chakra-ui/react';
import { MouseHandler, ResponsiveCirclePacking } from '@nivo/circle-packing';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import CircleComponent from './CircleComponent';
import CircleLabel from './CircleLabel';
import CirclePackSlider from './Slider';
import { useCirclePackTheme } from './useCirclePackTheme';
import { CirclePackRoot, parseAnalyticsTimepointsToCirclePackData } from './utils';
import { useCircleGraph } from 'contexts/CircleGraphProvider';
import { AnalyticsTimePointApiResponse } from 'models/Analytics';
import { VenueApiResponse } from 'models/Venue';
type Props = {
data: AnalyticsTimePointApiResponse[][];
venue: VenueApiResponse;
handle: FullScreenHandle;
};
const LiveViewCirclePack = ({ data, venue, handle }: Props) => {
const { t } = useTranslation();
const context = useCircleGraph();
const { colorMode } = useColorMode();
const theme = useCirclePackTheme();
const [pointIndex, setPointIndex] = React.useState(Math.max(data.length - 1, 0));
const [zoomedId, setZoomedId] = React.useState<string | null>(null);
const parsedData = React.useMemo(() => {
const dataIndex = data[pointIndex] || [];
if (dataIndex) {
try {
return parseAnalyticsTimepointsToCirclePackData(dataIndex, venue, colorMode);
} catch (e) {
return undefined;
}
}
return undefined;
}, [data, pointIndex, colorMode]);
const handleNodeClick: MouseHandler<CirclePackRoot> = React.useCallback(
(node) => {
setZoomedId(zoomedId === node.id ? null : node.id);
},
[zoomedId],
);
React.useEffect(() => {
setPointIndex(data.length - 1);
}, [data]);
return (
<Box px={10} h="100%">
{data.length > 0 && <CirclePackSlider index={pointIndex} setIndex={setPointIndex} points={data} />}
<Box w="100%" h={handle?.active ? 'calc(100vh - 200px)' : '600px'} ref={context?.popoverRef}>
{!parsedData ? (
<Center>
<Heading size="lg">{t('common.no_records_found')}</Heading>
</Center>
) : (
<ResponsiveCirclePacking
margin={theme.MARGINS}
padding={36}
defs={theme.shapeDefs}
animate={false}
fill={theme.getFill}
id="name"
value="scale"
data={parsedData}
enableLabels
labelsSkipRadius={42}
labelsFilter={theme.getLabelsFilter}
labelTextColor={theme.LABEL_TEXT_COLORS}
labelComponent={CircleLabel}
// onMouseEnter={null}
// tooltip={null}
circleComponent={CircleComponent}
zoomedId={zoomedId}
theme={theme.THEME}
onClick={handleNodeClick}
/>
)}
</Box>
</Box>
);
};
export default LiveViewCirclePack;

View File

@@ -1,101 +0,0 @@
import * as React from 'react';
import { useColorMode } from '@chakra-ui/react';
import { patternLinesDef } from '@nivo/core';
import { AssociationCircle, CirclePackRoot, RadioCircle, SsidCircle } from './utils';
const THEME = {
labels: {
text: {
background: 'black',
},
background: 'black',
},
};
const LABEL_TEXT_COLORS = {
from: 'color',
modifiers: [['darker', 4]],
};
const MARGINS = { top: 20, right: 20, bottom: 20, left: 20 };
const getFill = [
{
match: (d: { data: CirclePackRoot | SsidCircle | RadioCircle | AssociationCircle }) =>
d.data.type === 'association' && typeof d.data.details.rssi === 'number' && d.data.details.rssi >= -45,
id: 'assoc_success',
},
{
match: (d: { data: CirclePackRoot | SsidCircle | RadioCircle | AssociationCircle }) =>
d.data.type === 'association' && typeof d.data.details.rssi === 'number' && d.data.details.rssi >= -60,
id: 'assoc_warning',
},
{
match: (d: { data: CirclePackRoot | SsidCircle | RadioCircle | AssociationCircle }) =>
d.data.type === 'association' && typeof d.data.details.rssi === 'number' && d.data.details.rssi < -60,
id: 'assoc_danger',
},
];
const getLabelsFilter = (label: { node: { height: number } }) => label.node.height === 0;
export const useCirclePackTheme = () => {
const { colorMode } = useColorMode();
const shapeDefs = React.useMemo(
() => [
patternLinesDef(
'assoc_success',
colorMode === 'light'
? {
rotation: -45,
color: 'var(--chakra-colors-success-400)',
background: 'var(--chakra-colors-success-600)',
}
: {
rotation: -45,
color: 'var(--chakra-colors-success-400)',
background: 'var(--chakra-colors-success-600)',
},
),
patternLinesDef(
'assoc_warning',
colorMode === 'light'
? {
rotation: -45,
color: 'var(--chakra-colors-warning-100)',
background: 'var(--chakra-colors-warning-400)',
}
: {
rotation: -45,
color: 'var(--chakra-colors-warning-100)',
background: 'var(--chakra-colors-warning-400)',
},
),
patternLinesDef(
'assoc_danger',
colorMode === 'light'
? {
rotation: -45,
color: 'var(--chakra-colors-danger-200)',
background: 'var(--chakra-colors-danger-400)',
}
: {
rotation: -45,
color: 'var(--chakra-colors-danger-200)',
background: 'var(--chakra-colors-danger-400)',
},
),
],
[colorMode],
);
return {
shapeDefs,
THEME,
LABEL_TEXT_COLORS,
MARGINS,
getFill,
getLabelsFilter,
};
};

View File

@@ -1,259 +0,0 @@
import { v4 as uuid } from 'uuid';
import {
AnalyticsApData,
AnalyticsAssociationData,
AnalyticsBoardDevice,
AnalyticsRadioData,
AnalyticsSsidData,
AnalyticsTimePointApiResponse,
} from 'models/Analytics';
import { VenueApiResponse } from 'models/Venue';
import { getScaledArray } from 'utils/arrayHelpers';
import { errorColor, getBlendedColor, successColor, warningColor } from 'utils/colors';
import { parseDbm } from 'utils/stringHelper';
type ChangeTypeOfKeys<T extends object, Keys extends keyof T, NewType> = {
// Loop to every key. We gonna check if the key
// is assignable to Keys. If yes, change the type.
// Else, retain the type.
[key in keyof T]: key extends Keys ? NewType : T[key];
};
type CircleColor = 'green' | 'yellow' | 'red';
export type AssociationCircle = {
name: string;
type: 'association';
details: ChangeTypeOfKeys<AnalyticsAssociationData, 'rssi', number | '-'> & {
color: string;
tagColor: CircleColor;
};
scale: number;
totalBw: number;
};
export type SsidCircle = {
name: string;
type: 'ssid';
details: {
avgRssi: '-' | number;
color: string;
tagColor: CircleColor;
} & AnalyticsSsidData;
children: AssociationCircle[];
scale: number;
};
export type RadioCircle = {
name: string;
type: 'radio';
details: {
color: string;
tagColor: CircleColor;
} & AnalyticsRadioData;
children: SsidCircle[];
};
export type DeviceCircleInfo = {
name: string;
type: 'device';
details: {
deviceInfo: AnalyticsBoardDevice;
ssidData: AnalyticsSsidData[];
apData: AnalyticsApData;
color: string;
tagColor: CircleColor;
};
scale: number;
children: RadioCircle[];
};
export type CirclePackRoot = {
name: string;
type: 'venue';
details: {
avgHealth: number;
color: string;
tagColor: CircleColor;
};
children: DeviceCircleInfo[];
scale: number;
};
export const parseAnalyticsTimepointsToCirclePackData = (
data: AnalyticsTimePointApiResponse[],
venue: VenueApiResponse,
colorMode: 'light' | 'dark',
) => {
if (data.length === 0) return undefined;
const root: CirclePackRoot = {
name: venue.name,
details: {
avgHealth: 0,
color: 'green',
tagColor: 'green',
},
type: 'venue',
children: [],
scale: 1,
};
let globalVenueHealth = 0;
const globalBandwidth: number[] = [];
for (const device of data) {
globalVenueHealth += device.device_info.health;
const deviceCircleInfo: DeviceCircleInfo = {
name: `${device.device_info.serialNumber}/device/${uuid()}`,
type: 'device',
details: {
deviceInfo: device.device_info,
ssidData: device.ssid_data,
apData: device.ap_data,
color: 'green',
tagColor: 'green',
},
scale: 1,
children: [],
};
if (device.device_info.health >= 90) {
deviceCircleInfo.details.color = successColor(colorMode);
deviceCircleInfo.details.tagColor = 'green';
} else if (device.device_info.health >= 70) {
deviceCircleInfo.details.color = warningColor(colorMode);
deviceCircleInfo.details.tagColor = 'yellow';
} else {
deviceCircleInfo.details.color = errorColor(colorMode);
deviceCircleInfo.details.tagColor = 'red';
}
const radioChannelIndex: { [key: string]: number } = {};
for (const [i, radio] of device.radio_data.entries()) {
radioChannelIndex[radio.band] = i;
let tagColor: CircleColor = 'green';
if (radio.transmit_pct > 30) tagColor = 'yellow';
else if (radio.transmit_pct > 50) tagColor = 'red';
deviceCircleInfo.children.push({
name: `${radio.band}/radio/${uuid()}`,
type: 'radio',
details: {
...radio,
color: getBlendedColor('#0ba057', '#FD3049', radio.transmit_pct / 100),
tagColor,
},
children: [],
});
}
for (const ssid of device.ssid_data) {
const ssidInfo: SsidCircle = {
name: `${ssid.ssid}/ssid/${uuid()}`,
type: 'ssid',
details: {
...ssid,
avgRssi: '-',
color: 'green',
tagColor: 'green',
},
children: [],
scale: 1,
};
let totalSsidRssi = 0;
for (const association of ssid.associations) {
const bw = association.tx_bytes_bw + association.rx_bytes_bw;
globalBandwidth.push(bw);
const associationInfo: AssociationCircle = {
name: `${association.station}/assoc/${uuid()}`,
type: 'association',
details: {
...association,
rssi: parseDbm(association.rssi) as '-' | number,
color: 'green',
tagColor: 'green',
},
scale: 1,
totalBw: bw,
};
if (association.rssi >= -45) {
associationInfo.details.color = successColor(colorMode);
associationInfo.details.tagColor = 'green';
} else if (association.rssi >= -60) {
associationInfo.details.color = warningColor(colorMode);
associationInfo.details.tagColor = 'yellow';
} else {
associationInfo.details.color = errorColor(colorMode);
associationInfo.details.tagColor = 'red';
}
totalSsidRssi += association.rssi;
ssidInfo.children.push(associationInfo);
}
const index = radioChannelIndex[ssid.band];
if (index !== undefined) {
ssidInfo.details.avgRssi =
ssid.associations.length === 0
? '-'
: parseDbm(Math.floor(totalSsidRssi / Math.max(ssid.associations.length, 1)));
if (typeof ssidInfo.details.avgRssi === 'number') {
if (ssid.associations.length === 0 || ssidInfo.details.avgRssi >= -45) {
ssidInfo.details.color = successColor(colorMode);
ssidInfo.details.tagColor = 'green';
} else if (ssidInfo.details.avgRssi >= -60) {
ssidInfo.details.color = warningColor(colorMode);
ssidInfo.details.tagColor = 'yellow';
}
} else {
ssidInfo.details.color = errorColor(colorMode);
ssidInfo.details.tagColor = 'red';
}
deviceCircleInfo.children[index]?.children.push(ssidInfo);
}
}
root.details.avgHealth = Math.floor(globalVenueHealth / Math.max(data.length, 1));
if (root.details.avgHealth >= 90) {
root.details.color = successColor(colorMode);
root.details.tagColor = 'green';
} else if (root.details.avgHealth >= 70) {
root.details.color = warningColor(colorMode);
root.details.tagColor = 'yellow';
} else {
root.details.color = errorColor(colorMode);
root.details.tagColor = 'red';
}
root.children.push(deviceCircleInfo);
}
if (globalBandwidth.length > 0) {
const scaledArray = getScaledArray(globalBandwidth, 1, 30);
const bandwidthObj: { [key: number]: number } = {};
for (const [i, bw] of globalBandwidth.entries()) {
bandwidthObj[bw] = scaledArray[i];
}
for (const [deviceIndex, dev] of root.children.entries()) {
for (const [radioIndex, radio] of dev.children.entries()) {
for (const [ssidIndex, ssid] of radio.children.entries()) {
for (const [assocIndex, assoc] of ssid.children.entries()) {
if (root.children[deviceIndex]?.children[radioIndex]?.children[ssidIndex]?.children[assocIndex]?.scale)
// @ts-ignore
root.children[deviceIndex].children[radioIndex].children[ssidIndex].children[assocIndex].scale =
bandwidthObj[assoc.totalBw];
}
}
}
}
}
return root;
};

View File

@@ -1,33 +0,0 @@
import React from 'react';
import { IconButton, Tooltip } from '@chakra-ui/react';
import { ArrowsIn, ArrowsOut } from '@phosphor-icons/react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
type Props = {
isDisabled: boolean;
handle: FullScreenHandle;
};
const FullScreenLiveViewButton = ({ isDisabled, handle }: Props) => {
const { t } = useTranslation();
const handleClick = () => (handle.active ? handle.exit() : handle.enter());
const icon = () => (handle.active ? <ArrowsIn size={20} /> : <ArrowsOut size={20} />);
return (
<Tooltip label={handle.active ? t('common.exit_fullscreen') : t('common.fullscreen')}>
<IconButton
aria-label={handle.active ? t('common.exit_fullscreen') : t('common.fullscreen')}
type="button"
onClick={handleClick}
icon={icon()}
isDisabled={isDisabled}
colorScheme="teal"
mr={2}
/>
</Tooltip>
);
};
export default FullScreenLiveViewButton;

View File

@@ -1,49 +0,0 @@
import React from 'react';
import {
Heading,
IconButton,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverHeader,
PopoverTrigger,
Tooltip,
} from '@chakra-ui/react';
import { Question } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
const CirclePackInfoButton = () => {
const { t } = useTranslation();
return (
<Popover>
<PopoverTrigger>
<Tooltip label={t('configurations.explanation')}>
<IconButton aria-label={t('configurations.explanation')} icon={<Question size={20} />} colorScheme="blue" />
</Tooltip>
</PopoverTrigger>
<PopoverContent w="440px">
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
<PopoverHeader display="flex">{t('analytics.live_view_help')}</PopoverHeader>
<PopoverBody>
<Heading size="sm">{t('analytics.live_view_explanation_one')}</Heading>
<Heading size="sm" mt={4}>
{t('analytics.live_view_explanation_two')}
</Heading>
<Heading size="sm">{t('analytics.live_view_explanation_three')}</Heading>
<Heading size="sm" mt={4}>
{t('analytics.live_view_explanation_four')}
</Heading>
<Heading size="sm" mt={4}>
{t('analytics.live_view_explanation_five')}
</Heading>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default CirclePackInfoButton;

View File

@@ -1,60 +0,0 @@
import * as React from 'react';
import { Box, Flex, HStack, Spacer, useColorModeValue } from '@chakra-ui/react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import AnalyticsDatePickers from '../DatePickers';
import LiveViewCirclePack from './CirclePack';
import FullScreenLiveViewButton from './FullScreenLiveViewButton';
import CirclePackInfoButton from './InfoButton';
import { UseLiveViewReturn } from './useLiveView';
import RefreshButton from 'components/Buttons/RefreshButton';
import LoadingOverlay from 'components/LoadingOverlay';
import { CircleGraphProvider } from 'contexts/CircleGraphProvider';
import { AnalyticsTimePointApiResponse } from 'models/Analytics';
import { VenueApiResponse } from 'models/Venue';
import { getHoursAgo } from 'utils/dateFormatting';
type Props = {
data: AnalyticsTimePointApiResponse[][];
venue: VenueApiResponse;
isFetching?: boolean;
onChangeTime: UseLiveViewReturn['onChangeTime'];
onClearTime: UseLiveViewReturn['onClearTime'];
refreshData: () => void;
};
const LiveViewLayout = ({ data, venue, isFetching, onChangeTime, onClearTime, refreshData }: Props) => {
const color = useColorModeValue('gray.50', 'gray.800');
const handle = useFullScreenHandle();
return (
<LoadingOverlay isLoading={!!isFetching}>
<Box bgColor={handle?.active ? color : undefined} h="100%" p={0}>
<FullScreen handle={handle}>
<Box bgColor={handle?.active ? color : undefined} h="100%" p={0}>
<Flex mb={2} pt={2} px={2} mt={handle?.active ? 4 : undefined} mr={handle?.active ? 4 : undefined}>
<Spacer />
<HStack>
<CirclePackInfoButton />
<AnalyticsDatePickers
defaults={{
start: getHoursAgo(5),
end: new Date(),
}}
setTime={(start: Date, end: Date) => onChangeTime({ start, end })}
onClear={onClearTime}
/>
<FullScreenLiveViewButton isDisabled={isFetching || !data} handle={handle} />
<RefreshButton onClick={refreshData} isFetching={isFetching} isCompact />
</HStack>
</Flex>
<CircleGraphProvider>
<LiveViewCirclePack data={data} handle={handle} venue={venue} />
</CircleGraphProvider>
</Box>
</FullScreen>
</Box>
</LoadingOverlay>
);
};
export default React.memo(LiveViewLayout);

View File

@@ -1,64 +0,0 @@
import * as React from 'react';
import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, BoxProps, Center, Flex, Spinner } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import LiveViewLayout from './LiveViewLayout';
import { useLiveView } from './useLiveView';
import { VenueApiResponse } from 'models/Venue';
type Props = {
boardId: string;
venue: VenueApiResponse;
containerStyle?: BoxProps;
};
const LiveView = ({ boardId, venue, containerStyle }: Props) => {
const { t } = useTranslation();
const liveView = useLiveView({ boardId });
const contents = React.useMemo(() => {
if (liveView.getTimepoints.error) {
return (
<Flex justifyContent="center" alignItems="center" height="100%">
<Center>
<Alert status="error" w="unset" borderRadius="15px">
<AlertIcon />
<Box>
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>
{liveView.getTimepoints.error.response?.status === 404
? t('analytics.missing_board')
: liveView.getTimepoints.error.response?.data?.ErrorDescription}
</AlertDescription>
</Box>
</Alert>
</Center>
</Flex>
);
}
if (liveView.getTimepoints.isLoading || !liveView.getTimepoints.data) {
return (
<Flex justifyContent="center" alignItems="center" height="100%">
<Center>
<Spinner size="xl" />
</Center>
</Flex>
);
}
return (
<LiveViewLayout
data={liveView.getTimepoints.data}
isFetching={liveView.getTimepoints.isFetching}
venue={venue}
onChangeTime={liveView.onChangeTime}
onClearTime={liveView.onClearTime}
refreshData={liveView.getTimepoints.refetch}
/>
);
}, [liveView.getTimepoints]);
return <Box {...containerStyle}>{contents}</Box>;
};
export default React.memo(LiveView);

View File

@@ -1,42 +0,0 @@
import * as React from 'react';
import { useGetAnalyticsBoardTimepoints } from 'hooks/Network/Analytics';
import { getHoursAgo } from 'utils/dateFormatting';
export type UseLiveViewProps = {
boardId: string;
};
export type UseLiveViewReturn = {
time: { start: Date; end: Date };
onChangeTime: (newTime: { start: Date; end: Date }) => void;
onClearTime: () => void;
getTimepoints: ReturnType<typeof useGetAnalyticsBoardTimepoints>;
};
export const useLiveView = ({ boardId }: UseLiveViewProps) => {
const [time, setTime] = React.useState({
start: getHoursAgo(5),
end: new Date(),
});
const onChangeTime = (newTime: { start: Date; end: Date }) => setTime({ ...newTime });
const onClearTime = () => {
setTime({
start: getHoursAgo(5),
end: new Date(),
});
};
const getTimepoints = useGetAnalyticsBoardTimepoints({ id: boardId, startTime: time.start, endTime: time.end });
return React.useMemo(
() =>
({
time,
onChangeTime,
onClearTime,
getTimepoints,
} as UseLiveViewReturn),
[getTimepoints, time],
);
};

View File

@@ -1,65 +0,0 @@
import React from 'react';
import { Heading, List, ListItem } from '@chakra-ui/react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import SimpleStatDisplay from 'components/StatisticsDisplay/SimpleStatDisplay';
const propTypes = {
data: PropTypes.instanceOf(Object).isRequired,
handleModalClick: PropTypes.func.isRequired,
};
const DeviceTypeStat = ({ data, handleModalClick }) => {
const { t } = useTranslation();
const getTopDeviceTypes = () => {
const orderedTotals = Object.keys(data.deviceTypeTotals)
.map((k) => ({
deviceType: k,
amount: data.deviceTypeTotals[k],
}))
.sort((a, b) => (a.amount < b.amount ? 1 : -1));
if (orderedTotals.length <= 3) {
return orderedTotals;
}
let othersTotal = 0;
for (let i = 2; i < orderedTotals.length; i += 1) {
othersTotal += orderedTotals[i].amount;
}
return [orderedTotals[0], orderedTotals[1], { deviceType: 'Others', amount: othersTotal }];
};
return (
<SimpleStatDisplay
title={t('analytics.device_types')}
explanation={t('analytics.device_types_explanation')}
element={
<Heading size="sm">
<List>
{getTopDeviceTypes().map(({ deviceType, amount }) => (
<ListItem key={uuid()}>
{deviceType}: {amount}
</ListItem>
))}
</List>
</Heading>
}
openModal={handleModalClick({
prioritizedColumns: ['deviceType'],
sortBy: [
{
id: 'deviceType',
desc: false,
},
],
})}
/>
);
};
DeviceTypeStat.propTypes = propTypes;
export default DeviceTypeStat;

View File

@@ -1,65 +0,0 @@
import React from 'react';
import { Heading, List, ListItem } from '@chakra-ui/react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import SimpleStatDisplay from 'components/StatisticsDisplay/SimpleStatDisplay';
const propTypes = {
data: PropTypes.instanceOf(Object).isRequired,
handleModalClick: PropTypes.func.isRequired,
};
const FirmwareStat = ({ data, handleModalClick }) => {
const { t } = useTranslation();
const getTopFirmware = () => {
const orderedTotals = Object.keys(data.deviceFirmwareTotals)
.map((k) => ({
lastFirmware: k,
amount: data.deviceFirmwareTotals[k],
}))
.sort((a, b) => (a.amount < b.amount ? 1 : -1));
if (orderedTotals.length <= 3) {
return orderedTotals;
}
let othersTotal = 0;
for (let i = 3; i < orderedTotals.length; i += 1) {
othersTotal += orderedTotals[i].amount;
}
return [orderedTotals[0], orderedTotals[1], { lastFirmware: 'Others', amount: othersTotal }];
};
return (
<SimpleStatDisplay
title={t('analytics.firmware')}
explanation={t('analytics.last_firmware_explanation')}
element={
<Heading size="sm">
<List>
{getTopFirmware().map(({ lastFirmware, amount }) => (
<ListItem key={uuid()}>
{lastFirmware}: {amount}
</ListItem>
))}
</List>
</Heading>
}
openModal={handleModalClick({
prioritizedColumns: ['lastFirmware'],
sortBy: [
{
id: 'lastFirmware',
desc: false,
},
],
})}
/>
);
};
FirmwareStat.propTypes = propTypes;
export default FirmwareStat;

View File

@@ -1,44 +0,0 @@
import React from 'react';
import { useColorMode } from '@chakra-ui/react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import SimpleStatDisplay from 'components/StatisticsDisplay/SimpleStatDisplay';
const propTypes = {
data: PropTypes.instanceOf(Object).isRequired,
handleModalClick: PropTypes.func.isRequired,
};
const HealthStat = ({ data, handleModalClick }) => {
const { t } = useTranslation();
const { colorMode } = useColorMode();
const getHealthColor = () => {
if (data.avgHealth >= 90)
return colorMode === 'light' ? 'var(--chakra-colors-green-200)' : 'var(--chakra-colors-green-400)';
if (data.avgHealth >= 70)
return colorMode === 'light' ? 'var(--chakra-colors-yellow-200)' : 'var(--chakra-colors-yellow-400)';
return colorMode === 'light' ? 'var(--chakra-colors-red-200)' : 'var(--chakra-colors-red-400)';
};
return (
<SimpleStatDisplay
title={t('analytics.average_health')}
label={`${data.avgHealth}%`}
explanation={t('analytics.average_health_explanation')}
openModal={handleModalClick({
prioritizedColumns: ['lastHealth', 'health'],
sortBy: [
{
id: 'health',
desc: false,
},
],
})}
color={getHealthColor()}
/>
);
};
HealthStat.propTypes = propTypes;
export default HealthStat;

View File

@@ -1,44 +0,0 @@
import React from 'react';
import { useColorMode } from '@chakra-ui/react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import SimpleStatDisplay from 'components/StatisticsDisplay/SimpleStatDisplay';
const propTypes = {
data: PropTypes.instanceOf(Object).isRequired,
handleModalClick: PropTypes.func.isRequired,
};
const MemoryStat = ({ data, handleModalClick }) => {
const { t } = useTranslation();
const { colorMode } = useColorMode();
const getMemoryColor = () => {
if (data.avgMemoryUsed < 65)
return colorMode === 'light' ? 'var(--chakra-colors-green-200)' : 'var(--chakra-colors-green-400)';
if (data.avgMemoryUsed < 80)
return colorMode === 'light' ? 'var(--chakra-colors-yellow-200)' : 'var(--chakra-colors-yellow-400)';
return colorMode === 'light' ? 'var(--chakra-colors-red-200)' : 'var(--chakra-colors-red-400)';
};
return (
<SimpleStatDisplay
title={t('analytics.average_memory')}
label={`${data.avgMemoryUsed}%`}
explanation={t('analytics.average_memory_explanation')}
openModal={handleModalClick({
prioritizedColumns: ['lastPing', 'memory'],
sortBy: [
{
id: 'memory',
desc: true,
},
],
})}
color={getMemoryColor()}
/>
);
};
MemoryStat.propTypes = propTypes;
export default MemoryStat;

View File

@@ -1,126 +0,0 @@
import React from 'react';
import { Heading, List, ListItem, Text } from '@chakra-ui/react';
import { Circle } from '@phosphor-icons/react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import Masonry from 'react-masonry-css';
import DeviceTypeStat from './DeviceTypeStat';
import FirmwareStat from './FirmwareStat';
import HealthStat from './HealthStat';
import MemoryStat from './MemoryStat';
import SimpleStatDisplay from 'components/StatisticsDisplay/SimpleStatDisplay';
import { minimalSecondsToDetailed } from 'utils/dateFormatting';
const propTypes = {
data: PropTypes.instanceOf(Object).isRequired,
openModal: PropTypes.func.isRequired,
};
const VenueAnalyticsHeader = ({ data, openModal }) => {
const { t } = useTranslation();
const handleModalClick = (tableOptions) => () => openModal(tableOptions);
return (
<Masonry
breakpointCols={{
default: 4,
2200: 2,
1100: 3,
800: 2,
500: 1,
}}
className="my-masonry-grid"
columnClassName="my-masonry-grid_column"
>
<SimpleStatDisplay
title={t('common.status')}
explanation={t('analytics.total_devices_explanation', {
connectedCount: data.connectedDevices,
disconnectedCount: data.disconnectedDevices,
})}
element={
<Heading size="sm" display="flex">
<List>
<ListItem display="flex">
<Circle size={20} color="var(--chakra-colors-green-400)" weight="fill" />
<Text mt="2px" ml={1} mr={4}>
{`${data.connectedDevices} ${t('analytics.connected')}`}
</Text>
</ListItem>
<ListItem display="flex">
<Circle size={20} color="var(--chakra-colors-red-400)" weight="fill" />
<Text mt="2px" ml={1} mr={2}>
{data.disconnectedDevices} {t('analytics.disconnected')}
</Text>
</ListItem>
</List>
</Heading>
}
openModal={handleModalClick({
prioritizedColumns: ['connected'],
sortBy: [
{
id: 'connected',
desc: true,
},
],
})}
mb={4}
/>
<HealthStat data={data} handleModalClick={handleModalClick} />
<MemoryStat data={data} handleModalClick={handleModalClick} />
<DeviceTypeStat data={data} handleModalClick={handleModalClick} />
<FirmwareStat data={data} handleModalClick={handleModalClick} />
<SimpleStatDisplay
title={t('analytics.average_uptime')}
label={minimalSecondsToDetailed(data.avgUptime, t)}
explanation={t('analytics.average_uptime_explanation')}
openModal={handleModalClick({
prioritizedColumns: ['uptime', 'lastPing'],
sortBy: [
{
id: 'uptime',
desc: true,
},
],
})}
mb={4}
/>
<SimpleStatDisplay
title={t('analytics.associations')}
element={
<Heading size="sm">
<List>
<ListItem>{data.twoGAssociations} 2G</ListItem>
<ListItem>{data.fiveGAssociations} 5G</ListItem>
<ListItem>{data.sixGAssociations} 6G</ListItem>
</List>
</Heading>
}
explanation={t('analytics.associations_explanation')}
openModal={handleModalClick({
prioritizedColumns: ['6g', '5g', '2g'],
sortBy: [
{
id: '2g',
desc: true,
},
{
id: '5g',
desc: true,
},
{
id: '6g',
desc: true,
},
],
})}
mb={4}
/>
</Masonry>
);
};
VenueAnalyticsHeader.propTypes = propTypes;
export default VenueAnalyticsHeader;

View File

@@ -1,148 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Center, Flex, Heading, Spacer, Spinner, useDisclosure } from '@chakra-ui/react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import VenueAnalyticsHeader from './Header';
import VenueDashboardTableModal from './TableModal';
import RefreshButton from 'components/Buttons/RefreshButton';
import CardBody from 'components/Card/CardBody';
import LoadingOverlay from 'components/LoadingOverlay';
import { useGetAnalyticsBoardDevices } from 'hooks/Network/Analytics';
const propTypes = {
boardId: PropTypes.string.isRequired,
};
const VenueDashboard = ({ boardId }) => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure(false);
const [tableOptions, setTableOptions] = useState(null);
const { data: devices, isFetching, refetch } = useGetAnalyticsBoardDevices({ id: boardId });
const handleRefreshClick = () => {
refetch();
};
const openModal = (newOptions) => {
setTableOptions(newOptions);
onOpen();
};
const parsedData = useMemo(() => {
if (!devices) return {};
const finalData = {
totalDevices: 0,
connectedPercentage: 0,
connectedDevices: 0,
disconnectedDevices: 0,
avgMemoryUsed: 0,
avgHealth: 0,
avgUptime: 0,
twoGAssociations: 0,
fiveGAssociations: 0,
sixGAssociations: 0,
deviceTypeTotals: {},
deviceFirmwareTotals: {},
};
try {
// Temporary values
const finalDevices = [];
const ignoredDevices = [];
let totalHealth = 0;
let totalUptime = 0;
let totalMemory = 0;
for (let i = 0; i < devices.length; i += 1) {
const device = devices[i];
if (device.deviceType !== '') {
const splitFirmware = device.lastFirmware.split(' / ');
let firmware = splitFirmware.length > 1 ? splitFirmware[1] : device.lastFirmware;
if (device.lastFirmware.length === 0) firmware = 'Unknown';
if (finalData.deviceFirmwareTotals[firmware]) finalData.deviceFirmwareTotals[firmware] += 1;
else finalData.deviceFirmwareTotals[firmware] = 1;
if (finalData.deviceTypeTotals[device.deviceType]) finalData.deviceTypeTotals[device.deviceType] += 1;
else finalData.deviceTypeTotals[device.deviceType] = 1;
if (device.associations_2g > 0) finalData.twoGAssociations += device.associations_2g;
if (device.associations_5g > 0) finalData.fiveGAssociations += device.associations_5g;
if (device.associations_6g > 0) finalData.sixGAssociations += device.associations_6g;
device.memory = Math.round(device.memory);
if (device.connected) {
finalData.connectedDevices += 1;
totalHealth += device.health;
totalMemory += device.memory;
totalUptime += device.uptime;
} else finalData.disconnectedDevices += 1;
finalDevices.push(device);
} else {
ignoredDevices.push(device);
if (device.connected) {
finalData.connectedDevices += 1;
totalMemory += Math.round(device.memory);
totalUptime += device.uptime;
} else finalData.disconnectedDevices += 1;
if (finalData.deviceFirmwareTotals.Unknown > 0) finalData.deviceFirmwareTotals.Unknown += 1;
else finalData.deviceFirmwareTotals.Unknown = 1;
if (finalData.deviceTypeTotals.Unknown > 0) finalData.deviceTypeTotals.Unknown += 1;
else finalData.deviceTypeTotals.Unknown = 1;
}
}
finalData.totalDevices = finalDevices.length + ignoredDevices.length;
finalData.connectedPercentage = Math.round(
(finalData.connectedDevices / Math.max(1, finalData.totalDevices)) * 100,
);
finalData.devices = finalDevices;
finalData.avgHealth = Math.round(totalHealth / Math.max(1, finalData.connectedDevices));
finalData.avgUptime = Math.round(totalUptime / Math.max(1, finalData.connectedDevices));
finalData.avgMemoryUsed = Math.round(totalMemory / Math.max(1, finalData.connectedDevices));
finalData.devices = finalDevices;
finalData.ignoredDevices = ignoredDevices;
return finalData;
} catch (e) {
return finalData;
}
}, [devices]);
useEffect(() => {
if (!isOpen) setTableOptions(null);
}, [isOpen]);
return !devices ? (
<Center my={6}>
<Spinner size="xl" />
</Center>
) : (
<>
<Flex px={2} pt={2}>
<Heading size="md" my="auto">
{parsedData?.totalDevices} {t('devices.title')}
</Heading>
<Spacer />
<VenueDashboardTableModal
data={parsedData}
tableOptions={tableOptions}
isOpen={isOpen}
onOpen={onOpen}
onClose={onClose}
/>
<RefreshButton onClick={handleRefreshClick} isLoading={isFetching} ml={2} isCompact />
</Flex>
<CardBody p={4}>
<LoadingOverlay isLoading={isFetching}>
<Box w="100%">
<VenueAnalyticsHeader data={parsedData} openModal={openModal} />
</Box>
</LoadingOverlay>
</CardBody>
</>
);
};
VenueDashboard.propTypes = propTypes;
export default VenueDashboard;

View File

@@ -1,170 +0,0 @@
import * as React from 'react';
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Box,
Center,
Heading,
IconButton,
Spacer,
Spinner,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Tooltip,
useDisclosure,
} from '@chakra-ui/react';
import { MagnifyingGlass } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import LiveView from './LiveView';
import StartAnalyticsModal from './StartAnalyticsModal';
import StopMonitoringButton from './StopMonitoringButton';
import VenueClientLifecycle from './VenueClientLifecycle';
import VenueDashboard from './VenueDashboard';
import ViewAnalyticsSettingsModal from './ViewAnalyticsSettingsModal';
import Card from 'components/Card';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import { useGetAnalyticsBoardDevices } from 'hooks/Network/Analytics';
import { useGetVenue } from 'hooks/Network/Venues';
import { AxiosError } from 'models/Axios';
type Props = {
id: string;
};
const VenueAnalyticsCard = ({ id }: Props) => {
const { t } = useTranslation();
const { isOpen: isCreateOpen, onOpen: onCreateOpen, onClose: onCreateClose } = useDisclosure();
const { isOpen: isViewOpen, onOpen: onViewOpen, onClose: onViewClose } = useDisclosure();
const getVenue = useGetVenue({ id });
const boardId = getVenue.data?.boards[0];
const getDashboard = useGetAnalyticsBoardDevices({ id: boardId });
const body = React.useMemo(() => {
if (!boardId)
return (
<Card>
<CardHeader>
<Heading size="md">{t('analytics.title')}</Heading>
</CardHeader>
<CardBody>
<Center w="100%" mt={2}>
<Alert status="info" w="unset" borderRadius="15px" onClick={onCreateOpen} cursor="pointer">
<AlertIcon />
<Box>
<AlertTitle>{t('analytics.no_board')}</AlertTitle>
<AlertDescription>{t('analytics.no_board_description')}</AlertDescription>
</Box>
</Alert>
</Center>
</CardBody>
</Card>
);
if (getDashboard.error || getDashboard.isLoading || !getVenue.data)
return (
<Card>
<CardHeader>
<Heading size="md">{t('analytics.title')}</Heading>
</CardHeader>
<CardBody>
{getDashboard.error ? (
<Alert status="error" w="unset" borderRadius="15px" onClick={onCreateOpen} cursor="pointer">
<AlertIcon />
<Box>
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>
{getDashboard.error.response?.status === 404
? t('analytics.missing_board')
: (getDashboard.error as AxiosError).response?.data?.ErrorDescription}
</AlertDescription>
</Box>
</Alert>
) : (
<Center my={6}>
<Spinner size="xl" />
</Center>
)}
</CardBody>
</Card>
);
return (
<Card p={0}>
<Tabs variant="enclosed" isLazy>
<TabList>
<Tab>{t('analytics.dashboard')}</Tab>
<Tab>{t('analytics.live_view')}</Tab>
<Tab>{t('analytics.client_lifecycle')}</Tab>
<Spacer />
<StopMonitoringButton boardId={boardId} venueId={id} />
<Tooltip label={t('common.view_details')} hasArrow>
<IconButton
aria-label={t('common.view_details')}
icon={<MagnifyingGlass size={20} />}
h="41px"
borderTopLeftRadius={0}
borderBottomRadius="0px"
colorScheme="blue"
onClick={onViewOpen}
/>
</Tooltip>
</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"
>
<VenueDashboard boardId={boardId} />
</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"
>
<LiveView boardId={boardId} venue={getVenue.data} />
</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"
>
<VenueClientLifecycle venueId={id} />
</Box>
</TabPanel>
</TabPanels>
</Tabs>
<ViewAnalyticsSettingsModal isOpen={isViewOpen} boardId={boardId} venueId={id} onClose={onViewClose} />
</Card>
);
}, [boardId, getDashboard, getVenue]);
return (
<Box>
{body}
<StartAnalyticsModal isOpen={isCreateOpen} id={id} onClose={onCreateClose} />
</Box>
);
};
export default VenueAnalyticsCard;

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