[WIFI-11565] Added logs page

Signed-off-by: Charles <charles.bourque96@gmail.com>
This commit is contained in:
Charles
2022-11-09 18:39:37 +00:00
parent 147f296436
commit 17f451986b
38 changed files with 2439 additions and 155 deletions

99
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "wlan-cloud-owprov-ui",
"version": "2.8.0(11)",
"version": "2.8.0(12)",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "wlan-cloud-owprov-ui",
"version": "2.8.0(11)",
"version": "2.8.0(12)",
"license": "ISC",
"dependencies": {
"@chakra-ui/icons": "^2.0.11",
@@ -51,6 +51,8 @@
"react-router-dom": "^6.4.2",
"react-table": "^7.8.0",
"react-tooltip": "^4.4.2",
"react-virtualized-auto-sizer": "^1.0.7",
"react-window": "^1.8.8",
"source-map-explorer": "^2.5.3",
"typescript": "^4.8.4",
"uuid": "^9.0.0",
@@ -64,6 +66,8 @@
"@types/react-csv": "^1.1.3",
"@types/react-dom": "^18.0.6",
"@types/react-table": "^7.7.12",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/uuid": "^8.3.4",
"@vitejs/plugin-react": "^2.1.0",
"eslint": "8.25.0",
@@ -4111,6 +4115,24 @@
"@types/react": "*"
}
},
"node_modules/@types/react-virtualized-auto-sizer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.1.tgz",
"integrity": "sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-window": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz",
"integrity": "sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/resize-observer-browser": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/@types/resize-observer-browser/-/resize-observer-browser-0.1.7.tgz",
@@ -9654,6 +9676,39 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/react-virtualized-auto-sizer": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.7.tgz",
"integrity": "sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA==",
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc",
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc"
}
},
"node_modules/react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ==",
"dependencies": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
},
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-window/node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"node_modules/recrawl-sync": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/recrawl-sync/-/recrawl-sync-2.2.3.tgz",
@@ -14580,6 +14635,24 @@
"@types/react": "*"
}
},
"@types/react-virtualized-auto-sizer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.1.tgz",
"integrity": "sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/react-window": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz",
"integrity": "sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/resize-observer-browser": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/@types/resize-observer-browser/-/resize-observer-browser-0.1.7.tgz",
@@ -18432,6 +18505,28 @@
"prop-types": "^15.6.2"
}
},
"react-virtualized-auto-sizer": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.7.tgz",
"integrity": "sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA==",
"requires": {}
},
"react-window": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz",
"integrity": "sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ==",
"requires": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
},
"dependencies": {
"memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
}
}
},
"recrawl-sync": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/recrawl-sync/-/recrawl-sync-2.2.3.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "wlan-cloud-owprov-ui",
"version": "2.8.0(11)",
"version": "2.8.0(12)",
"description": "",
"main": "index.tsx",
"scripts": {
@@ -56,6 +56,8 @@
"react-router-dom": "^6.4.2",
"react-table": "^7.8.0",
"react-tooltip": "^4.4.2",
"react-virtualized-auto-sizer": "^1.0.7",
"react-window": "^1.8.8",
"source-map-explorer": "^2.5.3",
"vite": "^3.1.8",
"typescript": "^4.8.4",
@@ -69,6 +71,8 @@
"@types/react-csv": "^1.1.3",
"@types/react-dom": "^18.0.6",
"@types/react-table": "^7.7.12",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/uuid": "^8.3.4",
"eslint": "8.25.0",
"vite-tsconfig-paths": "^3.5.1",

BIN
public/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

165
public/public/favicon.svg Normal file
View File

@@ -0,0 +1,165 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 141.5 185.6" style="enable-background:new 0 0 141.5 185.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#414141;}
.st1{fill:#FFFFFF;}
.st2{fill:#FED206;}
.st3{fill:#EB6F53;}
.st4{fill:#3BA9B6;}
</style>
<g>
<g>
<path class="st0" d="M120.7,183.9H21.5c-10.8,0-19.5-8.7-19.5-19.5V20.5c0-10.8,8.7-19.5,19.5-19.5h99.2
c10.8,0,19.5,8.7,19.5,19.5v143.9C140.2,175.2,131.5,183.9,120.7,183.9z"/>
<g>
<g>
<g>
<path class="st1" d="M46.3,166.2v-3.4h-1.2v-0.6h3.1v0.6H47v3.4H46.3z"/>
</g>
<g>
<path class="st1" d="M49,166.2v-4h2.7v0.6h-2v1h2v0.6h-2v1.1h2v0.6H49z"/>
</g>
<g>
<path class="st1" d="M52.6,166.2v-4h0.7v3.4h1.8v0.6H52.6z"/>
</g>
<g>
<path class="st1" d="M55.7,166.2v-4h2.7v0.6h-2v1h2v0.6h-2v1.1h2v0.6H55.7z"/>
</g>
<g>
<path class="st1" d="M59.1,164.2c0-1.2,0.9-2.1,2.1-2.1c0.8,0,1.3,0.4,1.6,0.9l-0.6,0.3c-0.2-0.3-0.6-0.6-1-0.6
c-0.8,0-1.4,0.6-1.4,1.4c0,0.8,0.6,1.4,1.4,1.4c0.4,0,0.8-0.3,1-0.6l0.6,0.3c-0.3,0.5-0.8,0.9-1.6,0.9
C60,166.3,59.1,165.5,59.1,164.2z"/>
</g>
<g>
<path class="st1" d="M63.2,164.2c0-1.2,0.8-2.1,2-2.1c1.2,0,2,0.9,2,2.1c0,1.2-0.8,2.1-2,2.1C64,166.3,63.2,165.4,63.2,164.2z
M66.5,164.2c0-0.8-0.5-1.4-1.3-1.4c-0.8,0-1.3,0.6-1.3,1.4c0,0.8,0.5,1.4,1.3,1.4C66,165.7,66.5,165,66.5,164.2z"/>
</g>
<g>
<path class="st1" d="M71.3,166.2v-3.1l-1.2,3.1h-0.3l-1.2-3.1v3.1h-0.7v-4h1l1.1,2.7l1.1-2.7h1v4H71.3z"/>
</g>
<g>
<path class="st1" d="M75.7,166.2v-4h0.7v4H75.7z"/>
</g>
<g>
<path class="st1" d="M80.4,166.2l-2.1-2.8v2.8h-0.7v-4h0.7l2,2.8v-2.8h0.7v4H80.4z"/>
</g>
<g>
<path class="st1" d="M82.3,166.2v-4H85v0.6h-2v1h2v0.6h-2v1.7H82.3z"/>
</g>
<g>
<path class="st1" d="M87.9,166.2l-0.9-1.5h-0.7v1.5h-0.7v-4h1.7c0.8,0,1.3,0.5,1.3,1.2c0,0.7-0.5,1.1-0.9,1.2l1,1.6H87.9z
M88,163.5c0-0.4-0.3-0.6-0.7-0.6h-1v1.3h1C87.7,164.1,88,163.9,88,163.5z"/>
</g>
<g>
<path class="st1" d="M92.4,166.2l-0.3-0.8h-1.8l-0.3,0.8h-0.8l1.6-4h0.9l1.6,4H92.4z M91.2,162.9l-0.7,1.9h1.4L91.2,162.9z"/>
</g>
<g>
<path class="st1" d="M95.8,166.2v-4h1.5c0.8,0,1.2,0.5,1.2,1.2c0,0.6-0.4,1.2-1.2,1.2h-1.2v1.7H95.8z M98.2,163.4
c0-0.5-0.3-0.9-0.9-0.9h-1.1v1.7h1.1C97.8,164.3,98.2,163.9,98.2,163.4z"/>
</g>
<g>
<path class="st1" d="M101.5,166.2l-1.1-1.6h-0.9v1.6h-0.3v-4h1.5c0.7,0,1.2,0.4,1.2,1.2c0,0.7-0.5,1.1-1.1,1.1l1.2,1.7H101.5z
M101.6,163.4c0-0.5-0.4-0.9-0.9-0.9h-1.1v1.7h1.1C101.2,164.3,101.6,163.9,101.6,163.4z"/>
</g>
<g>
<path class="st1" d="M102.8,164.2c0-1.2,0.8-2.1,1.9-2.1c1.2,0,1.9,0.9,1.9,2.1c0,1.2-0.8,2.1-1.9,2.1
C103.6,166.3,102.8,165.4,102.8,164.2z M106.3,164.2c0-1-0.6-1.7-1.6-1.7c-1,0-1.6,0.7-1.6,1.7c0,1,0.6,1.7,1.6,1.7
C105.7,166,106.3,165.2,106.3,164.2z"/>
</g>
<g>
<path class="st1" d="M106.9,165.8l0.2-0.3c0.2,0.2,0.4,0.4,0.8,0.4c0.5,0,0.9-0.4,0.9-0.9v-2.8h0.3v2.8c0,0.8-0.5,1.2-1.2,1.2
C107.5,166.3,107.2,166.1,106.9,165.8z"/>
</g>
<g>
<path class="st1" d="M110.4,166.2v-4h2.5v0.3h-2.2v1.5h2.1v0.3h-2.1v1.6h2.2v0.3H110.4z"/>
</g>
<g>
<path class="st1" d="M113.5,164.2c0-1.2,0.9-2.1,2-2.1c0.6,0,1.1,0.3,1.5,0.7l-0.3,0.2c-0.3-0.3-0.7-0.6-1.2-0.6
c-0.9,0-1.7,0.7-1.7,1.7c0,1,0.7,1.7,1.7,1.7c0.5,0,0.9-0.2,1.2-0.6l0.3,0.2c-0.4,0.4-0.8,0.7-1.5,0.7
C114.4,166.3,113.5,165.5,113.5,164.2z"/>
</g>
<g>
<path class="st1" d="M118.7,166.2v-3.7h-1.3v-0.3h2.9v0.3H119v3.7H118.7z"/>
</g>
</g>
<g>
<polygon class="st1" points="26.3,163.8 31.6,158.5 36.9,163.8 37.7,163.8 31.6,157.6 25.5,163.8 "/>
<polygon class="st1" points="36.9,164.7 31.6,170 26.3,164.7 25.5,164.7 31.6,170.8 37.7,164.7 "/>
<polygon class="st1" points="31,163.8 36.3,158.5 41.6,163.8 42.5,163.8 36.3,157.6 30.2,163.8 "/>
<polygon class="st1" points="41.6,164.7 36.3,170 31,164.7 30.2,164.7 36.3,170.8 42.5,164.7 "/>
</g>
</g>
<g>
<path class="st1" d="M33.2,100.7c-4.6,0-8.3,3.7-8.3,8.3s3.7,8.3,8.3,8.3s8.3-3.7,8.3-8.3S37.8,100.7,33.2,100.7z"/>
</g>
<g>
<g>
<g>
<path class="st2" d="M33.2,35.2c40.7,0,73.8,33.1,73.8,73.8c0,0.7,0,1.4,0,2.1c0,1.7,0.6,3.3,1.7,4.6c1.2,1.2,2.8,1.9,4.5,2
l0.2,0c3.5,0,6.3-2.7,6.4-6.2c0-0.8,0-1.7,0-2.5c0-47.7-38.8-86.6-86.6-86.6c-0.8,0-1.7,0-2.5,0c-1.7,0-3.3,0.8-4.5,2
c-1.2,1.2-1.8,2.9-1.7,4.6c0.1,3.5,3,6.3,6.6,6.2C31.8,35.2,32.5,35.2,33.2,35.2z"/>
</g>
</g>
</g>
<g>
<g>
<g>
<path class="st3" d="M33.2,60.5c26.7,0,48.5,21.7,48.5,48.5c0,0.6,0,1.3,0,2c-0.1,1.7,0.5,3.3,1.7,4.6c1.2,1.3,2.7,2,4.4,2.1
c1.7,0.1,3.3-0.5,4.6-1.7c1.2-1.2,2-2.7,2-4.4c0-0.9,0.1-1.8,0.1-2.6c0-33.8-27.5-61.2-61.2-61.2c-0.8,0-1.6,0-2.6,0.1
c-1.7,0.1-3.3,0.8-4.4,2.1c-1.2,1.3-1.8,2.9-1.7,4.6s0.8,3.3,2.1,4.4c1.3,1.2,2.9,1.8,4.6,1.7C31.9,60.5,32.6,60.5,33.2,60.5z"
/>
</g>
</g>
</g>
<g>
<g>
<g>
<path class="st4" d="M33.2,86.7c12.3,0,22.3,10,22.3,22.3c0,0.5,0,1.1-0.1,1.8c-0.3,3.5,2.3,6.6,5.8,6.9
c3.5,0.3,6.6-2.3,6.9-5.8c0.1-1,0.1-1.9,0.1-2.8c0-19.3-15.7-35.1-35.1-35.1c-0.9,0-1.8,0-2.8,0.1c-1.7,0.1-3.2,0.9-4.3,2.2
c-1.1,1.3-1.6,2.9-1.5,4.6c0.1,1.7,0.9,3.2,2.2,4.3c1.3,1.1,2.9,1.6,4.6,1.5C32.1,86.7,32.7,86.7,33.2,86.7z"/>
</g>
</g>
</g>
</g>
<g>
<path class="st1" d="M35.8,130.4c1.1,0.6,2.1,1.5,2.7,2.6c0.7,1.1,1,2.3,1,3.7s-0.3,2.6-1,3.7c-0.7,1.1-1.6,2-2.7,2.6
c-1.1,0.6-2.4,1-3.8,1s-2.7-0.3-3.8-1c-1.1-0.6-2.1-1.5-2.7-2.6c-0.7-1.1-1-2.3-1-3.7c0-1.3,0.3-2.6,1-3.7c0.7-1.1,1.6-2,2.7-2.6
c1.1-0.6,2.4-0.9,3.8-0.9C33.4,129.5,34.7,129.8,35.8,130.4z M29.9,132.9c-0.7,0.4-1.2,0.9-1.6,1.6s-0.6,1.4-0.6,2.2
c0,0.8,0.2,1.6,0.6,2.3c0.4,0.7,0.9,1.2,1.6,1.6c0.7,0.4,1.4,0.6,2.1,0.6c0.8,0,1.5-0.2,2.1-0.6c0.6-0.4,1.2-0.9,1.5-1.6
c0.4-0.7,0.6-1.4,0.6-2.3c0-0.8-0.2-1.6-0.6-2.2s-0.9-1.2-1.5-1.6c-0.6-0.4-1.4-0.6-2.1-0.6C31.3,132.3,30.6,132.5,29.9,132.9z"/>
<path class="st1" d="M50.6,133.6c0.8,0.5,1.4,1.1,1.8,2c0.4,0.8,0.6,1.8,0.6,2.9c0,1.1-0.2,2-0.6,2.8c-0.4,0.8-1,1.5-1.8,1.9
c-0.8,0.5-1.6,0.7-2.6,0.7c-0.7,0-1.4-0.1-2-0.4s-1.1-0.7-1.5-1.2v5.4h-3.1V133h3.1v1.6c0.4-0.5,0.9-1,1.4-1.2s1.2-0.4,2-0.4
C48.9,132.9,49.8,133.1,50.6,133.6z M49.1,140.5c0.5-0.6,0.7-1.3,0.7-2.2c0-0.9-0.2-1.6-0.7-2.1c-0.5-0.6-1.1-0.8-1.9-0.8
s-1.4,0.3-1.9,0.8c-0.5,0.6-0.8,1.3-0.8,2.1c0,0.9,0.2,1.6,0.8,2.2s1.1,0.8,1.9,0.8S48.6,141,49.1,140.5z"/>
<path class="st1" d="M63.4,134.4c0.9,1,1.4,2.4,1.4,4.2c0,0.3,0,0.6,0,0.7H57c0.2,0.7,0.5,1.2,1,1.6c0.5,0.4,1.1,0.6,1.8,0.6
c0.5,0,1-0.1,1.5-0.3s0.9-0.5,1.3-0.9l1.6,1.6c-0.5,0.6-1.2,1.1-2,1.4c-0.8,0.3-1.6,0.5-2.6,0.5c-1.1,0-2.1-0.2-3-0.7
s-1.5-1.1-2-1.9c-0.5-0.8-0.7-1.8-0.7-2.9c0-1.1,0.2-2.1,0.7-2.9s1.1-1.5,2-1.9c0.8-0.5,1.8-0.7,2.9-0.7
C61.2,132.9,62.5,133.4,63.4,134.4z M61.8,137.5c0-0.7-0.3-1.3-0.7-1.7s-1-0.6-1.7-0.6c-0.7,0-1.2,0.2-1.7,0.6
c-0.4,0.4-0.7,1-0.9,1.7H61.8z"/>
<path class="st1" d="M76.2,134c0.7,0.7,1.1,1.7,1.1,3v6.8h-3.1v-5.9c0-0.7-0.2-1.2-0.6-1.6s-0.9-0.6-1.5-0.6
c-0.8,0-1.4,0.3-1.8,0.8c-0.4,0.5-0.7,1.2-0.7,2v5.3h-3.1V133h3.1v1.9c0.7-1.3,2-2,3.7-2C74.6,132.8,75.5,133.2,76.2,134z"/>
<path class="st1" d="M96,129.7h3.3l-4.7,14h-3.3l-2.9-10.1l-3,10.1h-3.2l-4.7-14h3.4l3,10.7l3-10.7H90l3.1,10.7L96,129.7z"/>
<path class="st1" d="M103.3,128.7c0.3,0.3,0.5,0.7,0.5,1.2s-0.2,0.9-0.5,1.2c-0.3,0.3-0.7,0.5-1.2,0.5c-0.5,0-0.9-0.2-1.2-0.5
c-0.3-0.3-0.5-0.7-0.5-1.2c0-0.5,0.2-0.9,0.5-1.2c0.3-0.3,0.7-0.5,1.2-0.5C102.6,128.2,103,128.3,103.3,128.7z M100.6,133h3.1
v10.8h-3.1V133z"/>
<path class="st1" d="M106.5,129.7h10.1l0,2.6h-6.9v3.4h6.3v2.6h-6.3v5.3h-3.2V129.7z"/>
<path class="st1" d="M120.9,128.7c0.3,0.3,0.5,0.7,0.5,1.2s-0.2,0.9-0.5,1.2c-0.3,0.3-0.7,0.5-1.2,0.5c-0.5,0-0.9-0.2-1.2-0.5
c-0.3-0.3-0.5-0.7-0.5-1.2c0-0.5,0.2-0.9,0.5-1.2c0.3-0.3,0.7-0.5,1.2-0.5C120.1,128.2,120.5,128.3,120.9,128.7z M118.1,133h3.1
v10.8h-3.1V133z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

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

View File

@@ -0,0 +1,51 @@
import * as React from 'react';
import { ChevronDownIcon } from '@chakra-ui/icons';
import { Button, Checkbox, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
export type ShownLogsDropdownProps = {
availableLogTypes: { id: number; helper?: string }[];
hiddenLogIds: number[];
setHiddenLogIds: (ids: number[]) => void;
helperLabels?: { [key: number]: string };
};
export const ShownLogsDropdown = ({
availableLogTypes,
hiddenLogIds,
setHiddenLogIds,
helperLabels,
}: ShownLogsDropdownProps) => {
const { t } = useTranslation();
const isActive = (id: number) => !hiddenLogIds.includes(id);
const onToggle = (id: number) => () => {
if (isActive(id)) {
setHiddenLogIds([...hiddenLogIds, id]);
} else {
setHiddenLogIds(hiddenLogIds.filter((hid) => hid !== id));
}
};
const label = (id: number, helper?: string) => {
if (helperLabels && helperLabels[id] !== undefined) {
return helperLabels[id];
}
return helper ?? id;
};
return (
<Menu closeOnSelect={false}>
<MenuButton as={Button} rightIcon={<ChevronDownIcon />} isDisabled={availableLogTypes.length === 0}>
{t('logs.receiving_types')} ({availableLogTypes.length - hiddenLogIds.length})
</MenuButton>
<MenuList>
{availableLogTypes.map((logType) => (
<MenuItem key={uuid()} onClick={onToggle(logType.id)} isFocusable={false}>
<Checkbox isChecked={isActive(logType.id)}>{label(logType.id, logType.helper)}</Checkbox>
</MenuItem>
))}
</MenuList>
</Menu>
);
};

View File

@@ -0,0 +1,92 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useFirmwareStore } from './useStore';
import { FirmwareSocketRawMessage } from './utils';
import { useAuth } from 'contexts/AuthProvider';
import { axiosFms, axiosSec } from 'utils/axiosInstances';
export type FirmwareSocketContextReturn = Record<string, unknown>;
const FirmwareSocketContext = React.createContext<FirmwareSocketContextReturn>({
webSocket: undefined,
isOpen: false,
});
export const FirmwareSocketProvider = ({ children }: { children: React.ReactElement }) => {
const { token, isUserLoaded } = useAuth();
const { addMessage, isOpen, webSocket, onStartWebSocket } = useFirmwareStore((state) => ({
addMessage: state.addMessage,
isOpen: state.isWebSocketOpen,
webSocket: state.webSocket,
onStartWebSocket: state.startWebSocket,
}));
const queryClient = useQueryClient();
const onMessage = useCallback((message: MessageEvent<string>) => {
try {
const data = JSON.parse(message.data) as FirmwareSocketRawMessage | undefined;
if (data) {
addMessage(data, queryClient);
}
return undefined;
} catch {
return undefined;
}
}, []);
// useEffect for created the WebSocket and 'storing' it in useRef
useEffect(() => {
if (isUserLoaded && axiosFms?.defaults?.baseURL !== axiosSec?.defaults?.baseURL) {
onStartWebSocket(token ?? '');
}
const wsCurrent = webSocket;
return () => wsCurrent?.close();
}, [isUserLoaded]);
// useEffect for generating global notifications
useEffect(() => {
if (webSocket) {
webSocket.addEventListener('message', onMessage);
}
return () => {
if (webSocket) webSocket.removeEventListener('message', onMessage);
};
}, [webSocket]);
useEffect(() => {
const handleVisibilityChange = () => {
let timeoutId;
if (webSocket) {
if (document.visibilityState === 'hidden') {
/* timeoutId = setTimeout(() => {
if (webSocket) webSocket.onclose = () => {};
webSocket?.close();
setIsOpen(false);
}, 5000); */
} else {
// If tab is active again, verify if browser killed the WS
clearTimeout(timeoutId);
if (!isOpen && isUserLoaded && axiosFms?.defaults?.baseURL !== axiosSec?.defaults?.baseURL) {
onStartWebSocket(token ?? '');
}
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [webSocket, isOpen]);
const values: FirmwareSocketContextReturn = useMemo(() => ({}), []);
return <FirmwareSocketContext.Provider value={values}>{children}</FirmwareSocketContext.Provider>;
};
export const useGlobalFirmwareSocket: () => FirmwareSocketContextReturn = () => React.useContext(FirmwareSocketContext);

View File

@@ -0,0 +1,158 @@
import { QueryClient } from '@tanstack/react-query';
import { v4 as uuid } from 'uuid';
import create from 'zustand';
import { FirmwareSocketRawMessage, SocketEventCallback, SocketWebSocketNotificationData } from './utils';
import { NotificationType } from 'models/Socket';
import { axiosFms } from 'utils/axiosInstances';
export type FirmwareWebSocketMessage =
| {
type: 'NOTIFICATION';
data: SocketWebSocketNotificationData;
timestamp: Date;
id: string;
}
| {
type: 'UNKNOWN';
data: {
type?: string;
type_id?: number;
[key: string]: unknown;
};
timestamp: Date;
id: string;
};
const parseRawWebSocketMessage = (message?: FirmwareSocketRawMessage): SocketWebSocketNotificationData | undefined => {
if (message && message.notification) {
if (message.notification.type_id === 1) {
return {
type: 'LOG',
log: message.notification.content,
};
}
} else if (message?.notificationTypes) {
return {
type: 'INITIAL_MESSAGE',
message,
};
}
return undefined;
};
export type FirmwareStoreState = {
availableLogTypes: NotificationType[];
hiddenLogIds: number[];
setHiddenLogIds: (logsToHide: number[]) => void;
lastMessage?: FirmwareWebSocketMessage;
allMessages: FirmwareWebSocketMessage[];
addMessage: (rawMsg: FirmwareSocketRawMessage, queryClient: QueryClient) => void;
eventListeners: SocketEventCallback[];
addEventListeners: (callback: SocketEventCallback[]) => void;
webSocket?: WebSocket;
send: (str: string) => void;
startWebSocket: (token: string, tries?: number) => void;
isWebSocketOpen: boolean;
setWebSocketOpen: (isOpen: boolean) => void;
lastSearchResults: string[];
setLastSearchResults: (result: string[]) => void;
errors: { str: string; timestamp: Date }[];
};
export const useFirmwareStore = create<FirmwareStoreState>((set, get) => ({
availableLogTypes: [],
hiddenLogIds: [],
setHiddenLogIds: (logsToHide: number[]) => {
get().send(JSON.stringify({ 'drop-notifications': logsToHide }));
set(() => ({
hiddenLogIds: logsToHide,
}));
},
allMessages: [] as FirmwareWebSocketMessage[],
addMessage: (rawMsg: FirmwareSocketRawMessage) => {
try {
const msg = parseRawWebSocketMessage(rawMsg);
if (msg) {
// Handle notification-specific logic
if (msg.type === 'INITIAL_MESSAGE') {
if (msg.message.notificationTypes) {
set({ availableLogTypes: msg.message.notificationTypes });
}
}
// General handling
const obj: FirmwareWebSocketMessage = {
type: 'NOTIFICATION',
data: msg,
timestamp: msg.log?.timestamp ? new Date(msg.log.timestamp * 1000) : new Date(),
id: uuid(),
};
const eventsToFire = get().eventListeners.filter(
({ type, serialNumber }) => type === msg.type && serialNumber === msg.serialNumber,
);
if (eventsToFire.length > 0) {
for (const event of eventsToFire) {
event.callback();
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
eventListeners: get().eventListeners.filter(
({ id }) => !eventsToFire.find(({ id: findId }) => findId === id),
),
}));
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
}));
}
return undefined;
} catch {
// TODO - Add error message to socket logs
return set((state) => ({
errors: [...state.errors, { str: JSON.stringify(rawMsg), timestamp: new Date() }],
}));
}
},
eventListeners: [] as SocketEventCallback[],
addEventListeners: (events: SocketEventCallback[]) =>
set((state) => ({ eventListeners: [...state.eventListeners, ...events] })),
isWebSocketOpen: false,
setWebSocketOpen: (isOpen: boolean) => set({ isWebSocketOpen: isOpen }),
send: (str: string) => {
const ws = get().webSocket;
if (ws) ws.send(str);
},
startWebSocket: (token: string, tries = 0) => {
const newTries = tries + 1;
if (tries <= 10) {
set({
webSocket: new WebSocket(
`${
axiosFms?.defaults?.baseURL ? axiosFms.defaults.baseURL.replace('https', 'wss').replace('http', 'ws') : ''
}/ws`,
),
});
const ws = get().webSocket;
if (ws) {
ws.onopen = () => {
set({ isWebSocketOpen: true });
ws.send(`token:${token}`);
};
ws.onclose = () => {
set({ isWebSocketOpen: false });
setTimeout(() => get().startWebSocket(token, newTries), 3000);
};
}
}
},
lastSearchResults: [] as string[],
setLastSearchResults: (results: string[]) => set({ lastSearchResults: results }),
errors: [],
}));

View File

@@ -0,0 +1,51 @@
import { InitialSocketMessage } from 'models/Socket';
export type FirmwareSocketNotificationTypeId = 1 | 1000 | 2000 | 3000 | 4000 | 5000 | 6000;
export const FirmwareSocketNotificationTypeMap = {
1: 'logs',
};
type LogMessage = {
notification: {
notificationId: number;
type?: undefined;
type_id: 1;
content: {
level: LogLevel;
msg: string;
source: string;
thread_id: number;
thread_name: string;
timestamp: number;
};
};
serialNumbers?: undefined;
notificationTypes?: undefined;
};
export type FirmwareSocketRawMessage = Partial<LogMessage> | InitialSocketMessage;
export type LogLevel = 'information' | 'critical' | 'debug' | 'error' | 'fatal' | 'notice' | 'trace' | 'warning';
export type SocketWebSocketNotificationData =
| {
type: 'LOG';
serialNumber?: undefined;
serialNumbers?: undefined;
notificationTypes?: undefined;
log: LogMessage['notification']['content'];
}
| {
type: 'INITIAL_MESSAGE';
serialNumber?: undefined;
serialNumbers?: undefined;
notificationTypes?: undefined;
log?: undefined;
message: InitialSocketMessage;
};
export type SocketEventCallback = {
id: string;
type: 'LOG';
serialNumber: string;
callback: () => void;
};

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import debounce from '../../../../utils/debounce';
import useWebSocketCommand from './useWebSocketCommand';
import { ProviderCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
import debounce from 'utils/debounce';
import { ProvisioningCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
export type UseDeviceSearchProps = {
minLength?: number;
@@ -14,7 +14,7 @@ export const useProviderDeviceSearch = ({ minLength = 4, operatorId }: UseDevice
{ command: string; serial_prefix: string; operatorId?: string } | undefined
>(undefined);
const [results, setResults] = useState<string[]>([]);
const onNewResult = (newResult: ProviderCommandResponse) => {
const onNewResult = (newResult: ProvisioningCommandResponse) => {
if (newResult.response.serialNumbers) setResults(newResult.response.serialNumbers as string[]);
};
const { isOpen, send } = useWebSocketCommand({ callback: onNewResult });

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import useWebSocketCommand from './useWebSocketCommand';
import { ProviderCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { ProvisioningCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
import debounce from 'utils/debounce';
export type UseLocationSearchProps = {
@@ -11,7 +11,7 @@ export const useLocationSearch = ({ minLength = 8 }: UseLocationSearchProps) =>
const [tempValue, setTempValue] = useState('');
const [waitingSearch, setWaitingSearch] = useState<{ command: string; address: string } | undefined>(undefined);
const [results, setResults] = useState<string[]>([]);
const onNewResult = (newResult: ProviderCommandResponse) => {
const onNewResult = (newResult: ProvisioningCommandResponse) => {
if (newResult.response.results) setResults(newResult.response.results as string[]);
};
const { isOpen, send } = useWebSocketCommand({ callback: onNewResult });

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import useWebSocketCommand from './useWebSocketCommand';
import { ProviderCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { ProvisioningCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { Subscriber } from 'models/Subscriber';
import debounce from 'utils/debounce';
@@ -15,7 +15,7 @@ export const useSubscriberSearch = ({ minLength = 4, operatorId, mode }: UseSubs
{ command: string; emailSearch?: string; nameSearch?: string; operatorId?: string } | undefined
>(undefined);
const [results, setResults] = useState<Subscriber[]>([]);
const onNewResult = (newResult: ProviderCommandResponse) => {
const onNewResult = (newResult: ProvisioningCommandResponse) => {
if (newResult.response.users) setResults(newResult.response.users as Subscriber[]);
};
const { isOpen, send } = useWebSocketCommand({ callback: onNewResult });

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useProviderStore } from 'contexts/ProvisioningSocketProvider/useStore';
import { ProviderCommandResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { useProvisioningStore } from '../../useStore';
import { ProvisioningCommandResponse } from '../../utils';
import { randomIntId } from 'utils/stringHelper';
const useProviderWebSocketCommand = ({ callback }: { callback: (command: ProviderCommandResponse) => void }) => {
const { isOpen, webSocket, lastMessage } = useProviderStore((state) => ({
const useProviderWebSocketCommand = ({ callback }: { callback: (command: ProvisioningCommandResponse) => void }) => {
const { isOpen, webSocket, lastMessage } = useProvisioningStore((state) => ({
isOpen: state.isWebSocketOpen,
webSocket: state.webSocket,
lastMessage: state.lastMessage,

View File

@@ -2,10 +2,10 @@ import React from 'react';
import { Box, Heading, ListItem, UnorderedList } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { ProviderWebSocketVenueUpdateResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { ProvisioningVenueNotificationMessage } from 'contexts/ProvisioningSocketProvider/utils';
interface Props {
notification: ProviderWebSocketVenueUpdateResponse;
notification: ProvisioningVenueNotificationMessage['notification'];
}
const ConfigurationPushesNotificationContent = ({ notification }: Props) => {

View File

@@ -2,10 +2,10 @@ import React from 'react';
import { Box, Heading, ListItem, UnorderedList } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { ProviderWebSocketVenueUpdateResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { ProvisioningVenueNotificationMessage } from 'contexts/ProvisioningSocketProvider/utils';
interface Props {
notification: ProviderWebSocketVenueUpdateResponse;
notification: ProvisioningVenueNotificationMessage['notification'];
}
const DeviceRebootNotificationContent = ({ notification }: Props) => {

View File

@@ -2,10 +2,10 @@ import React from 'react';
import { Box, Heading, ListItem, UnorderedList } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { ProviderWebSocketVenueUpdateResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { ProvisioningVenueNotificationMessage } from 'contexts/ProvisioningSocketProvider/utils';
interface Props {
notification: ProviderWebSocketVenueUpdateResponse;
notification: ProvisioningVenueNotificationMessage['notification'];
}
const DeviceUpgradeNotificationContent = ({ notification }: Props) => {

View File

@@ -2,24 +2,24 @@ import React from 'react';
import ConfigurationPushesNotificationContent from './ConfigurationPushes';
import DeviceRebootNotificationContent from './DeviceReboot';
import DeviceUpgradeNotificationContent from './DeviceUpgrade';
import { ProviderWebSocketVenueUpdateResponse } from 'contexts/ProvisioningSocketProvider/utils';
import { ProvisioningVenueNotificationMessage } from 'contexts/ProvisioningSocketProvider/utils';
interface Props {
notification?: ProviderWebSocketVenueUpdateResponse;
notification?: ProvisioningVenueNotificationMessage['notification'];
}
const NotificationContent = ({ notification }: Props) => {
if (!notification) return null;
if (notification.type === 'entity_configuration_update' || notification.type === 'venue_configuration_update') {
if (notification.type_id === 2000 || notification.type === 'venue_config_update') {
return <ConfigurationPushesNotificationContent notification={notification} />;
}
if (notification.type === 'venue_rebooter') {
if (notification.type_id === 3000 || notification.type === 'venue_rebooter') {
return <DeviceRebootNotificationContent notification={notification} />;
}
if (notification.type === 'venue_upgrader') {
if (notification.type_id === 1000 || notification.type === 'venue_fw_upgrade') {
return <DeviceUpgradeNotificationContent notification={notification} />;
}
return null;

View File

@@ -19,14 +19,14 @@ import {
IconButton,
Tooltip,
} from '@chakra-ui/react';
import { StringMap, TOptions } from 'i18next';
import { TOptions } from 'i18next';
import { X } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { ProviderWebSocketVenueUpdateResponse } from '../../utils';
import { ProvisioningVenueNotificationMessage } from '../../utils';
import NotificationContent from '.';
const getStatusFromNotification = (notification: ProviderWebSocketVenueUpdateResponse) => {
const getStatusFromNotification = (notification: ProvisioningVenueNotificationMessage['notification']) => {
let status: 'success' | 'warning' | 'error' = 'success';
if (notification.content.warning?.length > 0) status = 'warning';
if (notification.content.error?.length > 0) status = 'error';
@@ -35,15 +35,10 @@ const getStatusFromNotification = (notification: ProviderWebSocketVenueUpdateRes
};
const getNotificationDescription = (
t: (key: string, options?: string | TOptions<StringMap> | undefined) => string,
notification: ProviderWebSocketVenueUpdateResponse,
t: (key: string, options?: string | TOptions<Record<string, number>> | undefined) => string,
notification: ProvisioningVenueNotificationMessage['notification'],
) => {
if (
notification.content.type === 'venue_configuration_update' ||
notification.content.type === 'entity_configuration_update' ||
notification.content.type === 'venue_rebooter' ||
notification.content.type === 'venue_upgrader'
) {
if (notification.type_id === 1000 || notification.type_id === 2000 || notification.type_id === 3000) {
return t('configurations.notification_details', {
success: notification.content.success?.length ?? 0,
warning: notification.content.warning?.length ?? 0,
@@ -56,16 +51,19 @@ const getNotificationDescription = (
const useWebSocketNotification = () => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const [notif, setNotif] = useState<ProviderWebSocketVenueUpdateResponse | undefined>(undefined);
const [notif, setNotif] = useState<ProvisioningVenueNotificationMessage['notification'] | undefined>(undefined);
const toast = useToast();
const openDetails = useCallback((newObj: ProviderWebSocketVenueUpdateResponse, closeToast?: () => void) => {
setNotif(newObj);
if (closeToast) closeToast();
onOpen();
}, []);
const openDetails = useCallback(
(newObj: ProvisioningVenueNotificationMessage['notification'], closeToast?: () => void) => {
setNotif(newObj);
if (closeToast) closeToast();
onOpen();
},
[],
);
const pushNotification = useCallback((notification: ProviderWebSocketVenueUpdateResponse) => {
const pushNotification = useCallback((notification: ProvisioningVenueNotificationMessage['notification']) => {
toast({
id: uuid(),
duration: 5000,

View File

@@ -1,13 +1,13 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import useWebSocketNotification from './hooks/NotificationContent/useWebSocketNotification';
import { useProviderStore } from './useStore';
import { extractProviderWebSocketResponse } from './utils';
import { useProvisioningStore } from './useStore';
import { ProvisioningSocketRawMessage } from './utils';
import { useAuth } from 'contexts/AuthProvider';
import { axiosProv, axiosSec } from 'utils/axiosInstances';
export type ProviderSocketContextReturn = Record<string, unknown>;
export type ProvisioningSocketContextReturn = Record<string, unknown>;
const ProviderSocketContext = React.createContext<ProviderSocketContextReturn>({
const ProvisioningSocketContext = React.createContext<ProvisioningSocketContextReturn>({
webSocket: undefined,
isOpen: false,
});
@@ -15,22 +15,18 @@ const ProviderSocketContext = React.createContext<ProviderSocketContextReturn>({
export const ProvisioningSocketProvider = ({ children }: { children: React.ReactElement }) => {
const { token, isUserLoaded } = useAuth();
const { pushNotification, modal } = useWebSocketNotification();
const { addMessage, isOpen, setIsOpen, webSocket, onStartWebSocket } = useProviderStore((state) => ({
const { addMessage, isOpen, webSocket, onStartWebSocket } = useProvisioningStore((state) => ({
addMessage: state.addMessage,
setIsOpen: state.setWebSocketOpen,
isOpen: state.isWebSocketOpen,
webSocket: state.webSocket,
onStartWebSocket: state.startWebSocket,
}));
const onMessage = useCallback((msg: MessageEvent<string>) => {
const onMessage = useCallback((message: MessageEvent<string>) => {
try {
const extracted = extractProviderWebSocketResponse(msg);
if (extracted) {
addMessage(extracted);
if (extracted.type === 'NOTIFICATION') {
pushNotification(extracted.data);
}
const data = JSON.parse(message.data) as ProvisioningSocketRawMessage | undefined;
if (data) {
addMessage(data, pushNotification);
}
return undefined;
} catch {
@@ -65,12 +61,13 @@ export const ProvisioningSocketProvider = ({ children }: { children: React.React
if (webSocket) {
if (document.visibilityState === 'hidden') {
timeoutId = setTimeout(() => {
/* timeoutId = setTimeout(() => {
if (webSocket) webSocket.onclose = () => {};
webSocket?.close();
setIsOpen(false);
}, 5000);
}, 5000); */
} else {
// If tab is active again, verify if browser killed the WS
clearTimeout(timeoutId);
if (!isOpen && isUserLoaded && axiosProv?.defaults?.baseURL !== axiosSec?.defaults?.baseURL) {
@@ -86,17 +83,17 @@ export const ProvisioningSocketProvider = ({ children }: { children: React.React
};
}, [webSocket, isOpen]);
const values: ProviderSocketContextReturn = useMemo(() => ({}), []);
const values: ProvisioningSocketContextReturn = useMemo(() => ({}), []);
return (
<ProviderSocketContext.Provider value={values}>
<ProvisioningSocketContext.Provider value={values}>
<>
{children}
{modal}
</>
</ProviderSocketContext.Provider>
</ProvisioningSocketContext.Provider>
);
};
export const useGlobalProvisioningSocket: () => ProviderSocketContextReturn = () =>
React.useContext(ProviderSocketContext);
export const useGlobalProvisioningSocket: () => ProvisioningSocketContextReturn = () =>
React.useContext(ProvisioningSocketContext);

View File

@@ -1,41 +1,170 @@
import { v4 as uuid } from 'uuid';
import create from 'zustand';
import { ProviderWebSocketMessage, ProviderWebSocketParsedMessage } from './utils';
import {
ACCEPTED_VENUE_NOTIFICATION_TYPES,
ProvisioningCommandResponse,
ProvisioningSocketRawMessage,
ProvisioningVenueNotificationMessage,
SocketEventCallback,
SocketWebSocketNotificationData,
} from './utils';
import { NotificationType } from 'models/Socket';
import { axiosProv } from 'utils/axiosInstances';
export type ProviderStoreState = {
lastMessage?: ProviderWebSocketMessage;
allMessages: ProviderWebSocketMessage[];
addMessage: (message: ProviderWebSocketParsedMessage) => void;
export type ProvisioningWebSocketMessage =
| {
type: 'NOTIFICATION';
data: SocketWebSocketNotificationData;
timestamp: Date;
id: string;
}
| {
type: 'COMMAND';
data: ProvisioningCommandResponse;
timestamp: Date;
id: string;
}
| {
type: 'UNKNOWN';
data: {
type?: string;
type_id?: number;
[key: string]: unknown;
};
timestamp: Date;
id: string;
};
const parseRawWebSocketMessage = (
message?: ProvisioningSocketRawMessage,
): SocketWebSocketNotificationData | undefined => {
if (message && message.notification) {
if (message.notification.type_id === 1) {
return {
type: 'LOG',
log: message.notification.content,
};
}
if (
message.notification.type_id === 1000 ||
message.notification.type_id === 2000 ||
message.notification.type_id === 3000
) {
return {
type: 'NOTIFICATION',
data: message.notification,
};
}
} else if (message?.notificationTypes) {
return {
type: 'INITIAL_MESSAGE',
message,
};
} else if (message?.response && message.command_response_id) {
return {
type: 'COMMAND',
data: message as ProvisioningCommandResponse,
};
}
return undefined;
};
export type ProvisioningStoreState = {
availableLogTypes: NotificationType[];
hiddenLogIds: number[];
setHiddenLogIds: (logsToHide: number[]) => void;
lastMessage?: ProvisioningWebSocketMessage;
allMessages: ProvisioningWebSocketMessage[];
addMessage: (
rawMsg: ProvisioningSocketRawMessage,
pushNotification: (notification: ProvisioningVenueNotificationMessage['notification']) => void,
) => void;
eventListeners: SocketEventCallback[];
addEventListeners: (callback: SocketEventCallback[]) => void;
webSocket?: WebSocket;
send: (str: string) => void;
startWebSocket: (token: string, tries?: number) => void;
isWebSocketOpen: boolean;
setWebSocketOpen: (isOpen: boolean) => void;
lastSearchResults: string[];
setLastSearchResults: (result: string[]) => void;
errors: { str: string; timestamp: Date }[];
};
export const useProviderStore = create<ProviderStoreState>((set, get) => ({
allMessages: [] as ProviderWebSocketMessage[],
addMessage: (msg: ProviderWebSocketParsedMessage) => {
// @ts-ignore
const obj: ProviderWebSocketMessage =
msg.type === 'COMMAND'
? {
type: msg.type,
data: msg.data,
timestamp: new Date(),
}
: {
type: msg.type,
data: msg.data,
timestamp: new Date(),
};
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
export const useProvisioningStore = create<ProvisioningStoreState>((set, get) => ({
availableLogTypes: [],
hiddenLogIds: [],
setHiddenLogIds: (logsToHide: number[]) => {
get().send(JSON.stringify({ 'drop-notifications': logsToHide }));
set(() => ({
hiddenLogIds: logsToHide,
}));
},
allMessages: [] as ProvisioningWebSocketMessage[],
addMessage: (rawMsg, pushNotification) => {
try {
const msg = parseRawWebSocketMessage(rawMsg);
if (msg) {
// Handle notification-specific logic
if (msg.type === 'INITIAL_MESSAGE') {
if (msg.message.notificationTypes) {
set({ availableLogTypes: msg.message.notificationTypes });
}
}
// Handle venue notifications
if (msg.type === 'NOTIFICATION' && ACCEPTED_VENUE_NOTIFICATION_TYPES.includes(msg.data.type_id)) {
pushNotification(msg.data);
}
// General handling
const obj: ProvisioningWebSocketMessage =
msg.type === 'COMMAND'
? {
type: 'COMMAND',
data: msg.data,
timestamp: new Date(),
id: uuid(),
}
: {
type: 'NOTIFICATION',
data: msg,
timestamp: msg.log?.timestamp ? new Date(msg.log.timestamp * 1000) : new Date(),
id: uuid(),
};
const eventsToFire = get().eventListeners.filter(({ type }) => type === msg.type);
if (eventsToFire.length > 0) {
for (const event of eventsToFire) {
event.callback();
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
eventListeners: get().eventListeners.filter(
({ id }) => !eventsToFire.find(({ id: findId }) => findId === id),
),
}));
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
}));
}
return undefined;
} catch {
// TODO - Add error message to socket logs
return set((state) => ({
errors: [...state.errors, { str: JSON.stringify(rawMsg), timestamp: new Date() }],
}));
}
},
eventListeners: [] as SocketEventCallback[],
addEventListeners: (events: SocketEventCallback[]) =>
set((state) => ({ eventListeners: [...state.eventListeners, ...events] })),
isWebSocketOpen: false,
setWebSocketOpen: (isOpen: boolean) => set({ isWebSocketOpen: isOpen }),
send: (str: string) => {
@@ -65,4 +194,7 @@ export const useProviderStore = create<ProviderStoreState>((set, get) => ({
}
}
},
lastSearchResults: [] as string[],
setLastSearchResults: (results: string[]) => set({ lastSearchResults: results }),
errors: [],
}));

View File

@@ -1,76 +1,99 @@
import { InitialSocketMessage } from 'models/Socket';
import { Subscriber } from 'models/Subscriber';
// Notifications we react to from the WS
export const acceptedNotificationTypes = [
'venue_configuration_update',
'entity_configuration_update',
'venue_rebooter',
'venue_upgrader',
];
// Data received from WS on Venue update notification
export type ProviderWebSocketVenueUpdateResponse = {
notification_id: number;
type: 'venue_configuration_update' | 'entity_configuration_update' | 'venue_rebooter' | 'venue_upgrader';
content: {
type: 'venue_configuration_update' | 'entity_configuration_update' | 'venue_rebooter' | 'venue_upgrader';
title: string;
details: string;
success: string[];
noFirmware?: string[];
notConnected?: string[];
skipped?: string[];
warning: string[];
error: string[];
timeStamp: number;
};
export type ProvisioningSocketNotificationTypeId = 1 | 1000 | 2000 | 3000 | 4000 | 5000 | 6000;
export const ProvisioningSocketNotificationTypeMap = {
1: 'logs',
1000: 'venue_fw_upgrade',
2000: 'venue_config_update',
3000: 'venue_rebooter',
};
export type ProviderCommandResponse = {
export const ACCEPTED_VENUE_NOTIFICATION_TYPES = [1000, 2000, 3000];
export type ProvisioningCommandResponse = {
command_response_id: number;
response: { serialNumbers?: string[]; users?: Subscriber[]; results?: string[] };
notification?: undefined;
notificationTypes?: undefined;
};
// Parsed WebSocket message
export type ProviderWebSocketParsedMessage =
type LogMessage = {
notification: {
notificationId: number;
type?: undefined;
type_id: 1;
content: {
level: LogLevel;
msg: string;
source: string;
thread_id: number;
thread_name: string;
timestamp: number;
};
};
command_response_id?: undefined;
response?: undefined;
notificationTypes?: undefined;
};
export type ProvisioningVenueNotificationMessage = {
notification: {
notification_id: number;
type?: 'venue_fw_upgrade' | 'venue_config_update' | 'venue_rebooter';
type_id: 1000 | 2000 | 3000;
content: {
title: string;
details: string;
success: string[];
noFirmware?: string[];
notConnected?: string[];
skipped?: string[];
warning: string[];
error: string[];
timeStamp: number;
};
};
command_response_id?: undefined;
response?: undefined;
notificationTypes?: undefined;
};
export type ProvisioningSocketRawMessage =
| Partial<LogMessage>
| Partial<ProvisioningVenueNotificationMessage>
| Partial<ProvisioningCommandResponse>
| InitialSocketMessage;
export type LogLevel = 'information' | 'critical' | 'debug' | 'error' | 'fatal' | 'notice' | 'trace' | 'warning';
export type SocketWebSocketNotificationData =
| {
type: 'NOTIFICATION';
data: ProviderWebSocketVenueUpdateResponse;
data: ProvisioningVenueNotificationMessage['notification'];
log?: undefined;
notificationTypes?: undefined;
}
| {
type: 'LOG';
notificationTypes?: undefined;
log: LogMessage['notification']['content'];
}
| {
type: 'COMMAND';
data: ProviderCommandResponse;
data: ProvisioningCommandResponse;
notificationTypes?: undefined;
log?: undefined;
}
| {
type: 'INITIAL_MESSAGE';
notificationTypes?: undefined;
log?: undefined;
message: InitialSocketMessage;
};
// Parsing raw WS messages into a more usable format
export const extractProviderWebSocketResponse = (message: MessageEvent): ProviderWebSocketParsedMessage | undefined => {
try {
const data = JSON.parse(message.data);
if (data.notification && acceptedNotificationTypes.includes(data.notification.type)) {
const notification = data.notification as ProviderWebSocketVenueUpdateResponse;
return { data: notification, type: 'NOTIFICATION' };
}
if (data.command_response_id) {
return { data, type: 'COMMAND' } as {
type: 'COMMAND';
data: ProviderCommandResponse;
};
}
} catch {
return undefined;
}
return undefined;
export type SocketEventCallback = {
id: string;
type: 'LOG';
serialNumber: string;
callback: () => void;
};
// What we store in the store
export type ProviderWebSocketMessage =
| {
type: 'NOTIFICATION';
data: ProviderWebSocketParsedMessage;
timestamp: Date;
}
| {
type: 'COMMAND';
data: ProviderCommandResponse;
timestamp: Date;
};

View File

@@ -0,0 +1,91 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useSecurityStore } from './useStore';
import { SecuritySocketRawMessage } from './utils';
import { useAuth } from 'contexts/AuthProvider';
export type SecuritySocketContextReturn = Record<string, unknown>;
const SecuritySocketContext = React.createContext<SecuritySocketContextReturn>({
webSocket: undefined,
isOpen: false,
});
export const SecuritySocketProvider = ({ children }: { children: React.ReactElement }) => {
const { token, isUserLoaded } = useAuth();
const { addMessage, isOpen, webSocket, onStartWebSocket } = useSecurityStore((state) => ({
addMessage: state.addMessage,
isOpen: state.isWebSocketOpen,
webSocket: state.webSocket,
onStartWebSocket: state.startWebSocket,
}));
const queryClient = useQueryClient();
const onMessage = useCallback((message: MessageEvent<string>) => {
try {
const data = JSON.parse(message.data) as SecuritySocketRawMessage | undefined;
if (data) {
addMessage(data, queryClient);
}
return undefined;
} catch {
return undefined;
}
}, []);
// useEffect for created the WebSocket and 'storing' it in useRef
useEffect(() => {
if (isUserLoaded) {
onStartWebSocket(token ?? '');
}
const wsCurrent = webSocket;
return () => wsCurrent?.close();
}, [isUserLoaded]);
// useEffect for generating global notifications
useEffect(() => {
if (webSocket) {
webSocket.addEventListener('message', onMessage);
}
return () => {
if (webSocket) webSocket.removeEventListener('message', onMessage);
};
}, [webSocket]);
useEffect(() => {
const handleVisibilityChange = () => {
let timeoutId;
if (webSocket) {
if (document.visibilityState === 'hidden') {
/* timeoutId = setTimeout(() => {
if (webSocket) webSocket.onclose = () => {};
webSocket?.close();
setIsOpen(false);
}, 5000); */
} else {
// If tab is active again, verify if browser killed the WS
clearTimeout(timeoutId);
if (!isOpen && isUserLoaded) {
onStartWebSocket(token ?? '');
}
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [webSocket, isOpen]);
const values: SecuritySocketContextReturn = useMemo(() => ({}), []);
return <SecuritySocketContext.Provider value={values}>{children}</SecuritySocketContext.Provider>;
};
export const useGlobalSecuritySocket: () => SecuritySocketContextReturn = () => React.useContext(SecuritySocketContext);

View File

@@ -0,0 +1,158 @@
import { QueryClient } from '@tanstack/react-query';
import { v4 as uuid } from 'uuid';
import create from 'zustand';
import { SecuritySocketRawMessage, SocketEventCallback, SocketWebSocketNotificationData } from './utils';
import { NotificationType } from 'models/Socket';
import { axiosSec } from 'utils/axiosInstances';
export type SecurityWebSocketMessage =
| {
type: 'NOTIFICATION';
data: SocketWebSocketNotificationData;
timestamp: Date;
id: string;
}
| {
type: 'UNKNOWN';
data: {
type?: string;
type_id?: number;
[key: string]: unknown;
};
timestamp: Date;
id: string;
};
const parseRawWebSocketMessage = (message?: SecuritySocketRawMessage): SocketWebSocketNotificationData | undefined => {
if (message && message.notification) {
if (message.notification.type_id === 1) {
return {
type: 'LOG',
log: message.notification.content,
};
}
} else if (message?.notificationTypes) {
return {
type: 'INITIAL_MESSAGE',
message,
};
}
return undefined;
};
export type SecurityStoreState = {
availableLogTypes: NotificationType[];
hiddenLogIds: number[];
setHiddenLogIds: (logsToHide: number[]) => void;
lastMessage?: SecurityWebSocketMessage;
allMessages: SecurityWebSocketMessage[];
addMessage: (rawMsg: SecuritySocketRawMessage, queryClient: QueryClient) => void;
eventListeners: SocketEventCallback[];
addEventListeners: (callback: SocketEventCallback[]) => void;
webSocket?: WebSocket;
send: (str: string) => void;
startWebSocket: (token: string, tries?: number) => void;
isWebSocketOpen: boolean;
setWebSocketOpen: (isOpen: boolean) => void;
lastSearchResults: string[];
setLastSearchResults: (result: string[]) => void;
errors: { str: string; timestamp: Date }[];
};
export const useSecurityStore = create<SecurityStoreState>((set, get) => ({
availableLogTypes: [],
hiddenLogIds: [],
setHiddenLogIds: (logsToHide: number[]) => {
get().send(JSON.stringify({ 'drop-notifications': logsToHide }));
set(() => ({
hiddenLogIds: logsToHide,
}));
},
allMessages: [] as SecurityWebSocketMessage[],
addMessage: (rawMsg: SecuritySocketRawMessage) => {
try {
const msg = parseRawWebSocketMessage(rawMsg);
if (msg) {
// Handle notification-specific logic
if (msg.type === 'INITIAL_MESSAGE') {
if (msg.message.notificationTypes) {
set({ availableLogTypes: msg.message.notificationTypes });
}
}
// General handling
const obj: SecurityWebSocketMessage = {
type: 'NOTIFICATION',
data: msg,
timestamp: msg.log?.timestamp ? new Date(msg.log.timestamp * 1000) : new Date(),
id: uuid(),
};
const eventsToFire = get().eventListeners.filter(
({ type, serialNumber }) => type === msg.type && serialNumber === msg.serialNumber,
);
if (eventsToFire.length > 0) {
for (const event of eventsToFire) {
event.callback();
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
eventListeners: get().eventListeners.filter(
({ id }) => !eventsToFire.find(({ id: findId }) => findId === id),
),
}));
}
return set((state) => ({
allMessages:
state.allMessages.length <= 1000 ? [...state.allMessages, obj] : [...state.allMessages.slice(1), obj],
lastMessage: obj,
}));
}
return undefined;
} catch {
// TODO - Add error message to socket logs
return set((state) => ({
errors: [...state.errors, { str: JSON.stringify(rawMsg), timestamp: new Date() }],
}));
}
},
eventListeners: [] as SocketEventCallback[],
addEventListeners: (events: SocketEventCallback[]) =>
set((state) => ({ eventListeners: [...state.eventListeners, ...events] })),
isWebSocketOpen: false,
setWebSocketOpen: (isOpen: boolean) => set({ isWebSocketOpen: isOpen }),
send: (str: string) => {
const ws = get().webSocket;
if (ws) ws.send(str);
},
startWebSocket: (token: string, tries = 0) => {
const newTries = tries + 1;
if (tries <= 10) {
set({
webSocket: new WebSocket(
`${
axiosSec?.defaults?.baseURL ? axiosSec.defaults.baseURL.replace('https', 'wss').replace('http', 'ws') : ''
}/ws`,
),
});
const ws = get().webSocket;
if (ws) {
ws.onopen = () => {
set({ isWebSocketOpen: true });
ws.send(`token:${token}`);
};
ws.onclose = () => {
set({ isWebSocketOpen: false });
setTimeout(() => get().startWebSocket(token, newTries), 3000);
};
}
}
},
lastSearchResults: [] as string[],
setLastSearchResults: (results: string[]) => set({ lastSearchResults: results }),
errors: [],
}));

View File

@@ -0,0 +1,51 @@
import { InitialSocketMessage } from 'models/Socket';
export type SecuritySocketNotificationTypeId = 1 | 1000 | 2000 | 3000 | 4000 | 5000 | 6000;
export const SecuritySocketNotificationTypeMap = {
1: 'logs',
};
type LogMessage = {
notification: {
notificationId: number;
type?: undefined;
type_id: 1;
content: {
level: LogLevel;
msg: string;
source: string;
thread_id: number;
thread_name: string;
timestamp: number;
};
};
serialNumbers?: undefined;
notificationTypes?: undefined;
};
export type SecuritySocketRawMessage = Partial<LogMessage> | InitialSocketMessage;
export type LogLevel = 'information' | 'critical' | 'debug' | 'error' | 'fatal' | 'notice' | 'trace' | 'warning';
export type SocketWebSocketNotificationData =
| {
type: 'LOG';
serialNumber?: undefined;
serialNumbers?: undefined;
notificationTypes?: undefined;
log: LogMessage['notification']['content'];
}
| {
type: 'INITIAL_MESSAGE';
serialNumber?: undefined;
serialNumbers?: undefined;
notificationTypes?: undefined;
log?: undefined;
message: InitialSocketMessage;
};
export type SocketEventCallback = {
id: string;
type: 'LOG';
serialNumber: string;
callback: () => void;
};

View File

@@ -0,0 +1,384 @@
import { useToast } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
import { useAuth } from 'contexts/AuthProvider';
import { AxiosError } from 'models/Axios';
import { DeviceRttyApiResponse, GatewayDevice, WifiScanCommand, WifiScanResult } from 'models/Device';
import { Note } from 'models/Note';
import { PageInfo } from 'models/Table';
import { axiosGw } from 'utils/axiosInstances';
const getDeviceCount = () =>
axiosGw.get('devices?countOnly=true').then((response) => response.data) as Promise<{ count: number }>;
export const useGetDeviceCount = ({ enabled }: { enabled: boolean }) => {
const { t } = useTranslation();
const toast = useToast();
return useQuery(['devices', 'count'], getDeviceCount, {
enabled,
onError: (e: AxiosError) => {
if (!toast.isActive('inventory-fetching-error'))
toast({
id: 'inventory-fetching-error',
title: t('common.error'),
description: t('crud.error_fetching_obj', {
obj: t('devices.one'),
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
});
};
export type DeviceWithStatus = {
UUID: number;
associations_2G: number;
associations_5G: number;
compatible: string;
connected: boolean;
certificateExpiryDate?: number;
createdTimestamp: number;
devicePassword: string;
deviceType: 'AP' | 'SWITCH' | 'IOT' | 'MESH';
entity: string;
firmware: string;
fwUpdatePolicy: string;
ipAddress: string;
lastConfigurationChange: number;
lastConfigurationDownload: number;
lastContact: number | string;
lastFWUpdate: number;
locale: string;
location: string;
macAddress: string;
manufacturer: string;
messageCount: number;
modified: number;
notes: Note[];
owner: string;
restrictedDevice: boolean;
rxBytes: number;
serialNumber: string;
subscriber: string;
txBytes: number;
venue: string;
verifiedCertificate: string;
};
const getDevices = (limit: number, offset: number) =>
axiosGw
.get(`devices?deviceWithStatus=true&limit=${limit}&offset=${offset}`)
.then((response) => response.data) as Promise<{ devicesWithStatus: DeviceWithStatus[] }>;
export const useGetDevices = ({
pageInfo,
enabled,
onError,
}: {
pageInfo?: PageInfo;
enabled: boolean;
onError?: (e: AxiosError) => void;
}) => {
const offset = pageInfo?.limit !== undefined ? pageInfo.limit * pageInfo.index : 0;
return useQuery(
['devices', 'all', { limit: pageInfo?.limit, offset }],
() => getDevices(pageInfo?.limit || 0, offset),
{
keepPreviousData: true,
enabled: enabled && pageInfo !== undefined,
staleTime: 30000,
onError,
},
);
};
export type DeviceStatus = {
UUID: number;
associations_2G: number;
associations_5G: number;
connected: boolean;
certificateExpiryDate: number;
connectionCompletionTime: number;
firmware: string;
ipAddress: string;
kafkaClients: number;
kafkaPackets: number;
lastContact: number;
locale: string;
messageCount: number;
rxBytes: number;
sessionId: number;
started: number;
totalConnectionTime: number;
txBytes: number;
verifiedCertificate: string;
webSocketClients: number;
websocketPackets: number;
};
const getDeviceStatus = (serialNumber?: string) =>
axiosGw.get(`device/${serialNumber}/status`).then((response) => response.data) as Promise<DeviceStatus>;
export const useGetDeviceStatus = ({
serialNumber,
onError,
}: {
serialNumber?: string;
onError?: (e: AxiosError) => void;
}) =>
useQuery(['device', serialNumber, 'status'], () => getDeviceStatus(serialNumber), {
enabled: serialNumber !== undefined && serialNumber !== '',
onError,
});
export type DevicesStats = {
averageConnectionTime: number;
connectedDevices: number;
connectingDevices: number;
};
const getInitialStats = async () =>
axiosGw.get(`devices?connectionStatistics=true`).then(({ data }: { data: DevicesStats }) => data);
export const useGetDevicesStats = ({ onError }: { onError?: (e: AxiosError) => void }) => {
const { isUserLoaded } = useAuth();
return useQuery(['devices', 'all', 'connection-statistics'], () => getInitialStats(), {
enabled: isUserLoaded,
onError,
});
};
export type DeviceHealthCheck = {
serialNumber: string;
values: {
UUID: number;
recorded: number;
sanity: number;
values: {
unit: {
memory: number;
};
};
}[];
};
const getDeviceHealthChecks = (serialNumber?: string, limit?: number) =>
axiosGw
.get(`device/${serialNumber}/healthchecks?newest=true&limit=${limit ?? 50}`)
.then((response) => response.data) as Promise<DeviceHealthCheck>;
export const useGetDeviceHealthChecks = ({
serialNumber,
onError,
limit,
}: {
serialNumber?: string;
onError?: (e: AxiosError) => void;
limit?: number;
}) =>
useQuery(['device', serialNumber, 'healthchecks', { limit }], () => getDeviceHealthChecks(serialNumber, limit), {
enabled: serialNumber !== undefined && serialNumber !== '',
keepPreviousData: true,
onError,
});
export const useGetDevice = ({ serialNumber, onClose }: { serialNumber?: string; onClose?: () => void }) => {
const { t } = useTranslation();
const toast = useToast();
return useQuery(
['device', serialNumber],
() => axiosGw.get(`device/${serialNumber}`).then(({ data }: { data: GatewayDevice }) => data),
{
staleTime: 60 * 1000,
enabled: serialNumber !== undefined && serialNumber !== '',
onError: (e: AxiosError) => {
if (!toast.isActive('gateway-device-fetching-error'))
toast({
id: 'gateway-device-error',
title: t('common.error'),
description:
e?.response?.status === 404
? t('devices.not_found_gateway')
: t('crud.error_fetching_obj', {
e: e?.response?.data?.ErrorDescription,
obj: t('devices.one'),
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
if (onClose) onClose();
},
},
);
};
const deleteDevice = async (serialNumber: string) => axiosGw.delete(`device/${serialNumber}`);
export const useDeleteDevice = ({ serialNumber }: { serialNumber: string }) => {
const queryClient = useQueryClient();
return useMutation(deleteDevice, {
onSuccess: () => {
queryClient.invalidateQueries(['devices']);
queryClient.invalidateQueries(['device', serialNumber]);
},
});
};
export const useRebootDevice = ({ serialNumber }: { serialNumber: string }) =>
useMutation(() => axiosGw.post(`device/${serialNumber}/reboot`, { serialNumber, when: 0 }));
export const useBlinkDevice = ({ serialNumber }: { serialNumber: string }) =>
useMutation(() =>
axiosGw.post(`device/${serialNumber}/leds`, { serialNumber, when: 0, pattern: 'blink', duration: 30 }),
);
export const useFactoryReset = ({
serialNumber,
keepRedirector,
onClose,
}: {
serialNumber: string;
keepRedirector: boolean;
onClose: () => void;
}) => {
const { t } = useTranslation();
const toast = useToast();
return useMutation(() => axiosGw.post(`device/${serialNumber}/factory`, { serialNumber, keepRedirector }), {
onSuccess: () => {
toast({
id: `factory-reset-success-${uuid()}`,
title: t('common.success'),
description: t('commands.factory_reset_success'),
status: 'success',
duration: 5000,
isClosable: true,
position: 'top-right',
});
onClose();
},
onError: (e: AxiosError) => {
toast({
id: uuid(),
title: t('common.error'),
description: t('commands.factory_reset_error', {
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
});
};
export const useWifiScanDevice = ({ serialNumber }: { serialNumber: string }) => {
const toast = useToast();
const { t } = useTranslation();
return useMutation(
({ dfs, bandwidth, activeScan }: WifiScanCommand): Promise<WifiScanResult | undefined> =>
axiosGw
.post<WifiScanResult>(`device/${serialNumber}/wifiscan`, {
serialNumber,
override_dfs: dfs,
bandwidth: bandwidth !== '' ? bandwidth : undefined,
activeScan,
})
.then(({ data }: { data: WifiScanResult }) => data),
{
onSuccess: () => {},
onError: (e: AxiosError) => {
toast({
id: uuid(),
title: t('common.error'),
description: t('commands.wifiscan_error', {
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
},
);
};
export const useGetDeviceRtty = ({ serialNumber, extraId }: { serialNumber: string; extraId: string | number }) => {
const { t } = useTranslation();
const toast = useToast();
return useQuery(
['get-gateway-device-rtty', serialNumber, extraId],
() => axiosGw.get(`device/${serialNumber}/rtty`).then(({ data }: { data: DeviceRttyApiResponse }) => data),
{
enabled: false,
onSuccess: ({ server, viewport, connectionId }) => {
const url = `https://${server}:${viewport}/connect/${connectionId}`;
window.open(url, '_blank')?.focus();
},
onError: (e: AxiosError) => {
if (!toast.isActive('get-gateway-device-rtty-error'))
toast({
id: 'get-gateway-device-rtty',
title: t('common.error'),
description:
e?.response?.status === 404
? t('devices.not_found_gateway')
: t('devices.error_rtty', {
e: e?.response?.data?.ErrorDescription,
}),
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
},
},
);
};
export type DeviceCapabilities = {
capabilities: { [key: string]: unknown }[];
serialNumber: string;
firstUpdate: number;
lastUpdate: number;
};
const getCapabilities = async (serialNumber: string) =>
axiosGw.get(`device/${serialNumber}/capabilities`).then(({ data }: { data: DeviceCapabilities }) => data);
export const useGetDeviceCapabilities = ({
serialNumber,
onError,
}: {
serialNumber: string;
onError?: (e: AxiosError) => void;
}) => {
const { isUserLoaded } = useAuth();
return useQuery(['deviceCapabilities', serialNumber], () => getCapabilities(serialNumber), {
enabled: isUserLoaded,
onError,
});
};
const modifyDevice = async ({ serialNumber, notes }: { serialNumber: string; notes?: Note[] }) =>
axiosGw.put(`device/${serialNumber}`, { notes });
export const useUpdateDevice = ({ serialNumber }: { serialNumber: string }) => {
const queryClient = useQueryClient();
return useMutation(modifyDevice, {
onSuccess: () => {
queryClient.invalidateQueries(['devices']);
queryClient.invalidateQueries(['device', serialNumber]);
},
});
};

14
src/models/Socket.ts Normal file
View File

@@ -0,0 +1,14 @@
export type NotificationType = {
helper?: string;
id: number;
};
export type InitialSocketMessage = {
notification: undefined;
serialNumbers: undefined;
command_response_id: undefined;
response: undefined;
success?: string;
error?: string;
notificationTypes?: NotificationType[];
};

View File

@@ -0,0 +1,178 @@
import * as React from 'react';
import { Badge, Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react';
import { Download } from 'phosphor-react';
import { CSVLink } from 'react-csv';
import { useTranslation } from 'react-i18next';
import ReactVirtualizedAutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import { v4 as uuid } from 'uuid';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import { ShownLogsDropdown } from 'components/ShownLogsDropdown';
import { useFirmwareStore } from 'contexts/FirmwareSocketProvider/useStore';
import { LogLevel } from 'contexts/FirmwareSocketProvider/utils';
import { dateForFilename } from 'utils/dateFormatting';
import { uppercaseFirstLetter } from 'utils/stringHelper';
const FmsLogsCard = () => {
const { t } = useTranslation();
const { availableLogTypes, hiddenLogIds, setHiddenLogIds, logs } = useFirmwareStore((state) => ({
logs: state.allMessages,
availableLogTypes: state.availableLogTypes,
hiddenLogIds: state.hiddenLogIds,
setHiddenLogIds: state.setHiddenLogIds,
}));
const [level, setLevel] = React.useState<'' | LogLevel>('');
const data = React.useMemo(() => {
const arr = logs.filter(
(d) => d.type === 'NOTIFICATION' && d.data.type === 'LOG' && (level === '' || d.data.log.level === level),
);
return arr.reverse();
}, [logs, level]);
const colorSchemeMap: Record<LogLevel, string> = {
information: 'blue',
critical: 'red',
debug: 'teal',
error: 'red',
fatal: 'purple',
notice: 'blue',
trace: 'blue',
warning: 'yellow',
};
type RowProps = { index: number; style: React.CSSProperties };
const Row = React.useCallback(
({ index, style }: RowProps) => {
const msg = data[index];
if (msg) {
if (msg.type === 'NOTIFICATION' && msg.data.type === 'LOG') {
return (
<Box style={style}>
<Flex w="100%">
<Box flex="0 1 110px">
<Text>{msg.timestamp.toLocaleTimeString()}</Text>
</Box>
<Box flex="0 1 200px">
<Text w="200px" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{msg.data.log.source}
</Text>
</Box>
<Box flex="0 1 140px">
<Text w="140px" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{msg.data.log.thread_id}-{msg.data.log.thread_name}
</Text>
</Box>
<Box flex="0 1 110px">
<Badge
ml={1}
size="lg"
fontSize="0.9em"
variant="solid"
colorScheme={colorSchemeMap[msg.data.log.level]}
>
{msg.data.log.level}
</Badge>
</Box>
<Box textAlign="left" w="calc(100% - 180px - 210px - 120px - 60px)">
<Text textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{JSON.stringify(msg.data.log.msg).replace(/"/g, '')}
</Text>
</Box>
</Flex>
</Box>
);
}
}
return null;
},
[t, data],
);
const downloadableLogs = React.useMemo(
() =>
data.map((msg) =>
msg.type === 'NOTIFICATION' && msg.data.type === 'LOG'
? {
timestamp: msg.timestamp.toLocaleString(),
thread: `${msg.data.log.thread_id}-${msg.data.log.thread_name}`,
source: msg.data?.log?.source ?? '-',
level: msg.data?.log?.level,
message: JSON.stringify(msg.data?.log?.msg),
}
: {},
),
[data],
);
return (
<>
<CardHeader px={4} pt={4}>
<Spacer />
<HStack spacing={2}>
<ShownLogsDropdown
availableLogTypes={availableLogTypes}
setHiddenLogIds={setHiddenLogIds}
hiddenLogIds={hiddenLogIds}
helperLabels={{
1: t('logs.one'),
}}
/>
<Select size="md" value={level} onChange={(e) => setLevel(e.target.value as '' | LogLevel)} w="130px">
<option value="">{t('common.select_all')}</option>
{Object.keys(colorSchemeMap).map((key) => (
<option key={uuid()} value={key}>
{uppercaseFirstLetter(key)}
</option>
))}
</Select>
<CSVLink
filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={downloadableLogs as object[]}
>
<Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}>
{t('logs.export')}
</Button>
</CSVLink>
</HStack>
</CardHeader>
<CardBody p={4}>
<Box overflowX="auto" w="100%">
<Table size="sm">
<Thead>
<Tr>
<Th w="110px">{t('common.time')}</Th>
<Th w="200px">{t('logs.source')}</Th>
<Th w="160px">
{t('logs.thread')} ID-{t('common.name')}
</Th>
<Th w="90px" pl={0}>
{t('logs.level')}
</Th>
<Th>{t('logs.message')}</Th>
</Tr>
</Thead>
</Table>
<Box ml={4} h="calc(70vh)">
<ReactVirtualizedAutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
itemCount={data.length}
itemSize={35}
itemKey={(index) => data[index]?.id ?? uuid()}
>
{Row}
</List>
)}
</ReactVirtualizedAutoSizer>
</Box>
</Box>
</CardBody>
</>
);
};
export default FmsLogsCard;

View File

@@ -0,0 +1,181 @@
import * as React from 'react';
import { Badge, Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react';
import { Download } from 'phosphor-react';
import { CSVLink } from 'react-csv';
import { useTranslation } from 'react-i18next';
import ReactVirtualizedAutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import { v4 as uuid } from 'uuid';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import { ShownLogsDropdown } from 'components/ShownLogsDropdown';
import { useProvisioningStore } from 'contexts/ProvisioningSocketProvider/useStore';
import { LogLevel } from 'contexts/ProvisioningSocketProvider/utils';
import { dateForFilename } from 'utils/dateFormatting';
import { uppercaseFirstLetter } from 'utils/stringHelper';
const GeneralLogsCard = () => {
const { t } = useTranslation();
const { availableLogTypes, hiddenLogIds, setHiddenLogIds, logs } = useProvisioningStore((state) => ({
logs: state.allMessages,
availableLogTypes: state.availableLogTypes,
hiddenLogIds: state.hiddenLogIds,
setHiddenLogIds: state.setHiddenLogIds,
}));
const [level, setLevel] = React.useState<'' | LogLevel>('');
const data = React.useMemo(() => {
const arr = logs.filter(
(d) => d.type === 'NOTIFICATION' && d.data.type === 'LOG' && (level === '' || d.data.log.level === level),
);
return arr.reverse();
}, [logs, level]);
const colorSchemeMap: Record<LogLevel, string> = {
information: 'blue',
critical: 'red',
debug: 'teal',
error: 'red',
fatal: 'purple',
notice: 'blue',
trace: 'blue',
warning: 'yellow',
};
type RowProps = { index: number; style: React.CSSProperties };
const Row = React.useCallback(
({ index, style }: RowProps) => {
const msg = data[index];
if (msg) {
if (msg.type === 'NOTIFICATION' && msg.data.type === 'LOG') {
return (
<Box style={style}>
<Flex w="100%">
<Box flex="0 1 110px">
<Text>{msg.timestamp.toLocaleTimeString()}</Text>
</Box>
<Box flex="0 1 200px">
<Text w="200px" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{msg.data.log.source}
</Text>
</Box>
<Box flex="0 1 140px">
<Text w="140px" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{msg.data.log.thread_id}-{msg.data.log.thread_name}
</Text>
</Box>
<Box flex="0 1 110px">
<Badge
ml={1}
size="lg"
fontSize="0.9em"
variant="solid"
colorScheme={colorSchemeMap[msg.data.log.level]}
>
{msg.data.log.level}
</Badge>
</Box>
<Box textAlign="left" w="calc(100% - 180px - 210px - 120px - 60px)">
<Text textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{JSON.stringify(msg.data.log.msg).replace(/"/g, '')}
</Text>
</Box>
</Flex>
</Box>
);
}
}
return null;
},
[t, data],
);
const downloadableLogs = React.useMemo(
() =>
data.map((msg) =>
msg.type === 'NOTIFICATION' && msg.data.type === 'LOG'
? {
timestamp: msg.timestamp.toLocaleString(),
thread: `${msg.data.log.thread_id}-${msg.data.log.thread_name}`,
source: msg.data?.log?.source ?? '-',
level: msg.data?.log?.level,
message: JSON.stringify(msg.data?.log?.msg),
}
: {},
),
[data],
);
return (
<>
<CardHeader px={4} pt={4}>
<Spacer />
<HStack spacing={2}>
<ShownLogsDropdown
availableLogTypes={availableLogTypes}
setHiddenLogIds={setHiddenLogIds}
hiddenLogIds={hiddenLogIds}
helperLabels={{
1: t('logs.one'),
1000: t('logs.venue_upgrade'),
2000: t('logs.venue_config'),
3000: t('logs.venue_reboot'),
}}
/>
<Select size="md" value={level} onChange={(e) => setLevel(e.target.value as '' | LogLevel)} w="130px">
<option value="">{t('common.select_all')}</option>
{Object.keys(colorSchemeMap).map((key) => (
<option key={uuid()} value={key}>
{uppercaseFirstLetter(key)}
</option>
))}
</Select>
<CSVLink
filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={downloadableLogs as object[]}
>
<Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}>
{t('logs.export')}
</Button>
</CSVLink>
</HStack>
</CardHeader>
<CardBody p={4}>
<Box overflowX="auto" w="100%">
<Table size="sm">
<Thead>
<Tr>
<Th w="110px">{t('common.time')}</Th>
<Th w="200px">{t('logs.source')}</Th>
<Th w="160px">
{t('logs.thread')} ID-{t('common.name')}
</Th>
<Th w="90px" pl={0}>
{t('logs.level')}
</Th>
<Th>{t('logs.message')}</Th>
</Tr>
</Thead>
</Table>
<Box ml={4} h="calc(70vh)">
<ReactVirtualizedAutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
itemCount={data.length}
itemSize={35}
itemKey={(index) => data[index]?.id ?? uuid()}
>
{Row}
</List>
)}
</ReactVirtualizedAutoSizer>
</Box>
</Box>
</CardBody>
</>
);
};
export default GeneralLogsCard;

View File

@@ -0,0 +1,162 @@
import * as React from 'react';
import { Box, Button, Flex, HStack, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react';
import { Download } from 'phosphor-react';
import { CSVLink } from 'react-csv';
import { useTranslation } from 'react-i18next';
import ReactVirtualizedAutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import { ShownLogsDropdown } from 'components/ShownLogsDropdown';
import { useProvisioningStore } from 'contexts/ProvisioningSocketProvider/useStore';
import { dateForFilename } from 'utils/dateFormatting';
const NotificationsCard = () => {
const { t } = useTranslation();
const { availableLogTypes, hiddenLogIds, setHiddenLogIds, logs } = useProvisioningStore((state) => ({
logs: state.allMessages,
availableLogTypes: state.availableLogTypes,
hiddenLogIds: state.hiddenLogIds,
setHiddenLogIds: state.setHiddenLogIds,
}));
const labels = {
1: t('logs.one'),
1000: t('logs.venue_upgrade'),
2000: t('logs.venue_config'),
3000: t('logs.venue_reboot'),
};
const data = React.useMemo(() => {
const arr = logs.filter(
(log) =>
log.type === 'NOTIFICATION' &&
log.data.type === 'NOTIFICATION' &&
(log.data.data.type_id === 1000 || log.data.data.type_id === 2000 || log.data.data.type_id === 3000),
);
return arr.reverse() as {
type: 'NOTIFICATION';
data: {
type: 'NOTIFICATION';
data: {
notification_id: number;
type?: 'venue_fw_upgrade' | 'venue_config_update' | 'venue_rebooter';
type_id: 1000 | 2000 | 3000;
content: {
title: string;
details: string;
success: string[];
noFirmware?: string[];
notConnected?: string[];
skipped?: string[];
warning: string[];
error: string[];
timeStamp: number;
};
};
log?: undefined;
notificationTypes?: undefined;
};
timestamp: Date;
id: string;
}[];
}, [logs]);
type RowProps = { index: number; style: React.CSSProperties };
const Row = React.useCallback(
({ index, style }: RowProps) => {
const msg = data[index];
if (msg) {
if (msg.type === 'NOTIFICATION' && msg.data.type === 'NOTIFICATION') {
return (
<Box style={style}>
<Flex w="100%">
<Box flex="0 1 110px">
<Text>{msg.timestamp.toLocaleTimeString()}</Text>
</Box>
<Box flex="0 1 230px" textAlign="left">
<Text textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap" w="230px">
{msg.data.data.content.title ?? '-'}
</Text>
</Box>
<Box flex="0 1 140px">
<Text>{labels[msg.data.data.type_id]}</Text>
</Box>
<Box textAlign="left" w="calc(100% - 80px - 220px - 140px - 60px)">
<Text textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{JSON.stringify(msg.data.data.content)}
</Text>
</Box>
</Flex>
</Box>
);
}
}
return null;
},
[t, data],
);
const downloadableLogs = React.useMemo(
() =>
data.map((msg) => ({
timestamp: msg.timestamp.toLocaleString(),
serialNumber: labels[msg.data.data.type_id] ?? '-',
type: labels[msg.data.data.type_id] ?? '-',
data: JSON.stringify(msg.data.data),
})),
[data],
);
return (
<>
<CardHeader px={4} pt={4}>
<Spacer />
<HStack spacing={2}>
<ShownLogsDropdown
availableLogTypes={availableLogTypes}
setHiddenLogIds={setHiddenLogIds}
hiddenLogIds={hiddenLogIds}
helperLabels={labels}
/>
<CSVLink
filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={downloadableLogs as object[]}
>
<Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}>
{t('logs.export')}
</Button>
</CSVLink>
</HStack>
</CardHeader>
<CardBody p={4}>
<Box overflowX="auto" w="100%">
<Table size="sm">
<Thead>
<Tr>
<Th w="110px">{t('common.time')}</Th>
<Th w="250px">{t('contacts.title')}</Th>
<Th w="120px" pl={0}>
{t('common.type')}
</Th>
<Th>{t('analytics.raw_data')}</Th>
</Tr>
</Thead>
</Table>
<Box ml={4} h="calc(70vh)">
<ReactVirtualizedAutoSizer>
{({ height, width }) => (
<List height={height} width={width} itemCount={data.length} itemSize={35}>
{Row}
</List>
)}
</ReactVirtualizedAutoSizer>
</Box>
</Box>
</CardBody>
</>
);
};
export default NotificationsCard;

View File

@@ -0,0 +1,178 @@
import * as React from 'react';
import { Badge, Box, Button, Flex, HStack, Select, Spacer, Table, Text, Th, Thead, Tr } from '@chakra-ui/react';
import { Download } from 'phosphor-react';
import { CSVLink } from 'react-csv';
import { useTranslation } from 'react-i18next';
import ReactVirtualizedAutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import { v4 as uuid } from 'uuid';
import CardBody from 'components/Card/CardBody';
import CardHeader from 'components/Card/CardHeader';
import { ShownLogsDropdown } from 'components/ShownLogsDropdown';
import { useSecurityStore } from 'contexts/SecuritySocketProvider/useStore';
import { LogLevel } from 'contexts/SecuritySocketProvider/utils';
import { dateForFilename } from 'utils/dateFormatting';
import { uppercaseFirstLetter } from 'utils/stringHelper';
const SecLogsCard = () => {
const { t } = useTranslation();
const { availableLogTypes, hiddenLogIds, setHiddenLogIds, logs } = useSecurityStore((state) => ({
logs: state.allMessages,
availableLogTypes: state.availableLogTypes,
hiddenLogIds: state.hiddenLogIds,
setHiddenLogIds: state.setHiddenLogIds,
}));
const [level, setLevel] = React.useState<'' | LogLevel>('');
const data = React.useMemo(() => {
const arr = logs.filter(
(d) => d.type === 'NOTIFICATION' && d.data.type === 'LOG' && (level === '' || d.data.log.level === level),
);
return arr.reverse();
}, [logs, level]);
const colorSchemeMap: Record<LogLevel, string> = {
information: 'blue',
critical: 'red',
debug: 'teal',
error: 'red',
fatal: 'purple',
notice: 'blue',
trace: 'blue',
warning: 'yellow',
};
type RowProps = { index: number; style: React.CSSProperties };
const Row = React.useCallback(
({ index, style }: RowProps) => {
const msg = data[index];
if (msg) {
if (msg.type === 'NOTIFICATION' && msg.data.type === 'LOG') {
return (
<Box style={style}>
<Flex w="100%">
<Box flex="0 1 110px">
<Text>{msg.timestamp.toLocaleTimeString()}</Text>
</Box>
<Box flex="0 1 200px">
<Text w="200px" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{msg.data.log.source}
</Text>
</Box>
<Box flex="0 1 140px">
<Text w="140px" textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{msg.data.log.thread_id}-{msg.data.log.thread_name}
</Text>
</Box>
<Box flex="0 1 110px">
<Badge
ml={1}
size="lg"
fontSize="0.9em"
variant="solid"
colorScheme={colorSchemeMap[msg.data.log.level]}
>
{msg.data.log.level}
</Badge>
</Box>
<Box textAlign="left" w="calc(100% - 180px - 210px - 120px - 60px)">
<Text textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{JSON.stringify(msg.data.log.msg).replace(/"/g, '')}
</Text>
</Box>
</Flex>
</Box>
);
}
}
return null;
},
[t, data],
);
const downloadableLogs = React.useMemo(
() =>
data.map((msg) =>
msg.type === 'NOTIFICATION' && msg.data.type === 'LOG'
? {
timestamp: msg.timestamp.toLocaleString(),
thread: `${msg.data.log.thread_id}-${msg.data.log.thread_name}`,
source: msg.data?.log?.source ?? '-',
level: msg.data?.log?.level,
message: JSON.stringify(msg.data?.log?.msg),
}
: {},
),
[data],
);
return (
<>
<CardHeader px={4} pt={4}>
<Spacer />
<HStack spacing={2}>
<ShownLogsDropdown
availableLogTypes={availableLogTypes}
setHiddenLogIds={setHiddenLogIds}
hiddenLogIds={hiddenLogIds}
helperLabels={{
1: t('logs.one'),
}}
/>
<Select size="md" value={level} onChange={(e) => setLevel(e.target.value as '' | LogLevel)} w="130px">
<option value="">{t('common.select_all')}</option>
{Object.keys(colorSchemeMap).map((key) => (
<option key={uuid()} value={key}>
{uppercaseFirstLetter(key)}
</option>
))}
</Select>
<CSVLink
filename={`logs_${dateForFilename(new Date().getTime() / 1000)}.csv`}
data={downloadableLogs as object[]}
>
<Button onClick={() => {}} colorScheme="blue" leftIcon={<Download />}>
{t('logs.export')}
</Button>
</CSVLink>
</HStack>
</CardHeader>
<CardBody p={4}>
<Box overflowX="auto" w="100%">
<Table size="sm">
<Thead>
<Tr>
<Th w="110px">{t('common.time')}</Th>
<Th w="200px">{t('logs.source')}</Th>
<Th w="160px">
{t('logs.thread')} ID-{t('common.name')}
</Th>
<Th w="90px" pl={0}>
{t('logs.level')}
</Th>
<Th>{t('logs.message')}</Th>
</Tr>
</Thead>
</Table>
<Box ml={4} h="calc(70vh)">
<ReactVirtualizedAutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
itemCount={data.length}
itemSize={35}
itemKey={(index) => data[index]?.id ?? uuid()}
>
{Row}
</List>
)}
</ReactVirtualizedAutoSizer>
</Box>
</Box>
</CardBody>
</>
);
};
export default SecLogsCard;

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import FmsLogsCard from './FmsLogs';
import GeneralLogsCard from './GeneralLogs';
import LogsCard from './Notifications';
import SecLogsCard from './SecLogs';
import Card from 'components/Card';
import CardHeader from 'components/Card/CardHeader';
import { useAuth } from 'contexts/AuthProvider';
const INDEX_PARAM = 'notifications-tab-index';
const getDefaultTabIndex = () => {
const index = localStorage.getItem(INDEX_PARAM) || '0';
try {
return parseInt(index, 10);
} catch {
return 0;
}
};
const NotificationsPage = () => {
const { t } = useTranslation();
const { isUserLoaded } = useAuth();
const [tabIndex, setTabIndex] = React.useState(getDefaultTabIndex());
const handleTabChange = (index: number) => {
setTabIndex(index);
localStorage.setItem(INDEX_PARAM, index.toString());
};
return (
<Flex flexDirection="column" pt="75px">
{isUserLoaded && (
<Card p={0}>
<Tabs index={tabIndex} onChange={handleTabChange} variant="enclosed" isLazy>
<TabList>
<CardHeader>
<Tab>
{t('venues.one')} {t('notification.other')}
</Tab>
<Tab>Provisioning</Tab>
<Tab>{t('logs.security')}</Tab>
<Tab>{t('logs.firmware')}</Tab>
</CardHeader>
</TabList>
<TabPanels>
<TabPanel p={0}>
<Box
borderLeft="1px solid"
borderRight="1px solid"
borderBottom="1px solid"
borderColor="var(--chakra-colors-chakra-border-color)"
borderBottomLeftRadius="15px"
borderBottomRightRadius="15px"
>
<LogsCard />
</Box>
</TabPanel>
<TabPanel p={0}>
<Box
borderLeft="1px solid"
borderRight="1px solid"
borderBottom="1px solid"
borderColor="var(--chakra-colors-chakra-border-color)"
borderBottomLeftRadius="15px"
borderBottomRightRadius="15px"
>
<GeneralLogsCard />
</Box>
</TabPanel>
<TabPanel p={0}>
<Box
borderLeft="1px solid"
borderRight="1px solid"
borderBottom="1px solid"
borderColor="var(--chakra-colors-chakra-border-color)"
borderBottomLeftRadius="15px"
borderBottomRightRadius="15px"
>
<SecLogsCard />
</Box>
</TabPanel>
<TabPanel p={0}>
<Box
borderLeft="1px solid"
borderRight="1px solid"
borderBottom="1px solid"
borderColor="var(--chakra-colors-chakra-border-color)"
borderBottomLeftRadius="15px"
borderBottomRightRadius="15px"
>
<FmsLogsCard />
</Box>
</TabPanel>
</TabPanels>
</Tabs>
</Card>
)}
</Flex>
);
};
export default NotificationsPage;

View File

@@ -43,7 +43,7 @@ const SystemPage = () => {
<Card mb={4} py={2} px={4}>
<CardHeader>
<Heading size="md" my="auto">
{t('controller.firmware.endpoints')} ({endpoints?.length ?? 0})
{t('controller.firmware.endpoints')}
</Heading>
<Spacer />
<RefreshButton onClick={refetch} isFetching={isFetching} />

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Icon } from '@chakra-ui/react';
import { Info, Storefront, Tag, TreeStructure, UsersThree } from 'phosphor-react';
import { Info, ListBullets, Storefront, Tag, TreeStructure, UsersThree } from 'phosphor-react';
import { Route } from 'models/Routes';
const AccountPage = React.lazy(() => import('pages/Profile'));
@@ -8,6 +8,7 @@ const ConfigurationPage = React.lazy(() => import('pages/ConfigurationPage'));
const EntityPage = React.lazy(() => import('pages/EntityPage'));
const InventoryPage = React.lazy(() => import('pages/InventoryPage'));
const MapPage = React.lazy(() => import('pages/MapPage'));
const NotificationsPage = React.lazy(() => import('pages/Notifications'));
const OperatorPage = React.lazy(() => import('pages/OperatorPage'));
const OperatorsPage = React.lazy(() => import('pages/OperatorsPage'));
const SubscriberPage = React.lazy(() => import('pages/SubscriberPage'));
@@ -45,6 +46,15 @@ const routes: Route[] = [
),
component: OperatorsPage,
},
{
authorized: ['root', 'partner', 'admin', 'csr', 'system'],
path: '/logs',
name: 'controller.devices.logs',
icon: (active: boolean) => (
<Icon as={ListBullets} color="inherit" h={active ? '32px' : '24px'} w={active ? '32px' : '24px'} />
),
component: NotificationsPage,
},
{
authorized: ['root', 'partner', 'admin', 'csr', 'system'],
path: '/users',