Compare commits

...

38 Commits

Author SHA1 Message Date
TIP Automation User
177d24e508 Chg: update image tag in helm values to v2.9.0 2023-03-31 19:25:57 +00:00
TIP Automation User
69b0d1ee9d Chg: update image tag in helm values to v2.9.0-RC2 2023-03-20 16:53:34 +00:00
Charles Bourque
ef52497b04 Merge pull request #170 from Telecominfraproject/main
Version 2.9.0(23)
2023-03-20 17:20:28 +01:00
Charles Bourque
039e641046 Merge pull request #169 from stephb9959/main
[WIFI-12418] Memory chart display hidden automatically
2023-03-20 17:16:36 +01:00
Charles
f1f62efe6f [WIFI-12418] Memory chart display hidden automatically
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-03-20 17:15:16 +01:00
Charles Bourque
b3053f32b2 Merge pull request #168 from stephb9959/main
[WIFI-12413] Added toast on download trace/script result error
2023-03-17 10:24:15 +01:00
Charles
09184b0402 [WIFI-12413] Added toast on download trace/script result error
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-03-17 10:23:33 +01:00
Charles Bourque
98562fd967 Merge pull request #167 from stephb9959/main
[WIFI-12375] Download command results fix
2023-03-13 19:19:31 +01:00
Charles
65e9e64cb4 [WIFI-12375] Download command results fix
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-03-13 19:19:01 +01:00
Charles Bourque
573ecbd58d Merge pull request #166 from stephb9959/main
[WIFI-12364] Deferred scripts command history fix
2023-03-09 11:35:06 +01:00
Charles
a801fcca49 [WIFI-12364] Deferred scripts command history fix
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-03-09 11:34:36 +01:00
Charles Bourque
e9d16ee172 Merge pull request #165 from stephb9959/main
[WIFI-12360] Custom script run fix
2023-03-08 10:37:25 +01:00
Charles
db4dfc93e8 [WIFI-12360] Custom script run fix
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-03-07 20:08:40 +01:00
TIP Automation User
975b715a7c Chg: update image tag in helm values to v2.9.0-RC1 2023-02-28 18:27:34 +00:00
Charles Bourque
cf17f03ae0 Merge pull request #164 from stephb9959/main
[WIFI-12335] Display gateway tx/rx values
2023-02-25 10:10:47 +01:00
Charles
64f3ee797e [WIFI-12335] Display gateway tx/rx values
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-02-25 10:10:10 +01:00
Charles Bourque
e287705e88 Merge pull request #163 from stephb9959/main
[WIFI-12285] Add support for FMS database refreshes
2023-02-09 16:56:49 +01:00
Charles
9583b2bae0 [WIFI-12285] Add support for FMS database refreshes
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-02-09 16:55:48 +01:00
Charles Bourque
2698993a6d Merge pull request #162 from stephb9959/main
[WIFI-12270] Now displaying information related to restricted device in dev mode
2023-02-07 20:27:12 +01:00
Charles
a14b595e8c [WIFI-12270] Now displaying information related to restricted device in dev mode
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-02-07 20:26:09 +01:00
Charles Bourque
d7957b85ae Merge pull request #161 from stephb9959/main
[WIFI-12261] Added system secrets on the system page
2023-02-03 16:54:21 +01:00
Charles
227a51423d [WIFI-12261] Added system secrets on the system page
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-02-03 16:53:50 +01:00
Charles Bourque
ea0e7340cc Merge pull request #160 from stephb9959/main
[WIFI-12257] Display GPS location on device page
2023-02-01 19:52:28 +01:00
Charles
999680e94b [WIFI-12257] Display GPS location on device page
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-02-01 19:51:01 +01:00
Charles Bourque
566dbbb157 Merge pull request #159 from stephb9959/main
[WIFI-11239] Now sending signature on firmware upgrade as URL param
2023-01-30 12:53:50 +01:00
Charles
75d995d54e [WIFI-11239] Now sending signature on firmware upgrade as URL param
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-01-30 12:53:12 +01:00
Charles Bourque
908faa491b Merge pull request #158 from stephb9959/main
[WIFI-12226] Interface stats Y-axis now only 2 decimals or less
2023-01-27 10:40:29 +01:00
Charles
7a254e343e [WIFI-12226] Interface stats Y-axis now only 2 decimals or less
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-01-27 10:37:08 +01:00
Charles Bourque
016ac336b9 Merge pull request #157 from stephb9959/main
[WIFI-12223] User table state fix, with label correction and API logic update
2023-01-25 21:26:04 +01:00
Charles
1cfd3a10ad [WIFI-12223] User table state fix, with label correction and API logic update
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-01-25 21:22:17 +01:00
Charles Bourque
1838029d22 Merge pull request #156 from stephb9959/main
[WIFI-12067] Added crash logs to device details page
2023-01-06 14:55:06 -05:00
Charles
7767043a5a [WIFI-12067] Added crash logs to device details page
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-01-06 14:54:28 -05:00
Charles Bourque
b1cfa6db19 Merge pull request #155 from stephb9959/main
[WIFI-12031] Added confirmation modal to reboot process
2023-01-05 14:06:40 -05:00
Charles
623d5a5546 [WIFI-12031] Added confirmation modal to reboot process
Signed-off-by: Charles <charles.bourque96@gmail.com>
2023-01-05 14:02:00 -05:00
Charles Bourque
8c676eb965 Merge pull request #154 from stephb9959/main
[WIFI-10957] Updated statistics to use counters-aggregate if available
2022-12-14 14:36:17 -05:00
Charles
1e4ccce36c [WIFI-10957] Updated statistics to use counters-aggregate if available
Signed-off-by: Charles <charles.bourque96@gmail.com>
2022-12-14 14:35:45 -05:00
Charles Bourque
1808206e74 Merge pull request #153 from stephb9959/main
[WIFI-11936] Serial number in device table now real link
2022-12-14 09:00:51 -05:00
Charles
0fbc2b92aa [WIFI-11936] Serial number in device table now real link
Signed-off-by: Charles <charles.bourque96@gmail.com>
2022-12-14 09:00:12 -05:00
97 changed files with 2805 additions and 615 deletions

View File

@@ -8,7 +8,7 @@ fullnameOverride: ""
images: images:
owgwui: owgwui:
repository: tip-tip-wlan-cloud-ucentral.jfrog.io/owgw-ui repository: tip-tip-wlan-cloud-ucentral.jfrog.io/owgw-ui
tag: main tag: v2.9.0
pullPolicy: Always pullPolicy: Always
services: services:

97
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ucentral-client", "name": "ucentral-client",
"version": "2.8.0(44)", "version": "2.9.0(23)",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ucentral-client", "name": "ucentral-client",
"version": "2.8.0(44)", "version": "2.9.0(23)",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@chakra-ui/icons": "^2.0.11", "@chakra-ui/icons": "^2.0.11",
@@ -16,6 +16,8 @@
"@emotion/react": "^11.10.4", "@emotion/react": "^11.10.4",
"@emotion/styled": "^11.10.4", "@emotion/styled": "^11.10.4",
"@fontsource/inter": "^4.5.14", "@fontsource/inter": "^4.5.14",
"@googlemaps/react-wrapper": "^1.1.35",
"@googlemaps/typescript-guards": "^2.0.3",
"@react-spring/web": "^9.5.5", "@react-spring/web": "^9.5.5",
"@tanstack/react-query": "^4.12.0", "@tanstack/react-query": "^4.12.0",
"@textea/json-viewer": "^2.10.0", "@textea/json-viewer": "^2.10.0",
@@ -24,6 +26,7 @@
"chakra-react-select": "^4.3.0", "chakra-react-select": "^4.3.0",
"chart.js": "^3.9.1", "chart.js": "^3.9.1",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"fast-equals": "^4.0.3",
"formik": "^2.2.9", "formik": "^2.2.9",
"framer-motion": "^7.6.1", "framer-motion": "^7.6.1",
"i18next": "^22.0.0", "i18next": "^22.0.0",
@@ -54,6 +57,7 @@
"zustand": "^4.1.2" "zustand": "^4.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/google.maps": "^3.51.0",
"@types/node": "^18.11.2", "@types/node": "^18.11.2",
"@types/react": "^18.0.21", "@types/react": "^18.0.21",
"@types/react-csv": "^1.1.3", "@types/react-csv": "^1.1.3",
@@ -2848,6 +2852,30 @@
"version": "4.5.14", "version": "4.5.14",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@googlemaps/js-api-loader": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.15.1.tgz",
"integrity": "sha512-AsnEgNsB7S/VdrHGEQUaUM2e5tmjFGKBAfzR/AqO8O7TPq/jQGvoRw5liPBw4EMF38RDsHmKDV89q/X+qiUREQ==",
"dependencies": {
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/@googlemaps/react-wrapper": {
"version": "1.1.35",
"resolved": "https://registry.npmjs.org/@googlemaps/react-wrapper/-/react-wrapper-1.1.35.tgz",
"integrity": "sha512-vK+BDQMHN0Oqr66cW3ZPWVK43BUmJJBu6P8T74tc6/fKpUJUlFEaZsupgIIRRRDW9ejB8uGagUmwOnA2gdcvbw==",
"dependencies": {
"@googlemaps/js-api-loader": "^1.13.2"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@googlemaps/typescript-guards": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@googlemaps/typescript-guards/-/typescript-guards-2.0.3.tgz",
"integrity": "sha512-3iHuO8H0jPehftsMK0kgyJzPYU/g/oiTRw+wu/yltqSZ7wJPt3vfsJHkPiuRpQjbnnWygX+T3mkRGyK/eyZ/lw=="
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.10.7", "version": "0.10.7",
"dev": true, "dev": true,
@@ -3501,6 +3529,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/google.maps": {
"version": "3.51.0",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.51.0.tgz",
"integrity": "sha512-44/oQYjc5D6kxBcI3Qk9rk3IIOMwnlEMWDV7pwPJ2YI89s5Q1OzDrFvR7QJ3LFrpVXEhig+gyagFg54+foinFg==",
"dev": true
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.11", "version": "7.0.11",
"dev": true, "dev": true,
@@ -5529,7 +5563,6 @@
}, },
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": { "node_modules/fast-diff": {
@@ -5537,6 +5570,11 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg=="
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.2.12", "version": "3.2.12",
"dev": true, "dev": true,
@@ -6642,8 +6680,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/json5": { "node_modules/json5": {
"version": "2.2.1", "version": "2.2.3",
"license": "MIT", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"bin": { "bin": {
"json5": "lib/cli.js" "json5": "lib/cli.js"
}, },
@@ -8853,9 +8892,10 @@
} }
}, },
"node_modules/tsconfig-paths/node_modules/json5": { "node_modules/tsconfig-paths/node_modules/json5": {
"version": "1.0.1", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"minimist": "^1.2.0" "minimist": "^1.2.0"
}, },
@@ -11425,6 +11465,27 @@
"@fontsource/inter": { "@fontsource/inter": {
"version": "4.5.14" "version": "4.5.14"
}, },
"@googlemaps/js-api-loader": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.15.1.tgz",
"integrity": "sha512-AsnEgNsB7S/VdrHGEQUaUM2e5tmjFGKBAfzR/AqO8O7TPq/jQGvoRw5liPBw4EMF38RDsHmKDV89q/X+qiUREQ==",
"requires": {
"fast-deep-equal": "^3.1.3"
}
},
"@googlemaps/react-wrapper": {
"version": "1.1.35",
"resolved": "https://registry.npmjs.org/@googlemaps/react-wrapper/-/react-wrapper-1.1.35.tgz",
"integrity": "sha512-vK+BDQMHN0Oqr66cW3ZPWVK43BUmJJBu6P8T74tc6/fKpUJUlFEaZsupgIIRRRDW9ejB8uGagUmwOnA2gdcvbw==",
"requires": {
"@googlemaps/js-api-loader": "^1.13.2"
}
},
"@googlemaps/typescript-guards": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@googlemaps/typescript-guards/-/typescript-guards-2.0.3.tgz",
"integrity": "sha512-3iHuO8H0jPehftsMK0kgyJzPYU/g/oiTRw+wu/yltqSZ7wJPt3vfsJHkPiuRpQjbnnWygX+T3mkRGyK/eyZ/lw=="
},
"@humanwhocodes/config-array": { "@humanwhocodes/config-array": {
"version": "0.10.7", "version": "0.10.7",
"dev": true, "dev": true,
@@ -11786,6 +11847,12 @@
"version": "0.0.39", "version": "0.0.39",
"dev": true "dev": true
}, },
"@types/google.maps": {
"version": "3.51.0",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.51.0.tgz",
"integrity": "sha512-44/oQYjc5D6kxBcI3Qk9rk3IIOMwnlEMWDV7pwPJ2YI89s5Q1OzDrFvR7QJ3LFrpVXEhig+gyagFg54+foinFg==",
"dev": true
},
"@types/json-schema": { "@types/json-schema": {
"version": "7.0.11", "version": "7.0.11",
"dev": true "dev": true
@@ -13025,13 +13092,17 @@
} }
}, },
"fast-deep-equal": { "fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3"
"dev": true
}, },
"fast-diff": { "fast-diff": {
"version": "1.2.0", "version": "1.2.0",
"dev": true "dev": true
}, },
"fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg=="
},
"fast-glob": { "fast-glob": {
"version": "3.2.12", "version": "3.2.12",
"dev": true, "dev": true,
@@ -13688,7 +13759,9 @@
"dev": true "dev": true
}, },
"json5": { "json5": {
"version": "2.2.1" "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="
}, },
"jsonfile": { "jsonfile": {
"version": "6.1.0", "version": "6.1.0",
@@ -14981,7 +15054,9 @@
}, },
"dependencies": { "dependencies": {
"json5": { "json5": {
"version": "1.0.1", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dev": true, "dev": true,
"requires": { "requires": {
"minimist": "^1.2.0" "minimist": "^1.2.0"

View File

@@ -1,13 +1,13 @@
{ {
"name": "ucentral-client", "name": "ucentral-client",
"version": "2.8.0(44)", "version": "2.9.0(23)",
"description": "", "description": "",
"private": true, "private": true,
"main": "index.tsx", "main": "index.tsx",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"format": "prettier --write \"src/**/*.js\"", "format": "prettier --write \"src/**/*x.{ts,tsx,js,jsx}\"",
"analyze": "source-map-explorer 'build/static/js/*.js'", "analyze": "source-map-explorer 'build/static/js/*.js'",
"lint": "TIMING=1 eslint \"src/**/*.{ts,tsx,js,jsx}\" --fix", "lint": "TIMING=1 eslint \"src/**/*.{ts,tsx,js,jsx}\" --fix",
"clean": "rm -rf node_modules && rm -rf build" "clean": "rm -rf node_modules && rm -rf build"
@@ -22,12 +22,15 @@
"@emotion/react": "^11.10.4", "@emotion/react": "^11.10.4",
"@emotion/styled": "^11.10.4", "@emotion/styled": "^11.10.4",
"@fontsource/inter": "^4.5.14", "@fontsource/inter": "^4.5.14",
"@googlemaps/react-wrapper": "^1.1.35",
"@googlemaps/typescript-guards": "^2.0.3",
"@react-spring/web": "^9.5.5", "@react-spring/web": "^9.5.5",
"axios": "^1.1.3", "axios": "^1.1.3",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"chakra-react-select": "^4.3.0", "chakra-react-select": "^4.3.0",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"formik": "^2.2.9", "formik": "^2.2.9",
"fast-equals": "^4.0.3",
"framer-motion": "^7.6.1", "framer-motion": "^7.6.1",
"i18next": "^22.0.0", "i18next": "^22.0.0",
"i18next-browser-languagedetector": "^6.1.8", "i18next-browser-languagedetector": "^6.1.8",
@@ -60,6 +63,7 @@
"zustand": "^4.1.2" "zustand": "^4.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/google.maps": "^3.51.0",
"@types/node": "^18.11.2", "@types/node": "^18.11.2",
"@types/react": "^18.0.21", "@types/react": "^18.0.21",
"@types/react-csv": "^1.1.3", "@types/react-csv": "^1.1.3",

View File

@@ -79,8 +79,11 @@
"live_view_help": "Hilfe zur Live-Ansicht", "live_view_help": "Hilfe zur Live-Ansicht",
"memory": "Erinnerung", "memory": "Erinnerung",
"memory_used": "Verwendeter Speicher", "memory_used": "Verwendeter Speicher",
"missing_board": "Die Analytics-Überwachung an diesem Veranstaltungsort ist nicht mehr aktiv. Bitte starten Sie die Überwachung über das obere Menü neu", "missing_board": "Analytics-Überwachung an diesem Ort ist nicht mehr aktiv. Klicken Sie hier, um die Überwachung neu zu starten",
"mode": "Modus", "mode": "Modus",
"monitoring": "Überwachung",
"no_board": "Keine Überwachung",
"no_board_description": "Sie überwachen diesen Veranstaltungsort derzeit nicht, klicken Sie hier, um zu beginnen",
"noise": "Lärm", "noise": "Lärm",
"packets": "Pakete", "packets": "Pakete",
"radio": "RADIO", "radio": "RADIO",
@@ -91,6 +94,8 @@
"retries": "Wiederholungen", "retries": "Wiederholungen",
"search_serials": "Zeitschriften suchen", "search_serials": "Zeitschriften suchen",
"stop_monitoring": "Beenden Sie die Überwachung", "stop_monitoring": "Beenden Sie die Überwachung",
"stop_monitoring_success": "Überwachungsort gestoppt!",
"stop_monitoring_warning": "Bist du sicher? Dadurch werden alle aufgezeichneten Überwachungsdaten für diesen Veranstaltungsort gelöscht",
"temperature": "Temperatur", "temperature": "Temperatur",
"title": "ANALYTICS", "title": "ANALYTICS",
"total_data": "Gesamtdaten", "total_data": "Gesamtdaten",
@@ -175,6 +180,7 @@
"other": "Befehle", "other": "Befehle",
"override_dfs": "DFS überschreiben", "override_dfs": "DFS überschreiben",
"reboot": "Starten Sie neu", "reboot": "Starten Sie neu",
"reboot_description": "Möchten Sie dieses Gerät neu starten?",
"reboot_error": "Fehler beim Senden des Neustartbefehls: {{e}}", "reboot_error": "Fehler beim Senden des Neustartbefehls: {{e}}",
"reboot_success": "Neustartbefehl erfolgreich gesendet!", "reboot_success": "Neustartbefehl erfolgreich gesendet!",
"revision": "Revision", "revision": "Revision",
@@ -391,6 +397,7 @@
"warning_pushes_one": "Warten auf Geräteverbindung: {{count}}", "warning_pushes_one": "Warten auf Geräteverbindung: {{count}}",
"warning_pushes_other": "Warten auf Geräteverbindung: {{count}}", "warning_pushes_other": "Warten auf Geräteverbindung: {{count}}",
"weight": "Gewicht", "weight": "Gewicht",
"wifi_bands_max": "Es können nicht mehr als 8 SSIDs dieses WLAN-Band verwenden",
"wifi_frames": "WiFi-Frames" "wifi_frames": "WiFi-Frames"
}, },
"contacts": { "contacts": {
@@ -600,6 +607,7 @@
"certificate_expires_in": "Zertifikat läuft ab in", "certificate_expires_in": "Zertifikat läuft ab in",
"certificate_expiry": "Zert. Läuft ab in", "certificate_expiry": "Zert. Läuft ab in",
"connected": "In Verbindung gebracht", "connected": "In Verbindung gebracht",
"crash_logs": "Absturzprotokolle",
"create_errors": "Fehler beim Versuch, Geräte zu erstellen", "create_errors": "Fehler beim Versuch, Geräte zu erstellen",
"create_success": " Geräte erfolgreich erstellt", "create_success": " Geräte erfolgreich erstellt",
"current_firmware": "Aktuelle Firmware", "current_firmware": "Aktuelle Firmware",
@@ -613,6 +621,7 @@
"import_device_warning": "Bitte stellen Sie sicher, dass am Anfang oder Ende von Werten keine zusätzlichen Leerzeichen stehen, es sei denn, es handelt sich um einen Teil des gewünschten Werts", "import_device_warning": "Bitte stellen Sie sicher, dass am Anfang oder Ende von Werten keine zusätzlichen Leerzeichen stehen, es sei denn, es handelt sich um einen Teil des gewünschten Werts",
"import_explanation": "Für den Massenimport von Geräten müssen Sie eine CSV-Datei mit den folgenden Spalten verwenden: SerialNumber, DeviceType, Name, Description, Note", "import_explanation": "Für den Massenimport von Geräten müssen Sie eine CSV-Datei mit den folgenden Spalten verwenden: SerialNumber, DeviceType, Name, Description, Note",
"invalid_serial_number": "Ungültige Seriennummer (muss 12 HEX-Zeichen lang sein)", "invalid_serial_number": "Ungültige Seriennummer (muss 12 HEX-Zeichen lang sein)",
"logs_one": "Log",
"new_devices": "Neue Geräte", "new_devices": "Neue Geräte",
"no_model_image": "Kein Modellbild gefunden", "no_model_image": "Kein Modellbild gefunden",
"not_connected": "Nicht verbunden", "not_connected": "Nicht verbunden",
@@ -621,6 +630,8 @@
"one": "Gerät", "one": "Gerät",
"reassign_already_owned": "Geräte neu zuweisen, die bereits vorhanden sind und einem anderen Unternehmen/Veranstaltungsort/Abonnenten gehören?", "reassign_already_owned": "Geräte neu zuweisen, die bereits vorhanden sind und einem anderen Unternehmen/Veranstaltungsort/Abonnenten gehören?",
"restricted": "Beschränkt", "restricted": "Beschränkt",
"restricted_overriden": "Dies ist ein eingeschränktes Gerät, aber es befindet sich im Entwicklungsmodus. Alle Einschränkungen werden derzeit ignoriert",
"restrictions_overriden_title": "Dev-Modus",
"sanity": "Gesundheit", "sanity": "Gesundheit",
"start_import": "Geräteimport starten", "start_import": "Geräteimport starten",
"test_batch": "Testen Sie Importdaten", "test_batch": "Testen Sie Importdaten",
@@ -671,7 +682,15 @@
"test_digicert_creds": "Anmeldeinformationen testen", "test_digicert_creds": "Anmeldeinformationen testen",
"title": "Entitäten", "title": "Entitäten",
"tree": "Entitätsbaum", "tree": "Entitätsbaum",
"venues_under_root": "Veranstaltungsorte können nicht direkt unter der Root-Entität erstellt werden. Bitte erstellen Sie neue Entitäten und erstellen Sie Veranstaltungsorte unter diesen." "update_success": "Entität aktualisiert!",
"venues_under_root": "Veranstaltungsorte können nicht direkt unter der Root-Entität erstellt werden"
},
"firmware": {
"db_update_warning": "Dieser Vorgang wird täglich automatisch durchgeführt, ohne dass dieses manuelle Update verwendet werden muss. Die Aktualisierung dieser Datenbank kann bis zu 25 Minuten dauern",
"last_db_update_modal": "Firmware-Datenbank",
"last_db_update_title": "Datenbank",
"start_db_update": "Datenbankaktualisierung starten",
"started_db_update": "Datenbankaktualisierung gestartet, dieser Vorgang sollte bis zu 25 Minuten dauern"
}, },
"footer": { "footer": {
"powered_by": "Unterstützt von", "powered_by": "Unterstützt von",
@@ -765,13 +784,17 @@
"city": "Stadt", "city": "Stadt",
"claim_explanation": "Um Standorte zu beanspruchen, können Sie die folgende Tabelle verwenden", "claim_explanation": "Um Standorte zu beanspruchen, können Sie die folgende Tabelle verwenden",
"country": "Land", "country": "Land",
"elevation": "Elevation",
"geocode": "Geo-Code", "geocode": "Geo-Code",
"lat": "Breite",
"longitude": "Längengrad",
"one": "Ort", "one": "Ort",
"other": "Standorte", "other": "Standorte",
"postal": "Postleitzahl", "postal": "Postleitzahl",
"state": "Bundesstaat / Provinz", "state": "Bundesstaat / Provinz",
"title": "Standorte", "title": "Standorte",
"to_claim": "Standorte zu beanspruchen" "to_claim": "Standorte zu beanspruchen",
"view_gps": ""
}, },
"login": { "login": {
"access_policy": "Zugangsrichtlinien", "access_policy": "Zugangsrichtlinien",
@@ -797,6 +820,7 @@
"reset_password": "Passwort zurücksetzen", "reset_password": "Passwort zurücksetzen",
"sign_in": "Einloggen", "sign_in": "Einloggen",
"sms_instructions": "Sie sollten bald einen 6-stelligen Code auf Ihrem Telefon erhalten. Bitte geben Sie es unten ein, um sich anzumelden", "sms_instructions": "Sie sollten bald einen 6-stelligen Code auf Ihrem Telefon erhalten. Bitte geben Sie es unten ein, um sich anzumelden",
"suspended_error": "Konto gesperrt, wenden Sie sich bitte an Ihren Administrator",
"verification": "Bestätigen Sie Ihre Anmeldung", "verification": "Bestätigen Sie Ihre Anmeldung",
"waiting_for_email_verification": "Konto noch nicht per E-Mail validiert. Bitte sehen Sie in Ihrem Posteingang nach oder bitten Sie Ihren Administrator, eine Bestätigung erneut zu senden", "waiting_for_email_verification": "Konto noch nicht per E-Mail validiert. Bitte sehen Sie in Ihrem Posteingang nach oder bitten Sie Ihren Administrator, eine Bestätigung erneut zu senden",
"welcome_back": "Willkommen zurück!", "welcome_back": "Willkommen zurück!",
@@ -902,7 +926,7 @@
"dfs": "DFS-Überschreibung", "dfs": "DFS-Überschreibung",
"gw_commands": "Gateway-Befehle", "gw_commands": "Gateway-Befehle",
"identifier": "Identifikator", "identifier": "Identifikator",
"key_verification": "Überprüfung des Signaturschlüssels", "key_verification": "Signieren von Schlüsselinformationen",
"restricted": "Beschränkt", "restricted": "Beschränkt",
"signed_upgrade": "Nur signiertes Upgrade", "signed_upgrade": "Nur signiertes Upgrade",
"title": "Beschränkungen", "title": "Beschränkungen",
@@ -1022,6 +1046,7 @@
}, },
"system": { "system": {
"backend_logs": "Back-End-Protokolle", "backend_logs": "Back-End-Protokolle",
"configuration": "Aufbau",
"could_not_retrieve": "Fehler: {{name}} Systeminformationen konnten nicht abgerufen werden", "could_not_retrieve": "Fehler: {{name}} Systeminformationen konnten nicht abgerufen werden",
"endpoint": "Endpunkt", "endpoint": "Endpunkt",
"hostname": "Hostname", "hostname": "Hostname",
@@ -1032,6 +1057,10 @@
"os": "Betriebssystem", "os": "Betriebssystem",
"processors": "Prozessoren", "processors": "Prozessoren",
"reload_chosen_subsystems": "Ausgewählte Subsysteme neu laden", "reload_chosen_subsystems": "Ausgewählte Subsysteme neu laden",
"secrets": "Geheimnisse",
"secrets_create": "Geheimnis erstellen",
"secrets_one": "Geheimnis",
"services": "dienstleistungen",
"start": "Start", "start": "Start",
"subsystems": "Subsysteme", "subsystems": "Subsysteme",
"success_reload": "Reload-Befehl erfolgreich gesendet!", "success_reload": "Reload-Befehl erfolgreich gesendet!",
@@ -1053,9 +1082,11 @@
"previous_page": "Vorherige Seite" "previous_page": "Vorherige Seite"
}, },
"user": { "user": {
"email_not_validated": "E-Mail nicht validiert",
"error_fetching": "Fehler beim Abrufen der Benutzerinformationen: {{e}}", "error_fetching": "Fehler beim Abrufen der Benutzerinformationen: {{e}}",
"password": "Passwort", "password": "Passwort",
"role": "Rolle", "role": "Rolle",
"suspended": "Suspendiert",
"title": "Nutzer" "title": "Nutzer"
}, },
"users": { "users": {
@@ -1100,9 +1131,11 @@
"successfully_update_devices": " {{num}} Geräte werden aktualisiert!", "successfully_update_devices": " {{num}} Geräte werden aktualisiert!",
"title": "Veranstaltungsorte", "title": "Veranstaltungsorte",
"update_all_devices": "Alle Gerätekonfigurationen aktualisieren", "update_all_devices": "Alle Gerätekonfigurationen aktualisieren",
"upgrade_all_devices": "Aktualisieren Sie alle Geräte auf die neueste Firmware", "update_success": "Veranstaltungsort aktualisiert!",
"upgrade_all_devices": "Aktualisieren Sie die Firmware aller Geräte",
"upgrade_all_devices_error": "Fehler beim Aktualisieren von Geräten: {{e}}", "upgrade_all_devices_error": "Fehler beim Aktualisieren von Geräten: {{e}}",
"upgrade_all_devices_success": "Upgrade von Geräten erfolgreich gestartet!", "upgrade_all_devices_success": "Upgrade von Geräten erfolgreich gestartet!",
"upgrade_options_available": "Hier sind alle verfügbaren Revisionen, bitte wählen Sie diejenige aus, auf die ALLE Geräte dieses Veranstaltungsortes aktualisiert werden sollen",
"use_existing": "Benutze existierendes", "use_existing": "Benutze existierendes",
"use_existing_contacts": "Verwenden Sie vorhandene Kontakte", "use_existing_contacts": "Verwenden Sie vorhandene Kontakte",
"use_this_contact": "Verwenden Sie diesen Kontakt" "use_this_contact": "Verwenden Sie diesen Kontakt"

View File

@@ -79,8 +79,11 @@
"live_view_help": "Live View Help", "live_view_help": "Live View Help",
"memory": "Memory", "memory": "Memory",
"memory_used": "Memory Used", "memory_used": "Memory Used",
"missing_board": "Analytics monitoring on this venue is no longer active, please restart monitoring using the top menu", "missing_board": "Analytics monitoring on this venue is no longer active. Click here to restart monitoring",
"mode": "Mode", "mode": "Mode",
"monitoring": "Monitoring",
"no_board": "No Monitoring",
"no_board_description": "You are not monitoring this Venue at the moment, click here to start",
"noise": "Noise", "noise": "Noise",
"packets": "Packets", "packets": "Packets",
"radio": "Radio", "radio": "Radio",
@@ -91,6 +94,8 @@
"retries": "Retries", "retries": "Retries",
"search_serials": "Search Serials", "search_serials": "Search Serials",
"stop_monitoring": "Stop Monitoring", "stop_monitoring": "Stop Monitoring",
"stop_monitoring_success": "Stopped Monitoring Venue!",
"stop_monitoring_warning": "Are you sure? This will erase all recorded monitoring data for this venue",
"temperature": "Temperature", "temperature": "Temperature",
"title": "Analytics", "title": "Analytics",
"total_data": "Total Data", "total_data": "Total Data",
@@ -175,6 +180,7 @@
"other": "Commands", "other": "Commands",
"override_dfs": "Override DFS", "override_dfs": "Override DFS",
"reboot": "Reboot", "reboot": "Reboot",
"reboot_description": "Do you want to reboot this device?",
"reboot_error": "Error while sending reboot command: {{e}}", "reboot_error": "Error while sending reboot command: {{e}}",
"reboot_success": "Successfully sent reboot command!", "reboot_success": "Successfully sent reboot command!",
"revision": "Revision", "revision": "Revision",
@@ -391,6 +397,7 @@
"warning_pushes_one": "Waiting for devices to connect: {{count}}", "warning_pushes_one": "Waiting for devices to connect: {{count}}",
"warning_pushes_other": "Waiting for devices to connect: {{count}}", "warning_pushes_other": "Waiting for devices to connect: {{count}}",
"weight": "Weight", "weight": "Weight",
"wifi_bands_max": "There cannot be more than 8 SSIDs using this wifi-band",
"wifi_frames": "WiFi Frames" "wifi_frames": "WiFi Frames"
}, },
"contacts": { "contacts": {
@@ -600,6 +607,7 @@
"certificate_expires_in": "Certificate Expiry", "certificate_expires_in": "Certificate Expiry",
"certificate_expiry": "Cert. Expires In", "certificate_expiry": "Cert. Expires In",
"connected": "Connected", "connected": "Connected",
"crash_logs": "Crash Logs",
"create_errors": "errors while trying to create devices", "create_errors": "errors while trying to create devices",
"create_success": " devices successfully created", "create_success": " devices successfully created",
"current_firmware": "Current Firmware", "current_firmware": "Current Firmware",
@@ -613,6 +621,7 @@
"import_device_warning": "Please make sure there are no extra spaces at the start or end of any values unless it is part of the value desired", "import_device_warning": "Please make sure there are no extra spaces at the start or end of any values unless it is part of the value desired",
"import_explanation": "To bulk import devices, you need to use a CSV file with the following columns: SerialNumber, DeviceType, Name, Description, Note", "import_explanation": "To bulk import devices, you need to use a CSV file with the following columns: SerialNumber, DeviceType, Name, Description, Note",
"invalid_serial_number": "Invalid Serial Number (needs to be 12 HEX chars)", "invalid_serial_number": "Invalid Serial Number (needs to be 12 HEX chars)",
"logs_one": "Log",
"new_devices": "new devices", "new_devices": "new devices",
"no_model_image": "No Model Image Found", "no_model_image": "No Model Image Found",
"not_connected": "Not Connected", "not_connected": "Not Connected",
@@ -621,6 +630,8 @@
"one": "Device", "one": "Device",
"reassign_already_owned": "Reassign devices which already exist and are owned by another entity/venue/subscriber?", "reassign_already_owned": "Reassign devices which already exist and are owned by another entity/venue/subscriber?",
"restricted": "Restricted", "restricted": "Restricted",
"restricted_overriden": "This is a restricted device, but it is in development mode. All restrictions are currently ignored",
"restrictions_overriden_title": "Dev Mode",
"sanity": "Sanity", "sanity": "Sanity",
"start_import": "Start Device Importation", "start_import": "Start Device Importation",
"test_batch": "Test Import Data", "test_batch": "Test Import Data",
@@ -671,7 +682,15 @@
"test_digicert_creds": "Test Credentials", "test_digicert_creds": "Test Credentials",
"title": "Entities", "title": "Entities",
"tree": "Entity Tree", "tree": "Entity Tree",
"venues_under_root": "Venues cannot be created directly under the root entity. Please create new entities and create venues under these." "update_success": "Entity updated!",
"venues_under_root": "Venues cannot be created directly under the root entity"
},
"firmware": {
"db_update_warning": "This operation is done daily automatically without need to use this manual update. Updating this database can take up to 25 minutes",
"last_db_update_modal": "Firmware Database",
"last_db_update_title": "Database",
"start_db_update": "Start Database Update",
"started_db_update": "Started database update, this operation should take up to 25 minutes to complete"
}, },
"footer": { "footer": {
"powered_by": "Powered By", "powered_by": "Powered By",
@@ -765,13 +784,17 @@
"city": "City", "city": "City",
"claim_explanation": "To claim locations you can use the table below", "claim_explanation": "To claim locations you can use the table below",
"country": "Country", "country": "Country",
"elevation": "Elevation",
"geocode": "Geo Code", "geocode": "Geo Code",
"lat": "Latitude",
"longitude": "Longitude",
"one": "Location", "one": "Location",
"other": "Locations", "other": "Locations",
"postal": "ZIP/Postal Code", "postal": "ZIP/Postal Code",
"state": "State/Province", "state": "State/Province",
"title": "Locations", "title": "Locations",
"to_claim": "Locations to claim" "to_claim": "Locations to claim",
"view_gps": "View GPS Location"
}, },
"login": { "login": {
"access_policy": "Access Policy", "access_policy": "Access Policy",
@@ -797,6 +820,7 @@
"reset_password": "Reset Password", "reset_password": "Reset Password",
"sign_in": "Sign In", "sign_in": "Sign In",
"sms_instructions": "You should receive a 6-digit code on your phone soon. Please enter it below to login", "sms_instructions": "You should receive a 6-digit code on your phone soon. Please enter it below to login",
"suspended_error": "Suspended account, please contact your administrator",
"verification": "Verify your login", "verification": "Verify your login",
"waiting_for_email_verification": "Account not yet email validated. Please look at your inbox or ask your administrator to resend a validation", "waiting_for_email_verification": "Account not yet email validated. Please look at your inbox or ask your administrator to resend a validation",
"welcome_back": "Welcome Back!", "welcome_back": "Welcome Back!",
@@ -902,7 +926,7 @@
"dfs": "DFS Override", "dfs": "DFS Override",
"gw_commands": "Gateway Commands", "gw_commands": "Gateway Commands",
"identifier": "Identifier", "identifier": "Identifier",
"key_verification": "Signing Key Verification", "key_verification": "Signing Key Information",
"restricted": "Restricted", "restricted": "Restricted",
"signed_upgrade": "Signed Upgrade Only", "signed_upgrade": "Signed Upgrade Only",
"title": "Restrictions", "title": "Restrictions",
@@ -1022,6 +1046,7 @@
}, },
"system": { "system": {
"backend_logs": "Back-End Logs", "backend_logs": "Back-End Logs",
"configuration": "Configuration",
"could_not_retrieve": "Error: could not retrieve {{name}} system information", "could_not_retrieve": "Error: could not retrieve {{name}} system information",
"endpoint": "Endpoint", "endpoint": "Endpoint",
"hostname": "Host Name", "hostname": "Host Name",
@@ -1032,6 +1057,10 @@
"os": "Operating System", "os": "Operating System",
"processors": "Processors", "processors": "Processors",
"reload_chosen_subsystems": "Reload Chosen Subsystems", "reload_chosen_subsystems": "Reload Chosen Subsystems",
"secrets": "Secrets",
"secrets_create": "Create Secret",
"secrets_one": "Secret",
"services": "Services",
"start": "Start", "start": "Start",
"subsystems": "Subsystems", "subsystems": "Subsystems",
"success_reload": "Successfully sent reload command!", "success_reload": "Successfully sent reload command!",
@@ -1053,9 +1082,11 @@
"previous_page": "Previous Page" "previous_page": "Previous Page"
}, },
"user": { "user": {
"email_not_validated": "email not validated",
"error_fetching": "Error fetching user information: {{e}}", "error_fetching": "Error fetching user information: {{e}}",
"password": "Password", "password": "Password",
"role": "Role", "role": "Role",
"suspended": "suspended",
"title": "User" "title": "User"
}, },
"users": { "users": {
@@ -1100,9 +1131,11 @@
"successfully_update_devices": "Updating {{num}} devices!", "successfully_update_devices": "Updating {{num}} devices!",
"title": "Venues", "title": "Venues",
"update_all_devices": "Update All Device Configurations", "update_all_devices": "Update All Device Configurations",
"upgrade_all_devices": "Upgrade All Devices to Latest Firmware", "update_success": "Venue updated!",
"upgrade_all_devices": "Upgrade All Devices Firmware",
"upgrade_all_devices_error": "Error upgrading devices: {{e}}", "upgrade_all_devices_error": "Error upgrading devices: {{e}}",
"upgrade_all_devices_success": "Successfully started upgrading devices!", "upgrade_all_devices_success": "Successfully started upgrading devices!",
"upgrade_options_available": "Here are all available revisions, please select the one you want ALL of this venue's devices to be upgrade to",
"use_existing": "Use Existing", "use_existing": "Use Existing",
"use_existing_contacts": "Use Existing Contacts", "use_existing_contacts": "Use Existing Contacts",
"use_this_contact": "Use this contact" "use_this_contact": "Use this contact"

View File

@@ -79,8 +79,11 @@
"live_view_help": "Ayuda de visualización en vivo", "live_view_help": "Ayuda de visualización en vivo",
"memory": "Memoria", "memory": "Memoria",
"memory_used": "Memoria usada", "memory_used": "Memoria usada",
"missing_board": "El monitoreo analítico en este lugar ya no está activo, reinicie el monitoreo usando el menú superior", "missing_board": "El monitoreo analítico en este lugar ya no está activo. Haga clic aquí para reiniciar el monitoreo",
"mode": "Modo", "mode": "Modo",
"monitoring": "Vigilancia",
"no_board": "Sin monitoreo",
"no_board_description": "No está monitoreando este lugar en este momento, haga clic aquí para comenzar",
"noise": "Ruido", "noise": "Ruido",
"packets": "Paquetes", "packets": "Paquetes",
"radio": "RADIO", "radio": "RADIO",
@@ -91,6 +94,8 @@
"retries": "Reintentos", "retries": "Reintentos",
"search_serials": "Buscar seriales", "search_serials": "Buscar seriales",
"stop_monitoring": "Dejar de monitorear", "stop_monitoring": "Dejar de monitorear",
"stop_monitoring_success": "¡Se detuvo el lugar de monitoreo!",
"stop_monitoring_warning": "¿Está seguro? Esto borrará todos los datos de monitoreo grabados para este lugar.",
"temperature": "temperatura", "temperature": "temperatura",
"title": "ANALÍTICA", "title": "ANALÍTICA",
"total_data": "Datos totales", "total_data": "Datos totales",
@@ -175,6 +180,7 @@
"other": "comandos", "other": "comandos",
"override_dfs": "Anular DFS", "override_dfs": "Anular DFS",
"reboot": "Reiniciar", "reboot": "Reiniciar",
"reboot_description": "¿Quieres reiniciar este dispositivo?",
"reboot_error": "Error al enviar el comando de reinicio: {{e}}", "reboot_error": "Error al enviar el comando de reinicio: {{e}}",
"reboot_success": "¡Comando de reinicio enviado con éxito!", "reboot_success": "¡Comando de reinicio enviado con éxito!",
"revision": "revisión", "revision": "revisión",
@@ -391,6 +397,7 @@
"warning_pushes_one": "Esperando a que los dispositivos se conecten: {{count}}", "warning_pushes_one": "Esperando a que los dispositivos se conecten: {{count}}",
"warning_pushes_other": "Esperando a que los dispositivos se conecten: {{count}}", "warning_pushes_other": "Esperando a que los dispositivos se conecten: {{count}}",
"weight": "Peso", "weight": "Peso",
"wifi_bands_max": "No puede haber más de 8 SSID usando esta banda wifi",
"wifi_frames": "Marcos WiFi" "wifi_frames": "Marcos WiFi"
}, },
"contacts": { "contacts": {
@@ -600,6 +607,7 @@
"certificate_expires_in": "El certificado caduca en", "certificate_expires_in": "El certificado caduca en",
"certificate_expiry": "Cert. Expira en", "certificate_expiry": "Cert. Expira en",
"connected": "Conectado", "connected": "Conectado",
"crash_logs": "Registros de fallas",
"create_errors": "errores al intentar crear dispositivos", "create_errors": "errores al intentar crear dispositivos",
"create_success": " dispositivos creados con éxito", "create_success": " dispositivos creados con éxito",
"current_firmware": "Firmware actual", "current_firmware": "Firmware actual",
@@ -613,6 +621,7 @@
"import_device_warning": "Asegúrese de que no haya espacios adicionales al principio o al final de ningún valor a menos que sea parte del valor deseado", "import_device_warning": "Asegúrese de que no haya espacios adicionales al principio o al final de ningún valor a menos que sea parte del valor deseado",
"import_explanation": "Para importar dispositivos de forma masiva, debe usar un archivo CSV con las siguientes columnas: Número de serie, Tipo de dispositivo, Nombre, Descripción, Nota", "import_explanation": "Para importar dispositivos de forma masiva, debe usar un archivo CSV con las siguientes columnas: Número de serie, Tipo de dispositivo, Nombre, Descripción, Nota",
"invalid_serial_number": "Número de serie no válido (debe tener 12 caracteres HEX)", "invalid_serial_number": "Número de serie no válido (debe tener 12 caracteres HEX)",
"logs_one": "Iniciar sesión",
"new_devices": "Nuevos dispositivos", "new_devices": "Nuevos dispositivos",
"no_model_image": "No se encontró ninguna imagen de modelo", "no_model_image": "No se encontró ninguna imagen de modelo",
"not_connected": "No conectado", "not_connected": "No conectado",
@@ -621,6 +630,8 @@
"one": "Dispositivo", "one": "Dispositivo",
"reassign_already_owned": "¿Reasignar dispositivos que ya existen y son propiedad de otra entidad/lugar/suscriptor?", "reassign_already_owned": "¿Reasignar dispositivos que ya existen y son propiedad de otra entidad/lugar/suscriptor?",
"restricted": "Restringido", "restricted": "Restringido",
"restricted_overriden": "Este es un dispositivo restringido, pero está en modo de desarrollo. Actualmente se ignoran todas las restricciones.",
"restrictions_overriden_title": "MODO DE DESARROLLO",
"sanity": "Cordura", "sanity": "Cordura",
"start_import": "Iniciar la importación de dispositivos", "start_import": "Iniciar la importación de dispositivos",
"test_batch": "Datos de importación de prueba", "test_batch": "Datos de importación de prueba",
@@ -671,7 +682,15 @@
"test_digicert_creds": "Credenciales de prueba", "test_digicert_creds": "Credenciales de prueba",
"title": "entidades", "title": "entidades",
"tree": "Árbol de entidades", "tree": "Árbol de entidades",
"venues_under_root": "Los lugares no se pueden crear directamente bajo la entidad raíz. Cree nuevas entidades y cree lugares bajo estas." "update_success": "¡Entidad actualizada!",
"venues_under_root": "Los lugares no se pueden crear directamente bajo la entidad raíz"
},
"firmware": {
"db_update_warning": "Esta operación se realiza automáticamente todos los días de forma automática sin necesidad de utilizar esta actualización manual. La actualización de esta base de datos puede tardar hasta 25 minutos",
"last_db_update_modal": "Base de datos de firmware",
"last_db_update_title": "Base de datos",
"start_db_update": "Iniciar actualización de la base de datos",
"started_db_update": "Actualización de la base de datos iniciada, esta operación debería tardar hasta 25 minutos en completarse"
}, },
"footer": { "footer": {
"powered_by": "energizado por", "powered_by": "energizado por",
@@ -765,13 +784,17 @@
"city": "ciudad", "city": "ciudad",
"claim_explanation": "Para reclamar ubicaciones, puede usar la tabla a continuación", "claim_explanation": "Para reclamar ubicaciones, puede usar la tabla a continuación",
"country": "País", "country": "País",
"elevation": "Elevación",
"geocode": "Código geográfico", "geocode": "Código geográfico",
"lat": "Latitud",
"longitude": "Longitud",
"one": "Ubicación", "one": "Ubicación",
"other": "Ubicaciones", "other": "Ubicaciones",
"postal": "código postal", "postal": "código postal",
"state": "Provincia del estado", "state": "Provincia del estado",
"title": "Ubicaciones", "title": "Ubicaciones",
"to_claim": "Ubicaciones para reclamar" "to_claim": "Ubicaciones para reclamar",
"view_gps": ""
}, },
"login": { "login": {
"access_policy": "Política de acceso", "access_policy": "Política de acceso",
@@ -797,6 +820,7 @@
"reset_password": "Restablecer la contraseña", "reset_password": "Restablecer la contraseña",
"sign_in": "Registrarse", "sign_in": "Registrarse",
"sms_instructions": "Debería recibir un código de 6 dígitos en su teléfono pronto. Por favor, introdúzcalo a continuación para iniciar sesión", "sms_instructions": "Debería recibir un código de 6 dígitos en su teléfono pronto. Por favor, introdúzcalo a continuación para iniciar sesión",
"suspended_error": "Cuenta suspendida, comuníquese con su administrador",
"verification": "Verifica tu inicio de sesión", "verification": "Verifica tu inicio de sesión",
"waiting_for_email_verification": "Cuenta aún no validada por correo electrónico. Mire su bandeja de entrada o solicite a su administrador que vuelva a enviar una validación.", "waiting_for_email_verification": "Cuenta aún no validada por correo electrónico. Mire su bandeja de entrada o solicite a su administrador que vuelva a enviar una validación.",
"welcome_back": "¡Dar una buena acogida!", "welcome_back": "¡Dar una buena acogida!",
@@ -902,7 +926,7 @@
"dfs": "Anulación de DFS", "dfs": "Anulación de DFS",
"gw_commands": "Comandos de puerta de enlace", "gw_commands": "Comandos de puerta de enlace",
"identifier": "Identificador", "identifier": "Identificador",
"key_verification": "Verificación de clave de firma", "key_verification": "Información clave de firma",
"restricted": "Restringido", "restricted": "Restringido",
"signed_upgrade": "Solo actualización firmada", "signed_upgrade": "Solo actualización firmada",
"title": "Las restricciones", "title": "Las restricciones",
@@ -1022,6 +1046,7 @@
}, },
"system": { "system": {
"backend_logs": "Registros de back-end", "backend_logs": "Registros de back-end",
"configuration": "Configuración",
"could_not_retrieve": "Error: no se pudo recuperar la información del sistema {{name}} ", "could_not_retrieve": "Error: no se pudo recuperar la información del sistema {{name}} ",
"endpoint": "punto final", "endpoint": "punto final",
"hostname": "Nombre de host", "hostname": "Nombre de host",
@@ -1032,6 +1057,10 @@
"os": "sistema operativo", "os": "sistema operativo",
"processors": "Procesadores", "processors": "Procesadores",
"reload_chosen_subsystems": "Recargar subsistemas elegidos", "reload_chosen_subsystems": "Recargar subsistemas elegidos",
"secrets": "Misterios",
"secrets_create": "Crear secreto",
"secrets_one": "secreto",
"services": "Servicios",
"start": "comienzo", "start": "comienzo",
"subsystems": "Subsistemas", "subsystems": "Subsistemas",
"success_reload": "¡Comando de recarga enviado con éxito!", "success_reload": "¡Comando de recarga enviado con éxito!",
@@ -1053,9 +1082,11 @@
"previous_page": "Página anterior" "previous_page": "Página anterior"
}, },
"user": { "user": {
"email_not_validated": "correo electrónico no validado",
"error_fetching": "Error al obtener la información del usuario: {{e}}", "error_fetching": "Error al obtener la información del usuario: {{e}}",
"password": "Contraseña", "password": "Contraseña",
"role": "papel", "role": "papel",
"suspended": "Suspendido",
"title": "Usuario" "title": "Usuario"
}, },
"users": { "users": {
@@ -1100,9 +1131,11 @@
"successfully_update_devices": "¡Actualizando {{num}} dispositivos!", "successfully_update_devices": "¡Actualizando {{num}} dispositivos!",
"title": "Sedes", "title": "Sedes",
"update_all_devices": "Actualizar todas las configuraciones de dispositivos", "update_all_devices": "Actualizar todas las configuraciones de dispositivos",
"upgrade_all_devices": "Actualice todos los dispositivos al firmware más reciente", "update_success": "Lugar actualizado!",
"upgrade_all_devices": "Actualizar el firmware de todos los dispositivos",
"upgrade_all_devices_error": "Error al actualizar dispositivos: {{e}}", "upgrade_all_devices_error": "Error al actualizar dispositivos: {{e}}",
"upgrade_all_devices_success": "¡Comenzó con éxito la actualización de dispositivos!", "upgrade_all_devices_success": "¡Comenzó con éxito la actualización de dispositivos!",
"upgrade_options_available": "Aquí están todas las revisiones disponibles, seleccione la que desea que TODOS los dispositivos de este lugar se actualicen",
"use_existing": "Utilizar existente", "use_existing": "Utilizar existente",
"use_existing_contacts": "Usar contactos existentes", "use_existing_contacts": "Usar contactos existentes",
"use_this_contact": "Usa este contacto" "use_this_contact": "Usa este contacto"

View File

@@ -79,8 +79,11 @@
"live_view_help": "Aide sur l'affichage en direct", "live_view_help": "Aide sur l'affichage en direct",
"memory": "mémoire", "memory": "mémoire",
"memory_used": "Mémoire utilisée", "memory_used": "Mémoire utilisée",
"missing_board": "La surveillance analytique sur ce lieu n'est plus active, veuillez redémarrer la surveillance en utilisant le menu du haut", "missing_board": "La surveillance analytique sur ce site n'est plus active. Cliquez ici pour redémarrer la surveillance",
"mode": "Mode", "mode": "Mode",
"monitoring": "surveillance",
"no_board": "Aucune surveillance",
"no_board_description": "Vous ne surveillez pas ce lieu pour le moment, cliquez ici pour commencer",
"noise": "Bruit", "noise": "Bruit",
"packets": "Paquets", "packets": "Paquets",
"radio": "Radio", "radio": "Radio",
@@ -91,6 +94,8 @@
"retries": "Tentatives", "retries": "Tentatives",
"search_serials": "Rechercher des publications en série", "search_serials": "Rechercher des publications en série",
"stop_monitoring": "Arrêter la surveillance", "stop_monitoring": "Arrêter la surveillance",
"stop_monitoring_success": "Lieu de surveillance arrêté !",
"stop_monitoring_warning": "Êtes-vous sûr? Cela effacera toutes les données de surveillance enregistrées pour ce lieu",
"temperature": "Température", "temperature": "Température",
"title": "ANALYTIQUE", "title": "ANALYTIQUE",
"total_data": "Données totales", "total_data": "Données totales",
@@ -175,6 +180,7 @@
"other": "Les commandes", "other": "Les commandes",
"override_dfs": "Remplacer DFS", "override_dfs": "Remplacer DFS",
"reboot": "Redémarrer", "reboot": "Redémarrer",
"reboot_description": "Voulez-vous redémarrer cet appareil ?",
"reboot_error": "Erreur lors de l'envoi de la commande de redémarrage : {{e}}", "reboot_error": "Erreur lors de l'envoi de la commande de redémarrage : {{e}}",
"reboot_success": "Commande de redémarrage envoyée avec succès !", "reboot_success": "Commande de redémarrage envoyée avec succès !",
"revision": "Révision", "revision": "Révision",
@@ -391,6 +397,7 @@
"warning_pushes_one": "En attente de connexion des appareils : {{count}}", "warning_pushes_one": "En attente de connexion des appareils : {{count}}",
"warning_pushes_other": "En attente de connexion des appareils : {{count}}", "warning_pushes_other": "En attente de connexion des appareils : {{count}}",
"weight": "Poids", "weight": "Poids",
"wifi_bands_max": "Il ne peut y avoir plus de 8 SSID utilisant cette bande wifi",
"wifi_frames": "Cadres Wi-Fi" "wifi_frames": "Cadres Wi-Fi"
}, },
"contacts": { "contacts": {
@@ -600,6 +607,7 @@
"certificate_expires_in": "Le certificat expire dans", "certificate_expires_in": "Le certificat expire dans",
"certificate_expiry": "Cert. Expire dans", "certificate_expiry": "Cert. Expire dans",
"connected": "Connecté", "connected": "Connecté",
"crash_logs": "Journaux des plantages",
"create_errors": "erreurs lors de la tentative de création d'appareils", "create_errors": "erreurs lors de la tentative de création d'appareils",
"create_success": " appareils créés avec succès", "create_success": " appareils créés avec succès",
"current_firmware": "Firmware actuel", "current_firmware": "Firmware actuel",
@@ -613,6 +621,7 @@
"import_device_warning": "Veuillez vous assurer qu'il n'y a pas d'espaces supplémentaires au début ou à la fin des valeurs, sauf si cela fait partie de la valeur souhaitée", "import_device_warning": "Veuillez vous assurer qu'il n'y a pas d'espaces supplémentaires au début ou à la fin des valeurs, sauf si cela fait partie de la valeur souhaitée",
"import_explanation": "Pour importer en masse des appareils, vous devez utiliser un fichier CSV avec les colonnes suivantes : SerialNumber, DeviceType, Name, Description, Note", "import_explanation": "Pour importer en masse des appareils, vous devez utiliser un fichier CSV avec les colonnes suivantes : SerialNumber, DeviceType, Name, Description, Note",
"invalid_serial_number": "Numéro de série non valide (doit être composé de 12 caractères HEX)", "invalid_serial_number": "Numéro de série non valide (doit être composé de 12 caractères HEX)",
"logs_one": "Bûche",
"new_devices": "nouveaux appareils", "new_devices": "nouveaux appareils",
"no_model_image": "Aucune image de modèle trouvée", "no_model_image": "Aucune image de modèle trouvée",
"not_connected": "Pas connecté", "not_connected": "Pas connecté",
@@ -621,6 +630,8 @@
"one": "Dispositif", "one": "Dispositif",
"reassign_already_owned": "Réattribuer des appareils qui existent déjà et qui appartiennent à une autre entité/salle/abonné ?", "reassign_already_owned": "Réattribuer des appareils qui existent déjà et qui appartiennent à une autre entité/salle/abonné ?",
"restricted": "Limité", "restricted": "Limité",
"restricted_overriden": "Il s'agit d'un appareil restreint, mais il est en mode développement. Toutes les restrictions sont actuellement ignorées",
"restrictions_overriden_title": "Mode développement",
"sanity": "Santé mentale", "sanity": "Santé mentale",
"start_import": "Démarrer l'importation de l'appareil", "start_import": "Démarrer l'importation de l'appareil",
"test_batch": "Tester les données d'importation", "test_batch": "Tester les données d'importation",
@@ -671,7 +682,15 @@
"test_digicert_creds": "Tester les informations d'identification", "test_digicert_creds": "Tester les informations d'identification",
"title": "Entités", "title": "Entités",
"tree": "Arborescence des entités", "tree": "Arborescence des entités",
"venues_under_root": "Les sites ne peuvent pas être créés directement sous l'entité racine. Veuillez créer de nouvelles entités et créer des lieux sous celles-ci." "update_success": "Entité mise à jour !",
"venues_under_root": "Les lieux ne peuvent pas être créés directement sous l'entité racine"
},
"firmware": {
"db_update_warning": "Cette opération se fait automatiquement quotidiennement sans avoir besoin d'utiliser cette mise à jour manuelle. La mise à jour de cette base de données peut prendre jusqu'à 25 minutes",
"last_db_update_modal": "Base de données du micrologiciel",
"last_db_update_title": "Base de données",
"start_db_update": "Démarrer la mise à jour de la base de données",
"started_db_update": "Mise à jour de la base de données démarrée, cette opération devrait prendre jusqu'à 25 minutes"
}, },
"footer": { "footer": {
"powered_by": "Alimenté par", "powered_by": "Alimenté par",
@@ -765,13 +784,17 @@
"city": "Ville", "city": "Ville",
"claim_explanation": "Pour revendiquer des emplacements, vous pouvez utiliser le tableau ci-dessous", "claim_explanation": "Pour revendiquer des emplacements, vous pouvez utiliser le tableau ci-dessous",
"country": "Pays", "country": "Pays",
"elevation": "Élévation",
"geocode": "Geo code", "geocode": "Geo code",
"lat": "Latitude",
"longitude": "Longitude",
"one": "Emplacement", "one": "Emplacement",
"other": "Emplacements", "other": "Emplacements",
"postal": "Zip / code postal", "postal": "Zip / code postal",
"state": "Etat / Province", "state": "Etat / Province",
"title": "Emplacements", "title": "Emplacements",
"to_claim": "Emplacements à réclamer" "to_claim": "Emplacements à réclamer",
"view_gps": ""
}, },
"login": { "login": {
"access_policy": "Politique d'accès", "access_policy": "Politique d'accès",
@@ -797,6 +820,7 @@
"reset_password": "Réinitialiser le mot de passe", "reset_password": "Réinitialiser le mot de passe",
"sign_in": "se connecter", "sign_in": "se connecter",
"sms_instructions": "Vous devriez bientôt recevoir un code à 6 chiffres sur votre téléphone. Veuillez le saisir ci-dessous pour vous connecter", "sms_instructions": "Vous devriez bientôt recevoir un code à 6 chiffres sur votre téléphone. Veuillez le saisir ci-dessous pour vous connecter",
"suspended_error": "Compte suspendu, veuillez contacter votre administrateur",
"verification": "Vérifiez votre connexion", "verification": "Vérifiez votre connexion",
"waiting_for_email_verification": "Compte pas encore e-mail validé. Veuillez consulter votre boîte de réception ou demander à votre administrateur de renvoyer une validation", "waiting_for_email_verification": "Compte pas encore e-mail validé. Veuillez consulter votre boîte de réception ou demander à votre administrateur de renvoyer une validation",
"welcome_back": "Nous saluons le retour!", "welcome_back": "Nous saluons le retour!",
@@ -902,7 +926,7 @@
"dfs": "Remplacement DFS", "dfs": "Remplacement DFS",
"gw_commands": "Commandes de passerelle", "gw_commands": "Commandes de passerelle",
"identifier": "Identifiant", "identifier": "Identifiant",
"key_verification": "Vérification de la clé de signature", "key_verification": "Signature des informations clés",
"restricted": "Limité", "restricted": "Limité",
"signed_upgrade": "Mise à niveau signée uniquement", "signed_upgrade": "Mise à niveau signée uniquement",
"title": "Restrictions", "title": "Restrictions",
@@ -1022,6 +1046,7 @@
}, },
"system": { "system": {
"backend_logs": "Journaux principaux", "backend_logs": "Journaux principaux",
"configuration": "Configuration",
"could_not_retrieve": "Erreur : impossible de récupérer les informations système {{name}} ", "could_not_retrieve": "Erreur : impossible de récupérer les informations système {{name}} ",
"endpoint": "Point final", "endpoint": "Point final",
"hostname": "nom d'hôte", "hostname": "nom d'hôte",
@@ -1032,6 +1057,10 @@
"os": "Système opérateur", "os": "Système opérateur",
"processors": "Processeurs", "processors": "Processeurs",
"reload_chosen_subsystems": "Recharger les sous-systèmes choisis", "reload_chosen_subsystems": "Recharger les sous-systèmes choisis",
"secrets": "Secrets",
"secrets_create": "Créer un secret",
"secrets_one": "Secret",
"services": "Prestations de service",
"start": "Début", "start": "Début",
"subsystems": "Sous-systèmes", "subsystems": "Sous-systèmes",
"success_reload": "Commande de rechargement envoyée avec succès !", "success_reload": "Commande de rechargement envoyée avec succès !",
@@ -1053,9 +1082,11 @@
"previous_page": "Page précédente" "previous_page": "Page précédente"
}, },
"user": { "user": {
"email_not_validated": "Mail non valide",
"error_fetching": "Erreur lors de la récupération des informations utilisateur : {{e}}", "error_fetching": "Erreur lors de la récupération des informations utilisateur : {{e}}",
"password": "Mot de passe", "password": "Mot de passe",
"role": "Rôle", "role": "Rôle",
"suspended": "Suspendu",
"title": "Utilisateur" "title": "Utilisateur"
}, },
"users": { "users": {
@@ -1100,9 +1131,11 @@
"successfully_update_devices": "Mise à jour de {{num}} appareils !", "successfully_update_devices": "Mise à jour de {{num}} appareils !",
"title": "Les lieux", "title": "Les lieux",
"update_all_devices": "Mettre à jour toutes les configurations de périphérique", "update_all_devices": "Mettre à jour toutes les configurations de périphérique",
"upgrade_all_devices": "Mettre à niveau tous les appareils vers le dernier micrologiciel", "update_success": "Lieu mis à jour !",
"upgrade_all_devices": "Mettre à niveau le micrologiciel de tous les appareils",
"upgrade_all_devices_error": "Erreur lors de la mise à jour des appareils : {{e}}", "upgrade_all_devices_error": "Erreur lors de la mise à jour des appareils : {{e}}",
"upgrade_all_devices_success": "La mise à niveau des appareils a démarré avec succès !", "upgrade_all_devices_success": "La mise à niveau des appareils a démarré avec succès !",
"upgrade_options_available": "Voici toutes les révisions disponibles, veuillez sélectionner celle vers laquelle vous souhaitez que TOUS les appareils de ce lieu soient mis à niveau",
"use_existing": "Utiliser l'existant", "use_existing": "Utiliser l'existant",
"use_existing_contacts": "Utiliser les contacts existants", "use_existing_contacts": "Utiliser les contacts existants",
"use_this_contact": "Utilisez ce contact" "use_this_contact": "Utilisez ce contact"

View File

@@ -79,8 +79,11 @@
"live_view_help": "Ajuda da visualização ao vivo", "live_view_help": "Ajuda da visualização ao vivo",
"memory": "Memória", "memory": "Memória",
"memory_used": "Memória Usada", "memory_used": "Memória Usada",
"missing_board": "O monitoramento analítico neste local não está mais ativo, reinicie o monitoramento usando o menu superior", "missing_board": "O monitoramento analítico neste local não está mais ativo. Clique aqui para reiniciar o monitoramento",
"mode": "Modo", "mode": "Modo",
"monitoring": "Monitoramento",
"no_board": "Sem monitoramento",
"no_board_description": "Você não está monitorando este local no momento, clique aqui para começar",
"noise": "Barulho", "noise": "Barulho",
"packets": "Pacotes", "packets": "Pacotes",
"radio": "Rádio", "radio": "Rádio",
@@ -91,6 +94,8 @@
"retries": "Novas tentativas", "retries": "Novas tentativas",
"search_serials": "Pesquisar séries", "search_serials": "Pesquisar séries",
"stop_monitoring": "Parar o monitoramento", "stop_monitoring": "Parar o monitoramento",
"stop_monitoring_success": "Local de monitoramento interrompido!",
"stop_monitoring_warning": "Tem certeza? Isso apagará todos os dados de monitoramento gravados para este local",
"temperature": "Temperatura", "temperature": "Temperatura",
"title": "Analytics", "title": "Analytics",
"total_data": "Dados totais", "total_data": "Dados totais",
@@ -175,6 +180,7 @@
"other": "comandos", "other": "comandos",
"override_dfs": "Substituir DFS", "override_dfs": "Substituir DFS",
"reboot": "Reiniciar", "reboot": "Reiniciar",
"reboot_description": "Deseja reiniciar este dispositivo?",
"reboot_error": "Erro ao enviar o comando de reinicialização: {{e}}", "reboot_error": "Erro ao enviar o comando de reinicialização: {{e}}",
"reboot_success": "Comando de reinicialização enviado com sucesso!", "reboot_success": "Comando de reinicialização enviado com sucesso!",
"revision": "revisão", "revision": "revisão",
@@ -391,6 +397,7 @@
"warning_pushes_one": "Aguardando a conexão dos dispositivos: {{count}}", "warning_pushes_one": "Aguardando a conexão dos dispositivos: {{count}}",
"warning_pushes_other": "Aguardando a conexão dos dispositivos: {{count}}", "warning_pushes_other": "Aguardando a conexão dos dispositivos: {{count}}",
"weight": "Peso", "weight": "Peso",
"wifi_bands_max": "Não pode haver mais de 8 SSIDs usando esta banda wi-fi",
"wifi_frames": "Quadros WiFi" "wifi_frames": "Quadros WiFi"
}, },
"contacts": { "contacts": {
@@ -600,6 +607,7 @@
"certificate_expires_in": "Certificado expira em", "certificate_expires_in": "Certificado expira em",
"certificate_expiry": "Certificado expira em", "certificate_expiry": "Certificado expira em",
"connected": "Conectado", "connected": "Conectado",
"crash_logs": "Registros de falhas",
"create_errors": "erros ao tentar criar dispositivos", "create_errors": "erros ao tentar criar dispositivos",
"create_success": " dispositivos criados com sucesso", "create_success": " dispositivos criados com sucesso",
"current_firmware": "Firmware atual", "current_firmware": "Firmware atual",
@@ -613,6 +621,7 @@
"import_device_warning": "Certifique-se de que não há espaços extras no início ou no final de nenhum valor, a menos que faça parte do valor desejado", "import_device_warning": "Certifique-se de que não há espaços extras no início ou no final de nenhum valor, a menos que faça parte do valor desejado",
"import_explanation": "Para importar dispositivos em massa, você precisa usar um arquivo CSV com as seguintes colunas: SerialNumber, DeviceType, Name, Description, Note", "import_explanation": "Para importar dispositivos em massa, você precisa usar um arquivo CSV com as seguintes colunas: SerialNumber, DeviceType, Name, Description, Note",
"invalid_serial_number": "Número de série inválido (precisa ter 12 caracteres HEX)", "invalid_serial_number": "Número de série inválido (precisa ter 12 caracteres HEX)",
"logs_one": "Registro",
"new_devices": "novos dispositivos", "new_devices": "novos dispositivos",
"no_model_image": "Nenhuma imagem de modelo encontrada", "no_model_image": "Nenhuma imagem de modelo encontrada",
"not_connected": "Não conectado", "not_connected": "Não conectado",
@@ -621,6 +630,8 @@
"one": "Dispositivo", "one": "Dispositivo",
"reassign_already_owned": "Reatribuir dispositivos que já existem e são de propriedade de outra entidade/local/assinante?", "reassign_already_owned": "Reatribuir dispositivos que já existem e são de propriedade de outra entidade/local/assinante?",
"restricted": "Restrito", "restricted": "Restrito",
"restricted_overriden": "Este é um dispositivo restrito, mas está em modo de desenvolvimento. Todas as restrições são atualmente ignoradas",
"restrictions_overriden_title": "Modo de desenvolvedor",
"sanity": "Sanidade", "sanity": "Sanidade",
"start_import": "Iniciar importação de dispositivos", "start_import": "Iniciar importação de dispositivos",
"test_batch": "Dados de importação de teste", "test_batch": "Dados de importação de teste",
@@ -671,7 +682,15 @@
"test_digicert_creds": "Credenciais de teste", "test_digicert_creds": "Credenciais de teste",
"title": "Entidades", "title": "Entidades",
"tree": "Árvore de entidades", "tree": "Árvore de entidades",
"venues_under_root": "Os locais não podem ser criados diretamente na entidade raiz. Por favor, crie novas entidades e crie locais sob elas." "update_success": "Entidade atualizada!",
"venues_under_root": "Os locais não podem ser criados diretamente na entidade raiz"
},
"firmware": {
"db_update_warning": "Esta operação é feita automaticamente diariamente sem necessidade de usar esta atualização manual. A atualização deste banco de dados pode levar até 25 minutos",
"last_db_update_modal": "banco de dados de firmware",
"last_db_update_title": "base de dados",
"start_db_update": "Iniciar atualização do banco de dados",
"started_db_update": "Atualização do banco de dados iniciada, esta operação deve levar até 25 minutos para ser concluída"
}, },
"footer": { "footer": {
"powered_by": "Distribuído por", "powered_by": "Distribuído por",
@@ -765,13 +784,17 @@
"city": "Cidade", "city": "Cidade",
"claim_explanation": "Para reivindicar locais, você pode usar a tabela abaixo", "claim_explanation": "Para reivindicar locais, você pode usar a tabela abaixo",
"country": "País", "country": "País",
"elevation": "elevação",
"geocode": "Código geográfico", "geocode": "Código geográfico",
"lat": "Latitude",
"longitude": "Longitude",
"one": "Localização", "one": "Localização",
"other": "Localizações", "other": "Localizações",
"postal": "CEP / Código Postal", "postal": "CEP / Código Postal",
"state": "Estado / Província", "state": "Estado / Província",
"title": "Localizações", "title": "Localizações",
"to_claim": "Locais para reivindicar" "to_claim": "Locais para reivindicar",
"view_gps": ""
}, },
"login": { "login": {
"access_policy": "Política de Acesso", "access_policy": "Política de Acesso",
@@ -797,6 +820,7 @@
"reset_password": "Redefinir senha", "reset_password": "Redefinir senha",
"sign_in": "assinar em", "sign_in": "assinar em",
"sms_instructions": "Você deve receber um código de 6 dígitos em seu telefone em breve. Por favor, insira-o abaixo para fazer login", "sms_instructions": "Você deve receber um código de 6 dígitos em seu telefone em breve. Por favor, insira-o abaixo para fazer login",
"suspended_error": "Conta suspensa, entre em contato com seu administrador",
"verification": "Verifique seu login", "verification": "Verifique seu login",
"waiting_for_email_verification": "Conta ainda não validada por e-mail. Verifique sua caixa de entrada ou peça ao administrador para reenviar uma validação", "waiting_for_email_verification": "Conta ainda não validada por e-mail. Verifique sua caixa de entrada ou peça ao administrador para reenviar uma validação",
"welcome_back": "Bem vindo de volta!", "welcome_back": "Bem vindo de volta!",
@@ -902,7 +926,7 @@
"dfs": "Substituição DFS", "dfs": "Substituição DFS",
"gw_commands": "Comandos de gateway", "gw_commands": "Comandos de gateway",
"identifier": "Identificador", "identifier": "Identificador",
"key_verification": "Verificação da chave de assinatura", "key_verification": "Informações Chave de Assinatura",
"restricted": "Restrito", "restricted": "Restrito",
"signed_upgrade": "Somente atualização assinada", "signed_upgrade": "Somente atualização assinada",
"title": "RESTRIÇÕES", "title": "RESTRIÇÕES",
@@ -1022,6 +1046,7 @@
}, },
"system": { "system": {
"backend_logs": "Registros de back-end", "backend_logs": "Registros de back-end",
"configuration": "Configuração",
"could_not_retrieve": "Erro: não foi possível recuperar {{name}} informações do sistema", "could_not_retrieve": "Erro: não foi possível recuperar {{name}} informações do sistema",
"endpoint": "Ponto final", "endpoint": "Ponto final",
"hostname": "Nome de anfitrião", "hostname": "Nome de anfitrião",
@@ -1032,6 +1057,10 @@
"os": "Sistema Operacional", "os": "Sistema Operacional",
"processors": "Processadores", "processors": "Processadores",
"reload_chosen_subsystems": "Recarregar Subsistemas Escolhidos", "reload_chosen_subsystems": "Recarregar Subsistemas Escolhidos",
"secrets": "Segredos",
"secrets_create": "Criar Segredo",
"secrets_one": "Segredo",
"services": "Serviços",
"start": "Começar", "start": "Começar",
"subsystems": "Subsistemas", "subsystems": "Subsistemas",
"success_reload": "Comando de recarga enviado com sucesso!", "success_reload": "Comando de recarga enviado com sucesso!",
@@ -1053,9 +1082,11 @@
"previous_page": "Página anterior" "previous_page": "Página anterior"
}, },
"user": { "user": {
"email_not_validated": "e-mail não validado",
"error_fetching": "Erro ao buscar informações do usuário: {{e}}", "error_fetching": "Erro ao buscar informações do usuário: {{e}}",
"password": "Senha", "password": "Senha",
"role": "Função", "role": "Função",
"suspended": "Suspenso",
"title": "Do utilizador" "title": "Do utilizador"
}, },
"users": { "users": {
@@ -1100,9 +1131,11 @@
"successfully_update_devices": "Atualizando {{num}} dispositivos!", "successfully_update_devices": "Atualizando {{num}} dispositivos!",
"title": "Locais", "title": "Locais",
"update_all_devices": "Atualizar todas as configurações do dispositivo", "update_all_devices": "Atualizar todas as configurações do dispositivo",
"upgrade_all_devices": "Atualize todos os dispositivos para o firmware mais recente", "update_success": "Local atualizado!",
"upgrade_all_devices": "Atualize o firmware de todos os dispositivos",
"upgrade_all_devices_error": "Erro ao atualizar dispositivos: {{e}}", "upgrade_all_devices_error": "Erro ao atualizar dispositivos: {{e}}",
"upgrade_all_devices_success": "Atualização de dispositivos iniciada com sucesso!", "upgrade_all_devices_success": "Atualização de dispositivos iniciada com sucesso!",
"upgrade_options_available": "Aqui estão todas as revisões disponíveis, selecione aquela para a qual você deseja que TODOS os dispositivos deste local sejam atualizados",
"use_existing": "Usar existente", "use_existing": "Usar existente",
"use_existing_contacts": "Usar contatos existentes", "use_existing_contacts": "Usar contatos existentes",
"use_this_contact": "Use este contato" "use_this_contact": "Use este contato"

View File

@@ -12,7 +12,14 @@ export interface AlertButtonProps extends ThemeProps {
label?: string; label?: string;
} }
const _AlertButton: React.FC<AlertButtonProps> = ({ onClick, isDisabled, isLoading, isCompact, label, ...props }) => { const _AlertButton: React.FC<AlertButtonProps> = ({
onClick,
isDisabled,
isLoading,
isCompact = true,
label,
...props
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const breakpoint = useBreakpoint(); const breakpoint = useBreakpoint();

View File

@@ -11,7 +11,14 @@ export interface CreateButtonProps extends SpaceProps {
label?: string; label?: string;
} }
const _CreateButton: React.FC<CreateButtonProps> = ({ onClick, isDisabled, isLoading, isCompact, label, ...props }) => { const _CreateButton: React.FC<CreateButtonProps> = ({
onClick,
isDisabled,
isLoading,
isCompact = true,
label,
...props
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const breakpoint = useBreakpoint(); const breakpoint = useBreakpoint();

View File

@@ -16,7 +16,7 @@ const _DeleteButton: React.FC<DeleteButtonProps> = ({
onClick, onClick,
isDisabled, isDisabled,
isLoading, isLoading,
isCompact, isCompact = true,
label, label,
ml, ml,
...props ...props

View File

@@ -1,9 +1,19 @@
import React from 'react'; import React from 'react';
import { Button, IconButton, Menu, MenuButton, MenuItem, MenuList, Spinner, Tooltip, useToast } from '@chakra-ui/react'; import {
Button,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Portal,
Spinner,
Tooltip,
useToast,
} from '@chakra-ui/react';
import axios from 'axios'; import axios from 'axios';
import { Wrench } from 'phosphor-react'; import { Wrench } from 'phosphor-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import RebootMenuItem from './RebootButton';
import { useControllerStore } from 'contexts/ControllerSocketProvider/useStore'; import { useControllerStore } from 'contexts/ControllerSocketProvider/useStore';
import { useBlinkDevice, useGetDeviceRtty } from 'hooks/Network/Devices'; import { useBlinkDevice, useGetDeviceRtty } from 'hooks/Network/Devices';
import { useUpdateDeviceToLatest } from 'hooks/Network/Firmware'; import { useUpdateDeviceToLatest } from 'hooks/Network/Firmware';
@@ -22,6 +32,7 @@ interface Props {
onOpenConfigureModal: (serialNumber: string) => void; onOpenConfigureModal: (serialNumber: string) => void;
onOpenTelemetryModal: (serialNumber: string) => void; onOpenTelemetryModal: (serialNumber: string) => void;
onOpenScriptModal: (device: GatewayDevice) => void; onOpenScriptModal: (device: GatewayDevice) => void;
onOpenRebootModal: (serialNumber: string) => void;
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
isCompact?: boolean; isCompact?: boolean;
} }
@@ -38,8 +49,9 @@ const DeviceActionDropdown = ({
onOpenTelemetryModal, onOpenTelemetryModal,
onOpenConfigureModal, onOpenConfigureModal,
onOpenScriptModal, onOpenScriptModal,
onOpenRebootModal,
size, size,
isCompact, isCompact = true,
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
@@ -145,11 +157,13 @@ const DeviceActionDropdown = ({
}, },
); );
}; };
const handleConnectClick = () => getRtty(); const handleConnectClick = () => getRtty();
const handleRebootClick = () => onOpenRebootModal(device.serialNumber);
return ( return (
<Menu> <Menu>
<Tooltip label={t('commands.other')}> <Tooltip label={t('common.actions')}>
{size === undefined || isCompact ? ( {size === undefined || isCompact ? (
<MenuButton <MenuButton
as={IconButton} as={IconButton}
@@ -168,26 +182,28 @@ const DeviceActionDropdown = ({
isDisabled={isDisabled} isDisabled={isDisabled}
ml={2} ml={2}
> >
{t('commands.other')} {t('common.actions')}
</MenuButton> </MenuButton>
)} )}
</Tooltip> </Tooltip>
<MenuList> <Portal>
<MenuItem onClick={handleBlinkClick}>{t('commands.blink')}</MenuItem> <MenuList>
<MenuItem onClick={handleOpenConfigure}>{t('controller.configure.title')}</MenuItem> <MenuItem onClick={handleBlinkClick}>{t('commands.blink')}</MenuItem>
<MenuItem onClick={handleConnectClick}>{t('commands.connect')}</MenuItem> <MenuItem onClick={handleOpenConfigure}>{t('controller.configure.title')}</MenuItem>
<MenuItem onClick={handleOpenQueue}>{t('controller.queue.title')}</MenuItem> <MenuItem onClick={handleConnectClick}>{t('commands.connect')}</MenuItem>
<MenuItem onClick={handleOpenFactoryReset}>{t('commands.factory_reset')}</MenuItem> <MenuItem onClick={handleOpenQueue}>{t('controller.queue.title')}</MenuItem>
<MenuItem onClick={handleOpenUpgrade}>{t('commands.firmware_upgrade')}</MenuItem> <MenuItem onClick={handleOpenFactoryReset}>{t('commands.factory_reset')}</MenuItem>
<RebootMenuItem device={device} refresh={refresh} /> <MenuItem onClick={handleOpenUpgrade}>{t('commands.firmware_upgrade')}</MenuItem>
<MenuItem onClick={handleOpenTelemetry}>{t('controller.telemetry.title')}</MenuItem> <MenuItem onClick={handleRebootClick}>{t('commands.reboot')}</MenuItem>
<MenuItem onClick={handleOpenScript}>{t('script.one')}</MenuItem> <MenuItem onClick={handleOpenTelemetry}>{t('controller.telemetry.title')}</MenuItem>
<MenuItem onClick={handleOpenTrace}>{t('controller.devices.trace')}</MenuItem> <MenuItem onClick={handleOpenScript}>{t('script.one')}</MenuItem>
<MenuItem onClick={handleUpdateToLatest} hidden> <MenuItem onClick={handleOpenTrace}>{t('controller.devices.trace')}</MenuItem>
{t('premium.toolbox.upgrade_to_latest')} <MenuItem onClick={handleUpdateToLatest} hidden>
</MenuItem> {t('premium.toolbox.upgrade_to_latest')}
<MenuItem onClick={handleOpenScan}>{t('commands.wifiscan')}</MenuItem> </MenuItem>
</MenuList> <MenuItem onClick={handleOpenScan}>{t('commands.wifiscan')}</MenuItem>
</MenuList>
</Portal>
</Menu> </Menu>
); );
}; };

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { IconButton, Button, Tooltip, useBreakpoint } from '@chakra-ui/react'; import { IconButton, Button, Tooltip, useBreakpoint } from '@chakra-ui/react';
import { Pen } from 'phosphor-react'; import { Pen } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
export interface EditButtonProps { export interface EditButtonProps {
onClick: () => void; onClick: () => void;
@@ -11,7 +12,15 @@ export interface EditButtonProps {
ml?: string | number; ml?: string | number;
} }
const _EditButton: React.FC<EditButtonProps> = ({ onClick, label, isDisabled, isLoading, isCompact, ...props }) => { const _EditButton: React.FC<EditButtonProps> = ({
onClick,
label,
isDisabled,
isLoading,
isCompact = true,
...props
}) => {
const { t } = useTranslation();
const breakpoint = useBreakpoint(); const breakpoint = useBreakpoint();
if (!isCompact && breakpoint !== 'base' && breakpoint !== 'sm') { if (!isCompact && breakpoint !== 'base' && breakpoint !== 'sm') {
@@ -24,12 +33,12 @@ const _EditButton: React.FC<EditButtonProps> = ({ onClick, label, isDisabled, is
isDisabled={isDisabled} isDisabled={isDisabled}
{...props} {...props}
> >
{label} {label ?? t('common.edit')}
</Button> </Button>
); );
} }
return ( return (
<Tooltip label={label}> <Tooltip label={label ?? t('common.edit')} hasArrow>
<IconButton <IconButton
aria-label="edit" aria-label="edit"
colorScheme="gray" colorScheme="gray"

View File

@@ -17,7 +17,7 @@ const _RefreshButton: React.FC<RefreshButtonProps> = ({
onClick, onClick,
isDisabled, isDisabled,
isFetching, isFetching,
isCompact, isCompact = true,
ml, ml,
size, size,
...props ...props

View File

@@ -15,7 +15,7 @@ const _ResponsiveButton: React.FC<ResponsiveButtonProps> = ({
onClick, onClick,
isDisabled, isDisabled,
isLoading, isLoading,
isCompact, isCompact = true,
color, color,
label, label,
icon, icon,

View File

@@ -18,7 +18,7 @@ const _SaveButton: React.FC<SaveButtonProps> = ({
onClick, onClick,
isDisabled, isDisabled,
isLoading, isLoading,
isCompact, isCompact = true,
isDirty, isDirty,
dirtyCheck, dirtyCheck,
...props ...props

View File

@@ -20,7 +20,7 @@ const _ToggleEditButton: React.FC<ToggleEditButtonProps> = ({
isDirty, isDirty,
isDisabled, isDisabled,
isLoading, isLoading,
isCompact, isCompact = true,
ml, ml,
...props ...props
}) => { }) => {

View File

@@ -16,7 +16,7 @@ const _WarningButton: React.FC<WarningButtonProps> = ({
onClick, onClick,
isDisabled, isDisabled,
isLoading, isLoading,
isCompact, isCompact = true,
label, label,
...props ...props
}) => { }) => {

View File

@@ -21,7 +21,7 @@ export const ColumnPicker = ({
hiddenColumns, hiddenColumns,
setHiddenColumns, setHiddenColumns,
size, size,
isCompact, isCompact = true,
}: ColumnPickerProps) => { }: ColumnPickerProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { getPref, setPref } = useAuth(); const { getPref, setPref } = useAuth();

View File

@@ -44,15 +44,18 @@ const defaultProps = {
sortBy: [], sortBy: [],
}; };
export type DataTableProps = { export type DataTableProps<TValue> = {
columns: readonly Column<object>[]; columns: Column<TValue>[];
data: object[]; data: TValue[];
count?: number; count?: number;
setPageInfo?: React.Dispatch<React.SetStateAction<PageInfo | undefined>>; setPageInfo?: React.Dispatch<React.SetStateAction<PageInfo | undefined>>;
isLoading?: boolean; isLoading?: boolean;
onRowClick?: (row: TValue) => void;
isRowClickable?: (row: TValue) => boolean;
obj?: string; obj?: string;
sortBy?: { id: string; desc: boolean }[]; sortBy?: { id: string; desc: boolean }[];
hiddenColumns?: string[]; hiddenColumns?: string[];
hideEmptyListText?: boolean;
hideControls?: boolean; hideControls?: boolean;
minHeight?: string | number; minHeight?: string | number;
fullScreen?: boolean; fullScreen?: boolean;
@@ -67,7 +70,7 @@ type TableInstanceWithHooks<T extends object> = TableInstance<T> &
state: UsePaginationState<T>; state: UsePaginationState<T>;
}; };
const _DataTable = ({ const _DataTable = <TValue extends object>({
columns, columns,
data, data,
isLoading, isLoading,
@@ -77,15 +80,19 @@ const _DataTable = ({
sortBy, sortBy,
hiddenColumns, hiddenColumns,
hideControls, hideControls,
hideEmptyListText,
count, count,
setPageInfo, setPageInfo,
isManual, isManual,
saveSettingsId, saveSettingsId,
showAllRows, showAllRows,
}: DataTableProps) => { onRowClick,
isRowClickable,
}: DataTableProps<TValue>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const breakpoint = useBreakpoint(); const breakpoint = useBreakpoint();
const textColor = useColorModeValue('gray.700', 'white'); const textColor = useColorModeValue('gray.700', 'white');
const hoveredRowBg = useColorModeValue('gray.100', 'gray.600');
const getPageSize = () => { const getPageSize = () => {
try { try {
if (showAllRows) return 1000000; if (showAllRows) return 1000000;
@@ -140,8 +147,12 @@ const _DataTable = ({
}, },
useSortBy, useSortBy,
usePagination, usePagination,
) as TableInstanceWithHooks<object>; ) as TableInstanceWithHooks<TValue>;
const handleGoToPage = (newPage: number) => {
if (saveSettingsId) localStorage.setItem(`${saveSettingsId}.page`, String(newPage));
gotoPage(newPage);
};
const handleNextPage = () => { const handleNextPage = () => {
nextPage(); nextPage();
if (saveSettingsId) localStorage.setItem(`${saveSettingsId}.page`, String(pageIndex + 1)); if (saveSettingsId) localStorage.setItem(`${saveSettingsId}.page`, String(pageIndex + 1));
@@ -253,10 +264,19 @@ const _DataTable = ({
</Thead> </Thead>
{data.length > 0 && ( {data.length > 0 && (
<Tbody {...getTableBodyProps()}> <Tbody {...getTableBodyProps()}>
{page.map((row: Row) => { {page.map((row: Row<TValue>) => {
prepareRow(row); prepareRow(row);
const rowIsClickable = isRowClickable ? isRowClickable(row.original) : true;
const onClick = rowIsClickable && onRowClick ? () => onRowClick(row.original) : undefined;
return ( return (
<Tr {...row.getRowProps()} key={uuid()}> <Tr
{...row.getRowProps()}
key={uuid()}
_hover={{
backgroundColor: hoveredRowBg,
}}
onClick={onClick}
>
{ {
// @ts-ignore // @ts-ignore
row.cells.map((cell) => ( row.cells.map((cell) => (
@@ -275,8 +295,26 @@ const _DataTable = ({
fontSize="14px" fontSize="14px"
// @ts-ignore // @ts-ignore
textAlign={cell.column.isCentered ? 'center' : undefined} textAlign={cell.column.isCentered ? 'center' : undefined}
// @ts-ignore fontFamily={
fontFamily={cell.column.isMonospace ? 'monospace' : undefined} // @ts-ignore
cell.column.isMonospace
? 'Inter, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
: undefined
}
onClick={
// @ts-ignore
cell.column.stopPropagation || (cell.column.id === 'actions' && onRowClick)
? (e) => {
e.stopPropagation();
}
: undefined
}
cursor={
// @ts-ignore
!cell.column.stopPropagation && cell.column.id !== 'actions' && onRowClick
? 'pointer'
: undefined
}
> >
{cell.render('Cell')} {cell.render('Cell')}
</Td> </Td>
@@ -288,7 +326,7 @@ const _DataTable = ({
</Tbody> </Tbody>
)} )}
</Table> </Table>
{!isLoading && data.length === 0 && ( {!isLoading && data.length === 0 && !hideEmptyListText && (
<Center> <Center>
{obj ? ( {obj ? (
<Heading size="md" pt={12}> <Heading size="md" pt={12}>
@@ -309,7 +347,7 @@ const _DataTable = ({
<Tooltip label={t('table.first_page')}> <Tooltip label={t('table.first_page')}>
<IconButton <IconButton
aria-label="Go to first page" aria-label="Go to first page"
onClick={() => gotoPage(0)} onClick={() => handleGoToPage(0)}
isDisabled={!canPreviousPage} isDisabled={!canPreviousPage}
icon={<ArrowLeftIcon h={3} w={3} />} icon={<ArrowLeftIcon h={3} w={3} />}
mr={4} mr={4}
@@ -347,7 +385,7 @@ const _DataTable = ({
max={pageOptions.length} max={pageOptions.length}
onChange={(_: unknown, numberValue: number) => { onChange={(_: unknown, numberValue: number) => {
const newPage = numberValue ? numberValue - 1 : 0; const newPage = numberValue ? numberValue - 1 : 0;
gotoPage(newPage); handleGoToPage(newPage);
}} }}
defaultValue={pageIndex + 1} defaultValue={pageIndex + 1}
> >
@@ -386,7 +424,7 @@ const _DataTable = ({
<Tooltip label={t('table.last_page')}> <Tooltip label={t('table.last_page')}>
<IconButton <IconButton
aria-label="Go to last page" aria-label="Go to last page"
onClick={() => gotoPage(pageCount - 1)} onClick={() => handleGoToPage(pageCount - 1)}
isDisabled={!canNextPage} isDisabled={!canNextPage}
icon={<ArrowRightIcon h={3} w={3} />} icon={<ArrowRightIcon h={3} w={3} />}
ml={4} ml={4}
@@ -401,4 +439,4 @@ const _DataTable = ({
_DataTable.defaultProps = defaultProps; _DataTable.defaultProps = defaultProps;
export const DataTable = React.memo(_DataTable); export const DataTable = React.memo(_DataTable) as unknown as typeof _DataTable;

View File

@@ -100,6 +100,7 @@ const SortableDataTable: React.FC<Props> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const breakpoint = useBreakpoint(); const breakpoint = useBreakpoint();
const hoveredRowBg = useColorModeValue('gray.100', 'gray.600');
const textColor = useColorModeValue('gray.700', 'white'); const textColor = useColorModeValue('gray.700', 'white');
const getPageSize = () => { const getPageSize = () => {
const saved = saveSettingsId ? localStorage.getItem(saveSettingsId) : undefined; const saved = saveSettingsId ? localStorage.getItem(saveSettingsId) : undefined;
@@ -223,7 +224,13 @@ const SortableDataTable: React.FC<Props> = ({
{page.map((row: Row) => { {page.map((row: Row) => {
prepareRow(row); prepareRow(row);
return ( return (
<Tr {...row.getRowProps()} key={uuid()}> <Tr
{...row.getRowProps()}
key={uuid()}
_hover={{
backgroundColor: hoveredRowBg,
}}
>
{ {
// @ts-ignore // @ts-ignore
row.cells.map((cell) => ( row.cells.map((cell) => (
@@ -242,8 +249,12 @@ const SortableDataTable: React.FC<Props> = ({
fontSize="14px" fontSize="14px"
// @ts-ignore // @ts-ignore
textAlign={cell.column.isCentered ? 'center' : undefined} textAlign={cell.column.isCentered ? 'center' : undefined}
// @ts-ignore fontFamily={
fontFamily={cell.column.isMonospace ? 'monospace' : undefined} // @ts-ignore
cell.column.isMonospace
? 'Inter, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
: undefined
}
> >
{cell.render('Cell')} {cell.render('Cell')}
</Td> </Td>

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,89 @@
import * as React from 'react';
import { isLatLngLiteral } from '@googlemaps/typescript-guards';
import { createCustomEqual } from 'fast-equals';
const deepCompareEqualsForMaps = createCustomEqual((deepEqual) =>
// @ts-ignore
(a: number | google.maps.LatLng | google.maps.LatLngLiteral, b: number | google.maps.LatLng | google.maps.LatLngLiteral) => {
if (
isLatLngLiteral(a) ||
a instanceof google.maps.LatLng ||
isLatLngLiteral(b) ||
b instanceof google.maps.LatLng
) {
return new google.maps.LatLng(a).equals(new google.maps.LatLng(b));
}
// @ts-ignore
return deepEqual(a, b);
},
);
const useDeepCompareMemoize = (value: unknown) => {
const ref = React.useRef<unknown>();
if (!deepCompareEqualsForMaps(value, ref.current)) {
ref.current = value;
}
return ref.current;
};
const useDeepCompareEffectForMaps = (callback: React.EffectCallback, dependencies: unknown[]) => {
React.useEffect(callback, dependencies.map(useDeepCompareMemoize));
};
export interface GoogleMapProps extends google.maps.MapOptions {
style: { [key: string]: string };
onClick?: (e: google.maps.MapMouseEvent) => void;
onIdle?: (map: google.maps.Map) => void;
children?: React.ReactNode;
}
const _GoogleMap = ({ style, onClick, onIdle, children, ...options }: GoogleMapProps) => {
const ref = React.useRef<HTMLDivElement>(null);
const [map, setMap] = React.useState<google.maps.Map>();
// because React does not do deep comparisons, a custom hook is used
useDeepCompareEffectForMaps(() => {
if (map) {
map.setOptions(options);
}
}, [map, options]);
React.useEffect(() => {
if (ref.current && !map) {
setMap(new window.google.maps.Map(ref.current, {}));
}
}, [ref, map]);
React.useEffect(() => {
if (map) {
['click', 'idle'].forEach((eventName) => google.maps.event.clearListeners(map, eventName));
if (onClick) {
map.addListener('click', onClick);
}
if (onIdle) {
map.addListener('idle', () => onIdle(map));
}
}
}, [map, onClick, onIdle]);
return (
<>
<div ref={ref} style={style} />
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
// set the map prop on the child component
// @ts-ignore
return React.cloneElement(child, { map });
}
return null;
})}
</>
);
};
export const GoogleMap = React.memo(_GoogleMap);

View File

@@ -5,17 +5,22 @@ import {
AlertIcon, AlertIcon,
AlertTitle, AlertTitle,
Box, Box,
Button,
Flex,
FormControl, FormControl,
FormErrorMessage, FormErrorMessage,
FormLabel, FormLabel,
Textarea, Textarea,
useToast, useToast,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { ClipboardText } from 'phosphor-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SaveButton } from '../../Buttons/SaveButton'; import { SaveButton } from '../../Buttons/SaveButton';
import { Modal } from '../Modal'; import { Modal } from '../Modal';
import { FileInputButton } from 'components/Buttons/FileInputButton'; import { FileInputButton } from 'components/Buttons/FileInputButton';
import { useConfigureDevice } from 'hooks/Network/Commands'; import { useConfigureDevice } from 'hooks/Network/Commands';
import { useGetDevice } from 'hooks/Network/Devices';
import { AxiosError } from 'models/Axios';
export type ConfigureModalProps = { export type ConfigureModalProps = {
serialNumber: string; serialNumber: string;
@@ -29,11 +34,17 @@ export const ConfigureModal = ({ serialNumber, modalProps }: ConfigureModalProps
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const configure = useConfigureDevice({ serialNumber }); const configure = useConfigureDevice({ serialNumber });
const getDevice = useGetDevice({ serialNumber });
const [newConfig, setNewConfig] = React.useState(''); const [newConfig, setNewConfig] = React.useState('');
const onChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => { const onChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNewConfig(e.target.value); setNewConfig(e.target.value);
}, []); }, []);
const onImportConfiguration = () => {
setNewConfig(getDevice.data?.configuration ? JSON.stringify(getDevice.data.configuration, null, 4) : '');
};
const isValid = React.useMemo(() => { const isValid = React.useMemo(() => {
try { try {
JSON.parse(newConfig); JSON.parse(newConfig);
@@ -60,9 +71,7 @@ export const ConfigureModal = ({ serialNumber, modalProps }: ConfigureModalProps
modalProps.onClose(); modalProps.onClose();
}, },
}); });
} catch (e) { } catch (e) {}
// console.log(e);
}
}; };
return ( return (
@@ -79,10 +88,7 @@ export const ConfigureModal = ({ serialNumber, modalProps }: ConfigureModalProps
<AlertIcon /> <AlertIcon />
<Box> <Box>
<AlertTitle>{t('common.error')}</AlertTitle> <AlertTitle>{t('common.error')}</AlertTitle>
{ <AlertDescription>{(configure.error as AxiosError)?.response?.data?.ErrorDescription}</AlertDescription>
// @ts-ignore
<AlertDescription>{configure.error?.response?.data?.ErrorDescription}</AlertDescription>
}
</Box> </Box>
</Alert> </Alert>
)} )}
@@ -92,15 +98,25 @@ export const ConfigureModal = ({ serialNumber, modalProps }: ConfigureModalProps
</Alert> </Alert>
<FormControl isInvalid={!isValid && newConfig.length > 0}> <FormControl isInvalid={!isValid && newConfig.length > 0}>
<FormLabel>{t('configurations.one')}</FormLabel> <FormLabel>{t('configurations.one')}</FormLabel>
<Box mb={2} w="240px"> <Flex mb={2}>
<FileInputButton <Box w="240px">
value={newConfig} <FileInputButton
setValue={(v) => setNewConfig(v)} value={newConfig}
refreshId="1" setValue={(v) => setNewConfig(v)}
accept=".json" refreshId="1"
isStringFile accept=".json"
/> isStringFile
</Box> />
</Box>
<Button
rightIcon={<ClipboardText size={20} />}
onClick={onImportConfiguration}
hidden={!getDevice.data}
ml={2}
>
Current Configuration
</Button>
</Flex>
<Textarea height="auto" minH="600px" value={newConfig} onChange={onChange} /> <Textarea height="auto" minH="600px" value={newConfig} onChange={onChange} />
<FormErrorMessage>{t('controller.configure.invalid')}</FormErrorMessage> <FormErrorMessage>{t('controller.configure.invalid')}</FormErrorMessage>
</FormControl> </FormControl>

View File

@@ -57,7 +57,8 @@ export const FirmwareUpgradeModal = ({ modalProps: { isOpen, onClose }, serialNu
upgrade({ upgrade({
keepRedirector: isRedirector, keepRedirector: isRedirector,
uri, uri,
signature: device?.restrictedDevice ? ref.current?.values?.signature : undefined, signature:
device?.restrictedDevice && !device?.restrictionDetails?.developer ? ref.current?.values?.signature : undefined,
}); });
}; };
@@ -89,7 +90,7 @@ export const FirmwareUpgradeModal = ({ modalProps: { isOpen, onClose }, serialNu
</FormLabel> </FormLabel>
<Switch isChecked={isRedirector} onChange={toggle} borderRadius="15px" size="lg" /> <Switch isChecked={isRedirector} onChange={toggle} borderRadius="15px" size="lg" />
</FormControl> </FormControl>
{device?.restrictedDevice && ( {device?.restrictedDevice && !device?.restrictionDetails?.developer && (
<Formik<{ signature?: string }> <Formik<{ signature?: string }>
innerRef={ref as Ref<FormikProps<{ signature?: string | undefined }>> | undefined} innerRef={ref as Ref<FormikProps<{ signature?: string | undefined }>> | undefined}
key={formKey} key={formKey}

View File

@@ -1,15 +1,19 @@
import React from 'react'; import React from 'react';
import { Flex, ModalHeader as Header, Spacer } from '@chakra-ui/react'; import { Flex, HStack, ModalHeader as Header, Spacer } from '@chakra-ui/react';
export interface ModalHeaderProps { export interface ModalHeaderProps {
title: string; title: string;
left?: React.ReactNode;
right: React.ReactNode; right: React.ReactNode;
} }
const _ModalHeader: React.FC<ModalHeaderProps> = ({ title, right }) => ( const _ModalHeader: React.FC<ModalHeaderProps> = ({ title, left, right }) => (
<Header> <Header>
<Flex justifyContent="center" alignItems="center" maxW="100%" px={1}> <Flex justifyContent="center" alignItems="center" maxW="100%" px={1}>
{title} {title}
<HStack spacing={2} ml={2}>
{left ?? null}
</HStack>
<Spacer /> <Spacer />
{right} {right}
</Flex> </Flex>

View File

@@ -8,6 +8,7 @@ export type ModalProps = {
onClose: () => void; onClose: () => void;
title: string; title: string;
topRightButtons?: React.ReactNode; topRightButtons?: React.ReactNode;
tags?: React.ReactNode;
options?: { options?: {
modalSize?: 'sm' | 'md' | 'lg'; modalSize?: 'sm' | 'md' | 'lg';
maxWidth?: LayoutProps['maxWidth']; maxWidth?: LayoutProps['maxWidth'];
@@ -15,7 +16,7 @@ export type ModalProps = {
children: React.ReactElement; children: React.ReactElement;
}; };
const _Modal = ({ isOpen, onClose, title, topRightButtons, options, children }: ModalProps) => { const _Modal = ({ isOpen, onClose, title, topRightButtons, tags, options, children }: ModalProps) => {
const maxWidth = React.useMemo(() => { const maxWidth = React.useMemo(() => {
if (options?.maxWidth) return options.maxWidth; if (options?.maxWidth) return options.maxWidth;
if (options?.modalSize === 'sm') return undefined; if (options?.modalSize === 'sm') return undefined;
@@ -32,6 +33,7 @@ const _Modal = ({ isOpen, onClose, title, topRightButtons, options, children }:
<ModalContent maxWidth={maxWidth}> <ModalContent maxWidth={maxWidth}>
<ModalHeader <ModalHeader
title={title} title={title}
left={tags}
right={ right={
<HStack spacing={2}> <HStack spacing={2}>
{topRightButtons} {topRightButtons}

View File

@@ -1,40 +1,42 @@
import * as React from 'react'; import * as React from 'react';
import { MenuItem, useToast } from '@chakra-ui/react'; import { Alert, AlertIcon, Box, Button, Center, useToast } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Modal } from '../Modal';
import { useControllerStore } from 'contexts/ControllerSocketProvider/useStore'; import { useControllerStore } from 'contexts/ControllerSocketProvider/useStore';
import { useRebootDevice } from 'hooks/Network/Devices'; import { useRebootDevice } from 'hooks/Network/Devices';
import { useMutationResult } from 'hooks/useMutationResult'; import { useMutationResult } from 'hooks/useMutationResult';
import { AxiosError } from 'models/Axios'; import { AxiosError } from 'models/Axios';
import { GatewayDevice } from 'models/Device';
type Props = { export type RebootModalProps = {
device: GatewayDevice; serialNumber: string;
refresh: () => void; modalProps: {
isOpen: boolean;
onClose: () => void;
};
}; };
const RebootMenuItem = ({ device, refresh }: Props) => { export const RebootModal = ({ serialNumber, modalProps }: RebootModalProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const addEventListeners = useControllerStore((state) => state.addEventListeners); const addEventListeners = useControllerStore((state) => state.addEventListeners);
const { mutateAsync: reboot } = useRebootDevice({ serialNumber: device.serialNumber }); const { mutateAsync: reboot, isLoading } = useRebootDevice({ serialNumber });
const { onSuccess: onRebootSuccess, onError: onRebootError } = useMutationResult({ const { onSuccess: onRebootSuccess, onError: onRebootError } = useMutationResult({
objName: t('devices.one'), objName: t('devices.one'),
operationType: 'reboot', operationType: 'reboot',
refresh: () => { refresh: () => {
refresh();
addEventListeners([ addEventListeners([
{ {
id: `device-connection-${device.serialNumber}`, id: `device-connection-${serialNumber}`,
type: 'DEVICE_CONNECTION', type: 'DEVICE_CONNECTION',
serialNumber: device.serialNumber, serialNumber,
callback: () => { callback: () => {
const id = `device-connection-notification-${device.serialNumber}`; const id = `device-connection-notification-${serialNumber}`;
if (!toast.isActive(id)) { if (!toast.isActive(id)) {
toast({ toast({
id, id,
title: t('common.success'), title: t('common.success'),
description: t('controller.devices.finished_reboot', { serialNumber: device.serialNumber }), description: t('controller.devices.finished_reboot', { serialNumber }),
status: 'success', status: 'success',
duration: 5000, duration: 5000,
isClosable: true, isClosable: true,
@@ -44,17 +46,17 @@ const RebootMenuItem = ({ device, refresh }: Props) => {
}, },
}, },
{ {
id: `device-disconnected-${device.serialNumber}`, id: `device-disconnected-${serialNumber}`,
type: 'DEVICE_DISCONNECTION', type: 'DEVICE_DISCONNECTION',
serialNumber: device.serialNumber, serialNumber,
callback: () => { callback: () => {
const id = `device-disconnection-notification-${device.serialNumber}`; const id = `device-disconnection-notification-${serialNumber}`;
if (!toast.isActive(id)) { if (!toast.isActive(id)) {
toast({ toast({
id, id,
title: t('common.success'), title: t('common.success'),
description: t('controller.devices.started_reboot', { serialNumber: device.serialNumber }), description: t('controller.devices.started_reboot', { serialNumber }),
status: 'success', status: 'success',
duration: 5000, duration: 5000,
isClosable: true, isClosable: true,
@@ -66,17 +68,39 @@ const RebootMenuItem = ({ device, refresh }: Props) => {
]); ]);
}, },
}); });
const handleRebootClick = () => const handleRebootClick = () =>
reboot(undefined, { reboot(undefined, {
onSuccess: () => { onSuccess: () => {
onRebootSuccess(); onRebootSuccess();
modalProps.onClose();
}, },
onError: (e) => { onError: (e) => {
onRebootError(e as AxiosError); onRebootError(e as AxiosError);
}, },
}); });
return <MenuItem onClick={handleRebootClick}>{t('commands.reboot')}</MenuItem>; return (
<Modal
{...modalProps}
title={t('commands.reboot')}
topRightButtons={
<Button colorScheme="blue" onClick={handleRebootClick} isLoading={isLoading}>
{t('commands.reboot')}
</Button>
}
options={{
modalSize: 'sm',
}}
>
<Box>
<Center mb={2}>
<Alert status="info" w="unset">
<AlertIcon />
{t('commands.reboot_description')}
</Alert>
</Center>
</Box>
</Modal>
);
}; };
export default RebootMenuItem;

View File

@@ -183,7 +183,9 @@ const CustomScriptForm = ({
<> <>
<Flex> <Flex>
<Box> <Box>
{device?.restrictedDevice && <SignatureField name="signature" isDisabled={areFieldsDisabled} />} {device?.restrictedDevice && !device?.restrictionDetails?.developer && (
<SignatureField name="signature" isDisabled={areFieldsDisabled} />
)}
</Box> </Box>
</Flex> </Flex>
<SelectField <SelectField

View File

@@ -60,8 +60,14 @@ export const ScriptModal = ({ device, modalProps }: ScriptModalProps) => {
let requestData: { let requestData: {
[k: string]: unknown; [k: string]: unknown;
serialNumber: string; serialNumber: string;
script?: string;
timeout?: number | undefined; timeout?: number | undefined;
} = data; } = data;
if (requestData.script) {
requestData.script = btoa(requestData.script);
}
if (selectedScript === 'diagnostics') { if (selectedScript === 'diagnostics') {
requestData = { requestData = {
serialNumber: device?.serialNumber ?? '', serialNumber: device?.serialNumber ?? '',
@@ -76,7 +82,7 @@ export const ScriptModal = ({ device, modalProps }: ScriptModalProps) => {
when: 0, when: 0,
deferred: data.deferred, deferred: data.deferred,
timeout: data.timeout, timeout: data.timeout,
signature: device?.restrictedDevice ? data.signature : undefined, signature: device?.restrictedDevice && !device?.restrictionDetails?.developer ? data.signature : undefined,
uri: data.defaultUploadURI && data.defaultUploadURI?.length > 0 ? data.defaultUploadURI : undefined, uri: data.defaultUploadURI && data.defaultUploadURI?.length > 0 ? data.defaultUploadURI : undefined,
scriptId: selectedScript, scriptId: selectedScript,
type: data.type, type: data.type,
@@ -88,6 +94,19 @@ export const ScriptModal = ({ device, modalProps }: ScriptModalProps) => {
setValue(response.results?.status?.result ?? JSON.stringify(response.results ?? {}, null, 2)); setValue(response.results?.status?.result ?? JSON.stringify(response.results ?? {}, null, 2));
queryClient.invalidateQueries(['commands', device?.serialNumber ?? '']); queryClient.invalidateQueries(['commands', device?.serialNumber ?? '']);
}, },
onError: (e) => {
if (axios.isAxiosError(e) && e.response?.data?.ErrorDescription) {
toast({
id: 'script-update-error',
title: t('common.error'),
description: e.response?.data?.ErrorDescription,
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
}
},
}); });
if (!waitForResponse) { if (!waitForResponse) {
toast({ toast({

View File

@@ -57,7 +57,7 @@ export const WifiScanModal = ({ modalProps: { isOpen, onClose }, serialNumber }:
if (isOpen) resetData(); if (isOpen) resetData();
}, [isOpen]); }, [isOpen]);
return ( return (
(<Modal onClose={closeModal} isOpen={isOpen} size="xl" scrollBehavior="inside"> <Modal onClose={closeModal} isOpen={isOpen} size="xl" scrollBehavior="inside">
<ModalOverlay /> <ModalOverlay />
<ModalContent maxWidth={{ sm: '600px', md: '700px', lg: '800px', xl: '50%' }}> <ModalContent maxWidth={{ sm: '600px', md: '700px', lg: '800px', xl: '50%' }}>
<ModalHeader <ModalHeader
@@ -66,7 +66,7 @@ export const WifiScanModal = ({ modalProps: { isOpen, onClose }, serialNumber }:
<> <>
{csvData ? ( {csvData ? (
// @ts-ignore // @ts-ignore
(<CSVLink <CSVLink
filename={`wifi_scan_${serialNumber}_${dateForFilename(new Date().getTime() / 1000)}.csv`} filename={`wifi_scan_${serialNumber}_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={csvData as object[]} data={csvData as object[]}
> >
@@ -77,7 +77,7 @@ export const WifiScanModal = ({ modalProps: { isOpen, onClose }, serialNumber }:
label={t('common.download')} label={t('common.download')}
onClick={() => {}} onClick={() => {}}
/> />
</CSVLink>) </CSVLink>
) : ( ) : (
<ResponsiveButton <ResponsiveButton
color="gray" color="gray"
@@ -118,6 +118,6 @@ export const WifiScanModal = ({ modalProps: { isOpen, onClose }, serialNumber }:
confirm={closeCancelAndForm} confirm={closeCancelAndForm}
cancel={closeConfirm} cancel={closeConfirm}
/> />
</Modal>) </Modal>
); );
}; };

View File

@@ -149,6 +149,8 @@ export const useControllerStore = create<ControllerStoreState>((set, get) => ({
connectedDevices: msg.statistics.numberOfDevices, connectedDevices: msg.statistics.numberOfDevices,
connectingDevices: msg.statistics.numberOfConnectingDevices, connectingDevices: msg.statistics.numberOfConnectingDevices,
averageConnectionTime: msg.statistics.averageConnectedTime, averageConnectionTime: msg.statistics.averageConnectedTime,
tx: msg.statistics.tx,
rx: msg.statistics.rx,
}, },
queryClient, queryClient,
); );

View File

@@ -52,6 +52,8 @@ type ConnectionStatisticsMessage = {
numberOfDevices: number; numberOfDevices: number;
numberOfConnectingDevices: number; numberOfConnectingDevices: number;
averageConnectedTime: number; averageConnectedTime: number;
tx: number;
rx: number;
}; };
}; };
serialNumbers?: undefined; serialNumbers?: undefined;
@@ -85,6 +87,8 @@ export type SocketWebSocketNotificationData =
numberOfDevices: number; numberOfDevices: number;
numberOfConnectingDevices: number; numberOfConnectingDevices: number;
averageConnectedTime: number; averageConnectedTime: number;
rx: number;
tx: number;
}; };
serialNumber?: undefined; serialNumber?: undefined;
log?: undefined; log?: undefined;

View File

@@ -110,6 +110,18 @@ export const useDeleteCommand = () => {
}); });
}; };
export const useGetSingleCommandHistory = ({ serialNumber, commandId }: { serialNumber: string; commandId: string }) =>
useQuery(
['commands', serialNumber, commandId],
() =>
axiosGw
.get(`command/${commandId}?serialNumber=${serialNumber}`)
.then((response) => response.data as DeviceCommandHistory),
{
enabled: serialNumber !== undefined && serialNumber !== '' && commandId !== undefined && commandId !== '',
},
);
export type EventQueueResponse = { export type EventQueueResponse = {
UUID: string; UUID: string;
attachFile: number; attachFile: number;
@@ -245,6 +257,7 @@ export const useDeviceScript = ({ serialNumber }: { serialNumber: string }) => {
queryClient.invalidateQueries(['commands', serialNumber]); queryClient.invalidateQueries(['commands', serialNumber]);
}, },
onError: (e) => { onError: (e) => {
queryClient.invalidateQueries(['commands', serialNumber]);
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
toast({ toast({
id: 'script-error', id: 'script-error',
@@ -263,14 +276,44 @@ export const useDeviceScript = ({ serialNumber }: { serialNumber: string }) => {
const downloadScript = (serialNumber: string, commandId: string) => const downloadScript = (serialNumber: string, commandId: string) =>
axiosGw.get(`file/${commandId}?serialNumber=${serialNumber}`, { responseType: 'arraybuffer' }); axiosGw.get(`file/${commandId}?serialNumber=${serialNumber}`, { responseType: 'arraybuffer' });
export const useDownloadScriptResult = ({ serialNumber, commandId }: { serialNumber: string; commandId: string }) => export const useDownloadScriptResult = ({ serialNumber, commandId }: { serialNumber: string; commandId: string }) => {
useQuery(['download-script', serialNumber, commandId], () => downloadScript(serialNumber, commandId), { const { t } = useTranslation();
const toast = useToast();
return useQuery(['download-script', serialNumber, commandId], () => downloadScript(serialNumber, commandId), {
enabled: false, enabled: false,
onSuccess: (response) => { onSuccess: (response) => {
const blob = new Blob([response.data], { type: 'application/octet-stream' }); const blob = new Blob([response.data], { type: 'application/octet-stream' });
const link = document.createElement('a'); const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob); link.href = window.URL.createObjectURL(blob);
link.download = `Script_${commandId}.tar.gz`; const headerLine =
(response.headers['content-disposition'] as string | undefined) ??
(response.headers['content-disposition'] as string | undefined);
const filename = headerLine?.split('filename=')[1]?.split(',')[0] ?? `Script_${commandId}.tar.gz`;
link.download = filename;
link.click(); link.click();
}, },
onError: (e) => {
if (axios.isAxiosError(e)) {
const bufferResponse = e.response?.data;
let errorMessage = '';
// If the response is a buffer, parse to JSON object
if (bufferResponse instanceof ArrayBuffer) {
const decoder = new TextDecoder('utf-8');
const json = JSON.parse(decoder.decode(bufferResponse));
errorMessage = json.ErrorDescription;
}
toast({
id: `script-download-error-${serialNumber}`,
title: t('common.error'),
description: errorMessage,
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
}
},
}); });
};

View File

@@ -11,8 +11,10 @@ export type DeviceLog = {
severity: number; severity: number;
}; };
const getDeviceLogs = (limit: number, serialNumber?: string) => async () => const getDeviceLogs = (limit: number, serialNumber?: string, logType?: 0 | 1) => async () =>
axiosGw.get(`device/${serialNumber}/logs?newest=true&limit=${limit}`).then((response) => response.data) as Promise<{ axiosGw
.get(`device/${serialNumber}/logs?newest=true&limit=${limit}&logType=${logType}`)
.then((response) => response.data) as Promise<{
values: DeviceLog[]; values: DeviceLog[];
serialNumber: string; serialNumber: string;
}>; }>;
@@ -21,20 +23,29 @@ export const useGetDeviceLogs = ({
serialNumber, serialNumber,
limit, limit,
onError, onError,
logType,
}: { }: {
serialNumber?: string; serialNumber?: string;
limit: number; limit: number;
onError?: (e: AxiosError) => void; onError?: (e: AxiosError) => void;
logType?: 0 | 1;
}) => }) =>
useQuery(['devicelogs', serialNumber, { limit }], getDeviceLogs(limit, serialNumber), { useQuery(['devicelogs', serialNumber, { limit, logType }], getDeviceLogs(limit, serialNumber, logType ?? 0), {
keepPreviousData: true, keepPreviousData: true,
enabled: serialNumber !== undefined && serialNumber !== '', enabled: serialNumber !== undefined && serialNumber !== '',
staleTime: 30000, staleTime: 30000,
onError, onError,
}); });
const deleteLogs = async ({ serialNumber, endDate }: { serialNumber: string; endDate: number }) => const deleteLogs = async ({
axiosGw.delete(`device/${serialNumber}/logs?endDate=${endDate}`); serialNumber,
endDate,
logType,
}: {
serialNumber: string;
endDate: number;
logType: 0 | 1;
}) => axiosGw.delete(`device/${serialNumber}/logs?endDate=${endDate}&logType=${logType}`);
export const useDeleteLogs = () => { export const useDeleteLogs = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -45,46 +56,62 @@ export const useDeleteLogs = () => {
}); });
}; };
const getLogsBatch = (serialNumber?: string, start?: number, end?: number, limit?: number, offset?: number) => const getLogsBatch = (
serialNumber?: string,
start?: number,
end?: number,
limit?: number,
offset?: number,
logType?: 0 | 1,
) =>
axiosGw axiosGw
.get(`device/${serialNumber}/logs?startDate=${start}&endDate=${end}&limit=${limit}&offset=${offset}`) .get(
`device/${serialNumber}/logs?startDate=${start}&endDate=${end}&limit=${limit}&offset=${offset}&logType=${logType}`,
)
.then((response) => response.data) as Promise<{ .then((response) => response.data) as Promise<{
values: DeviceLog[]; values: DeviceLog[];
serialNumber: string; serialNumber: string;
}>; }>;
const getDeviceLogsWithTimestamps = (serialNumber?: string, start?: number, end?: number) => async () => { const getDeviceLogsWithTimestamps =
let offset = 0; (serialNumber?: string, start?: number, end?: number, logType?: 0 | 1) => async () => {
const limit = 100; let offset = 0;
let logs: DeviceLog[] = []; const limit = 100;
let latestResponse: { let logs: DeviceLog[] = [];
values: DeviceLog[]; let latestResponse: {
serialNumber: string; values: DeviceLog[];
serialNumber: string;
};
do {
// eslint-disable-next-line no-await-in-loop
latestResponse = await getLogsBatch(serialNumber, start, end, limit, offset, logType);
logs = logs.concat(latestResponse.values);
offset += limit;
} while (latestResponse.values.length === limit);
return {
values: logs,
};
}; };
do {
// eslint-disable-next-line no-await-in-loop
latestResponse = await getLogsBatch(serialNumber, start, end, limit, offset);
logs = logs.concat(latestResponse.values);
offset += limit;
} while (latestResponse.values.length === limit);
return {
values: logs,
};
};
export const useGetDeviceLogsWithTimestamps = ({ export const useGetDeviceLogsWithTimestamps = ({
serialNumber, serialNumber,
start, start,
end, end,
onError, onError,
logType,
}: { }: {
serialNumber?: string; serialNumber?: string;
start?: number; start?: number;
end?: number; end?: number;
onError?: (e: AxiosError) => void; onError?: (e: AxiosError) => void;
logType?: 0 | 1;
}) => }) =>
useQuery(['devicelogs', serialNumber, { start, end }], getDeviceLogsWithTimestamps(serialNumber, start, end), { useQuery(
enabled: serialNumber !== undefined && serialNumber !== '' && start !== undefined && end !== undefined, ['devicelogs', serialNumber, { start, end, logType }],
staleTime: 1000 * 60, getDeviceLogsWithTimestamps(serialNumber, start, end, logType ?? 0),
onError, {
}); enabled: serialNumber !== undefined && serialNumber !== '' && start !== undefined && end !== undefined,
staleTime: 1000 * 60,
onError,
},
);

View File

@@ -165,7 +165,10 @@ export type DevicesStats = {
averageConnectionTime: number; averageConnectionTime: number;
connectedDevices: number; connectedDevices: number;
connectingDevices: number; connectingDevices: number;
tx: number;
rx: number;
}; };
const getInitialStats = async () => const getInitialStats = async () =>
axiosGw.get(`devices?connectionStatistics=true`).then(({ data }: { data: DevicesStats }) => data); axiosGw.get(`devices?connectionStatistics=true`).then(({ data }: { data: DevicesStats }) => data);
export const useGetDevicesStats = ({ onError }: { onError?: (e: AxiosError) => void }) => { export const useGetDevicesStats = ({ onError }: { onError?: (e: AxiosError) => void }) => {

View File

@@ -7,35 +7,49 @@ import { AxiosError } from 'models/Axios';
import { Firmware } from 'models/Firmware'; import { Firmware } from 'models/Firmware';
import { Note } from 'models/Note'; import { Note } from 'models/Note';
const getAvailableFirmwareBatch = async (deviceType: string, limit: number, offset: number) =>
axiosFms
.get(`firmwares?deviceType=${deviceType}&limit=${limit}&offset=${offset}`)
.then(({ data }: { data: { firmwares: Firmware[] } }) => data);
const getAllAvailableFirmware = async (deviceType: string) => {
const limit = 500;
let offset = 0;
let data: { firmwares: Firmware[] } = { firmwares: [] };
let lastResponse: { firmwares: Firmware[] } = { firmwares: [] };
do {
// eslint-disable-next-line no-await-in-loop
lastResponse = await getAvailableFirmwareBatch(deviceType, limit, offset);
data = {
firmwares: [...data.firmwares, ...lastResponse.firmwares],
};
offset += 500;
} while (lastResponse.firmwares.length === 500);
return data;
};
export const useGetAvailableFirmware = ({ deviceType }: { deviceType: string }) => { export const useGetAvailableFirmware = ({ deviceType }: { deviceType: string }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
return useQuery( return useQuery(['get-device-profile'], () => getAllAvailableFirmware(deviceType), {
['get-device-profile'], enabled: deviceType !== '',
() => onError: (e: AxiosError) => {
axiosFms if (!toast.isActive('firmware-fetching-error'))
.get(`firmwares?deviceType=${deviceType}&limit=10000&offset=0`) toast({
.then(({ data }: { data: { firmwares: Firmware[] } }) => data), id: 'firmware-fetching-error',
{ title: t('common.error'),
enabled: deviceType !== '', description: t('crud.error_fetching_obj', {
onError: (e: AxiosError) => { e: e?.response?.data?.ErrorDescription,
if (!toast.isActive('firmware-fetching-error')) obj: t('analytics.firmware'),
toast({ }),
id: 'firmware-fetching-error', status: 'error',
title: t('common.error'), duration: 5000,
description: t('crud.error_fetching_obj', { isClosable: true,
e: e?.response?.data?.ErrorDescription, position: 'top-right',
obj: t('analytics.firmware'), });
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
}, },
); });
}; };
export const useUpdateDeviceToLatest = ({ serialNumber, compatible }: { serialNumber: string; compatible: string }) => export const useUpdateDeviceToLatest = ({ serialNumber, compatible }: { serialNumber: string; compatible: string }) =>
@@ -56,7 +70,13 @@ export const useUpdateDeviceFirmware = ({ serialNumber, onClose }: { serialNumbe
return useMutation( return useMutation(
({ keepRedirector, uri, signature }: { keepRedirector: boolean; uri: string; signature?: string }) => ({ keepRedirector, uri, signature }: { keepRedirector: boolean; uri: string; signature?: string }) =>
axiosGw.post(`device/${serialNumber}/upgrade`, { serialNumber, when: 0, keepRedirector, uri, signature }), axiosGw.post(`device/${serialNumber}/upgrade${signature ? `?FWsignature=${signature}` : ''}`, {
serialNumber,
when: 0,
keepRedirector,
uri,
signature,
}),
{ {
onSuccess: () => { onSuccess: () => {
toast({ toast({
@@ -222,3 +242,23 @@ export const useGetFirmwareDashboard = () =>
keepPreviousData: true, keepPreviousData: true,
refetchInterval: 30000, refetchInterval: 30000,
}); });
const getLastDbUpdate = async () =>
axiosFms.get(`firmwares?updateTimeOnly=true`).then((response) => response.data as { lastUpdateTime: number });
export const useGetFirmwareDbUpdate = () =>
useQuery(['firmware', 'db'], getLastDbUpdate, {
keepPreviousData: true,
staleTime: 30 * 1000,
});
const updateDb = async () => axiosFms.put(`firmwares?update=true`);
export const useUpdateFirmwareDb = () => {
const queryClient = useQueryClient();
return useMutation(updateDb, {
onSuccess: () => {
queryClient.invalidateQueries(['firmware', 'db']);
},
});
};

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { QueryFunctionContext, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { axiosGw } from 'constants/axiosInstances'; import { axiosGw } from 'constants/axiosInstances';
import { AxiosError } from 'models/Axios'; import { AxiosError } from 'models/Axios';
@@ -89,3 +89,18 @@ export const useDeleteHealthChecks = () => {
}, },
}); });
}; };
const getDevicesWithHealthBetween = (
context: QueryFunctionContext<[string, string, { lowerLimit: number; upperLimit: number }]>,
) =>
axiosGw
.get(`devices?health=true&lowLimit=${context.queryKey[2].lowerLimit}&highLimit=${context.queryKey[2].upperLimit}`)
.then((res) => res.data.serialNumbers as string[]);
export const useGetDevicesWithHealthBetween = ({
lowerLimit,
upperLimit,
}: {
lowerLimit: number;
upperLimit: number;
}) => useQuery(['devices', 'health', { lowerLimit, upperLimit }], getDevicesWithHealthBetween);

View File

@@ -0,0 +1,83 @@
import { QueryFunctionContext, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { axiosSec } from 'constants/axiosInstances';
export type SecretName = 'google.maps.apikey' | string;
export type Secret = {
key: SecretName;
value: string;
};
export type SecretDictionaryValue = {
key: SecretName;
description: string;
};
const getSecret = async (context: QueryFunctionContext<string[], unknown>) =>
axiosSec.get(`/systemSecret/${context.queryKey[1]}`).then(({ data }: { data: Secret }) => data);
export const useGetSystemSecret = ({ secret }: { secret: SecretName }) =>
useQuery(['secrets', secret], getSecret, {
staleTime: 1000 * 60 * 10,
refetchInterval: 1000 * 60 * 10,
});
const getAllSecrets = async () =>
axiosSec.get('/systemSecret/0?all=true').then(({ data }: { data: { secrets: Secret[] } }) => data.secrets);
export const useGetAllSystemSecrets = () => {
const queryClient = useQueryClient();
return useQuery(['secrets'], getAllSecrets, {
staleTime: 1000 * 60 * 10,
refetchInterval: 1000 * 60 * 10,
onSuccess: (data) => {
for (const secret of data) {
queryClient.setQueryData(['secrets', secret.key], secret);
}
},
});
};
const getSecretsDictionary = async () =>
axiosSec
.get('/systemSecret/0?dictionary=true')
.then(({ data }: { data: { knownKeys: SecretDictionaryValue[] } }) => data.knownKeys);
export const useGetSystemSecretsDictionary = () =>
useQuery(['secrets', 'dictionary'], getSecretsDictionary, {
staleTime: 1000 * 60 * 10,
refetchInterval: 1000 * 60 * 10,
});
const updateSecret = async ({ key, value }: { key: string; value: string }) =>
axiosSec.put(`/systemSecret/${key}?value=${value}`, { key, value });
export const useUpdateSystemSecret = () => {
const queryClient = useQueryClient();
return useMutation(updateSecret, {
onSuccess: () => {
queryClient.invalidateQueries(['secrets']);
},
});
};
export const useCreateSystemSecret = () => {
const queryClient = useQueryClient();
return useMutation(updateSecret, {
onSuccess: () => {
queryClient.invalidateQueries(['secrets']);
},
});
};
const deleteSecret = async (key: string) => axiosSec.delete(`/systemSecret/${key}`);
export const useDeleteSystemSecret = () => {
const queryClient = useQueryClient();
return useMutation(deleteSecret, {
onSuccess: () => {
queryClient.invalidateQueries(['secrets']);
},
});
};

View File

@@ -1,4 +1,4 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { axiosGw } from 'constants/axiosInstances'; import { axiosGw } from 'constants/axiosInstances';
import { AxiosError } from 'models/Axios'; import { AxiosError } from 'models/Axios';
@@ -21,6 +21,18 @@ type DeviceInterfaceStatistics = {
tx_errors: number; tx_errors: number;
tx_packets: number; tx_packets: number;
}; };
'counters-aggregate'?: {
collisions: number;
multicast: number;
rx_bytes: number;
rx_dropped: number;
rx_errors: number;
rx_packets: number;
tx_bytes: number;
tx_dropped: number;
tx_errors: number;
tx_packets: number;
};
ssids?: { ssids?: {
associations?: { associations?: {
ack_signal: number; ack_signal: number;
@@ -148,6 +160,11 @@ export type DeviceStatistics = {
}; };
}; };
}; };
gps?: {
elevation: string;
latitude: string;
longitude: string;
};
version?: number; version?: number;
}; };
const getLastStats = (serialNumber?: string) => const getLastStats = (serialNumber?: string) =>
@@ -163,7 +180,7 @@ export const useGetDeviceLastStats = ({
onError?: (e: AxiosError) => void; onError?: (e: AxiosError) => void;
}) => }) =>
useQuery(['device', serialNumber, 'last-statistics'], () => getLastStats(serialNumber), { useQuery(['device', serialNumber, 'last-statistics'], () => getLastStats(serialNumber), {
enabled: serialNumber !== undefined && serialNumber !== '' && false, enabled: serialNumber !== undefined && serialNumber !== '',
staleTime: 1000 * 60, staleTime: 1000 * 60,
onError, onError,
}); });
@@ -183,24 +200,12 @@ export const useGetDeviceNewestStats = ({
serialNumber?: string; serialNumber?: string;
limit: number; limit: number;
onError?: (e: AxiosError) => void; onError?: (e: AxiosError) => void;
}) => { }) =>
const queryClient = useQueryClient(); useQuery(['deviceStatistics', serialNumber, 'newest', { limit }], getNewestStats(limit, serialNumber), {
return useQuery(['deviceStatistics', serialNumber, 'newest', { limit }], getNewestStats(limit, serialNumber), {
enabled: serialNumber !== undefined && serialNumber !== '', enabled: serialNumber !== undefined && serialNumber !== '',
staleTime: 1000 * 60, staleTime: 1000 * 60,
onSuccess: (response) => {
const entry = response.data[0];
// If we have a valid entry, we prefill lastStats, if not we trigger a fetch of the last statistics
if (entry) {
queryClient.setQueryData(['device', serialNumber, 'last-statistics'], entry.data);
} else {
queryClient.fetchQuery(['device', serialNumber, 'last-statistics']);
}
},
onError, onError,
}); });
};
const getOuis = (macs?: string[]) => async () => const getOuis = (macs?: string[]) => async () =>
axiosGw.get(`/ouis?macList=${macs?.join(',')}`).then((response) => response.data) as Promise<{ axiosGw.get(`/ouis?macList=${macs?.join(',')}`).then((response) => response.data) as Promise<{

View File

@@ -1,5 +1,6 @@
import { useToast } from '@chakra-ui/react'; import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { axiosGw } from 'constants/axiosInstances'; import { axiosGw } from 'constants/axiosInstances';
@@ -85,14 +86,44 @@ export const useTrace = ({ serialNumber, alertOnCompletion }: { serialNumber: st
export const downloadTrace = (serialNumber: string, commandId: string) => export const downloadTrace = (serialNumber: string, commandId: string) =>
axiosGw.get(`file/${commandId}?serialNumber=${serialNumber}`, { responseType: 'arraybuffer' }); axiosGw.get(`file/${commandId}?serialNumber=${serialNumber}`, { responseType: 'arraybuffer' });
export const useDownloadTrace = ({ serialNumber, commandId }: { serialNumber: string; commandId: string }) => export const useDownloadTrace = ({ serialNumber, commandId }: { serialNumber: string; commandId: string }) => {
useQuery(['download-trace', serialNumber, commandId], () => downloadTrace(serialNumber, commandId), { const { t } = useTranslation();
const toast = useToast();
return useQuery(['download-trace', serialNumber, commandId], () => downloadTrace(serialNumber, commandId), {
enabled: false, enabled: false,
onSuccess: (response) => { onSuccess: (response) => {
const blob = new Blob([response.data], { type: 'application/octet-stream' }); const blob = new Blob([response.data], { type: 'application/octet-stream' });
const link = document.createElement('a'); const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob); link.href = window.URL.createObjectURL(blob);
link.download = `Trace_${commandId}.pcap`; const headerLine =
(response.headers['content-disposition'] as string | undefined) ??
(response.headers['content-disposition'] as string | undefined);
const filename = headerLine?.split('filename=')[1]?.split(',')[0] ?? `Trace_${commandId}.pcap`;
link.download = filename;
link.click(); link.click();
}, },
onError: (e) => {
if (axios.isAxiosError(e)) {
const bufferResponse = e.response?.data;
let errorMessage = '';
// If the response is a buffer, parse to JSON object
if (bufferResponse instanceof ArrayBuffer) {
const decoder = new TextDecoder('utf-8');
const json = JSON.parse(decoder.decode(bufferResponse));
errorMessage = json.ErrorDescription;
}
toast({
id: `trace-download-error-${serialNumber}`,
title: t('common.error'),
description: errorMessage,
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
}
},
}); });
};

View File

@@ -1,5 +1,5 @@
import { useToast } from '@chakra-ui/react'; import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { axiosSec } from 'constants/axiosInstances'; import { axiosSec } from 'constants/axiosInstances';
import { AxiosError } from 'models/Axios'; import { AxiosError } from 'models/Axios';
@@ -58,12 +58,24 @@ export type User = {
waitingForEmailCheck: boolean; waitingForEmailCheck: boolean;
}; };
const getAvatarPromises = (userList: User[]) => { const getAvatarPromises = (userList: User[], queryClient: QueryClient) => {
const promises = userList.map(async (user) => { const promises = userList.map(async (user) => {
if (user.avatar !== '' && user.avatar !== '0') { if (user.avatar !== '' && user.avatar !== '0') {
return axiosSec.get(`avatar/${user.id}?cache=${user.avatar}`, { // If the avatar is already in the cache, return it
responseType: 'arraybuffer', const cachedAvatar = queryClient.getQueryData(['avatar', user.id, user.avatar]);
}); if (cachedAvatar) return cachedAvatar;
return axiosSec
.get(`avatar/${user.id}?cache=${user.avatar}`, {
responseType: 'arraybuffer',
})
.then((response) => {
queryClient.setQueryData(['avatar', user.id, user.avatar], response);
return response;
})
.catch((e) => {
throw e;
});
} }
return Promise.resolve(''); return Promise.resolve('');
}); });
@@ -71,10 +83,35 @@ const getAvatarPromises = (userList: User[]) => {
return promises; return promises;
}; };
const getUsers = async () => { const getBatchUsers = async (offset: number, limit: number) => {
const users = await axiosSec.get('users').then(({ data }) => data.users as User[]); const users = await axiosSec
.get(`users?offset=${offset}&limit=${limit}&withExtendedInfo=true`)
.then(({ data }) => data.users as User[]);
const avatars = await Promise.allSettled(getAvatarPromises(users)).then((results) => return users;
};
const getAllUsers = async () => {
let users: User[] = [];
let offset = 0;
const limit = 500;
let lastResponseLength = 0;
do {
// eslint-disable-next-line no-await-in-loop
const response = await getBatchUsers(offset, limit);
users = [...users, ...response];
offset += limit;
lastResponseLength = response.length;
} while (lastResponseLength === limit);
return users;
};
const getUsers = async (queryClient: QueryClient) => {
const users = await getAllUsers();
const avatars = await Promise.allSettled(getAvatarPromises(users, queryClient)).then((results) =>
results.map((response) => { results.map((response) => {
if (response.status === 'fulfilled' && response?.value !== '') { if (response.status === 'fulfilled' && response?.value !== '') {
const base64 = btoa( const base64 = btoa(
@@ -93,8 +130,10 @@ const getUsers = async () => {
export const useGetUsers = () => { export const useGetUsers = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const queryClient = useQueryClient();
return useQuery(['users'], getUsers, { return useQuery(['users'], () => getUsers(queryClient), {
staleTime: 30 * 1000,
onError: (e: AxiosError) => { onError: (e: AxiosError) => {
if (!toast.isActive('users-fetching-error')) if (!toast.isActive('users-fetching-error'))
toast({ toast({
@@ -118,7 +157,7 @@ export const useGetUser = ({ id, enabled }: { id: string; enabled: boolean }) =>
const toast = useToast(); const toast = useToast();
return useQuery( return useQuery(
['get-user', id], ['users', id],
() => axiosSec.get(`user/${id}?withExtendedInfo=true`).then(({ data }) => data as User), () => axiosSec.get(`user/${id}?withExtendedInfo=true`).then(({ data }) => data as User),
{ {
enabled, enabled,
@@ -173,16 +212,41 @@ export const useSendUserEmailValidation = ({ id, refresh }: { id: string; refres
}, },
}); });
}; };
export const useSuspendUser = ({ id }: { id: string }) => export const useSuspendUser = ({ id }: { id: string }) => {
useMutation((isSuspended: boolean) => const queryClient = useQueryClient();
axiosSec.put(`user/${id}`, {
suspended: isSuspended,
}),
);
export const useResetMfa = ({ id }: { id: string }) => useMutation(() => axiosSec.put(`user/${id}?resetMFA=true`, {}));
export const useResetPassword = ({ id }: { id: string }) => return useMutation(
useMutation(() => axiosSec.put(`user/${id}?forgotPassword=true`, {})); (isSuspended: boolean) =>
axiosSec.put(`user/${id}`, {
suspended: isSuspended,
}),
{
onSuccess: () => {
queryClient.invalidateQueries(['users']);
},
},
);
};
export const useResetMfa = ({ id }: { id: string }) => {
const queryClient = useQueryClient();
return useMutation(() => axiosSec.put(`user/${id}?resetMFA=true`, {}), {
onSuccess: () => {
queryClient.invalidateQueries(['users']);
},
});
};
export const useResetPassword = ({ id }: { id: string }) => {
const queryClient = useQueryClient();
return useMutation(() => axiosSec.put(`user/${id}?forgotPassword=true`, {}), {
onSuccess: () => {
queryClient.invalidateQueries(['users']);
},
});
};
const deleteUser = async (userId: string) => axiosSec.delete(`/user/${userId}`); const deleteUser = async (userId: string) => axiosSec.delete(`/user/${userId}`);
export const useDeleteUser = () => { export const useDeleteUser = () => {

View File

@@ -1,7 +1,20 @@
import * as React from 'react'; import * as React from 'react';
import { Flex, Heading, Tooltip, VStack } from '@chakra-ui/react'; import {
Box,
CircularProgress,
CircularProgressLabel,
Flex,
Heading,
Icon,
Text,
Tooltip,
VStack,
} from '@chakra-ui/react';
import { ArrowSquareDown, ArrowSquareUp, Clock } from 'phosphor-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from 'components/Containers/Card';
import { compactSecondsToDetailed, minimalSecondsToDetailed } from 'helpers/dateFormatting'; import { compactSecondsToDetailed, minimalSecondsToDetailed } from 'helpers/dateFormatting';
import { bytesString } from 'helpers/stringHelper';
import { useGetDevicesStats } from 'hooks/Network/Devices'; import { useGetDevicesStats } from 'hooks/Network/Devices';
const SidebarDevices = () => { const SidebarDevices = () => {
@@ -10,18 +23,19 @@ const SidebarDevices = () => {
const [lastTime, setLastTime] = React.useState<Date | undefined>(); const [lastTime, setLastTime] = React.useState<Date | undefined>();
const [lastUpdate, setLastUpdate] = React.useState<Date | undefined>(); const [lastUpdate, setLastUpdate] = React.useState<Date | undefined>();
const getTime = () => { const time = React.useMemo(() => {
if (lastTime === undefined || lastUpdate === undefined) return null; if (lastTime === undefined || lastUpdate === undefined) return null;
const seconds = lastTime.getTime() - lastUpdate.getTime(); const seconds = lastTime.getTime() - lastUpdate.getTime();
return Math.max(0, Math.floor(seconds / 1000)); return Math.max(0, Math.floor(seconds / 1000));
}; }, [lastTime, lastUpdate]);
const refresh = () => { const circleColor = () => {
if (document.visibilityState !== 'hidden') { if (time === null) return 'gray.300';
getStats.refetch(); if (time < 10) return 'green.300';
} if (time < 30) return 'yellow.300';
return 'red.300';
}; };
React.useEffect(() => { React.useEffect(() => {
@@ -37,37 +51,60 @@ const SidebarDevices = () => {
}; };
}, []); }, []);
React.useEffect(() => {
document.addEventListener('visibilitychange', refresh);
return () => {
document.removeEventListener('visibilitychange', refresh);
};
}, []);
if (!getStats.data) return null; if (!getStats.data) return null;
return ( return (
<VStack spacing={4}> <Card borderWidth="2px">
<Flex flexDir="column" textAlign="center"> <Tooltip hasArrow label={t('controller.stats.seconds_ago', { s: time })}>
<Heading size="md">{getStats.data.connectedDevices}</Heading> <CircularProgress
<Heading size="xs"> isIndeterminate
{t('common.connected')} {t('devices.title')} color={circleColor()}
</Heading> position="absolute"
<Heading size="xs" mt={1} fontStyle="italic" fontWeight="normal" color="gray.400"> right="6px"
({getStats.data.connectingDevices} {t('controller.devices.connecting')}) top="6px"
</Heading> w="unset"
<Tooltip hasArrow label={compactSecondsToDetailed(getStats.data.averageConnectionTime, t)}> size={6}
<Heading size="md" textAlign="center" mt={2}> thickness="14px"
{minimalSecondsToDetailed(getStats.data.averageConnectionTime, t)} >
<CircularProgressLabel fontSize="1.9em">{time}s</CircularProgressLabel>
</CircularProgress>
</Tooltip>
<Tooltip hasArrow label={t('controller.stats.seconds_ago', { s: time })}>
<Box position="absolute" right="8px" top="8px" w="unset" hidden>
<Clock size={16} />
</Box>
</Tooltip>
<VStack mb={-1}>
<Flex flexDir="column" textAlign="center">
<Heading size="md">{getStats.data.connectedDevices}</Heading>
<Heading size="xs" display="flex" justifyContent="center">
<Text>
{t('common.connected')} {t('devices.title')}{' '}
</Text>{' '}
</Heading> </Heading>
</Tooltip> <Tooltip hasArrow label={compactSecondsToDetailed(getStats.data.averageConnectionTime, t)}>
<Heading size="xs">{t('controller.devices.average_uptime')}</Heading> <Heading size="md" textAlign="center" mt={1}>
<Heading size="xs" mt={2} fontStyle="italic" fontWeight="normal" color="gray.400"> {minimalSecondsToDetailed(getStats.data.averageConnectionTime, t)}
{t('controller.stats.seconds_ago', { s: getTime() })} </Heading>
</Heading> </Tooltip>
</Flex> <Heading size="xs">{t('controller.devices.average_uptime')}</Heading>
</VStack> <Flex fontSize="sm" fontWeight="bold" alignItems="center" justifyContent="center" mt={1}>
<Tooltip hasArrow label="Rx">
<Flex alignItems="center" mr={1}>
<Icon as={ArrowSquareUp} weight="bold" boxSize={5} mt="1px" color="blue.400" />{' '}
{getStats.data.rx !== undefined ? bytesString(getStats.data.rx, 0) : '-'}
</Flex>
</Tooltip>
<Tooltip hasArrow label="Tx">
<Flex alignItems="center">
<Icon as={ArrowSquareDown} weight="bold" boxSize={5} mt="1px" color="purple.400" />{' '}
{getStats.data.tx !== undefined ? bytesString(getStats.data.tx, 0) : '-'}
</Flex>
</Tooltip>
</Flex>
</Flex>
</VStack>
</Card>
); );
}; };

View File

@@ -20,6 +20,7 @@ export interface Column<T> {
alwaysShow?: boolean; alwaysShow?: boolean;
Footer?: string; Footer?: string;
accessor?: string; accessor?: string;
stopPropagation?: boolean;
disableSortBy?: boolean; disableSortBy?: boolean;
hasPopover?: boolean; hasPopover?: boolean;
customMaxWidth?: string; customMaxWidth?: string;

View File

@@ -99,13 +99,14 @@ const DefaultConfigurationsList = () => {
<CardBody> <CardBody>
<Box overflowX="auto" w="100%"> <Box overflowX="auto" w="100%">
<LoadingOverlay isLoading={getConfigs.isFetching}> <LoadingOverlay isLoading={getConfigs.isFetching}>
<DataTable <DataTable<DefaultConfigurationResponse>
columns={columns as Column<object>[]} columns={columns}
saveSettingsId="firmware.table" saveSettingsId="firmware.table"
data={getConfigs.data ?? []} data={getConfigs.data ?? []}
obj={t('controller.configurations.title')} obj={t('controller.configurations.title')}
minHeight="200px" minHeight="200px"
sortBy={[{ id: 'name', desc: true }]} sortBy={[{ id: 'name', desc: true }]}
onRowClick={onViewDetails}
/> />
</LoadingOverlay> </LoadingOverlay>
</Box> </Box>

View File

@@ -0,0 +1,75 @@
import * as React from 'react';
import { Box, Button, Flex, FormControl, FormLabel, useDisclosure } from '@chakra-ui/react';
import { Wrapper } from '@googlemaps/react-wrapper';
import { Globe } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { GoogleMap } from 'components/Maps/GoogleMap';
import { GoogleMapMarker } from 'components/Maps/GoogleMap/Marker';
import { Modal } from 'components/Modals/Modal';
import { useGetSystemSecret } from 'hooks/Network/Secrets';
import { useGetDeviceLastStats } from 'hooks/Network/Statistics';
type Props = {
serialNumber: string;
};
const LocationDisplayButton = ({ serialNumber }: Props) => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const getGoogleApiKey = useGetSystemSecret({ secret: 'google.maps.apikey' });
const getLastStats = useGetDeviceLastStats({ serialNumber });
const location: google.maps.LatLngLiteral | undefined = React.useMemo(() => {
if (!getLastStats.data?.gps) return undefined;
try {
return {
lat: Number.parseFloat(getLastStats.data.gps.latitude),
lng: Number.parseFloat(getLastStats.data.gps.longitude),
};
} catch (e) {
return undefined;
}
}, [getLastStats.data?.gps]);
if (!location) {
return null;
}
return (
<>
<Button variant="link" onClick={onOpen} rightIcon={<Globe size={20} />} colorScheme="blue">
{t('locations.view_gps')}
</Button>
<Modal isOpen={isOpen} onClose={onClose} title={t('locations.one')}>
<Box w="100%" h="100%">
<Flex mb={4}>
<FormControl w="unset">
<FormLabel>{t('locations.lat')}</FormLabel>
<pre>{location.lat}</pre>
</FormControl>
<FormControl w="unset" mx={4}>
<FormLabel>{t('locations.longitude')}</FormLabel>
<pre>{location.lng}</pre>
</FormControl>
<FormControl w="unset">
<FormLabel>{t('locations.elevation')}</FormLabel>
<pre>{getLastStats.data?.gps?.elevation}</pre>
</FormControl>
</Flex>
{getGoogleApiKey.data ? (
<Box h="500px">
<Wrapper apiKey={getGoogleApiKey.data.value}>
<GoogleMap center={location} style={{ flexGrow: '1', height: '100%' }} zoom={10}>
<GoogleMapMarker position={location} />
</GoogleMap>
</Wrapper>
</Box>
) : null}
</Box>
</Modal>
</>
);
};
export default LocationDisplayButton;

View File

@@ -28,7 +28,7 @@ import { Modal } from 'components/Modals/Modal';
import WifiScanResultDisplay from 'components/Modals/WifiScanModal/ResultDisplay'; import WifiScanResultDisplay from 'components/Modals/WifiScanModal/ResultDisplay';
import { compactDate } from 'helpers/dateFormatting'; import { compactDate } from 'helpers/dateFormatting';
import { uppercaseFirstLetter } from 'helpers/stringHelper'; import { uppercaseFirstLetter } from 'helpers/stringHelper';
import { DeviceCommandHistory } from 'hooks/Network/Commands'; import { DeviceCommandHistory, useGetSingleCommandHistory } from 'hooks/Network/Commands';
import { WifiScanResult } from 'models/Device'; import { WifiScanResult } from 'models/Device';
type Props = { type Props = {
@@ -39,9 +39,13 @@ type Props = {
command?: DeviceCommandHistory; command?: DeviceCommandHistory;
}; };
const CommandResultModal = ({ modalProps, command }: Props) => { const CommandResultModal = ({ modalProps, command: initialCommandInfo }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const { data: command } = useGetSingleCommandHistory({
commandId: initialCommandInfo?.UUID ?? '',
serialNumber: initialCommandInfo?.serialNumber ?? '',
});
if (!command) return null; if (!command) return null;

View File

@@ -44,13 +44,13 @@ const CommandHistory = ({ serialNumber }: Props) => {
<Box textAlign="right" display="flex"> <Box textAlign="right" display="flex">
<Spacer /> <Spacer />
<HStack> <HStack>
<HistoryDatePickers defaults={time} setTime={setNewTime} onClear={onClear} />
<ColumnPicker <ColumnPicker
columns={columns as Column<unknown>[]} columns={columns as Column<unknown>[]}
hiddenColumns={hiddenColumns} hiddenColumns={hiddenColumns}
setHiddenColumns={setHiddenColumns} setHiddenColumns={setHiddenColumns}
preference="gateway.device.commandshistory.hiddenColumns" preference="gateway.device.commandshistory.hiddenColumns"
/> />
<HistoryDatePickers defaults={time} setTime={setNewTime} onClear={onClear} />
<RefreshButton <RefreshButton
isCompact isCompact
isFetching={getCommands.isFetching} isFetching={getCommands.isFetching}

View File

@@ -99,16 +99,6 @@ const useCommandHistoryTable = ({ serialNumber, limit }: Props) => {
const actionCell = React.useCallback( const actionCell = React.useCallback(
(command: DeviceCommandHistory) => ( (command: DeviceCommandHistory) => (
<HStack> <HStack>
<Tooltip label={t('common.view_details')}>
<IconButton
aria-label={t('common.view_details')}
onClick={onOpenDetails(command)}
colorScheme="blue"
icon={<MagnifyingGlass size={20} />}
size="sm"
isLoading={loadingDeleteSerial === command.UUID}
/>
</Tooltip>
<Tooltip label={t('crud.delete')}> <Tooltip label={t('crud.delete')}>
<IconButton <IconButton
aria-label={t('crud.delete')} aria-label={t('crud.delete')}
@@ -119,6 +109,16 @@ const useCommandHistoryTable = ({ serialNumber, limit }: Props) => {
isLoading={loadingDeleteSerial === command.UUID} isLoading={loadingDeleteSerial === command.UUID}
/> />
</Tooltip> </Tooltip>
<Tooltip label={t('common.view_details')}>
<IconButton
aria-label={t('common.view_details')}
onClick={onOpenDetails(command)}
colorScheme="blue"
icon={<MagnifyingGlass size={20} />}
size="sm"
isLoading={loadingDeleteSerial === command.UUID}
/>
</Tooltip>
</HStack> </HStack>
), ),
[loadingDeleteSerial], [loadingDeleteSerial],

View File

@@ -0,0 +1,94 @@
import * as React from 'react';
import { Box, Button, Center, Flex, Heading, HStack, Spacer } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import HistoryDatePickers from '../DatePickers';
import DeleteLogModal from './DeleteModal';
import useDeviceLogsTable from './useDeviceLogsTable';
import { RefreshButton } from 'components/Buttons/RefreshButton';
import { ColumnPicker } from 'components/DataTables/ColumnPicker';
import { DataTable } from 'components/DataTables/DataTable';
import { Column } from 'models/Table';
type Props = {
serialNumber: string;
};
const CrashLogs = ({ serialNumber }: Props) => {
const { t } = useTranslation();
const [limit, setLimit] = React.useState(25);
const [hiddenColumns, setHiddenColumns] = React.useState<string[]>([]);
const { time, setTime, getCustomLogs, getLogs, columns, modal } = useDeviceLogsTable({
serialNumber,
limit,
logType: 1,
});
const setNewTime = (start: Date, end: Date) => {
setTime({ start, end });
};
const onClear = () => {
setTime(undefined);
};
const raiseLimit = () => {
setLimit(limit + 25);
};
const noMoreAvailable = getLogs.data !== undefined && getLogs.data.values.length < limit;
const data = React.useMemo(() => {
if (getCustomLogs.data) return getCustomLogs.data.values.sort((a, b) => b.recorded - a.recorded);
if (getLogs.data) return getLogs.data.values;
return [];
}, [getLogs.data, getCustomLogs.data]);
return (
<Box>
<Flex>
<Spacer />
<HStack>
<HistoryDatePickers defaults={time} setTime={setNewTime} onClear={onClear} />
<ColumnPicker
columns={columns as Column<unknown>[]}
hiddenColumns={hiddenColumns}
setHiddenColumns={setHiddenColumns}
preference="gateway.device.logs.hiddenColumns"
/>
<DeleteLogModal serialNumber={serialNumber} logType={0} />
<RefreshButton isCompact isFetching={getLogs.isFetching} onClick={getLogs.refetch} colorScheme="blue" />
</HStack>
</Flex>
<Box overflowY="auto" h="300px">
<DataTable
columns={
columns as {
id: string;
Header: string;
Footer: string;
accessor: string;
}[]
}
data={data}
isLoading={getLogs.isFetching || getCustomLogs.isFetching}
hiddenColumns={hiddenColumns}
obj={t('controller.devices.logs')}
// @ts-ignore
hideControls
showAllRows
/>
{getLogs.data !== undefined && (
<Center mt={1} hidden={getCustomLogs.data !== undefined}>
{!noMoreAvailable || getLogs.isFetching ? (
<Button colorScheme="blue" onClick={raiseLimit} isLoading={getLogs.isFetching}>
{t('controller.devices.show_more')}
</Button>
) : (
<Heading size="sm">{t('controller.devices.no_more_available')}!</Heading>
)}
</Center>
)}
</Box>
{modal}
</Box>
);
};
export default CrashLogs;

View File

@@ -16,8 +16,8 @@ const CustomInputButton = React.forwardRef(
), ),
); );
type Props = { serialNumber: string }; type Props = { serialNumber: string; logType: 0 | 1 };
const DeleteLogModal = ({ serialNumber }: Props) => { const DeleteLogModal = ({ serialNumber, logType }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const modalProps = useDisclosure(); const modalProps = useDisclosure();
@@ -26,7 +26,7 @@ const DeleteLogModal = ({ serialNumber }: Props) => {
const onDeleteClick = () => { const onDeleteClick = () => {
deleteLogs.mutate( deleteLogs.mutate(
{ endDate: Math.floor(date.getTime() / 1000), serialNumber }, { endDate: Math.floor(date.getTime() / 1000), serialNumber, logType },
{ {
onSuccess: () => { onSuccess: () => {
modalProps.onClose(); modalProps.onClose();

View File

@@ -0,0 +1,55 @@
import * as React from 'react';
import { Box, Button, Code, Heading, useClipboard } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import FormattedDate from 'components/InformationDisplays/FormattedDate';
import { Modal } from 'components/Modals/Modal';
import { DeviceLog } from 'hooks/Network/DeviceLogs';
type Props = {
modalProps: {
isOpen: boolean;
onClose: () => void;
};
log?: DeviceLog;
};
const DetailedLogViewModal = ({ modalProps, log }: Props) => {
const { t } = useTranslation();
const { hasCopied, onCopy, setValue } = useClipboard(JSON.stringify(log?.log ?? {}, null, 2));
React.useEffect(() => {
setValue(JSON.stringify(log?.log ?? {}, null, 2));
}, [log]);
if (!log) return null;
return (
<Modal
isOpen={modalProps.isOpen}
onClose={modalProps.onClose}
title={t('devices.logs_one')}
topRightButtons={
<Button onClick={onCopy} size="md" colorScheme="teal">
{hasCopied ? `${t('common.copied')}!` : t('common.copy')}
</Button>
}
>
<Box>
<Heading size="sm">
<FormattedDate date={log.recorded} />
</Heading>
<Heading size="sm">
{t('controller.devices.severity')}: {log.severity}
</Heading>
<Heading size="sm">
{t('controller.devices.config_id')}: {log.UUID}
</Heading>
<Code whiteSpace="pre-line" mt={2}>
{log.log}
</Code>
</Box>
</Modal>
);
};
export default DetailedLogViewModal;

View File

@@ -16,7 +16,7 @@ const LogHistory = ({ serialNumber }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [limit, setLimit] = React.useState(25); const [limit, setLimit] = React.useState(25);
const [hiddenColumns, setHiddenColumns] = React.useState<string[]>([]); const [hiddenColumns, setHiddenColumns] = React.useState<string[]>([]);
const { time, setTime, getCustomLogs, getLogs, columns } = useDeviceLogsTable({ serialNumber, limit }); const { time, setTime, getCustomLogs, getLogs, columns } = useDeviceLogsTable({ serialNumber, limit, logType: 0 });
const setNewTime = (start: Date, end: Date) => { const setNewTime = (start: Date, end: Date) => {
setTime({ start, end }); setTime({ start, end });
@@ -48,7 +48,7 @@ const LogHistory = ({ serialNumber }: Props) => {
setHiddenColumns={setHiddenColumns} setHiddenColumns={setHiddenColumns}
preference="gateway.device.logs.hiddenColumns" preference="gateway.device.logs.hiddenColumns"
/> />
<DeleteLogModal serialNumber={serialNumber} /> <DeleteLogModal serialNumber={serialNumber} logType={0} />
<RefreshButton isCompact isFetching={getLogs.isFetching} onClick={getLogs.refetch} colorScheme="blue" /> <RefreshButton isCompact isFetching={getLogs.isFetching} onClick={getLogs.refetch} colorScheme="blue" />
</HStack> </HStack>
</Flex> </Flex>

View File

@@ -1,6 +1,8 @@
import * as React from 'react'; import * as React from 'react';
import { Box } from '@chakra-ui/react'; import { Box, IconButton, Text, useDisclosure } from '@chakra-ui/react';
import { MagnifyingGlass } from 'phosphor-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import DetailedLogViewModal from './DetailedLogViewModal';
import FormattedDate from 'components/InformationDisplays/FormattedDate'; import FormattedDate from 'components/InformationDisplays/FormattedDate';
import { DeviceLog, useGetDeviceLogs, useGetDeviceLogsWithTimestamps } from 'hooks/Network/DeviceLogs'; import { DeviceLog, useGetDeviceLogs, useGetDeviceLogsWithTimestamps } from 'hooks/Network/DeviceLogs';
import { Column } from 'models/Table'; import { Column } from 'models/Table';
@@ -8,18 +10,49 @@ import { Column } from 'models/Table';
type Props = { type Props = {
serialNumber: string; serialNumber: string;
limit: number; limit: number;
logType: 0 | 1;
}; };
const useDeviceLogsTable = ({ serialNumber, limit }: Props) => { const useDeviceLogsTable = ({ serialNumber, limit, logType }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const getLogs = useGetDeviceLogs({ serialNumber, limit }); const getLogs = useGetDeviceLogs({ serialNumber, limit, logType });
const modalProps = useDisclosure();
const [log, setLog] = React.useState<DeviceLog | undefined>();
const [time, setTime] = React.useState<{ start: Date; end: Date } | undefined>(); const [time, setTime] = React.useState<{ start: Date; end: Date } | undefined>();
const getCustomLogs = useGetDeviceLogsWithTimestamps({ const getCustomLogs = useGetDeviceLogsWithTimestamps({
serialNumber, serialNumber,
start: time ? Math.floor(time.start.getTime() / 1000) : undefined, start: time ? Math.floor(time.start.getTime() / 1000) : undefined,
end: time ? Math.floor(time.end.getTime() / 1000) : undefined, end: time ? Math.floor(time.end.getTime() / 1000) : undefined,
logType,
}); });
const onOpen = React.useCallback((v: DeviceLog) => {
setLog(v);
modalProps.onOpen();
}, []);
const logCell = React.useCallback(
(v: DeviceLog) =>
logType === 1 ? (
<Box display="flex">
<IconButton
aria-label="Open Log Details"
onClick={() => onOpen(v)}
colorScheme="blue"
icon={<MagnifyingGlass size={16} />}
size="xs"
mr={2}
/>
<Text my="auto" maxW="calc(20vw)" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{v.log}
</Text>
</Box>
) : (
v.log
),
[onOpen],
);
const dateCell = React.useCallback( const dateCell = React.useCallback(
(v: number) => ( (v: number) => (
<Box> <Box>
@@ -65,6 +98,7 @@ const useDeviceLogsTable = ({ serialNumber, limit }: Props) => {
Footer: '', Footer: '',
accessor: 'log', accessor: 'log',
customWidth: '35px', customWidth: '35px',
Cell: (v) => logCell(v.cell.row.original),
disableSortBy: true, disableSortBy: true,
}, },
{ {
@@ -85,6 +119,7 @@ const useDeviceLogsTable = ({ serialNumber, limit }: Props) => {
getCustomLogs, getCustomLogs,
time, time,
setTime, setTime,
modal: <DetailedLogViewModal modalProps={modalProps} log={log} />,
}; };
}; };

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import CommandHistory from './CommandHistory'; import CommandHistory from './CommandHistory';
import HealthCheckHistory from './HealthCheckHistory'; import HealthCheckHistory from './HealthCheckHistory';
import LogHistory from './LogHistory'; import LogHistory from './LogHistory';
import CrashLogs from './LogHistory/CrashLogs';
import { Card } from 'components/Containers/Card'; import { Card } from 'components/Containers/Card';
import { CardBody } from 'components/Containers/Card/CardBody'; import { CardBody } from 'components/Containers/Card/CardBody';
@@ -32,6 +33,9 @@ const DeviceLogsCard = ({ serialNumber }: Props) => {
<Tab fontSize="lg" fontWeight="bold"> <Tab fontSize="lg" fontWeight="bold">
{t('controller.devices.logs')} {t('controller.devices.logs')}
</Tab> </Tab>
<Tab fontSize="lg" fontWeight="bold">
{t('devices.crash_logs')}
</Tab>
</TabList> </TabList>
<TabPanels> <TabPanels>
<TabPanel p={0}> <TabPanel p={0}>
@@ -51,10 +55,12 @@ const DeviceLogsCard = ({ serialNumber }: Props) => {
<TabPanel> <TabPanel>
<HealthCheckHistory serialNumber={serialNumber} /> <HealthCheckHistory serialNumber={serialNumber} />
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
<LogHistory serialNumber={serialNumber} /> <LogHistory serialNumber={serialNumber} />
</TabPanel> </TabPanel>
<TabPanel>
<CrashLogs serialNumber={serialNumber} />
</TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>
</CardBody> </CardBody>

View File

@@ -1,5 +1,16 @@
import * as React from 'react'; import * as React from 'react';
import { Box, Flex, Heading, ListItem, Text, UnorderedList } from '@chakra-ui/react'; import {
Box,
Flex,
Heading,
ListItem,
Tag,
TagLabel,
TagLeftIcon,
Text,
Tooltip,
UnorderedList,
} from '@chakra-ui/react';
import { LockSimple, LockSimpleOpen } from 'phosphor-react'; import { LockSimple, LockSimpleOpen } from 'phosphor-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from 'components/Containers/Card'; import { Card } from 'components/Containers/Card';
@@ -20,7 +31,7 @@ const RestrictionsCard = ({ serialNumber }: Props) => {
ssh: 'SSH', ssh: 'SSH',
rtty: 'RTTY', rtty: 'RTTY',
tty: t('restrictions.tty'), tty: t('restrictions.tty'),
developer: t('restrictions.developer'), // developer: t('restrictions.developer'),
upgrade: t('restrictions.signed_upgrade'), upgrade: t('restrictions.signed_upgrade'),
commands: t('restrictions.gw_commands'), commands: t('restrictions.gw_commands'),
} as { [key: string]: string }; } as { [key: string]: string };
@@ -38,27 +49,52 @@ const RestrictionsCard = ({ serialNumber }: Props) => {
return restrictedKeys.map(([k]) => <ListItem key={k}>{LABELS[k]}</ListItem>); return restrictedKeys.map(([k]) => <ListItem key={k}>{LABELS[k]}</ListItem>);
}; };
const isMissingSigningInfo =
!restrictions.key_info ||
(!restrictions.key_info.algo && !restrictions.key_info.vendor) ||
(restrictions.key_info.algo.length === 0 && restrictions.key_info.vendor.length === 0);
return ( return (
<Card mb={4}> <Card mb={4}>
<CardHeader> <CardHeader>
<Heading size="md">{t('restrictions.title')}</Heading> <Heading size="md" my="auto" mr={2}>
{t('restrictions.title')}
</Heading>
{getDevice.data?.restrictionDetails?.developer ? (
<Tooltip label={t('devices.restricted_overriden')} hasArrow>
<Tag size="lg" colorScheme="green">
<TagLeftIcon boxSize="18px" as={LockSimpleOpen} />
<TagLabel>{t('devices.restrictions_overriden_title')}</TagLabel>
</Tag>
</Tooltip>
) : null}
</CardHeader> </CardHeader>
<CardBody p={0} display="block"> <CardBody p={0} display="block">
<Flex mt={2}> <Flex mt={2}>
<Heading size="sm" mr={2}> <Heading size="sm" mr={2} my="auto">
{t('restrictions.countries')}: {t('restrictions.countries')}:
</Heading> </Heading>
<Text>{restrictions.country.join(', ')}</Text> <Text my="auto">
{restrictions.country?.length === 0 ? t('common.all') : restrictions.country.join(', ')}
</Text>
</Flex> </Flex>
<Heading size="sm" mt={2}> <Flex mt={2}>
{t('restrictions.key_verification')} <Heading size="sm" mt={2} my="auto">
</Heading> {t('restrictions.key_verification')} {isMissingSigningInfo ? ':' : ''}
<UnorderedList> </Heading>
{isMissingSigningInfo ? (
<Text my="auto" ml={2}>
{t('common.none')}
</Text>
) : null}
</Flex>
<UnorderedList hidden={isMissingSigningInfo}>
<ListItem> <ListItem>
{t('controller.wifi.vendor')}: {restrictions.key_info?.vendor} {t('controller.wifi.vendor')}:{' '}
{restrictions.key_info?.vendor?.length > 0 ? restrictions.key_info?.vendor : '-'}
</ListItem> </ListItem>
<ListItem> <ListItem>
{t('restrictions.algo')}: {restrictions.key_info?.algo} {t('restrictions.algo')}: {restrictions.key_info?.algo?.length > 0 ? restrictions.key_info?.algo : '-'}
</ListItem> </ListItem>
</UnorderedList> </UnorderedList>
<Flex mt={2}> <Flex mt={2}>

View File

@@ -9,10 +9,12 @@ import {
Title, Title,
Tooltip, Tooltip,
Legend, Legend,
Filler,
ChartData,
} from 'chart.js'; } from 'chart.js';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler);
const getDivisionFactor = (maxBytes: number) => { const getDivisionFactor = (maxBytes: number) => {
if (maxBytes < 1024) { if (maxBytes < 1024) {
@@ -42,24 +44,30 @@ const InterfaceChart = ({ data }: Props) => {
const { factor, unit } = getDivisionFactor(data.maxTx); const { factor, unit } = getDivisionFactor(data.maxTx);
const points = { const points: ChartData<'line', string[], string> = {
labels: data.recorded.map((recorded) => new Date(recorded * 1000).toLocaleTimeString()), labels: data.recorded.map((recorded) => new Date(recorded * 1000).toLocaleTimeString()),
datasets: [ datasets: [
{ {
// Real 'Tx', but shown as 'Rx' // Real 'Tx', but shown as 'Rx'
label: 'Tx', label: 'Tx',
data: data.rx.map((tx) => Math.floor((tx / factor) * 100) / 100), data: data.rx.map((tx) => (Math.floor((tx / factor) * 100) / 100).toFixed(2)),
borderColor: colorMode === 'light' ? '#63B3ED' : '#BEE3F8', // blue-300 - blue-100 borderColor: colorMode === 'light' ? 'rgba(99, 179, 237, 1)' : 'rgba(190, 227, 248, 1)', // blue-300 - blue-100
backgroundColor: colorMode === 'light' ? '#63B3ED' : '#BEE3F8', // blue-300 - blue-100 backgroundColor: colorMode === 'light' ? 'rgba(99, 179, 237, 0.3)' : 'rgba(190, 227, 248, 0.3)', // blue-300 - blue-100
}, tension: 0.5,
{ pointRadius: 0,
// Real 'Rx', but shown as 'Tx' fill: 'start',
label: 'Rx', },
data: data.tx.map((rx) => Math.floor((rx / factor) * 100) / 100), {
borderColor: colorMode === 'light' ? '#48BB78' : '#9AE6B4', // green-400 - green-200 // Real 'Rx', but shown as 'Tx'
backgroundColor: colorMode === 'light' ? '#48BB78' : '#9AE6B4', // green-400 - green-200 label: 'Rx',
}, data: data.tx.map((rx) => (Math.floor((rx / factor) * 100) / 100).toFixed(2)),
], borderColor: colorMode === 'light' ? 'rgba(72, 187, 120, 1)' : 'rgba(154, 230, 180, 1)', // green-400 - green-200
backgroundColor: colorMode === 'light' ? 'rgba(72, 187, 120, 0.3)' : 'rgba(154, 230, 180, 0.3)', // green-400 - green-200
tension: 0.5,
pointRadius: 0,
fill: 'start',
},
],
}; };
return ( return (

View File

@@ -34,20 +34,29 @@ const DeviceMemoryChart = ({ data }: Props) => {
{ {
label: 'Free', label: 'Free',
data: data.free.map((free) => Math.floor(free / 1024 / 1024)), data: data.free.map((free) => Math.floor(free / 1024 / 1024)),
borderColor: colorMode === 'light' ? '#63B3ED' : '#BEE3F8', // blue-300 - blue-100 borderColor: colorMode === 'light' ? 'rgb(99, 179, 237, 1)' : 'rgb(190, 227, 248, 1)', // blue-300 - blue-100
backgroundColor: colorMode === 'light' ? '#63B3ED' : '#BEE3F8', // blue-300 - blue-100 backgroundColor: colorMode === 'light' ? 'rgb(99, 179, 237, 0.3)' : 'rgb(190, 227, 248, 0.3)', // blue-300 - blue-100
}, tension: 0.5,
{ pointRadius: 0,
label: 'Buffered', fill: '+1',
data: data.buffered.map((buffered) => Math.floor(buffered / 1024 / 1024)),
borderColor: colorMode === 'light' ? '#ECC94B' : '#FAF089', // yellow-400 - yellow-200
backgroundColor: colorMode === 'light' ? '#ECC94B' : '#FAF089', // yellow-400 - yellow-200
}, },
{ {
label: 'Cached', label: 'Cached',
data: data.cached.map((cached) => Math.floor(cached / 1024 / 1024)), data: data.cached.map((cached) => Math.floor(cached / 1024 / 1024)),
borderColor: colorMode === 'light' ? '#ED64A6' : '#FBB6CE', // pink-400 - pink-200 borderColor: colorMode === 'light' ? 'rgb(237, 100, 166, 1)' : 'rgb(251, 182, 206, 1)', // pink-400 - pink-200
backgroundColor: colorMode === 'light' ? '#ED64A6' : '#FBB6CE', // pink-400 - pink-200 backgroundColor: colorMode === 'light' ? 'rgb(237, 100, 166, 0.3)' : 'rgb(251, 182, 206, 0.3)', // pink-400 - pink-200
tension: 0.5,
pointRadius: 0,
fill: '+1',
},
{
label: 'Buffered',
data: data.buffered.map((buffered) => Math.floor(buffered / 1024 / 1024)),
borderColor: colorMode === 'light' ? 'rgb(255, 240, 31, 1)' : 'rgb(250, 240, 137, 1)', // yellow-400 - yellow-200
backgroundColor: colorMode === 'light' ? 'rgb(255, 240, 31, 0.3)' : 'rgb(250, 240, 137, 0.3)', // yellow-400 - yellow-200
tension: 0.5,
pointRadius: 0,
fill: 'origin',
}, },
], ],
}; };

View File

@@ -38,7 +38,7 @@ const ViewLastStatsModal = ({ serialNumber }: Props) => {
if (getLastStats.data) { if (getLastStats.data) {
setValue(JSON.stringify(getLastStats.data, null, 2)); setValue(JSON.stringify(getLastStats.data, null, 2));
} }
}, [getLastStats.data]); }, [getLastStats.data, isOpen]);
return ( return (
<> <>
<Tooltip label={t('statistics.last_stats')}> <Tooltip label={t('statistics.last_stats')}>

View File

@@ -52,8 +52,6 @@ const DeviceStatisticsCard = ({ serialNumber }: Props) => {
<Heading size="md">{t('configurations.statistics')}</Heading> <Heading size="md">{t('configurations.statistics')}</Heading>
<Spacer /> <Spacer />
<HStack> <HStack>
<ViewLastStatsModal serialNumber={serialNumber} />
<StatisticsCardDatePickers defaults={time} setTime={setNewTime} onClear={onClear} />
<Select value={selected} onChange={onSelectInterface}> <Select value={selected} onChange={onSelectInterface}>
{parsedData?.interfaces {parsedData?.interfaces
? Object.keys(parsedData.interfaces).map((v) => ( ? Object.keys(parsedData.interfaces).map((v) => (
@@ -64,6 +62,8 @@ const DeviceStatisticsCard = ({ serialNumber }: Props) => {
: null} : null}
<option value="memory">{t('statistics.memory')}</option> <option value="memory">{t('statistics.memory')}</option>
</Select> </Select>
<StatisticsCardDatePickers defaults={time} setTime={setNewTime} onClear={onClear} />
<ViewLastStatsModal serialNumber={serialNumber} />
<RefreshButton <RefreshButton
size="sm" size="sm"
onClick={refresh} onClick={refresh}

View File

@@ -16,6 +16,7 @@ type Props = {
export const useStatisticsCard = ({ serialNumber }: Props) => { export const useStatisticsCard = ({ serialNumber }: Props) => {
const [selected, setSelected] = React.useState('memory'); const [selected, setSelected] = React.useState('memory');
const [progress, setProgress] = React.useState(0); const [progress, setProgress] = React.useState(0);
const [hasSelectedNew, setHasSelectedNew] = React.useState(false);
const [time, setTime] = React.useState<{ start: Date; end: Date } | undefined>(); const [time, setTime] = React.useState<{ start: Date; end: Date } | undefined>();
const onProgressChange = React.useCallback((newProgress: number) => { const onProgressChange = React.useCallback((newProgress: number) => {
setProgress(newProgress); setProgress(newProgress);
@@ -29,13 +30,17 @@ export const useStatisticsCard = ({ serialNumber }: Props) => {
}); });
const onSelectInterface = (event: React.ChangeEvent<HTMLSelectElement>) => { const onSelectInterface = (event: React.ChangeEvent<HTMLSelectElement>) => {
setHasSelectedNew(true);
setSelected(event.target.value); setSelected(event.target.value);
}; };
const parsedData = React.useMemo(() => { const parsedData = React.useMemo(() => {
if (!getStats.data && !getCustomStats.data) return undefined; if (!getStats.data && !getCustomStats.data) return undefined;
const data: Record<string, { tx: number[]; rx: number[]; recorded: number[]; maxRx: number; maxTx: number }> = {}; const data: Record<
string,
{ tx: number[]; rx: number[]; recorded: number[]; maxRx: number; maxTx: number; removed?: boolean }
> = {};
const memoryData = { const memoryData = {
used: [] as number[], used: [] as number[],
buffered: [] as number[], buffered: [] as number[],
@@ -56,7 +61,7 @@ export const useStatisticsCard = ({ serialNumber }: Props) => {
if (index === 0) { if (index === 0) {
let updated = false; let updated = false;
for (const inter of stat.data.interfaces ?? []) { for (const inter of stat.data.interfaces ?? []) {
if (!updated && selected === 'memory') { if (!hasSelectedNew && !updated && selected === 'memory') {
updated = true; updated = true;
setSelected(inter.name); setSelected(inter.name);
} }
@@ -77,7 +82,10 @@ export const useStatisticsCard = ({ serialNumber }: Props) => {
let rx = inter.counters?.rx_bytes ?? 0; let rx = inter.counters?.rx_bytes ?? 0;
let tx = inter.counters?.tx_bytes ?? 0; let tx = inter.counters?.tx_bytes ?? 0;
if (isInterUpstream) { if (inter['counters-aggregate']) {
rx = inter['counters-aggregate'].rx_bytes;
tx = inter['counters-aggregate'].tx_bytes;
} else if (isInterUpstream) {
for (const ssid of inter.ssids ?? []) { for (const ssid of inter.ssids ?? []) {
rx += ssid.counters?.rx_bytes ?? 0; rx += ssid.counters?.rx_bytes ?? 0;
tx += ssid.counters?.tx_bytes ?? 0; tx += ssid.counters?.tx_bytes ?? 0;
@@ -97,6 +105,18 @@ export const useStatisticsCard = ({ serialNumber }: Props) => {
maxRx: rxDelta, maxRx: rxDelta,
}; };
else { else {
if (data[inter.name] && !data[inter.name]?.removed && data[inter.name]?.recorded.length === 1) {
data[inter.name]?.tx.shift();
data[inter.name]?.rx.shift();
data[inter.name]?.recorded.shift();
// @ts-ignore
data[inter.name].maxRx = rxDelta;
// @ts-ignore
data[inter.name].maxTx = txDelta;
// @ts-ignore
data[inter.name].removed = true;
}
data[inter.name]?.rx.push(rxDelta); data[inter.name]?.rx.push(rxDelta);
data[inter.name]?.tx.push(txDelta); data[inter.name]?.tx.push(txDelta);
data[inter.name]?.recorded.push(stat.recorded); data[inter.name]?.recorded.push(stat.recorded);

View File

@@ -1,7 +1,8 @@
import * as React from 'react'; import * as React from 'react';
import { Flex, Grid, GridItem, Heading, Image, Tag } from '@chakra-ui/react'; import { Box, Flex, Grid, GridItem, Heading, Image, Tag } from '@chakra-ui/react';
import ReactCountryFlag from 'react-country-flag'; import ReactCountryFlag from 'react-country-flag';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import LocationDisplayButton from './LocationDisplayButton';
import { Card } from 'components/Containers/Card'; import { Card } from 'components/Containers/Card';
import { CardBody } from 'components/Containers/Card/CardBody'; import { CardBody } from 'components/Containers/Card/CardBody';
import FormattedDate from 'components/InformationDisplays/FormattedDate'; import FormattedDate from 'components/InformationDisplays/FormattedDate';
@@ -90,11 +91,12 @@ const DeviceSummary = ({ serialNumber }: Props) => {
{!getDevice.data?.locale || getDevice.data?.locale === '' ? ( {!getDevice.data?.locale || getDevice.data?.locale === '' ? (
'-' '-'
) : ( ) : (
<> <Box mr={2}>
<ReactCountryFlag style={ICON_STYLE} countryCode={getDevice.data.locale} svg /> <ReactCountryFlag style={ICON_STYLE} countryCode={getDevice.data.locale} svg />
{COUNTRY_LIST.find(({ value }) => value === getDevice.data.locale)?.label} {COUNTRY_LIST.find(({ value }) => value === getDevice.data.locale)?.label}
</> </Box>
)} )}
<LocationDisplayButton serialNumber={serialNumber} />
</GridItem> </GridItem>
<GridItem colSpan={1} alignContent="center" alignItems="center"> <GridItem colSpan={1} alignContent="center" alignItems="center">
<Heading size="sm">{t('analytics.last_contact')}:</Heading> <Heading size="sm">{t('analytics.last_contact')}:</Heading>

View File

@@ -9,12 +9,15 @@ import {
Button, Button,
Center, Center,
Heading, Heading,
IconButton,
Spinner, Spinner,
Tooltip,
useClipboard, useClipboard,
useColorMode, useColorMode,
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { JsonViewer } from '@textea/json-viewer'; import { JsonViewer } from '@textea/json-viewer';
import { ListDashes } from 'phosphor-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RefreshButton } from 'components/Buttons/RefreshButton'; import { RefreshButton } from 'components/Buttons/RefreshButton';
import FormattedDate from 'components/InformationDisplays/FormattedDate'; import FormattedDate from 'components/InformationDisplays/FormattedDate';
@@ -43,9 +46,15 @@ const ViewCapabilitiesModal = ({ serialNumber }: Props) => {
return ( return (
<> <>
<Button onClick={onOpen} colorScheme="pink" mr={2}> <Tooltip label={t('controller.devices.capabilities')} hasArrow>
{t('controller.devices.capabilities')} <IconButton
</Button> aria-label={t('controller.devices.capabilities')}
icon={<ListDashes size={20} />}
onClick={onOpen}
colorScheme="pink"
mr={2}
/>
</Tooltip>
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
title={t('controller.devices.capabilities')} title={t('controller.devices.capabilities')}

View File

@@ -7,11 +7,14 @@ import {
AccordionPanel, AccordionPanel,
Box, Box,
Button, Button,
IconButton,
Tooltip,
useClipboard, useClipboard,
useColorMode, useColorMode,
useDisclosure, useDisclosure,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { JsonViewer } from '@textea/json-viewer'; import { JsonViewer } from '@textea/json-viewer';
import { Barcode } from 'phosphor-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Modal } from 'components/Modals/Modal'; import { Modal } from 'components/Modals/Modal';
import { DeviceConfiguration } from 'models/Device'; import { DeviceConfiguration } from 'models/Device';
@@ -30,9 +33,14 @@ const ViewConfigurationModal = ({ configuration }: { configuration?: DeviceConfi
return ( return (
<> <>
<Button onClick={onOpen} isDisabled={!configuration} colorScheme="purple"> <Tooltip label={t('configurations.one')} hasArrow>
{t('configurations.one')} <IconButton
</Button> aria-label={t('configurations.one')}
icon={<Barcode size={20} />}
onClick={onOpen}
colorScheme="purple"
/>
</Tooltip>
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
title={t('configurations.one')} title={t('configurations.one')}

View File

@@ -1,6 +1,13 @@
import * as React from 'react'; import * as React from 'react';
import { import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Box, Box,
Button,
Heading, Heading,
HStack, HStack,
Portal, Portal,
@@ -12,10 +19,13 @@ import {
useBreakpoint, useBreakpoint,
useColorModeValue, useColorModeValue,
useDisclosure, useDisclosure,
useToast,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { Heart, HeartBreak, LockSimple, WifiHigh, WifiSlash } from 'phosphor-react'; import axios from 'axios';
import { Heart, HeartBreak, LockSimple, LockSimpleOpen, WifiHigh, WifiSlash } from 'phosphor-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import Masonry from 'react-masonry-css'; import Masonry from 'react-masonry-css';
import { useNavigate } from 'react-router-dom';
import DeviceDetails from './Details'; import DeviceDetails from './Details';
import DeviceLogsCard from './LogsCard'; import DeviceLogsCard from './LogsCard';
import DeviceNotes from './Notes'; import DeviceNotes from './Notes';
@@ -23,6 +33,7 @@ import RestrictionsCard from './RestrictionsCard';
import DeviceStatisticsCard from './StatisticsCard'; import DeviceStatisticsCard from './StatisticsCard';
import DeviceSummary from './Summary'; import DeviceSummary from './Summary';
import WifiAnalysisCard from './WifiAnalysis'; import WifiAnalysisCard from './WifiAnalysis';
import { DeleteButton } from 'components/Buttons/DeleteButton';
import DeviceActionDropdown from 'components/Buttons/DeviceActionDropdown'; import DeviceActionDropdown from 'components/Buttons/DeviceActionDropdown';
import { RefreshButton } from 'components/Buttons/RefreshButton'; import { RefreshButton } from 'components/Buttons/RefreshButton';
import { Card } from 'components/Containers/Card'; import { Card } from 'components/Containers/Card';
@@ -33,11 +44,12 @@ import { ConfigureModal } from 'components/Modals/ConfigureModal';
import { EventQueueModal } from 'components/Modals/EventQueueModal'; import { EventQueueModal } from 'components/Modals/EventQueueModal';
import FactoryResetModal from 'components/Modals/FactoryResetModal'; import FactoryResetModal from 'components/Modals/FactoryResetModal';
import { FirmwareUpgradeModal } from 'components/Modals/FirmwareUpgradeModal'; import { FirmwareUpgradeModal } from 'components/Modals/FirmwareUpgradeModal';
import { RebootModal } from 'components/Modals/RebootModal';
import { useScriptModal } from 'components/Modals/ScriptModal/useScriptModal'; import { useScriptModal } from 'components/Modals/ScriptModal/useScriptModal';
import { TelemetryModal } from 'components/Modals/TelemetryModal'; import { TelemetryModal } from 'components/Modals/TelemetryModal';
import { TraceModal } from 'components/Modals/TraceModal'; import { TraceModal } from 'components/Modals/TraceModal';
import { WifiScanModal } from 'components/Modals/WifiScanModal'; import { WifiScanModal } from 'components/Modals/WifiScanModal';
import { useGetDevice, useGetDeviceHealthChecks, useGetDeviceStatus } from 'hooks/Network/Devices'; import { useDeleteDevice, useGetDevice, useGetDeviceHealthChecks, useGetDeviceStatus } from 'hooks/Network/Devices';
type Props = { type Props = {
serialNumber: string; serialNumber: string;
@@ -45,10 +57,17 @@ type Props = {
const DevicePageWrapper = ({ serialNumber }: Props) => { const DevicePageWrapper = ({ serialNumber }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast();
const breakpoint = useBreakpoint(); const breakpoint = useBreakpoint();
const cancelRef = React.useRef(null);
const navigate = useNavigate();
const { mutateAsync: deleteDevice, isLoading: isDeleting } = useDeleteDevice({
serialNumber,
});
const getDevice = useGetDevice({ serialNumber }); const getDevice = useGetDevice({ serialNumber });
const getStatus = useGetDeviceStatus({ serialNumber }); const getStatus = useGetDeviceStatus({ serialNumber });
const getHealth = useGetDeviceHealthChecks({ serialNumber, limit: 1 }); const getHealth = useGetDeviceHealthChecks({ serialNumber, limit: 1 });
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
const scanModalProps = useDisclosure(); const scanModalProps = useDisclosure();
const resetModalProps = useDisclosure(); const resetModalProps = useDisclosure();
const eventQueueProps = useDisclosure(); const eventQueueProps = useDisclosure();
@@ -56,7 +75,40 @@ const DevicePageWrapper = ({ serialNumber }: Props) => {
const upgradeModalProps = useDisclosure(); const upgradeModalProps = useDisclosure();
const telemetryModalProps = useDisclosure(); const telemetryModalProps = useDisclosure();
const traceModalProps = useDisclosure(); const traceModalProps = useDisclosure();
const rebootModalProps = useDisclosure();
const scriptModal = useScriptModal(); const scriptModal = useScriptModal();
// Sticky-top styles
const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md';
const boxShadow = useColorModeValue('0px 7px 23px rgba(0, 0, 0, 0.05)', 'none');
const handleDeleteClick = () =>
deleteDevice(serialNumber, {
onSuccess: () => {
toast({
id: `delete-device-success-${serialNumber}`,
title: t('common.success'),
status: 'success',
duration: 5000,
isClosable: true,
position: 'top-right',
});
navigate('/devices');
},
onError: (e) => {
if (axios.isAxiosError(e)) {
toast({
id: `delete-device-error-${serialNumber}`,
title: t('common.error'),
description: e.response?.data?.ErrorDescription,
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
}
},
});
const connectedTag = React.useMemo(() => { const connectedTag = React.useMemo(() => {
if (!getStatus.data) return null; if (!getStatus.data) return null;
@@ -100,9 +152,28 @@ const DevicePageWrapper = ({ serialNumber }: Props) => {
); );
}, [getStatus.data, getHealth.data]); }, [getStatus.data, getHealth.data]);
// Sticky-top styles const restrictedTag = React.useMemo(() => {
const isCompact = breakpoint === 'base' || breakpoint === 'sm' || breakpoint === 'md'; if (!getDevice.data || !getDevice.data.restrictedDevice) return null;
const boxShadow = useColorModeValue('0px 7px 23px rgba(0, 0, 0, 0.05)', 'none');
if (getDevice.data.restrictionDetails?.developer)
return (
<Tooltip label={t('devices.restricted_overriden')} hasArrow>
<Tag size="lg" colorScheme="green">
<TagLeftIcon boxSize="18px" as={LockSimpleOpen} />
<TagLabel>
{t('devices.restricted')} {isCompact ? '' : '(Dev Mode)'}
</TagLabel>
</Tag>
</Tooltip>
);
return (
<Tag size="lg" colorScheme="red">
<TagLeftIcon boxSize="18px" as={LockSimple} />
<TagLabel>{t('devices.restricted')}</TagLabel>
</Tag>
);
}, [getDevice.data, isCompact]);
const refresh = () => { const refresh = () => {
getDevice.refetch(); getDevice.refetch();
@@ -119,16 +190,12 @@ const DevicePageWrapper = ({ serialNumber }: Props) => {
<Heading size="md">{serialNumber}</Heading> <Heading size="md">{serialNumber}</Heading>
{connectedTag} {connectedTag}
{healthTag} {healthTag}
{getDevice.data?.restrictedDevice && ( {restrictedTag}
<Tag size="lg" colorScheme="gray">
<TagLeftIcon boxSize="18px" as={LockSimple} />
<TagLabel>{t('devices.restricted')}</TagLabel>
</Tag>
)}
</HStack> </HStack>
<Spacer /> <Spacer />
<HStack spacing={2}> <HStack spacing={2}>
{breakpoint !== 'base' && breakpoint !== 'md' && <DeviceSearchBar />} {breakpoint !== 'base' && breakpoint !== 'md' && <DeviceSearchBar />}
<DeleteButton isCompact onClick={onDeleteOpen} />
{getDevice?.data && ( {getDevice?.data && (
<DeviceActionDropdown <DeviceActionDropdown
// @ts-ignore // @ts-ignore
@@ -142,6 +209,7 @@ const DevicePageWrapper = ({ serialNumber }: Props) => {
onOpenConfigureModal={configureModalProps.onOpen} onOpenConfigureModal={configureModalProps.onOpen}
onOpenTelemetryModal={telemetryModalProps.onOpen} onOpenTelemetryModal={telemetryModalProps.onOpen}
onOpenScriptModal={scriptModal.openModal} onOpenScriptModal={scriptModal.openModal}
onOpenRebootModal={rebootModalProps.onOpen}
size="md" size="md"
isCompact isCompact
/> />
@@ -172,16 +240,12 @@ const DevicePageWrapper = ({ serialNumber }: Props) => {
<Heading size="md">{serialNumber}</Heading> <Heading size="md">{serialNumber}</Heading>
{connectedTag} {connectedTag}
{healthTag} {healthTag}
{getDevice.data?.restrictedDevice && ( {restrictedTag}
<Tag size="lg" colorScheme="gray">
<TagLeftIcon boxSize="18px" as={LockSimple} />
<TagLabel>{t('devices.restricted')}</TagLabel>
</Tag>
)}
</HStack> </HStack>
<Spacer /> <Spacer />
<HStack spacing={2}> <HStack spacing={2}>
<DeviceSearchBar /> <DeviceSearchBar />
<DeleteButton isCompact onClick={onDeleteOpen} />
{getDevice?.data && ( {getDevice?.data && (
<DeviceActionDropdown <DeviceActionDropdown
// @ts-ignore // @ts-ignore
@@ -194,8 +258,10 @@ const DevicePageWrapper = ({ serialNumber }: Props) => {
onOpenEventQueue={eventQueueProps.onOpen} onOpenEventQueue={eventQueueProps.onOpen}
onOpenConfigureModal={configureModalProps.onOpen} onOpenConfigureModal={configureModalProps.onOpen}
onOpenTelemetryModal={telemetryModalProps.onOpen} onOpenTelemetryModal={telemetryModalProps.onOpen}
onOpenRebootModal={rebootModalProps.onOpen}
onOpenScriptModal={scriptModal.openModal} onOpenScriptModal={scriptModal.openModal}
size="md" size="md"
isCompact
/> />
)} )}
<RefreshButton <RefreshButton
@@ -210,6 +276,24 @@ const DevicePageWrapper = ({ serialNumber }: Props) => {
</Card> </Card>
</Portal> </Portal>
)} )}
<AlertDialog isOpen={isDeleteOpen} leastDestructiveRef={cancelRef} onClose={onDeleteClose}>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{t('crud.delete')} {serialNumber}
</AlertDialogHeader>
<AlertDialogBody>{t('crud.delete_confirm', { obj: t('devices.one') })}</AlertDialogBody>
<AlertDialogFooter>
<Button colorScheme="gray" mr="1" onClick={onDeleteClose} ref={cancelRef}>
{t('common.cancel')}
</Button>
<Button colorScheme="red" ml="1" onClick={handleDeleteClick} isLoading={isDeleting}>
{t('common.yes')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
<WifiScanModal modalProps={scanModalProps} serialNumber={serialNumber} /> <WifiScanModal modalProps={scanModalProps} serialNumber={serialNumber} />
<FirmwareUpgradeModal modalProps={upgradeModalProps} serialNumber={serialNumber} /> <FirmwareUpgradeModal modalProps={upgradeModalProps} serialNumber={serialNumber} />
<FactoryResetModal modalProps={resetModalProps} serialNumber={serialNumber} /> <FactoryResetModal modalProps={resetModalProps} serialNumber={serialNumber} />
@@ -217,6 +301,7 @@ const DevicePageWrapper = ({ serialNumber }: Props) => {
<EventQueueModal serialNumber={serialNumber} modalProps={eventQueueProps} /> <EventQueueModal serialNumber={serialNumber} modalProps={eventQueueProps} />
<ConfigureModal serialNumber={serialNumber} modalProps={configureModalProps} /> <ConfigureModal serialNumber={serialNumber} modalProps={configureModalProps} />
<TelemetryModal serialNumber={serialNumber} modalProps={telemetryModalProps} /> <TelemetryModal serialNumber={serialNumber} modalProps={telemetryModalProps} />
<RebootModal serialNumber={serialNumber} modalProps={rebootModalProps} />
{scriptModal.modal} {scriptModal.modal}
<Box mt={isCompact ? '0px' : '68px'}> <Box mt={isCompact ? '0px' : '68px'}>
<Masonry <Masonry

View File

@@ -1,27 +1,16 @@
import * as React from 'react'; import * as React from 'react';
import { import { FormControl, FormErrorMessage, FormLabel, Input, Textarea, useDisclosure, useToast } from '@chakra-ui/react';
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Box,
FormControl,
FormErrorMessage,
FormLabel,
Input,
Textarea,
useDisclosure,
useToast,
} from '@chakra-ui/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CreateButton } from 'components/Buttons/CreateButton'; import { CreateButton } from 'components/Buttons/CreateButton';
import { SaveButton } from 'components/Buttons/SaveButton'; import { SaveButton } from 'components/Buttons/SaveButton';
import { Modal } from 'components/Modals/Modal'; import { Modal } from 'components/Modals/Modal';
import { useCreateBlacklist } from 'hooks/Network/Blacklist'; import { useCreateBlacklist } from 'hooks/Network/Blacklist';
import { AxiosError } from 'models/Axios';
const CreateBlacklistModal = () => { const CreateBlacklistModal = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const initialRef = React.useRef<HTMLInputElement>(null);
const modalProps = useDisclosure(); const modalProps = useDisclosure();
const createDevice = useCreateBlacklist(); const createDevice = useCreateBlacklist();
const [serialNumber, setSerialNumber] = React.useState<string>(''); const [serialNumber, setSerialNumber] = React.useState<string>('');
@@ -43,41 +32,50 @@ const CreateBlacklistModal = () => {
}); });
modalProps.onClose(); modalProps.onClose();
}, },
onError: (e) => {
toast({
id: 'add-blacklist-error',
title: t('common.error'),
description: (e as AxiosError)?.response?.data?.ErrorDescription,
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
}, },
); );
}; };
const isSerialValid = serialNumber.length === 12 && serialNumber.match('^[a-fA-F0-9]+$') !== null; const isSerialValid = serialNumber.length === 12 && serialNumber.match('^[a-fA-F0-9]+$') !== null;
React.useEffect(() => { const onOpen = () => {
setSerialNumber(''); setSerialNumber('');
setReason(''); setReason('');
}, [modalProps.isOpen]); modalProps.onOpen();
setTimeout(() => {
initialRef.current?.focus();
}, 200);
};
return ( return (
<> <>
<CreateButton onClick={modalProps.onOpen} isCompact ml={2} /> <CreateButton onClick={onOpen} isCompact ml={2} />
<Modal <Modal
{...modalProps} {...modalProps}
title={t('controller.devices.add_blacklist')} title={t('controller.devices.add_blacklist')}
topRightButtons={<SaveButton onClick={onSave} isLoading={createDevice.isLoading} isCompact />} topRightButtons={<SaveButton onClick={onSave} isLoading={createDevice.isLoading} isCompact />}
> >
<> <>
{createDevice.error && (
<Alert status="error" mb={4}>
<AlertIcon />
<Box>
<AlertTitle>{t('common.error')}</AlertTitle>
{
// @ts-ignore
<AlertDescription>{createDevice.error?.response?.data?.ErrorDescription}</AlertDescription>
}
</Box>
</Alert>
)}
<FormControl isInvalid={!isSerialValid} mb={2}> <FormControl isInvalid={!isSerialValid} mb={2}>
<FormLabel>{t('inventory.serial_number')}</FormLabel> <FormLabel>{t('inventory.serial_number')}</FormLabel>
<Input type="text" onChange={(e) => setSerialNumber(e.target.value)} value={serialNumber} w="140px" /> <Input
type="text"
onChange={(e) => setSerialNumber(e.target.value)}
value={serialNumber}
w="140px"
ref={initialRef}
/>
<FormErrorMessage>{t('inventory.invalid_serial_number')}</FormErrorMessage> <FormErrorMessage>{t('inventory.invalid_serial_number')}</FormErrorMessage>
</FormControl> </FormControl>
<FormControl> <FormControl>

View File

@@ -1,5 +1,22 @@
import * as React from 'react'; import * as React from 'react';
import { useColorMode } from '@chakra-ui/react'; import { CopyIcon } from '@chakra-ui/icons';
import {
useColorMode,
Alert,
AlertDescription,
AlertTitle,
Box,
Center,
Heading,
IconButton,
Link,
ListItem,
Spinner,
UnorderedList,
useClipboard,
Tooltip as ChakraTooltip,
useDisclosure,
} from '@chakra-ui/react';
import { import {
Chart as ChartJS, Chart as ChartJS,
CategoryScale, CategoryScale,
@@ -11,20 +28,57 @@ import {
Legend, Legend,
ChartData, ChartData,
ArcElement, ArcElement,
ChartTypeRegistry,
ScatterDataPoint,
BubbleDataPoint,
} from 'chart.js'; } from 'chart.js';
import { Pie } from 'react-chartjs-2'; import { Pie, getElementAtEvent } from 'react-chartjs-2';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import GraphStatDisplay from 'components/Containers/GraphStatDisplay'; import GraphStatDisplay from 'components/Containers/GraphStatDisplay';
import { Modal } from 'components/Modals/Modal';
import { ControllerDashboardHealth } from 'hooks/Network/Controller'; import { ControllerDashboardHealth } from 'hooks/Network/Controller';
import { useGetDevicesWithHealthBetween } from 'hooks/Network/HealthChecks';
import { AxiosError } from 'models/Axios';
const LABEL_TO_LIMITS = {
'100%': { lowerLimit: 100, upperLimit: 100, label: 'With 100% Health' },
'>90%': { lowerLimit: 90, upperLimit: 99, label: 'Between 90% and 99%' },
'>60%': { lowerLimit: 60, upperLimit: 89, label: 'Between 60% and 89%' },
'<=60%': { lowerLimit: 0, upperLimit: 59, label: 'Between 0% and 59%' },
} as const;
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, ArcElement); ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, ArcElement);
type Props = { type Props = {
data: ControllerDashboardHealth[]; data: ControllerDashboardHealth[];
}; };
const OverallHealthPieChart = ({ data }: Props) => { const OverallHealthPieChart = ({ data }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const { hasCopied, onCopy, setValue } = useClipboard('');
const modalProps = useDisclosure();
const [deviceCategory, setDeviceCategory] = React.useState<{ lowerLimit: number; upperLimit: number; label: string }>(
LABEL_TO_LIMITS['100%'],
);
const serialNumbersFromCategory = useGetDevicesWithHealthBetween(deviceCategory);
const chartRef =
React.useRef<ChartJS<keyof ChartTypeRegistry, (number | ScatterDataPoint | BubbleDataPoint | null)[], unknown>>(
null,
);
const onClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
if (chartRef.current) {
const element = getElementAtEvent(chartRef.current, event)?.[0];
if (element && element.index !== undefined) {
const label = chartRef.current?.data?.labels?.[element.index] as keyof typeof LABEL_TO_LIMITS | undefined;
if (label && LABEL_TO_LIMITS[label]) {
setDeviceCategory(LABEL_TO_LIMITS[label]);
modalProps.onOpen();
}
}
}
};
const parsedData: ChartData<'pie', number[], unknown> = React.useMemo(() => { const parsedData: ChartData<'pie', number[], unknown> = React.useMemo(() => {
const totalDevices = data.reduce( const totalDevices = data.reduce(
@@ -85,7 +139,7 @@ const OverallHealthPieChart = ({ data }: Props) => {
} }
if (totalDevices['<60%'] > 0) { if (totalDevices['<60%'] > 0) {
newData.push(totalDevices['<60%']); newData.push(totalDevices['<60%']);
labels.push('<60%'); labels.push('<=60%');
const color = colorMode === 'light' ? '#FC8181' : '#FC8181'; const color = colorMode === 'light' ? '#FC8181' : '#FC8181';
backgroundColor.push(color); backgroundColor.push(color);
borderColor.push(color); borderColor.push(color);
@@ -105,38 +159,108 @@ const OverallHealthPieChart = ({ data }: Props) => {
}; };
}, [data, colorMode]); }, [data, colorMode]);
React.useEffect(() => {
if (serialNumbersFromCategory.data) setValue(serialNumbersFromCategory.data.join(','));
}, [serialNumbersFromCategory.data]);
return ( return (
<GraphStatDisplay <>
title={t('controller.dashboard.overall_health')} <GraphStatDisplay
explanation={t('controller.dashboard.overall_health_explanation_pie')} title={t('controller.dashboard.overall_health')}
chart={ explanation={t('controller.dashboard.overall_health_explanation_pie')}
<Pie chart={
data={parsedData} <Pie
options={{ // @ts-ignore
plugins: { ref={chartRef}
legend: { data={parsedData}
position: 'top' as const, onClick={onClick}
labels: { options={{
color: colorMode === 'dark' ? 'white' : undefined, onHover: (e, elements) => {
const element = e.native?.target as unknown as { style: { cursor: string } };
if (element && elements.length > 0) {
element.style.cursor = 'pointer';
} else if (element) {
element.style.cursor = 'default';
}
},
plugins: {
legend: {
position: 'top' as const,
labels: {
color: colorMode === 'dark' ? 'white' : undefined,
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
label: (context) =>
`${context.label}: ${context.formattedValue} (${Math.round(
// @ts-ignore
(context.raw / context.dataset.data.reduce((acc, curr) => acc + curr, 0)) * 100,
)}%)`,
},
}, },
}, },
title: { }}
display: false, />
}, }
tooltip: { />
callbacks: { <Modal
label: (context) => title={t('controller.dashboard.overall_health')}
`${context.label}: ${context.formattedValue} (${Math.round( {...modalProps}
// @ts-ignore options={{
(context.raw / context.dataset.data.reduce((acc, curr) => acc + curr, 0)) * 100, modalSize: 'sm',
)}%)`, }}
}, topRightButtons={
}, <ChakraTooltip label={hasCopied ? `${t('common.copied')}!` : t('common.copy')} hasArrow closeOnClick={false}>
}, <IconButton
}} aria-label={t('common.copy')}
/> icon={<CopyIcon h={5} w={5} />}
} onClick={onCopy}
/> colorScheme="teal"
hidden={!serialNumbersFromCategory.data || serialNumbersFromCategory.data.length === 0}
/>
</ChakraTooltip>
}
>
{serialNumbersFromCategory.isFetching ? (
<Center my={8}>
<Spinner size="xl" />
</Center>
) : (
<Box>
{serialNumbersFromCategory.error ? (
<Alert mb={4} status="error">
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>
{(serialNumbersFromCategory.error as AxiosError).response?.data.ErrorDescription}
</AlertDescription>
</Alert>
) : null}
{serialNumbersFromCategory.data ? (
<Box>
<Heading size="md" mb={4}>
{serialNumbersFromCategory.data.length} {t('devices.title')} {deviceCategory.label}
</Heading>
<Box maxH="70vh" overflowY="auto" overflowX="hidden">
<UnorderedList pl={2}>
{serialNumbersFromCategory.data
.sort((a, b) => a.localeCompare(b))
.map((device) => (
<ListItem key={device} fontFamily="mono">
<Link href={`#/devices/${device}`}>{device}</Link>
</ListItem>
))}
</UnorderedList>
</Box>
</Box>
) : null}
</Box>
)}
</Modal>
</>
); );
}; };

View File

@@ -34,6 +34,7 @@ interface Props {
onOpenConfigureModal: (serialNumber: string) => void; onOpenConfigureModal: (serialNumber: string) => void;
onOpenTelemetryModal: (serialNumber: string) => void; onOpenTelemetryModal: (serialNumber: string) => void;
onOpenScriptModal: (device: GatewayDevice) => void; onOpenScriptModal: (device: GatewayDevice) => void;
onOpenRebootModal: (serialNumber: string) => void;
} }
const Actions: React.FC<Props> = ({ const Actions: React.FC<Props> = ({
@@ -47,6 +48,7 @@ const Actions: React.FC<Props> = ({
onOpenConfigureModal, onOpenConfigureModal,
onOpenTelemetryModal, onOpenTelemetryModal,
onOpenScriptModal, onOpenScriptModal,
onOpenRebootModal,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
@@ -102,6 +104,7 @@ const Actions: React.FC<Props> = ({
onOpenConfigureModal={onOpenConfigureModal} onOpenConfigureModal={onOpenConfigureModal}
onOpenTelemetryModal={onOpenTelemetryModal} onOpenTelemetryModal={onOpenTelemetryModal}
onOpenScriptModal={onOpenScriptModal} onOpenScriptModal={onOpenScriptModal}
onOpenRebootModal={onOpenRebootModal}
/> />
<Tooltip hasArrow label={t('common.view_details')} placement="top"> <Tooltip hasArrow label={t('common.view_details')} placement="top">
<Link href={`#/devices/${device.serialNumber}`}> <Link href={`#/devices/${device.serialNumber}`}>

View File

@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { Box, Button, Heading, Image, Spacer, Tooltip, useDisclosure } from '@chakra-ui/react'; import { Box, Heading, Image, Link, Spacer, Tooltip, useDisclosure } from '@chakra-ui/react';
import { LockSimple } from 'phosphor-react'; import { LockSimple } from 'phosphor-react';
import ReactCountryFlag from 'react-country-flag'; import ReactCountryFlag from 'react-country-flag';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -21,6 +21,7 @@ import { ConfigureModal } from 'components/Modals/ConfigureModal';
import { EventQueueModal } from 'components/Modals/EventQueueModal'; import { EventQueueModal } from 'components/Modals/EventQueueModal';
import FactoryResetModal from 'components/Modals/FactoryResetModal'; import FactoryResetModal from 'components/Modals/FactoryResetModal';
import { FirmwareUpgradeModal } from 'components/Modals/FirmwareUpgradeModal'; import { FirmwareUpgradeModal } from 'components/Modals/FirmwareUpgradeModal';
import { RebootModal } from 'components/Modals/RebootModal';
import { useScriptModal } from 'components/Modals/ScriptModal/useScriptModal'; import { useScriptModal } from 'components/Modals/ScriptModal/useScriptModal';
import { TelemetryModal } from 'components/Modals/TelemetryModal'; import { TelemetryModal } from 'components/Modals/TelemetryModal';
import { TraceModal } from 'components/Modals/TraceModal'; import { TraceModal } from 'components/Modals/TraceModal';
@@ -60,6 +61,7 @@ const DeviceListCard = () => {
const eventQueueProps = useDisclosure(); const eventQueueProps = useDisclosure();
const telemetryModalProps = useDisclosure(); const telemetryModalProps = useDisclosure();
const configureModalProps = useDisclosure(); const configureModalProps = useDisclosure();
const rebootModalProps = useDisclosure();
const scriptModal = useScriptModal(); const scriptModal = useScriptModal();
const getCount = useGetDeviceCount({ enabled: true }); const getCount = useGetDeviceCount({ enabled: true });
const getDevices = useGetDevices({ const getDevices = useGetDevices({
@@ -98,9 +100,9 @@ const DeviceListCard = () => {
setSerialNumber(serial); setSerialNumber(serial);
configureModalProps.onOpen(); configureModalProps.onOpen();
}; };
const onOpenReboot = (serial: string) => {
const goToSerial = (serial: string) => () => { setSerialNumber(serial);
navigate(`/devices/${serial}`); rebootModalProps.onOpen();
}; };
const badgeCell = React.useCallback( const badgeCell = React.useCallback(
@@ -160,9 +162,9 @@ const DeviceListCard = () => {
const serialCell = React.useCallback( const serialCell = React.useCallback(
(device: DeviceWithStatus) => ( (device: DeviceWithStatus) => (
<Button variant="link" onClick={goToSerial(device.serialNumber)} fontSize="sm"> <Link href={`#/devices/${device.serialNumber}`} fontSize="sm" my="auto" pt={1}>
<pre>{device.serialNumber}</pre> <pre>{device.serialNumber}</pre>
</Button> </Link>
), ),
[], [],
); );
@@ -216,6 +218,7 @@ const DeviceListCard = () => {
onOpenConfigureModal={onOpenConfigure} onOpenConfigureModal={onOpenConfigure}
onOpenTelemetryModal={onOpenTelemetry} onOpenTelemetryModal={onOpenTelemetry}
onOpenScriptModal={scriptModal.openModal} onOpenScriptModal={scriptModal.openModal}
onOpenRebootModal={onOpenReboot}
/> />
), ),
[], [],
@@ -251,6 +254,7 @@ const DeviceListCard = () => {
Footer: '', Footer: '',
accessor: 'firmware', accessor: 'firmware',
Cell: (v) => firmwareCell(v.cell.row.original), Cell: (v) => firmwareCell(v.cell.row.original),
stopPropagation: true,
customWidth: '50px', customWidth: '50px',
disableSortBy: true, disableSortBy: true,
}, },
@@ -388,7 +392,7 @@ const DeviceListCard = () => {
</CardHeader> </CardHeader>
<CardBody p={4}> <CardBody p={4}>
<Box overflowX="auto" w="100%"> <Box overflowX="auto" w="100%">
<DataTable <DataTable<DeviceWithStatus>
columns={ columns={
columns.filter(({ id }) => !hiddenColumns.find((hidden) => hidden === id)) as { columns.filter(({ id }) => !hiddenColumns.find((hidden) => hidden === id)) as {
id: string; id: string;
@@ -406,7 +410,8 @@ const DeviceListCard = () => {
// @ts-ignore // @ts-ignore
setPageInfo={setPageInfo} setPageInfo={setPageInfo}
saveSettingsId="gateway.devices.table" saveSettingsId="gateway.devices.table"
minHeight="600px" onRowClick={(device) => navigate(`devices/${device.serialNumber}`)}
isRowClickable={() => true}
/> />
</Box> </Box>
</CardBody> </CardBody>
@@ -417,6 +422,7 @@ const DeviceListCard = () => {
<EventQueueModal modalProps={eventQueueProps} serialNumber={serialNumber} /> <EventQueueModal modalProps={eventQueueProps} serialNumber={serialNumber} />
<ConfigureModal modalProps={configureModalProps} serialNumber={serialNumber} /> <ConfigureModal modalProps={configureModalProps} serialNumber={serialNumber} />
<TelemetryModal modalProps={telemetryModalProps} serialNumber={serialNumber} /> <TelemetryModal modalProps={telemetryModalProps} serialNumber={serialNumber} />
<RebootModal modalProps={rebootModalProps} serialNumber={serialNumber} />
{scriptModal.modal} {scriptModal.modal}
</> </>
); );

View File

@@ -191,6 +191,7 @@ const FirmwareDetailsModal = ({ modalProps, firmware }: Props) => {
ml={2} ml={2}
/> />
{isEditingDescription && ( {isEditingDescription && (
// @ts-ignore
<SaveButton onClick={onSaveDescription} ml={2} isCompact size="sm" isLoading={updateFirmware.isLoading} /> <SaveButton onClick={onSaveDescription} ml={2} isCompact size="sm" isLoading={updateFirmware.isLoading} />
)} )}
</FormLabel> </FormLabel>
@@ -202,48 +203,51 @@ const FirmwareDetailsModal = ({ modalProps, firmware }: Props) => {
isDisabled={!isEditingDescription} isDisabled={!isEditingDescription}
/> />
</FormControl> </FormControl>
<FormControl>
<FormLabel>
{t('common.notes')}{' '}
<Popover trigger="click" placement="auto">
{({ onClose }) => (
<>
<PopoverTrigger>
<IconButton
aria-label={`${t('crud.add')} ${t('common.note')}`}
size="sm"
icon={<Plus size={20} />}
/>
</PopoverTrigger>
<PopoverContent w={breakpoint === 'base' ? 'calc(80vw)' : '500px'}>
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
<PopoverHeader display="flex">{t('profile.add_new_note')}</PopoverHeader>
<PopoverBody>
<Box>
<Textarea h="100px" placeholder="Your new note" value={newNote} onChange={onNoteChange} />
</Box>
<Center mt={2}>
<Button
colorScheme="blue"
isDisabled={newNote.length === 0}
onClick={onNoteSubmit(onClose)}
isLoading={updateFirmware.isLoading}
>
{t('crud.add')}
</Button>
</Center>
</PopoverBody>
</PopoverContent>
</>
)}
</Popover>
</FormLabel>
<Box overflowX="auto" overflowY="auto" maxH="400px">
<DataTable columns={columns as Column<object>[]} data={notes} obj={t('common.notes')} minHeight="200px" />
</Box>
</FormControl>
</SimpleGrid> </SimpleGrid>
<FormControl mt={2}>
<FormLabel>
{t('common.notes')}{' '}
<Popover trigger="click" placement="auto">
{({ onClose }) => (
<>
<PopoverTrigger>
<IconButton aria-label={`${t('crud.add')} ${t('common.note')}`} size="sm" icon={<Plus size={20} />} />
</PopoverTrigger>
<PopoverContent w={breakpoint === 'base' ? 'calc(80vw)' : '500px'}>
<PopoverArrow />
<PopoverCloseButton alignContent="center" mt={1} />
<PopoverHeader display="flex">{t('profile.add_new_note')}</PopoverHeader>
<PopoverBody>
<Box>
<Textarea h="100px" placeholder="Your new note" value={newNote} onChange={onNoteChange} />
</Box>
<Center mt={2}>
<Button
colorScheme="blue"
isDisabled={newNote.length === 0}
onClick={onNoteSubmit(onClose)}
isLoading={updateFirmware.isLoading}
>
{t('crud.add')}
</Button>
</Center>
</PopoverBody>
</PopoverContent>
</>
)}
</Popover>
</FormLabel>
</FormControl>
<Box overflowX="auto" overflowY="auto" maxH="400px" mb={4}>
<DataTable
columns={columns as Column<object>[]}
data={notes}
obj={t('common.notes')}
minHeight="200px"
showAllRows
hideControls
/>
</Box>
</Modal> </Modal>
); );
}; };

View File

@@ -0,0 +1,108 @@
import * as React from 'react';
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Box,
Button,
Center,
IconButton,
Tag,
TagLabel,
Text,
Tooltip,
useDisclosure,
useToast,
} from '@chakra-ui/react';
import axios from 'axios';
import { Database } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import FormattedDate from 'components/InformationDisplays/FormattedDate';
import { Modal } from 'components/Modals/Modal';
import { useGetFirmwareDbUpdate, useUpdateFirmwareDb } from 'hooks/Network/Firmware';
const UpdateDbButton = () => {
const { t } = useTranslation();
const toast = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
const updateDb = useUpdateFirmwareDb();
const getLastUpdate = useGetFirmwareDbUpdate();
const onUpdateClick = async () => {
updateDb.mutate(undefined, {
onSuccess: () => {
toast({
id: `firmware-db-update-success`,
title: t('common.success'),
description: t('firmware.started_db_update'),
status: 'success',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
onError: (e) => {
if (axios.isAxiosError(e)) {
toast({
id: `firmware-db-update-error`,
title: t('common.error'),
description: e?.response?.data?.ErrorDescription,
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
}
},
});
};
return (
<>
<Tooltip label={t('firmware.last_db_update_title')}>
<IconButton
aria-label={t('firmware.last_db_update_title')}
colorScheme="teal"
icon={<Database size={20} />}
onClick={onOpen}
/>
</Tooltip>
<Modal
isOpen={isOpen}
onClose={onClose}
title={t('firmware.last_db_update_modal')}
tags={
<Tag colorScheme="blue" size="lg">
<TagLabel display="flex">
<Text mr={1}>Last Update:</Text>
<FormattedDate date={getLastUpdate.data?.lastUpdateTime} />
</TagLabel>
</Tag>
}
>
<Box>
<Alert status="warning">
<AlertIcon />
<Box>
<AlertTitle>{t('common.warning')}</AlertTitle>
<AlertDescription>{t('firmware.db_update_warning')}</AlertDescription>
</Box>
</Alert>
<Center my={4}>
<Button
colorScheme="red"
leftIcon={<Database size={20} />}
onClick={onUpdateClick}
isLoading={updateDb.isLoading}
>
{t('firmware.start_db_update')}
</Button>
</Center>
</Box>
</Modal>
</>
);
};
export default UpdateDbButton;

View File

@@ -11,7 +11,15 @@ const UriCell = ({ uri }: Props) => {
return ( return (
<Box display="flex"> <Box display="flex">
<Button onClick={copy.onCopy} size="xs" colorScheme="teal" mr={2}> <Button
onClick={(e) => {
copy.onCopy();
e.stopPropagation();
}}
size="xs"
colorScheme="teal"
mr={2}
>
{copy.hasCopied ? `${t('common.copied')}!` : t('common.copy')} {copy.hasCopied ? `${t('common.copied')}!` : t('common.copy')}
</Button> </Button>
<Text my="auto">{uri}</Text> <Text my="auto">{uri}</Text>

View File

@@ -16,6 +16,7 @@ import { MagnifyingGlass } from 'phosphor-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import FirmwareDetailsModal from './Modal'; import FirmwareDetailsModal from './Modal';
import UpdateDbButton from './UpdateDbButton';
import UriCell from './UriCell'; import UriCell from './UriCell';
import { RefreshButton } from 'components/Buttons/RefreshButton'; import { RefreshButton } from 'components/Buttons/RefreshButton';
import { CardBody } from 'components/Containers/Card/CardBody'; import { CardBody } from 'components/Containers/Card/CardBody';
@@ -143,6 +144,7 @@ const FirmwareListTable = () => {
</Box> </Box>
<Text>{t('controller.firmware.show_dev_releases')}</Text> <Text>{t('controller.firmware.show_dev_releases')}</Text>
<Switch isChecked={showDevFirmware} onChange={toggle} size="lg" /> <Switch isChecked={showDevFirmware} onChange={toggle} size="lg" />
<UpdateDbButton />
<RefreshButton <RefreshButton
onClick={() => { onClick={() => {
getDeviceTypes.refetch(); getDeviceTypes.refetch();
@@ -156,13 +158,14 @@ const FirmwareListTable = () => {
<CardBody p={4}> <CardBody p={4}>
<Box overflowX="auto" w="100%"> <Box overflowX="auto" w="100%">
<LoadingOverlay isLoading={getDeviceTypes.isFetching || getFirmware.isFetching}> <LoadingOverlay isLoading={getDeviceTypes.isFetching || getFirmware.isFetching}>
<DataTable <DataTable<Firmware>
columns={columns as Column<object>[]} columns={columns}
saveSettingsId="firmware.table" saveSettingsId="firmware.table"
data={getFirmware.data?.filter((firmw) => showDevFirmware || !firmw.revision.includes('devel')) ?? []} data={getFirmware.data?.filter((firmw) => showDevFirmware || !firmw.revision.includes('devel')) ?? []}
obj={t('analytics.firmware')} obj={t('analytics.firmware')}
minHeight="200px" minHeight="200px"
sortBy={[{ id: 'imageDate', desc: true }]} sortBy={[{ id: 'imageDate', desc: true }]}
onRowClick={(firmw) => handleViewDetailsClick(firmw)()}
/> />
</LoadingOverlay> </LoadingOverlay>
</Box> </Box>

View File

@@ -48,7 +48,10 @@ const _LoginForm: React.FC<_LoginFormProps> = ({ setActiveForm }) => {
const displayError = useMemo(() => { const displayError = useMemo(() => {
const loginError: AxiosError = error as AxiosError; const loginError: AxiosError = error as AxiosError;
if (loginError?.response?.data?.ErrorCode === 4) return t('login.waiting_for_email_verification'); if (loginError?.response?.data?.ErrorCode === 5) return t('login.waiting_for_email_verification');
if (loginError?.response?.data?.ErrorCode === 15) {
return t('login.suspended_error');
}
return t('login.invalid_credentials'); return t('login.invalid_credentials');
}, [t, error]); }, [t, error]);

View File

@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react'; import { Box, Flex, HStack, IconButton, Select, Spacer, Table, Text, Th, Thead, Tooltip, Tr } from '@chakra-ui/react';
import { Download } from 'phosphor-react'; import { Download } from 'phosphor-react';
import { CSVLink } from 'react-csv'; import { CSVLink } from 'react-csv';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -127,9 +127,9 @@ const LogsCard = () => {
filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`} filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={downloadableLogs as object[]} data={downloadableLogs as object[]}
> >
<Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}> <Tooltip label={t('logs.export')} hasArrow>
{t('logs.export')} <IconButton aria-label={t('logs.export')} icon={<Download />} colorScheme="blue" />
</Button> </Tooltip>
</CSVLink> </CSVLink>
</HStack> </HStack>
</CardHeader> </CardHeader>

View File

@@ -1,5 +1,19 @@
import * as React from 'react'; import * as React from 'react';
import { Badge, Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react'; import {
Badge,
Box,
Flex,
HStack,
IconButton,
Select,
Spacer,
Table,
Text,
Th,
Thead,
Tooltip,
Tr,
} from '@chakra-ui/react';
import { Download } from 'phosphor-react'; import { Download } from 'phosphor-react';
import { CSVLink } from 'react-csv'; import { CSVLink } from 'react-csv';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -128,9 +142,9 @@ const FmsLogsCard = () => {
filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`} filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={downloadableLogs as object[]} data={downloadableLogs as object[]}
> >
<Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}> <Tooltip label={t('logs.export')} hasArrow>
{t('logs.export')} <IconButton aria-label={t('logs.export')} icon={<Download />} colorScheme="blue" />
</Button> </Tooltip>
</CSVLink> </CSVLink>
</HStack> </HStack>
</CardHeader> </CardHeader>

View File

@@ -1,5 +1,19 @@
import * as React from 'react'; import * as React from 'react';
import { Badge, Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react'; import {
Badge,
Box,
Flex,
HStack,
IconButton,
Select,
Spacer,
Table,
Text,
Th,
Thead,
Tooltip,
Tr,
} from '@chakra-ui/react';
import { Download } from 'phosphor-react'; import { Download } from 'phosphor-react';
import { CSVLink } from 'react-csv'; import { CSVLink } from 'react-csv';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -128,9 +142,9 @@ const GeneralLogsCard = () => {
filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`} filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={downloadableLogs as object[]} data={downloadableLogs as object[]}
> >
<Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}> <Tooltip label={t('logs.export')} hasArrow>
{t('logs.export')} <IconButton aria-label={t('logs.export')} icon={<Download />} colorScheme="blue" />
</Button> </Tooltip>
</CSVLink> </CSVLink>
</HStack> </HStack>
</CardHeader> </CardHeader>

View File

@@ -1,5 +1,19 @@
import * as React from 'react'; import * as React from 'react';
import { Badge, Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react'; import {
Badge,
Box,
Flex,
HStack,
IconButton,
Select,
Spacer,
Table,
Text,
Th,
Thead,
Tooltip,
Tr,
} from '@chakra-ui/react';
import { Download } from 'phosphor-react'; import { Download } from 'phosphor-react';
import { CSVLink } from 'react-csv'; import { CSVLink } from 'react-csv';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -128,9 +142,9 @@ const SecLogsCard = () => {
filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`} filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={downloadableLogs as object[]} data={downloadableLogs as object[]}
> >
<Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}> <Tooltip label={t('logs.export')} hasArrow>
{t('logs.export')} <IconButton aria-label={t('logs.export')} icon={<Download />} colorScheme="blue" />
</Button> </Tooltip>
</CSVLink> </CSVLink>
</HStack> </HStack>
</CardHeader> </CardHeader>

View File

@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { Box } from '@chakra-ui/react';
import ApiKeyTable from './Table'; import ApiKeyTable from './Table';
import { Card } from 'components/Containers/Card'; import { Card } from 'components/Containers/Card';
import { CardBody } from 'components/Containers/Card/CardBody'; import { CardBody } from 'components/Containers/Card/CardBody';
@@ -10,7 +11,9 @@ const ApiKeysCard = () => {
return ( return (
<Card p={4}> <Card p={4}>
<CardBody> <CardBody>
<ApiKeyTable userId={user?.id ?? ''} /> <Box w="100%">
<ApiKeyTable userId={user?.id ?? ''} />
</Box>
</CardBody> </CardBody>
</Card> </Card>
); );

View File

@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { Box, Button, Heading, HStack, Spacer } from '@chakra-ui/react'; import { Box, Button, Heading, HStack, Spacer } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import ScriptTableActions from './Actions'; import ScriptTableActions from './Actions';
import CreateScriptButton from './CreateButton'; import CreateScriptButton from './CreateButton';
import useScriptsTable from './useScriptsTable'; import useScriptsTable from './useScriptsTable';
@@ -21,6 +22,7 @@ type Props = {
const ScriptTableCard = ({ onIdSelect }: Props) => { const ScriptTableCard = ({ onIdSelect }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { query, hiddenColumns } = useScriptsTable(); const { query, hiddenColumns } = useScriptsTable();
const { id } = useParams();
const dateCell = React.useCallback((date: number) => <FormattedDate date={date} />, []); const dateCell = React.useCallback((date: number) => <FormattedDate date={date} />, []);
const actionCell = React.useCallback( const actionCell = React.useCallback(
@@ -108,8 +110,8 @@ const ScriptTableCard = ({ onIdSelect }: Props) => {
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<Box w="100%" h="300px" overflowY="auto"> <Box w="100%" h="300px" overflowY="auto">
<DataTable <DataTable<Script>
columns={columns as Column<object>[]} columns={columns}
saveSettingsId="apiKeys.profile.table" saveSettingsId="apiKeys.profile.table"
data={query.data ?? []} data={query.data ?? []}
obj={t('script.other')} obj={t('script.other')}
@@ -118,6 +120,8 @@ const ScriptTableCard = ({ onIdSelect }: Props) => {
hiddenColumns={hiddenColumns[0]} hiddenColumns={hiddenColumns[0]}
showAllRows showAllRows
hideControls hideControls
onRowClick={(script) => onIdSelect(script.id)}
isRowClickable={(script) => script.id !== id}
/> />
</Box> </Box>
</CardBody> </CardBody>

View File

@@ -0,0 +1,139 @@
import React from 'react';
import { CopyIcon } from '@chakra-ui/icons';
import {
IconButton,
Tooltip,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverFooter,
PopoverHeader,
PopoverTrigger,
Center,
Box,
Button,
useDisclosure,
HStack,
Text,
useClipboard,
} from '@chakra-ui/react';
import { Eye, Trash } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import EditSecretButton from './EditButton';
import { Secret, useDeleteSystemSecret } from 'hooks/Network/Secrets';
interface Props {
secret: Secret;
isDisabled?: boolean;
}
const SystemSecretActions = ({ secret, isDisabled }: Props) => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const deleteSecret = useDeleteSystemSecret();
const { hasCopied, onCopy } = useClipboard(secret.value);
const handleDeleteClick = React.useCallback(() => {
deleteSecret.mutate(secret.key, {
onSuccess: () => {
onClose();
},
});
}, []);
return (
<HStack mx="auto">
<Popover isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
<Tooltip hasArrow label={t('crud.delete')} placement="top" isDisabled={isOpen}>
<Box>
<PopoverTrigger>
<IconButton
aria-label="delete-device"
colorScheme="red"
icon={<Trash size={20} />}
size="sm"
isDisabled={isDisabled}
/>
</PopoverTrigger>
</Box>
</Tooltip>
<PopoverContent w="340px">
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>
{t('crud.delete')} {secret.key}
</PopoverHeader>
<PopoverBody>
<Text whiteSpace="break-spaces">{t('crud.delete_confirm', { obj: t('system.secrets_one') })}</Text>
</PopoverBody>
<PopoverFooter>
<Center>
<Button colorScheme="gray" mr="1" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button colorScheme="red" ml="1" onClick={handleDeleteClick} isLoading={deleteSecret.isLoading}>
{t('common.yes')}
</Button>
</Center>
</PopoverFooter>
</PopoverContent>
</Popover>
<Tooltip
label={hasCopied ? `${t('common.copied')}!` : `${t('common.copy')} ${t('system.secrets_one')}`}
hasArrow
closeOnClick={false}
>
<IconButton
aria-label={t('common.copy')}
icon={<CopyIcon h={5} w={5} />}
onClick={onCopy}
size="sm"
colorScheme="teal"
mr={2}
/>
</Tooltip>
<EditSecretButton secret={secret} />
<Popover>
<Tooltip label={`${t('common.view')} ${t('system.secrets_one')}`} hasArrow closeOnClick={false}>
<Box>
<PopoverTrigger>
<IconButton aria-label={t('common.view')} icon={<Eye size={20} />} size="sm" colorScheme="purple" />
</PopoverTrigger>
</Box>
</Tooltip>
<PopoverContent w="560px">
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>
{t('common.view')} {secret.key}
<Tooltip
label={hasCopied ? `${t('common.copied')}!` : `${t('common.copy')} ${t('system.secrets_one')}`}
hasArrow
closeOnClick={false}
>
<IconButton
aria-label={t('common.copy')}
icon={<CopyIcon h={4} w={4} />}
onClick={onCopy}
size="xs"
colorScheme="teal"
ml={2}
/>
</Tooltip>
</PopoverHeader>
<PopoverBody>
<Text whiteSpace="break-spaces">
<Center>
<pre style={{ fontFamily: 'monospace' }}>{secret.value}</pre>
</Center>
</Text>
</PopoverBody>
</PopoverContent>
</Popover>
</HStack>
);
};
export default SystemSecretActions;

View File

@@ -0,0 +1,118 @@
import * as React from 'react';
import {
Box,
FormControl,
FormErrorMessage,
FormLabel,
Input,
Textarea,
useDisclosure,
useToast,
} from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { CreateButton } from '../../../components/Buttons/CreateButton';
import { SaveButton } from '../../../components/Buttons/SaveButton';
import { Modal } from '../../../components/Modals/Modal';
import { useCreateSystemSecret } from 'hooks/Network/Secrets';
import { AxiosError } from 'models/Axios';
type FormValues = {
key: string;
value: string;
};
const DEFAULT_FORM_VALUES: FormValues = {
key: '',
value: '',
};
const SystemSecretCreateButton = () => {
const { t } = useTranslation();
const toast = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
const [form, setForm] = React.useState<FormValues>(DEFAULT_FORM_VALUES);
const [isNameChanged, setIsNameChanged] = React.useState(false);
const [isValueChanged, setIsValueChanged] = React.useState(false);
const create = useCreateSystemSecret();
const onKeyChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, key: e.target.value });
if (!isNameChanged) setIsNameChanged(true);
};
const onValueChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setForm({ ...form, value: e.target.value });
if (!isValueChanged) setIsValueChanged(true);
};
const isNameError = form.key.length === 0;
const isValueError = form.value.length === 0;
const onSubmit = () => {
create.mutate(form, {
onSuccess: () => {
toast({
id: 'create-system-secret-success',
title: t('common.success'),
description: t('crud.success_update_obj', {
obj: t('system.secrets_one'),
}),
status: 'success',
duration: 5000,
isClosable: true,
position: 'top-right',
});
onClose();
},
onError: (e) => {
toast({
id: 'create-system-secret-error',
title: t('common.error'),
description: (e as AxiosError)?.response?.data?.ErrorDescription,
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
});
};
const handleOpenClick = () => {
setIsNameChanged(false);
setIsValueChanged(false);
setForm(DEFAULT_FORM_VALUES);
onOpen();
};
return (
<>
<CreateButton onClick={handleOpenClick} isCompact />
<Modal
isOpen={isOpen}
onClose={onClose}
title={t('system.secrets_create')}
topRightButtons={
<SaveButton onClick={onSubmit} isDisabled={isNameError || isValueError} isLoading={create.isLoading} />
}
options={{
modalSize: 'sm',
}}
>
<Box>
<FormControl mb={2} isInvalid={isNameError && isNameChanged}>
<FormLabel>{t('common.name')}</FormLabel>
<Input value={form.key} onChange={onKeyChange} />
<FormErrorMessage>{t('form.required')}</FormErrorMessage>
</FormControl>
<FormControl mb={2} isInvalid={isValueError && isValueChanged}>
<FormLabel>{t('common.value')}</FormLabel>
<Textarea value={form.value} onChange={onValueChange} rows={2} />
<FormErrorMessage>{t('form.required')}</FormErrorMessage>
</FormControl>
</Box>
</Modal>
</>
);
};
export default SystemSecretCreateButton;

View File

@@ -0,0 +1,146 @@
import * as React from 'react';
import {
Box,
Button,
Center,
FormControl,
FormErrorMessage,
FormLabel,
IconButton,
Input,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverFooter,
PopoverHeader,
PopoverTrigger,
Text,
Textarea,
Tooltip,
useDisclosure,
useToast,
} from '@chakra-ui/react';
import { Pencil } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { Secret, useUpdateSystemSecret } from 'hooks/Network/Secrets';
import { AxiosError } from 'models/Axios';
type FormValues = {
key: string;
value: string;
};
type Props = {
secret: Secret;
};
const EditSecretButton = ({ secret }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
const [form, setForm] = React.useState<FormValues>({
key: secret.key,
value: secret.value,
});
const [isNameChanged, setIsNameChanged] = React.useState(false);
const [isValueChanged, setIsValueChanged] = React.useState(false);
const update = useUpdateSystemSecret();
const onKeyChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, key: e.target.value });
if (!isNameChanged) setIsNameChanged(true);
};
const onValueChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setForm({ ...form, value: e.target.value });
if (!isValueChanged) setIsValueChanged(true);
};
const isNameError = form.key.length === 0;
const isValueError = form.value.length === 0;
const onSubmit = () => {
update.mutate(form, {
onSuccess: () => {
toast({
id: 'create-system-secret-success',
title: t('common.success'),
status: 'success',
duration: 5000,
isClosable: true,
position: 'top-right',
});
onClose();
},
onError: (e) => {
toast({
id: 'create-system-secret-error',
title: t('common.error'),
description: (e as AxiosError)?.response?.data?.ErrorDescription,
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
});
};
const handleOpenClick = () => {
setIsNameChanged(false);
setIsValueChanged(false);
setForm({
key: secret.key,
value: secret.value,
});
onOpen();
};
return (
<Popover isOpen={isOpen} onOpen={handleOpenClick} onClose={onClose}>
<Tooltip hasArrow label={t('crud.edit')} placement="top" isDisabled={isOpen}>
<Box>
<PopoverTrigger>
<IconButton aria-label="delete-device" colorScheme="blue" icon={<Pencil size={20} />} size="sm" />
</PopoverTrigger>
</Box>
</Tooltip>
<PopoverContent w="340px">
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>
{t('crud.edit')} {secret.key}
</PopoverHeader>
<PopoverBody>
<Text whiteSpace="break-spaces">
<Box>
<FormControl mb={2} isInvalid={isNameError && isNameChanged}>
<FormLabel>{t('common.name')}</FormLabel>
<Input value={form.key} onChange={onKeyChange} />
<FormErrorMessage>{t('form.required')}</FormErrorMessage>
</FormControl>
<FormControl mb={2} isInvalid={isValueError && isValueChanged}>
<FormLabel>{t('common.value')}</FormLabel>
<Textarea value={form.value} onChange={onValueChange} rows={2} />
<FormErrorMessage>{t('form.required')}</FormErrorMessage>
</FormControl>
</Box>
</Text>
</PopoverBody>
<PopoverFooter>
<Center>
<Button colorScheme="gray" mr="1" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button colorScheme="blue" ml="1" onClick={onSubmit} isLoading={update.isLoading}>
{t('common.save')}
</Button>
</Center>
</PopoverFooter>
</PopoverContent>
</Popover>
);
};
export default EditSecretButton;

View File

@@ -0,0 +1,70 @@
import * as React from 'react';
import { Box } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { DataTable } from '../../../components/DataTables/DataTable';
import SystemSecretActions from './Actions';
import { Secret, useGetAllSystemSecrets, useGetSystemSecretsDictionary } from 'hooks/Network/Secrets';
import { Column } from 'models/Table';
const SystemSecretsTable = () => {
const { t } = useTranslation();
const getSecrets = useGetAllSystemSecrets();
const getDictionary = useGetSystemSecretsDictionary();
const descriptionCell = React.useCallback(
(secret: Secret) => {
if (!getDictionary.data) return '-';
return getDictionary.data.find((d) => d.key === secret.key)?.description ?? '-';
},
[getDictionary.data],
);
const actionCell = React.useCallback((secret: Secret) => <SystemSecretActions secret={secret} />, []);
const columns = React.useMemo(
(): Column<Secret>[] => [
{
id: 'key',
Header: t('common.name'),
Footer: '',
accessor: 'key',
alwaysShow: true,
customWidth: '120px',
},
{
id: 'description',
Header: t('common.description'),
Footer: '',
Cell: ({ cell }) => descriptionCell(cell.row.original),
accessor: 'description',
hasPopover: true,
},
{
id: 'actions',
Header: t('common.actions'),
Footer: '',
Cell: (v) => actionCell(v.cell.row.original),
disableSortBy: true,
customWidth: '120px',
alwaysShow: true,
},
],
[t, descriptionCell],
);
return (
<Box w="100%">
<DataTable
columns={columns as Column<object>[]}
saveSettingsId="system.secrets.table"
data={getSecrets.data ?? []}
obj={t('keys.other')}
sortBy={[{ id: 'key', desc: false }]}
showAllRows
hideControls
/>
</Box>
);
};
export default SystemSecretsTable;

View File

@@ -0,0 +1,53 @@
import * as React from 'react';
import {
BackgroundProps,
Box,
EffectProps,
Heading,
InteractivityProps,
LayoutProps,
PositionProps,
SpaceProps,
Spacer,
} from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import SystemSecretCreateButton from './CreateButton';
import SystemSecretsTable from './Table';
import { Card } from 'components/Containers/Card';
import { CardBody } from 'components/Containers/Card/CardBody';
import { CardHeader } from 'components/Containers/Card/CardHeader';
import { useAuth } from 'contexts/AuthProvider';
export interface SystemSecretsCardProps
extends LayoutProps,
SpaceProps,
BackgroundProps,
InteractivityProps,
PositionProps,
EffectProps {}
export const SystemSecretsCard = ({ ...props }: SystemSecretsCardProps) => {
const { t } = useTranslation();
const { user } = useAuth();
if (!user || user.userRole !== 'root') {
return null;
}
return (
<Box px={4} py={4}>
<Card variant="widget" {...props}>
<CardHeader>
<Heading size="md" my="auto">
{t('system.secrets')}
</Heading>
<Spacer />
<SystemSecretCreateButton />
</CardHeader>
<CardBody p={4}>
<SystemSecretsTable />
</CardBody>
</Card>
</Box>
);
};

View File

@@ -21,8 +21,8 @@ import axios from 'axios';
import { FloppyDisk } from 'phosphor-react'; import { FloppyDisk } from 'phosphor-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { Modal } from '../../../../components/Modals/Modal';
import { LoadingOverlay } from 'components/LoadingOverlay'; import { LoadingOverlay } from 'components/LoadingOverlay';
import { Modal } from 'components/Modals/Modal';
import { useGetSystemLogLevelNames, useGetSystemLogLevels, useUpdateSystemLogLevels } from 'hooks/Network/System'; import { useGetSystemLogLevelNames, useGetSystemLogLevels, useUpdateSystemLogLevels } from 'hooks/Network/System';
type Props = { type Props = {

View File

@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { Button, useDisclosure } from '@chakra-ui/react'; import { IconButton, Tooltip, useDisclosure } from '@chakra-ui/react';
import { Article } from 'phosphor-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SystemLoggingModal from './Modal'; import SystemLoggingModal from './Modal';
import { EndpointApiResponse } from 'hooks/Network/Endpoints'; import { EndpointApiResponse } from 'hooks/Network/Endpoints';
@@ -15,9 +16,17 @@ const SystemLoggingButton = ({ endpoint, token }: Props) => {
return ( return (
<> <>
<Button colorScheme="teal" onClick={modalProps.onOpen} mr={2} my="auto"> <Tooltip label={t('system.logging')} hasArrow>
{t('system.logging')} <IconButton
</Button> aria-label={t('system.logging')}
colorScheme="teal"
type="button"
my="auto"
onClick={modalProps.onOpen}
icon={<Article size={20} />}
mr={2}
/>
</Tooltip>
<SystemLoggingModal modalProps={modalProps} endpoint={endpoint.uri} token={token} /> <SystemLoggingModal modalProps={modalProps} endpoint={endpoint.uri} token={token} />
</> </>
); );

View File

@@ -1,6 +1,6 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { DataTable } from 'components/DataTables/DataTable'; import { DataTable } from '../../../components/DataTables/DataTable';
import { compactDate } from 'helpers/dateFormatting'; import { compactDate } from 'helpers/dateFormatting';
import { Column } from 'models/Table'; import { Column } from 'models/Table';

View File

@@ -19,11 +19,12 @@ import {
import { MultiValue, Select } from 'chakra-react-select'; import { MultiValue, Select } from 'chakra-react-select';
import { ArrowsClockwise } from 'phosphor-react'; import { ArrowsClockwise } from 'phosphor-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import FormattedDate from '../../../components/InformationDisplays/FormattedDate';
import SystemLoggingButton from './LoggingButton'; import SystemLoggingButton from './LoggingButton';
import SystemCertificatesTable from './SystemCertificatesTable'; import SystemCertificatesTable from './SystemCertificatesTable';
import { RefreshButton } from 'components/Buttons/RefreshButton';
import { Card } from 'components/Containers/Card'; import { Card } from 'components/Containers/Card';
import { CardBody } from 'components/Containers/Card/CardBody'; import { CardBody } from 'components/Containers/Card/CardBody';
import FormattedDate from 'components/InformationDisplays/FormattedDate';
import { compactSecondsToDetailed } from 'helpers/dateFormatting'; import { compactSecondsToDetailed } from 'helpers/dateFormatting';
import { EndpointApiResponse } from 'hooks/Network/Endpoints'; import { EndpointApiResponse } from 'hooks/Network/Endpoints';
import { useGetSubsystems, useGetSystemInfo, useReloadSubsystems } from 'hooks/Network/System'; import { useGetSubsystems, useGetSystemInfo, useReloadSubsystems } from 'hooks/Network/System';
@@ -65,21 +66,12 @@ const SystemTile = ({ endpoint, token }: Props) => {
return ( return (
<> <>
<Card> <Card variant="widget">
<Box display="flex" mb={2}> <Box display="flex" mb={2}>
<Heading pt={0}>{endpoint.type}</Heading> <Heading pt={0}>{endpoint.type}</Heading>
<Spacer /> <Spacer />
<SystemLoggingButton endpoint={endpoint} token={token} /> <SystemLoggingButton endpoint={endpoint} token={token} />
<Button <RefreshButton onClick={refresh} isFetching={isFetchingSystem || isFetchingSubsystems} />
mt={1}
minWidth="112px"
colorScheme="gray"
rightIcon={<ArrowsClockwise />}
onClick={refresh}
isLoading={isFetchingSystem || isFetchingSubsystems}
>
{t('common.refresh')}
</Button>
</Box> </Box>
<CardBody> <CardBody>
<VStack w="100%"> <VStack w="100%">
@@ -179,7 +171,7 @@ const SystemTile = ({ endpoint, token }: Props) => {
ml={2} ml={2}
onClick={handleReloadClick} onClick={handleReloadClick}
icon={<ArrowsClockwise size={20} />} icon={<ArrowsClockwise size={20} />}
colorScheme="gray" colorScheme="blue"
isLoading={isReloading} isLoading={isReloading}
isDisabled={subs.length === 0} isDisabled={subs.length === 0}
/> />

View File

@@ -1,27 +1,47 @@
import React from 'react'; import React from 'react';
import { Heading, SimpleGrid, Spacer } from '@chakra-ui/react'; import { Box, SimpleGrid, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { RefreshButton } from '../../components/Buttons/RefreshButton';
import { SystemSecretsCard } from './SystemSecrets';
import SystemTile from './SystemTile'; import SystemTile from './SystemTile';
import { RefreshButton } from 'components/Buttons/RefreshButton';
import { Card } from 'components/Containers/Card'; import { Card } from 'components/Containers/Card';
import { CardHeader } from 'components/Containers/Card/CardHeader'; import { CardHeader } from 'components/Containers/Card/CardHeader';
import { axiosSec } from 'constants/axiosInstances'; import { axiosSec } from 'constants/axiosInstances';
import { useAuth } from 'contexts/AuthProvider'; import { useAuth } from 'contexts/AuthProvider';
import { useGetEndpoints } from 'hooks/Network/Endpoints'; import { useGetEndpoints } from 'hooks/Network/Endpoints';
const SystemPage = () => { const getDefaultTabIndex = () => {
const index = localStorage.getItem('system-tab-index') || '0';
try {
return parseInt(index, 10);
} catch {
return 0;
}
};
type Props = {
isOnlySec?: boolean;
};
const SystemPage = ({ isOnlySec }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { token } = useAuth(); const { token, user } = useAuth();
const { data: endpoints, refetch, isFetching } = useGetEndpoints({ onSuccess: () => {} }); const { data: endpoints, refetch, isFetching } = useGetEndpoints({ onSuccess: () => {} });
const [tabIndex, setTabIndex] = React.useState(getDefaultTabIndex());
const handleTabChange = (index: number) => {
setTabIndex(index);
localStorage.setItem('system-tab-index', index.toString());
};
const isRoot = user && user.userRole === 'root';
const endpointsList = React.useMemo(() => { const endpointsList = React.useMemo(() => {
if (!endpoints || !token) return null; if (!token || (!isOnlySec && !endpoints)) return null;
const endpointList = [...endpoints]; const endpointList = endpoints ? [...endpoints] : [];
endpointList.push({ endpointList.push({
uri: axiosSec.defaults.baseURL?.split('/api/v1')[0] ?? '', uri: axiosSec.defaults.baseURL?.split('/api/v1')[0] ?? '',
type: 'owsec', type: isOnlySec ? '' : 'owsec',
id: 0, id: 0,
vendor: 'owsec', vendor: 'owsec',
authenticationType: '', authenticationType: '',
@@ -37,20 +57,51 @@ const SystemPage = () => {
}, [endpoints, token]); }, [endpoints, token]);
return ( return (
<> <Card p={0}>
<Card mb={4} py={2} px={4}> <Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
<CardHeader> <TabList>
<Heading size="md" my="auto"> <CardHeader>
{t('controller.firmware.endpoints')} <Tab>{t('system.services')}</Tab>
</Heading> <Tab hidden={!isRoot}>{t('system.configuration')}</Tab>
<Spacer /> </CardHeader>
<RefreshButton onClick={refetch} isFetching={isFetching} /> </TabList>
</CardHeader> <TabPanels>
</Card> <TabPanel p={0}>
<SimpleGrid minChildWidth="500px" spacing="20px" mb={3}> <Box
{endpointsList} borderLeft="1px solid"
</SimpleGrid> borderRight="1px solid"
</> borderBottom="1px solid"
borderColor="var(--chakra-colors-chakra-border-color)"
borderBottomLeftRadius="15px"
borderBottomRightRadius="15px"
>
{!isOnlySec && (
<CardHeader px={4} pt={4}>
<Spacer />
<RefreshButton onClick={refetch} isFetching={isFetching} />
</CardHeader>
)}
<SimpleGrid minChildWidth="500px" spacing="20px" p={4}>
{endpointsList}
</SimpleGrid>
</Box>
</TabPanel>
<TabPanel p={0}>
<Box
borderLeft="1px solid"
borderRight="1px solid"
borderBottom="1px solid"
borderColor="var(--chakra-colors-chakra-border-color)"
borderBottomLeftRadius="15px"
borderBottomRightRadius="15px"
>
<SystemSecretsCard />
</Box>
</TabPanel>
</TabPanels>
</Tabs>
</Card>
); );
}; };
export default SystemPage; export default SystemPage;

View File

@@ -12,9 +12,11 @@ interface Props {
isSuspended: boolean; isSuspended: boolean;
isWaitingForCheck: boolean; isWaitingForCheck: boolean;
refresh: () => void; refresh: () => void;
isDisabled?: boolean;
size?: 'sm' | 'md' | 'lg';
} }
const UserActions: React.FC<Props> = ({ id, isSuspended, isWaitingForCheck, refresh }) => { const UserActions = ({ id, isSuspended, isWaitingForCheck, refresh, size = 'sm', isDisabled }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const { mutateAsync: sendValidation } = useSendUserEmailValidation({ id, refresh }); const { mutateAsync: sendValidation } = useSendUserEmailValidation({ id, refresh });
@@ -75,10 +77,17 @@ const UserActions: React.FC<Props> = ({ id, isSuspended, isWaitingForCheck, refr
return ( return (
<Menu> <Menu>
<Tooltip label={t('commands.other')}> <Tooltip label={t('common.actions')}>
<MenuButton as={IconButton} aria-label="Commands" icon={<Wrench size={20} />} size="sm" ml={2} /> <MenuButton
as={IconButton}
aria-label="Commands"
icon={<Wrench size={20} />}
size={size}
ml={2}
isDisabled={isDisabled}
/>
</Tooltip> </Tooltip>
<MenuList> <MenuList fontSize="md">
<MenuItem onClick={handleSuspendClick}> <MenuItem onClick={handleSuspendClick}>
{isSuspended ? t('users.reactivate_user') : t('users.suspend')} {isSuspended ? t('users.reactivate_user') : t('users.suspend')}
</MenuItem> </MenuItem>

View File

@@ -1,11 +1,11 @@
import * as React from 'react'; import * as React from 'react';
import { AddIcon } from '@chakra-ui/icons'; import { useDisclosure } from '@chakra-ui/react';
import { Button, useDisclosure } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SaveButton } from '../../../../components/Buttons/SaveButton'; import { SaveButton } from '../../../../components/Buttons/SaveButton';
import { ConfirmCloseAlertModal } from '../../../../components/Modals/ConfirmCloseAlert'; import { ConfirmCloseAlertModal } from '../../../../components/Modals/ConfirmCloseAlert';
import { Modal } from '../../../../components/Modals/Modal'; import { Modal } from '../../../../components/Modals/Modal';
import CreateUserForm, { CreateUserFormValues } from './Form'; import CreateUserForm, { CreateUserFormValues } from './Form';
import { CreateButton } from 'components/Buttons/CreateButton';
import { useAuth } from 'contexts/AuthProvider'; import { useAuth } from 'contexts/AuthProvider';
import { useFormRef } from 'hooks/useFormRef'; import { useFormRef } from 'hooks/useFormRef';
@@ -25,16 +25,7 @@ const CreateUserModal = () => {
return ( return (
<> <>
<Button {user?.userRole === 'CSR' ? null : <CreateButton onClick={onOpen} ml={2} />}
hidden={user?.userRole === 'csr'}
alignItems="center"
colorScheme="blue"
rightIcon={<AddIcon />}
onClick={onOpen}
ml={2}
>
{t('crud.create')}
</Button>
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={closeModal} onClose={closeModal}

View File

@@ -62,7 +62,7 @@ const UpdateUserForm = ({ editing, isOpen, onClose, selectedUser, formRef }: Pro
useEffect(() => { useEffect(() => {
setFormKey(uuid()); setFormKey(uuid());
}, [isOpen]); }, [isOpen, editing]);
return ( return (
<Formik <Formik

View File

@@ -1,12 +1,14 @@
import * as React from 'react'; import * as React from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Spinner, Center, useDisclosure, useBoolean } from '@chakra-ui/react'; import { Spinner, Center, useDisclosure, useBoolean, Tag } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EditButton } from '../../../../components/Buttons/EditButton';
import { SaveButton } from '../../../../components/Buttons/SaveButton'; import { SaveButton } from '../../../../components/Buttons/SaveButton';
import { ConfirmCloseAlertModal } from '../../../../components/Modals/ConfirmCloseAlert'; import { ConfirmCloseAlertModal } from '../../../../components/Modals/ConfirmCloseAlert';
import { Modal } from '../../../../components/Modals/Modal'; import { Modal } from '../../../../components/Modals/Modal';
import ActionsDropdown from '../ActionsDropdown';
import UpdateUserForm from './Form'; import UpdateUserForm from './Form';
import { ToggleEditButton } from 'components/Buttons/ToggleEditButton';
import { useGetUser, User } from 'hooks/Network/Users'; import { useGetUser, User } from 'hooks/Network/Users';
import { useFormRef } from 'hooks/useFormRef'; import { useFormRef } from 'hooks/useFormRef';
@@ -19,10 +21,11 @@ type Props = {
const EditUserModal = ({ isOpen, onClose, userId }: Props) => { const EditUserModal = ({ isOpen, onClose, userId }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [editing, setEditing] = useBoolean(); const [editing, setEditing] = useBoolean();
const queryClient = useQueryClient();
const { isOpen: showConfirm, onOpen: openConfirm, onClose: closeConfirm } = useDisclosure(); const { isOpen: showConfirm, onOpen: openConfirm, onClose: closeConfirm } = useDisclosure();
const { form, formRef } = useFormRef<User>(); const { form, formRef } = useFormRef<User>();
const canFetchUser = userId !== '' && isOpen; const canFetchUser = userId !== '' && isOpen;
const { data: user, isFetching } = useGetUser({ id: userId ?? '', enabled: canFetchUser }); const { data: user, isFetching, refetch } = useGetUser({ id: userId ?? '', enabled: canFetchUser });
const closeModal = () => (form.dirty ? openConfirm() : onClose()); const closeModal = () => (form.dirty ? openConfirm() : onClose());
@@ -31,6 +34,11 @@ const EditUserModal = ({ isOpen, onClose, userId }: Props) => {
onClose(); onClose();
}; };
const refresh = () => {
refetch();
queryClient.invalidateQueries(['users']);
};
useEffect(() => { useEffect(() => {
if (isOpen) setEditing.off(); if (isOpen) setEditing.off();
}, [isOpen]); }, [isOpen]);
@@ -40,15 +48,40 @@ const EditUserModal = ({ isOpen, onClose, userId }: Props) => {
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={closeModal} onClose={closeModal}
title={t('crud.edit_obj', { obj: t('user.title') })} title={user?.name ?? t('crud.edit_obj', { obj: t('user.title') })}
tags={
<>
{user?.suspended ? (
<Tag colorScheme="yellow" size="lg">
{t('user.suspended')}
</Tag>
) : null}
{user?.waitingForEmailCheck ? (
<Tag colorScheme="blue" size="lg">
{t('user.email_not_validated')}
</Tag>
) : null}
</>
}
topRightButtons={ topRightButtons={
<> <>
<SaveButton <SaveButton
onClick={form.submitForm} onClick={form.submitForm}
isLoading={form.isSubmitting} isLoading={form.isSubmitting}
isDisabled={!editing || !form.isValid || !form.dirty} isDisabled={!editing || !form.isValid || !form.dirty}
hidden={!editing}
/> />
<EditButton ml={2} isDisabled={editing} onClick={setEditing.toggle} isCompact /> <ToggleEditButton ml={2} isEditing={editing} toggleEdit={setEditing.toggle} isDirty={form.dirty} />
{user ? (
<ActionsDropdown
id={user?.id}
isSuspended={user?.suspended}
isWaitingForCheck={user?.waitingForEmailCheck}
refresh={refresh}
size="md"
isDisabled={editing}
/>
) : null}
</> </>
} }
> >

View File

@@ -1,6 +1,5 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { Avatar, Box, Button, Flex, useDisclosure } from '@chakra-ui/react'; import { Avatar, Box, Flex, useDisclosure } from '@chakra-ui/react';
import { ArrowsClockwise } from 'phosphor-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { ColumnPicker } from '../../../components/DataTables/ColumnPicker'; import { ColumnPicker } from '../../../components/DataTables/ColumnPicker';
@@ -9,6 +8,7 @@ import FormattedDate from '../../../components/InformationDisplays/FormattedDate
import CreateUserModal from './CreateUserModal'; import CreateUserModal from './CreateUserModal';
import EditUserModal from './EditUserModal'; import EditUserModal from './EditUserModal';
import UserActions from './UserActions'; import UserActions from './UserActions';
import { RefreshButton } from 'components/Buttons/RefreshButton';
import { Card } from 'components/Containers/Card'; import { Card } from 'components/Containers/Card';
import { CardBody } from 'components/Containers/Card/CardBody'; import { CardBody } from 'components/Containers/Card/CardBody';
import { CardHeader } from 'components/Containers/Card/CardHeader'; import { CardHeader } from 'components/Containers/Card/CardHeader';
@@ -25,10 +25,10 @@ const UserTable = () => {
const { isOpen: editOpen, onOpen: openEdit, onClose: closeEdit } = useDisclosure(); const { isOpen: editOpen, onOpen: openEdit, onClose: closeEdit } = useDisclosure();
const { data: users, refetch: refreshUsers, isFetching } = useGetUsers(); const { data: users, refetch: refreshUsers, isFetching } = useGetUsers();
const openEditModal = (editUser: User) => { const openEditModal = React.useCallback((editUser: User) => {
setEditId(editUser.id); setEditId(editUser.id);
openEdit(); openEdit();
}; }, []);
const memoizedActions = useCallback( const memoizedActions = useCallback(
(userActions: User) => ( (userActions: User) => (
@@ -99,7 +99,7 @@ const UserTable = () => {
]; ];
if (user?.userRole !== 'csr') if (user?.userRole !== 'csr')
baseColumns.push({ baseColumns.push({
id: 'user', id: 'actions',
Header: t('common.actions'), Header: t('common.actions'),
Footer: '', Footer: '',
accessor: 'Id', accessor: 'Id',
@@ -125,28 +125,21 @@ const UserTable = () => {
preference="provisioning.userTable.hiddenColumns" preference="provisioning.userTable.hiddenColumns"
/> />
<CreateUserModal /> <CreateUserModal />
<Button <RefreshButton onClick={refreshUsers} isFetching={isFetching} ml={2} />
colorScheme="gray"
onClick={() => refreshUsers()}
rightIcon={<ArrowsClockwise />}
ml={2}
isLoading={isFetching}
>
{t('common.refresh')}
</Button>
</Box> </Box>
</Flex> </Flex>
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<Box overflowX="auto" w="100%"> <Box overflowX="auto" w="100%">
<DataTable <DataTable<User>
columns={columns as Column<object>[]} columns={columns}
data={users?.filter((curr) => curr.email !== user?.email) ?? []} data={users ?? []}
isLoading={isFetching} isLoading={isFetching}
obj={t('users.title')} obj={t('users.title')}
sortBy={[{ id: 'email', desc: false }]} sortBy={[{ id: 'email', desc: false }]}
hiddenColumns={hiddenColumns} hiddenColumns={hiddenColumns}
fullScreen fullScreen
onRowClick={openEditModal}
/> />
</Box> </Box>
</CardBody> </CardBody>

View File

@@ -1,4 +1,4 @@
import { extendTheme, type ThemeConfig } from '@chakra-ui/react'; import { extendTheme, Tooltip, type ThemeConfig } from '@chakra-ui/react';
import CardComponent from './additions/card/Card'; import CardComponent from './additions/card/Card';
import CardBodyComponent from './additions/card/CardBody'; import CardBodyComponent from './additions/card/CardBody';
import CardHeaderComponent from './additions/card/CardHeader'; import CardHeaderComponent from './additions/card/CardHeader';
@@ -37,4 +37,6 @@ const theme = extendTheme({
}, },
}); });
Tooltip.defaultProps = { ...Tooltip.defaultProps, hasArrow: true };
export default theme; export default theme;

View File

@@ -1,49 +1,9 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths'; import tsconfigPaths from 'vite-tsconfig-paths';
import { VitePWA } from 'vite-plugin-pwa';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [tsconfigPaths(), react()],
tsconfigPaths(),
react(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: true,
/* other options */
},
manifest: {
name: 'OpenWiFi Controller App',
short_name: 'OpenWiFiController',
description: 'OpenWiFi Controller App',
theme_color: '#000000',
icons: [
{
src: 'android-chrome-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'android-chrome-384x384.png',
sizes: '384x384',
type: 'image/png',
},
{
src: 'android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: 'android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable',
},
],
},
}),
],
build: { build: {
outDir: './build', outDir: './build',
chunkSizeWarningLimit: 1000, chunkSizeWarningLimit: 1000,