mirror of
https://github.com/jambonz/jambonz-webapp.git
synced 2026-02-08 04:59:37 +00:00
Compare commits
42 Commits
feature/up
...
v0.8.4-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dd9548600 | ||
|
|
96ffce8cd1 | ||
|
|
b1fe033c12 | ||
|
|
a8d12546d9 | ||
|
|
724d86821d | ||
|
|
f91bbe9245 | ||
|
|
91625612d5 | ||
|
|
fbe71925b4 | ||
|
|
377fd40e2c | ||
|
|
af37066201 | ||
|
|
fffd86619d | ||
|
|
77c270e078 | ||
|
|
54ff53817f | ||
|
|
986b9a5eeb | ||
|
|
683693ec0e | ||
|
|
6cb1c50cf0 | ||
|
|
3d9a39ac3b | ||
|
|
4d7e84fa43 | ||
|
|
41423a443a | ||
|
|
bfbd66ef5c | ||
|
|
414bca4fdc | ||
|
|
a6c8257b60 | ||
|
|
dbb39db54e | ||
|
|
6712d8944b | ||
|
|
7051985aad | ||
|
|
88dae20666 | ||
|
|
977a08eaf7 | ||
|
|
1bfc722960 | ||
|
|
49c18ebd26 | ||
|
|
aba8b2be3a | ||
|
|
f4d7880ab7 | ||
|
|
7f93489580 | ||
|
|
19fafdc908 | ||
|
|
a165bfc4d6 | ||
|
|
e26d9b95cb | ||
|
|
e425d825bc | ||
|
|
a8d28da221 | ||
|
|
e3855e83f7 | ||
|
|
446b6e76e2 | ||
|
|
0b55cdcf85 | ||
|
|
f1743a9129 | ||
|
|
ec46121696 |
11
.env
11
.env
@@ -2,4 +2,13 @@ VITE_API_BASE_URL=http://127.0.0.1:3000/v1
|
||||
VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
|
||||
|
||||
## enables choosing units and lisenced account call limits
|
||||
# VITE_APP_ENABLE_ACCOUNT_LIMITS_ALL=true
|
||||
# VITE_APP_ENABLE_ACCOUNT_LIMITS_ALL=true
|
||||
|
||||
# disables controls for default application routing to carrier for SP and account level users
|
||||
#VITE_APP_DISABLE_DEFAULT_TRUNK_ROUTING=true
|
||||
## disables Least cost routing feature
|
||||
#VITE_APP_LCR_DISABLED=true
|
||||
## disables Jaeger Tracing feature
|
||||
#VITE_APP_JAEGER_TRACING_DISABLED=true
|
||||
## enable record All Calls feature
|
||||
#VITE_APP_DISABLE_CALL_RECORDING=true
|
||||
47
.github/workflows/docker-publish.yml
vendored
47
.github/workflows/docker-publish.yml
vendored
@@ -2,16 +2,8 @@ name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
# Publish `main` as Docker `latest` image.
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Publish `v1.2.3` tags as releases.
|
||||
tags:
|
||||
- v*
|
||||
|
||||
env:
|
||||
IMAGE_NAME: webapp
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
push:
|
||||
@@ -19,20 +11,13 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build image
|
||||
run: docker build . --file Dockerfile --tag $IMAGE_NAME
|
||||
|
||||
- name: Log into registry
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Push image
|
||||
- name: prepare tag
|
||||
id: prepare_tag
|
||||
run: |
|
||||
IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
|
||||
|
||||
# Change all uppercase to lowercase
|
||||
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
|
||||
IMAGE_ID=jambonz/webapp
|
||||
|
||||
# Strip git ref prefix from version
|
||||
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
|
||||
@@ -46,5 +31,21 @@ jobs:
|
||||
echo IMAGE_ID=$IMAGE_ID
|
||||
echo VERSION=$VERSION
|
||||
|
||||
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
|
||||
docker push $IMAGE_ID:$VERSION
|
||||
echo "image_id=$IMAGE_ID" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.prepare_tag.outputs.image_id }}:${{ steps.prepare_tag.outputs.version }}
|
||||
build-args: |
|
||||
GITHUB_REPOSITORY=$GITHUB_REPOSITORY
|
||||
GITHUB_REF=$GITHUB_REF
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:18.14.0-alpine3.16 as builder
|
||||
FROM node:18.15-alpine3.16 as builder
|
||||
RUN apk update && apk add --no-cache python3 make g++
|
||||
COPY . /opt/app
|
||||
WORKDIR /opt/app/
|
||||
@@ -6,7 +6,7 @@ RUN npm install
|
||||
RUN npm run build
|
||||
RUN npm prune
|
||||
|
||||
FROM node:18.9.0-alpine as webapp
|
||||
FROM node:18.14.1-alpine as webapp
|
||||
RUN apk add curl
|
||||
WORKDIR /opt/app
|
||||
COPY . /opt/app
|
||||
|
||||
@@ -9,9 +9,16 @@ API_PORT="${API_PORT:-3000}"
|
||||
API_VERSION="${API_VERSION:-v1}"
|
||||
API_BASE_URL=${API_BASE_URL:-http://$PUBLIC_IPV4:$API_PORT/$API_VERSION}
|
||||
|
||||
# Default to "false" if not set
|
||||
DISABLE_LCR=${DISABLE_LCR:-false}
|
||||
DISABLE_JAEGER_TRACING=${DISABLE_JAEGER_TRACING:-false}
|
||||
DISABLE_CUSTOM_SPEECH=${DISABLE_CUSTOM_SPEECH:-false}
|
||||
ENABLE_FORGOT_PASSWORD=${ENABLE_FORGOT_PASSWORD:-false}
|
||||
DISABLE_CALL_RECORDING=${DISABLE_CALL_RECORDING:-false}
|
||||
|
||||
# Serialize window global to provide the API URL to static frontend dist
|
||||
# This is declared and utilized in the web app: src/api/constants.ts
|
||||
SCRIPT_TAG="<script>window.JAMBONZ = { API_BASE_URL: \"${API_BASE_URL}\" };</script>"
|
||||
SCRIPT_TAG="<script>window.JAMBONZ = {API_BASE_URL: \"${API_BASE_URL}\",DISABLE_LCR: \"${DISABLE_LCR}\",DISABLE_JAEGER_TRACING: \"${DISABLE_JAEGER_TRACING}\",DISABLE_CUSTOM_SPEECH: \"${DISABLE_CUSTOM_SPEECH}\",ENABLE_FORGOT_PASSWORD: \"${ENABLE_FORGOT_PASSWORD}\",DISABLE_CALL_RECORDING: \"${DISABLE_CALL_RECORDING}\"};</script>"
|
||||
sed -i -e "\@</head>@i\ $SCRIPT_TAG" ./dist/index.html
|
||||
|
||||
# Start the frontend web app static server
|
||||
|
||||
237
package-lock.json
generated
237
package-lock.json
generated
@@ -1,21 +1,25 @@
|
||||
{
|
||||
"name": "jambonz-webapp",
|
||||
"version": "v1.0.0",
|
||||
"version": "0.8.3",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "jambonz-webapp",
|
||||
"version": "v1.0.0",
|
||||
"version": "0.8.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jambonz/ui-kit": "^0.0.21",
|
||||
"dayjs": "^1.11.5",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"react": "^18.0.0",
|
||||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-router-dom": "^6.3.0"
|
||||
"react-router-dom": "^6.3.0",
|
||||
"wavesurfer.js": "^6.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
@@ -23,6 +27,8 @@
|
||||
"@types/node": "^18.6.1",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/wavesurfer.js": "^6.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||
"@typescript-eslint/parser": "^5.30.6",
|
||||
"@vitejs/plugin-react": "^1.3.0",
|
||||
@@ -743,6 +749,21 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-dnd/asap": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
|
||||
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
|
||||
},
|
||||
"node_modules/@react-dnd/invariant": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
|
||||
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
|
||||
},
|
||||
"node_modules/@react-dnd/shallowequal": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
|
||||
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "4.2.1",
|
||||
"dev": true,
|
||||
@@ -804,6 +825,12 @@
|
||||
"integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/debounce": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.1.tgz",
|
||||
"integrity": "sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "4.17.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz",
|
||||
@@ -842,11 +869,11 @@
|
||||
"version": "18.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.6.1.tgz",
|
||||
"integrity": "sha512-z+2vB6yDt1fNwKOeGbckpmirO+VBDuQqecXkgeIqDlaOtmKn6hPR/viQ8cxCfqLU4fTlvM3+YjM367TukWdxpg==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.5",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
@@ -863,7 +890,7 @@
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.0.15",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -881,7 +908,7 @@
|
||||
},
|
||||
"node_modules/@types/scheduler": {
|
||||
"version": "0.16.2",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
@@ -906,6 +933,21 @@
|
||||
"integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/wavesurfer.js": {
|
||||
"version": "6.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/wavesurfer.js/-/wavesurfer.js-6.0.6.tgz",
|
||||
"integrity": "sha512-fD54o0RXZXxkOb+69Rt6rGViaHpIc1Mmde2aOX9qPhlQhrCPepybGnsekiG407+7scPlaK+hmuPez5AnnmlzGg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/debounce": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz",
|
||||
@@ -2284,7 +2326,7 @@
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.0",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cypress": {
|
||||
@@ -2640,6 +2682,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dnd-core": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
|
||||
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
|
||||
"dependencies": {
|
||||
"@react-dnd/asap": "^5.0.1",
|
||||
"@react-dnd/invariant": "^4.0.1",
|
||||
"redux": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"dev": true,
|
||||
@@ -3341,7 +3393,6 @@
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
@@ -3833,6 +3884,14 @@
|
||||
"@babel/runtime": "^7.7.6"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"dependencies": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
@@ -3925,6 +3984,11 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immutability-helper": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz",
|
||||
"integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ=="
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "4.1.0",
|
||||
"dev": true,
|
||||
@@ -5333,6 +5397,43 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
|
||||
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
|
||||
"dependencies": {
|
||||
"@react-dnd/invariant": "^4.0.1",
|
||||
"@react-dnd/shallowequal": "^4.0.1",
|
||||
"dnd-core": "^16.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"hoist-non-react-statics": "^3.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/hoist-non-react-statics": ">= 3.3.1",
|
||||
"@types/node": ">= 12",
|
||||
"@types/react": ">= 16",
|
||||
"react": ">= 16.14"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/hoist-non-react-statics": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd-html5-backend": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
|
||||
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
|
||||
"dependencies": {
|
||||
"dnd-core": "^16.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.2.0",
|
||||
"license": "MIT",
|
||||
@@ -5399,6 +5500,14 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.9",
|
||||
"license": "MIT"
|
||||
@@ -6426,6 +6535,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wavesurfer.js": {
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-6.6.3.tgz",
|
||||
"integrity": "sha512-XqUOXe8+SOTe8uKCHaqW0vJ5etCCQvq/NgaPycn9HAX/nUi+2zoWD+w9i7H5vBT9UCDNawOia+vS5Ct3kZGQzA=="
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"dev": true,
|
||||
@@ -7048,6 +7162,21 @@
|
||||
"fastq": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"@react-dnd/asap": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
|
||||
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
|
||||
},
|
||||
"@react-dnd/invariant": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
|
||||
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
|
||||
},
|
||||
"@react-dnd/shallowequal": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
|
||||
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
|
||||
},
|
||||
"@rollup/pluginutils": {
|
||||
"version": "4.2.1",
|
||||
"dev": true,
|
||||
@@ -7105,6 +7234,12 @@
|
||||
"integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/debounce": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.1.tgz",
|
||||
"integrity": "sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/express": {
|
||||
"version": "4.17.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz",
|
||||
@@ -7142,11 +7277,11 @@
|
||||
"version": "18.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.6.1.tgz",
|
||||
"integrity": "sha512-z+2vB6yDt1fNwKOeGbckpmirO+VBDuQqecXkgeIqDlaOtmKn6hPR/viQ8cxCfqLU4fTlvM3+YjM367TukWdxpg==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"@types/prop-types": {
|
||||
"version": "15.7.5",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"@types/qs": {
|
||||
"version": "6.9.7",
|
||||
@@ -7162,7 +7297,7 @@
|
||||
},
|
||||
"@types/react": {
|
||||
"version": "18.0.15",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"requires": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
@@ -7178,7 +7313,7 @@
|
||||
},
|
||||
"@types/scheduler": {
|
||||
"version": "0.16.2",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"@types/serve-static": {
|
||||
"version": "1.15.0",
|
||||
@@ -7202,6 +7337,21 @@
|
||||
"integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/wavesurfer.js": {
|
||||
"version": "6.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/wavesurfer.js/-/wavesurfer.js-6.0.6.tgz",
|
||||
"integrity": "sha512-fD54o0RXZXxkOb+69Rt6rGViaHpIc1Mmde2aOX9qPhlQhrCPepybGnsekiG407+7scPlaK+hmuPez5AnnmlzGg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/debounce": "*"
|
||||
}
|
||||
},
|
||||
"@types/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz",
|
||||
@@ -8042,7 +8192,7 @@
|
||||
},
|
||||
"csstype": {
|
||||
"version": "3.1.0",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"cypress": {
|
||||
"version": "10.8.0",
|
||||
@@ -8288,6 +8438,16 @@
|
||||
"path-type": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"dnd-core": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
|
||||
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
|
||||
"requires": {
|
||||
"@react-dnd/asap": "^5.0.1",
|
||||
"@react-dnd/invariant": "^4.0.1",
|
||||
"redux": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"doctrine": {
|
||||
"version": "2.1.0",
|
||||
"dev": true,
|
||||
@@ -8782,8 +8942,7 @@
|
||||
"dev": true
|
||||
},
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"dev": true
|
||||
"version": "3.1.3"
|
||||
},
|
||||
"fast-glob": {
|
||||
"version": "3.2.11",
|
||||
@@ -9127,6 +9286,14 @@
|
||||
"@babel/runtime": "^7.7.6"
|
||||
}
|
||||
},
|
||||
"hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"requires": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
@@ -9178,6 +9345,11 @@
|
||||
"version": "5.2.0",
|
||||
"dev": true
|
||||
},
|
||||
"immutability-helper": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz",
|
||||
"integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ=="
|
||||
},
|
||||
"immutable": {
|
||||
"version": "4.1.0",
|
||||
"dev": true
|
||||
@@ -10059,6 +10231,26 @@
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"react-dnd": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
|
||||
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
|
||||
"requires": {
|
||||
"@react-dnd/invariant": "^4.0.1",
|
||||
"@react-dnd/shallowequal": "^4.0.1",
|
||||
"dnd-core": "^16.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"hoist-non-react-statics": "^3.3.2"
|
||||
}
|
||||
},
|
||||
"react-dnd-html5-backend": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
|
||||
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
|
||||
"requires": {
|
||||
"dnd-core": "^16.0.1"
|
||||
}
|
||||
},
|
||||
"react-dom": {
|
||||
"version": "18.2.0",
|
||||
"requires": {
|
||||
@@ -10099,6 +10291,14 @@
|
||||
"picomatch": "^2.2.1"
|
||||
}
|
||||
},
|
||||
"redux": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.9"
|
||||
},
|
||||
@@ -10758,6 +10958,11 @@
|
||||
"rollup": "^2.75.6"
|
||||
}
|
||||
},
|
||||
"wavesurfer.js": {
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-6.6.3.tgz",
|
||||
"integrity": "sha512-XqUOXe8+SOTe8uKCHaqW0vJ5etCCQvq/NgaPycn9HAX/nUi+2zoWD+w9i7H5vBT9UCDNawOia+vS5Ct3kZGQzA=="
|
||||
},
|
||||
"which": {
|
||||
"version": "2.0.2",
|
||||
"dev": true,
|
||||
|
||||
12
package.json
12
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jambonz-webapp",
|
||||
"description": "A simple provisioning web app for jambonz",
|
||||
"version": "v1.0.0",
|
||||
"version": "0.8.3",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -41,12 +41,16 @@
|
||||
"deploy": "npm i && npm run build && npm run pm2"
|
||||
},
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.5",
|
||||
"@jambonz/ui-kit": "^0.0.21",
|
||||
"dayjs": "^1.11.5",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"react": "^18.0.0",
|
||||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-router-dom": "^6.3.0"
|
||||
"react-router-dom": "^6.3.0",
|
||||
"wavesurfer.js": "^6.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
@@ -54,6 +58,8 @@
|
||||
"@types/node": "^18.6.1",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/wavesurfer.js": "^6.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||
"@typescript-eslint/parser": "^5.30.6",
|
||||
"@vitejs/plugin-react": "^1.3.0",
|
||||
|
||||
@@ -31,9 +31,10 @@ app.get(
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const attempted_at = new Date(start.getTime() + i * increment);
|
||||
const failed = 0 === i % 5;
|
||||
const call_sid = nanoid();
|
||||
const call: RecentCall = {
|
||||
account_sid: req.params.account_sid,
|
||||
call_sid: nanoid(),
|
||||
call_sid,
|
||||
from: "15083084809",
|
||||
to: "18882349999",
|
||||
answered: !failed,
|
||||
@@ -48,6 +49,8 @@ app.get(
|
||||
remote_host: "3.55.24.34",
|
||||
direction: 0 === i % 2 ? "inbound" : "outbound",
|
||||
trunk: 0 === i % 2 ? "twilio" : "user",
|
||||
trace_id: nanoid(),
|
||||
recording_url: `http://127.0.0.1:3002/api/Accounts/${req.params.account_sid}/RecentCalls/${call_sid}/record`,
|
||||
};
|
||||
data.push(call);
|
||||
}
|
||||
@@ -136,6 +139,35 @@ app.get(
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/Accounts/:account_sid/RecentCalls/:call_sid/record",
|
||||
(req: Request, res: Response) => {
|
||||
/** Sample pcap file from: https://wiki.wireshark.org/SampleCaptures#sip-and-rtp */
|
||||
const wav: Buffer = fs.readFileSync(
|
||||
path.resolve(process.cwd(), "server", "example.mp3")
|
||||
);
|
||||
|
||||
res
|
||||
.status(200)
|
||||
.set({
|
||||
"Content-Type": "audio/wav",
|
||||
"Content-Disposition": "attachment",
|
||||
})
|
||||
.send(wav); // server: Buffer => client: Blob
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/Accounts/:account_sid/RecentCalls/trace/:trace_id",
|
||||
(req: Request, res: Response) => {
|
||||
const json = fs.readFileSync(
|
||||
path.resolve(process.cwd(), "server", "sample-jaeger.json"),
|
||||
{ encoding: "utf8" }
|
||||
);
|
||||
res.status(200).json(JSON.parse(json));
|
||||
}
|
||||
);
|
||||
|
||||
/** Alerts mock API responses for local dev */
|
||||
app.get("/api/Accounts/:account_sid/Alerts", (req: Request, res: Response) => {
|
||||
const data: Alert[] = [];
|
||||
|
||||
BIN
server/example.mp3
Normal file
BIN
server/example.mp3
Normal file
Binary file not shown.
4588
server/sample-jaeger.json
Normal file
4588
server/sample-jaeger.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,11 @@ import type {
|
||||
/** The API url is constructed with the docker containers `ip:port` */
|
||||
interface JambonzWindowObject {
|
||||
API_BASE_URL: string;
|
||||
DISABLE_LCR: string;
|
||||
DISABLE_JAEGER_TRACING: string;
|
||||
DISABLE_CUSTOM_SPEECH: string;
|
||||
ENABLE_FORGOT_PASSWORD: string;
|
||||
DISABLE_CALL_RECORDING: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -28,6 +33,30 @@ export const API_BASE_URL =
|
||||
/** Serves mock API responses from a local dev API server */
|
||||
export const DEV_BASE_URL = import.meta.env.VITE_DEV_BASE_URL;
|
||||
|
||||
/** Disable custom speech vendor*/
|
||||
export const DISABLE_CUSTOM_SPEECH: boolean =
|
||||
window.JAMBONZ?.DISABLE_CUSTOM_SPEECH === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_DISABLE_CUSTOM_SPEECH || "false");
|
||||
|
||||
/** Enable Forgot Password */
|
||||
export const ENABLE_FORGOT_PASSWORD: boolean =
|
||||
window.JAMBONZ?.ENABLE_FORGOT_PASSWORD === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_ENABLE_FORGOT_PASSWORD || "false");
|
||||
/** Disable Lcr */
|
||||
export const DISABLE_LCR: boolean =
|
||||
window.JAMBONZ?.DISABLE_LCR === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_APP_LCR_DISABLED || "false");
|
||||
|
||||
/** Disable jaeger tracing */
|
||||
export const DISABLE_JAEGER_TRACING: boolean =
|
||||
window.JAMBONZ?.DISABLE_JAEGER_TRACING === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_APP_JAEGER_TRACING_DISABLED || "false");
|
||||
|
||||
/** Enable Record All Call Feature */
|
||||
export const DISABLE_CALL_RECORDING: boolean =
|
||||
window.JAMBONZ?.DISABLE_CALL_RECORDING === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_APP_DISABLE_CALL_RECORDING || "false");
|
||||
|
||||
/** TCP Max Port */
|
||||
export const TCP_MAX_PORT = 65535;
|
||||
|
||||
@@ -54,7 +83,7 @@ export const DEFAULT_SIP_GATEWAY: SipGateway = {
|
||||
ipv4: "",
|
||||
port: 5060,
|
||||
netmask: 32,
|
||||
is_active: false,
|
||||
is_active: true,
|
||||
inbound: 1,
|
||||
outbound: 0,
|
||||
};
|
||||
@@ -69,7 +98,6 @@ export const DEFAULT_SMPP_GATEWAY: SmppGateway = {
|
||||
inbound: 1,
|
||||
outbound: 1,
|
||||
};
|
||||
|
||||
/** Netmask Bits */
|
||||
export const NETMASK_BITS = Array(32)
|
||||
.fill(0)
|
||||
@@ -81,6 +109,49 @@ export const NETMASK_OPTIONS = NETMASK_BITS.map((bit) => ({
|
||||
value: bit.toString(),
|
||||
}));
|
||||
|
||||
/** SIP Gateway Protocol */
|
||||
export const SIP_GATEWAY_PROTOCOL_OPTIONS = [
|
||||
{
|
||||
name: "UDP",
|
||||
value: "udp",
|
||||
},
|
||||
{
|
||||
name: "TCP",
|
||||
value: "tcp",
|
||||
},
|
||||
{
|
||||
name: "TLS",
|
||||
value: "tls",
|
||||
},
|
||||
{
|
||||
name: "TLS/SRTP",
|
||||
value: "tls/srtp",
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Record bucket type
|
||||
*/
|
||||
export const BUCKET_VENDOR_OPTIONS = [
|
||||
{
|
||||
name: "NONE",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
name: "AWS S3",
|
||||
value: "aws_s3",
|
||||
},
|
||||
];
|
||||
|
||||
export const AUDIO_FORMAT_OPTIONS = [
|
||||
{
|
||||
name: "mp3",
|
||||
value: "mp3",
|
||||
},
|
||||
{
|
||||
name: "wav",
|
||||
value: "wav",
|
||||
},
|
||||
];
|
||||
/** Password Length options */
|
||||
|
||||
export const PASSWORD_MIN = 8;
|
||||
@@ -182,8 +253,13 @@ export const CRED_OK = "ok";
|
||||
export const CRED_FAIL = "fail";
|
||||
export const CRED_NOT_TESTED = "not tested";
|
||||
|
||||
/** Voip Carrier Register result status values */
|
||||
export const CARRIER_REG_OK = "ok";
|
||||
export const CARRIER_REG_FAIL = "fail";
|
||||
|
||||
/** API base paths */
|
||||
export const API_LOGIN = `${API_BASE_URL}/login`;
|
||||
export const API_LOGOUT = `${API_BASE_URL}/logout`;
|
||||
export const API_SBCS = `${API_BASE_URL}/Sbcs`;
|
||||
export const API_USERS = `${API_BASE_URL}/Users`;
|
||||
export const API_API_KEYS = `${API_BASE_URL}/ApiKeys`;
|
||||
@@ -196,3 +272,10 @@ export const API_CARRIERS = `${API_BASE_URL}/VoipCarriers`;
|
||||
export const API_SMPP_GATEWAY = `${API_BASE_URL}/SmppGateways`;
|
||||
export const API_SIP_GATEWAY = `${API_BASE_URL}/SipGateways`;
|
||||
export const API_PASSWORD_SETTINGS = `${API_BASE_URL}/PasswordSettings`;
|
||||
export const API_FORGOT_PASSWORD = `${API_BASE_URL}/forgot-password`;
|
||||
export const API_SYSTEM_INFORMATION = `${API_BASE_URL}/SystemInformation`;
|
||||
export const API_LCRS = `${API_BASE_URL}/Lcrs`;
|
||||
export const API_LCR_ROUTES = `${API_BASE_URL}/LcrRoutes`;
|
||||
export const API_LCR_CARRIER_SET_ENTRIES = `${API_BASE_URL}/LcrCarrierSetEntries`;
|
||||
export const API_TTS_CACHE = `${API_BASE_URL}/TtsCache`;
|
||||
export const API_CLIENTS = `${API_BASE_URL}/Clients`;
|
||||
|
||||
180
src/api/index.ts
180
src/api/index.ts
@@ -17,7 +17,15 @@ import {
|
||||
API_SMPP_GATEWAY,
|
||||
API_SIP_GATEWAY,
|
||||
API_PASSWORD_SETTINGS,
|
||||
API_FORGOT_PASSWORD,
|
||||
USER_ACCOUNT,
|
||||
API_LOGOUT,
|
||||
API_SYSTEM_INFORMATION,
|
||||
API_LCR_ROUTES,
|
||||
API_LCR_CARRIER_SET_ENTRIES,
|
||||
API_LCRS,
|
||||
API_TTS_CACHE,
|
||||
API_CLIENTS,
|
||||
} from "./constants";
|
||||
import { ROUTE_LOGIN } from "src/router/routes";
|
||||
import {
|
||||
@@ -58,8 +66,17 @@ import type {
|
||||
Limit,
|
||||
LimitCategories,
|
||||
PasswordSettings,
|
||||
ForgotPassword,
|
||||
SystemInformation,
|
||||
Lcr,
|
||||
LcrRoute,
|
||||
LcrCarrierSetEntry,
|
||||
BucketCredential,
|
||||
BucketCredentialTestResult,
|
||||
Client,
|
||||
} from "./types";
|
||||
import { StatusCodes } from "./types";
|
||||
import { JaegerRoot } from "./jaeger-types";
|
||||
|
||||
/** Wrap all requests to normalize response handling */
|
||||
const fetchTransport = <Type>(
|
||||
@@ -70,6 +87,7 @@ const fetchTransport = <Type>(
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const transport: FetchTransport<Type> = {
|
||||
headers: response.headers,
|
||||
status: response.status,
|
||||
json: <Type>{},
|
||||
};
|
||||
@@ -233,6 +251,10 @@ export const postLogin = (payload: UserLoginPayload) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const postLogout = () => {
|
||||
return postFetch<undefined>(API_LOGOUT);
|
||||
};
|
||||
|
||||
/** Named wrappers for `postFetch` */
|
||||
|
||||
export const postServiceProviders = (payload: Partial<ServiceProvider>) => {
|
||||
@@ -250,6 +272,16 @@ export const postAccount = (payload: Partial<Account>) => {
|
||||
return postFetch<SidResponse, Partial<Account>>(API_ACCOUNTS, payload);
|
||||
};
|
||||
|
||||
export const postAccountBucketCredentialTest = (
|
||||
sid: string,
|
||||
payload: Partial<BucketCredential>
|
||||
) => {
|
||||
return postFetch<BucketCredentialTestResult, Partial<BucketCredential>>(
|
||||
`${API_ACCOUNTS}/${sid}/BucketCredentialTest`,
|
||||
payload
|
||||
);
|
||||
};
|
||||
|
||||
export const postApplication = (payload: Partial<Application>) => {
|
||||
return postFetch<SidResponse, Partial<Application>>(
|
||||
API_APPLICATIONS,
|
||||
@@ -346,6 +378,41 @@ export const postPasswordSettings = (payload: Partial<PasswordSettings>) => {
|
||||
payload
|
||||
);
|
||||
};
|
||||
|
||||
export const postForgotPassword = (payload: Partial<ForgotPassword>) => {
|
||||
return postFetch<EmptyResponse, Partial<ForgotPassword>>(
|
||||
API_FORGOT_PASSWORD,
|
||||
payload
|
||||
);
|
||||
};
|
||||
|
||||
export const postSystemInformation = (payload: Partial<SystemInformation>) => {
|
||||
return postFetch<SystemInformation, Partial<SystemInformation>>(
|
||||
API_SYSTEM_INFORMATION,
|
||||
payload
|
||||
);
|
||||
};
|
||||
|
||||
export const postLcr = (payload: Partial<Lcr>) => {
|
||||
return postFetch<SidResponse, Partial<Lcr>>(API_LCRS, payload);
|
||||
};
|
||||
|
||||
export const postLcrRoute = (payload: Partial<LcrRoute>) => {
|
||||
return postFetch<SidResponse, Partial<LcrRoute>>(API_LCR_ROUTES, payload);
|
||||
};
|
||||
|
||||
export const postLcrCarrierSetEntry = (
|
||||
payload: Partial<LcrCarrierSetEntry>
|
||||
) => {
|
||||
return postFetch<SidResponse, Partial<LcrCarrierSetEntry>>(
|
||||
API_LCR_CARRIER_SET_ENTRIES,
|
||||
payload
|
||||
);
|
||||
};
|
||||
|
||||
export const postClient = (payload: Partial<Client>) => {
|
||||
return postFetch<SidResponse, Partial<Client>>(API_CLIENTS, payload);
|
||||
};
|
||||
/** Named wrappers for `putFetch` */
|
||||
|
||||
export const putUser = (sid: string, payload: Partial<UserUpdatePayload>) => {
|
||||
@@ -438,6 +505,33 @@ export const putSmppGateway = (sid: string, payload: Partial<SmppGateway>) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const putLcr = (sid: string, payload: Partial<Lcr>) => {
|
||||
return putFetch<EmptyResponse, Partial<Lcr>>(`${API_LCRS}/${sid}`, payload);
|
||||
};
|
||||
|
||||
export const putLcrRoutes = (sid: string, payload: Partial<LcrRoute>) => {
|
||||
return putFetch<EmptyResponse, Partial<LcrRoute>>(
|
||||
`${API_LCR_ROUTES}/${sid}`,
|
||||
payload
|
||||
);
|
||||
};
|
||||
|
||||
export const putLcrCarrierSetEntries = (
|
||||
sid: string,
|
||||
payload: Partial<LcrCarrierSetEntry>
|
||||
) => {
|
||||
return putFetch<EmptyResponse, Partial<LcrCarrierSetEntry>>(
|
||||
`${API_LCR_CARRIER_SET_ENTRIES}/${sid}`,
|
||||
payload
|
||||
);
|
||||
};
|
||||
|
||||
export const putClient = (sid: string, payload: Partial<Client>) => {
|
||||
return putFetch<EmptyResponse, Partial<Client>>(
|
||||
`${API_CLIENTS}/${sid}`,
|
||||
payload
|
||||
);
|
||||
};
|
||||
/** Named wrappers for `deleteFetch` */
|
||||
|
||||
export const deleteUser = (sid: string) => {
|
||||
@@ -501,6 +595,25 @@ export const deleteAccountLimit = (sid: string, cat: LimitCategories) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteLcr = (sid: string) => {
|
||||
return deleteFetch<EmptyResponse>(`${API_LCRS}/${sid}`);
|
||||
};
|
||||
|
||||
export const deleteLcrRoute = (sid: string) => {
|
||||
return deleteFetch<EmptyResponse>(`${API_LCR_ROUTES}/${sid}`);
|
||||
};
|
||||
|
||||
export const deleteTtsCache = () => {
|
||||
return deleteFetch<EmptyResponse>(API_TTS_CACHE);
|
||||
};
|
||||
|
||||
export const deleteAccountTtsCache = (sid: string) => {
|
||||
return deleteFetch<EmptyResponse>(`${API_BASE_URL}/Accounts/${sid}/TtsCache`);
|
||||
};
|
||||
|
||||
export const deleteClient = (sid: string) => {
|
||||
return deleteFetch<EmptyResponse>(`${API_CLIENTS}/${sid}`);
|
||||
};
|
||||
/** Named wrappers for `getFetch` */
|
||||
|
||||
export const getUser = (sid: string) => {
|
||||
@@ -517,6 +630,36 @@ export const getAccountWebhook = (sid: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const getLcrs = () => {
|
||||
return getFetch<Lcr[]>(API_LCRS);
|
||||
};
|
||||
|
||||
export const getLcr = (sid: string) => {
|
||||
return getFetch<Lcr>(`${API_LCRS}/${sid}`);
|
||||
};
|
||||
|
||||
export const getLcrRoutes = (sid: string) => {
|
||||
return getFetch<LcrRoute[]>(`${API_LCR_ROUTES}?lcr_sid=${sid}`);
|
||||
};
|
||||
|
||||
export const getLcrRoute = (sid: string) => {
|
||||
return getFetch<LcrRoute>(`${API_LCR_ROUTES}/${sid}`);
|
||||
};
|
||||
|
||||
export const getLcrCarrierSetEtries = (sid: string) => {
|
||||
return getFetch<LcrCarrierSetEntry[]>(
|
||||
`${API_LCR_CARRIER_SET_ENTRIES}?lcr_route_sid=${sid}`
|
||||
);
|
||||
};
|
||||
|
||||
export const getClients = () => {
|
||||
return getFetch<Client[]>(API_CLIENTS);
|
||||
};
|
||||
|
||||
export const getClient = (sid: string) => {
|
||||
return getFetch<Client[]>(`${API_CLIENTS}/${sid}`);
|
||||
};
|
||||
|
||||
/** Wrappers for APIs that can have a mock dev server response */
|
||||
|
||||
export const getRecentCalls = (sid: string, query: Partial<CallQuery>) => {
|
||||
@@ -537,11 +680,42 @@ export const getRecentCall = (sid: string, sipCallId: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const getPcap = (sid: string, sipCallId: string) => {
|
||||
export const getPcap = (sid: string, sipCallId: string, method: string) => {
|
||||
return getBlob(
|
||||
import.meta.env.DEV
|
||||
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}/pcap`
|
||||
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}/pcap`
|
||||
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
|
||||
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
|
||||
);
|
||||
};
|
||||
|
||||
export const getJaegerTrace = (sid: string, traceId: string) => {
|
||||
return getFetch<JaegerRoot>(
|
||||
import.meta.env.DEV
|
||||
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/trace/${traceId}`
|
||||
: `${API_ACCOUNTS}/${sid}/RecentCalls/trace/${traceId}`
|
||||
);
|
||||
};
|
||||
|
||||
export const getServiceProviderRecentCall = (
|
||||
sid: string,
|
||||
sipCallId: string
|
||||
) => {
|
||||
return getFetch<TotalResponse>(
|
||||
import.meta.env.DEV
|
||||
? `${DEV_BASE_URL}/ServiceProviders/${sid}/RecentCalls/${sipCallId}`
|
||||
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}`
|
||||
);
|
||||
};
|
||||
|
||||
export const getServiceProviderPcap = (
|
||||
sid: string,
|
||||
sipCallId: string,
|
||||
method: string
|
||||
) => {
|
||||
return getBlob(
|
||||
import.meta.env.DEV
|
||||
? `${DEV_BASE_URL}/ServiceProviders/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
|
||||
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
75
src/api/jaeger-types.ts
Normal file
75
src/api/jaeger-types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export interface JaegerRoot {
|
||||
resourceSpans: JaegerResourceSpan[];
|
||||
}
|
||||
|
||||
export interface JaegerResourceSpan {
|
||||
resource: JaegerResource;
|
||||
instrumentationLibrarySpans: InstrumentationLibrarySpan[];
|
||||
}
|
||||
|
||||
export interface JaegerResource {
|
||||
attributes: JaegerAttribute[];
|
||||
}
|
||||
|
||||
export interface InstrumentationLibrarySpan {
|
||||
instrumentationLibrary: InstrumentationLibrary;
|
||||
spans: JaegerSpan[];
|
||||
}
|
||||
|
||||
export interface InstrumentationLibrary {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface JaegerSpan {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
startTimeUnixNano: number;
|
||||
endTimeUnixNano: number;
|
||||
attributes: JaegerAttribute[];
|
||||
}
|
||||
|
||||
export interface JaegerAttribute {
|
||||
key: string;
|
||||
value: JaegerValue;
|
||||
}
|
||||
|
||||
export interface WaveSufferSttResult {
|
||||
vendor: string;
|
||||
transcript: string;
|
||||
confidence: number;
|
||||
language_code: string;
|
||||
}
|
||||
|
||||
export interface WaveSufferDtmfResult {
|
||||
dtmf: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
export interface JaegerValue {
|
||||
stringValue: string;
|
||||
doubleValue: string;
|
||||
boolValue: string;
|
||||
}
|
||||
|
||||
export interface JaegerGroup {
|
||||
level: number;
|
||||
startPx: number;
|
||||
endPx: number;
|
||||
durationPx: number;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
durationMs: number;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
startTimeUnixNano: number;
|
||||
endTimeUnixNano: number;
|
||||
attributes: JaegerAttribute[];
|
||||
children: JaegerGroup[];
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export enum StatusCodes {
|
||||
/** Fetch transport interfaces */
|
||||
|
||||
export interface FetchTransport<Type> {
|
||||
headers: Headers;
|
||||
status: StatusCodes;
|
||||
json: Type;
|
||||
blob?: Blob;
|
||||
@@ -87,7 +88,7 @@ export interface SelectorOptions {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Pcap {
|
||||
export interface DownloadedBlob {
|
||||
data_url: string;
|
||||
file_name: string;
|
||||
}
|
||||
@@ -102,6 +103,11 @@ export interface CredentialTestResult {
|
||||
tts: CredentialTest;
|
||||
}
|
||||
|
||||
export interface BucketCredentialTestResult {
|
||||
status: CredentialStatus;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface LimitField {
|
||||
label: string;
|
||||
category: LimitCategories;
|
||||
@@ -113,6 +119,20 @@ export interface PasswordSettings {
|
||||
require_special_character: number;
|
||||
}
|
||||
|
||||
export interface ForgotPassword {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface SystemInformation {
|
||||
domain_name: string;
|
||||
sip_domain_name: string;
|
||||
monitoring_domain_name: string;
|
||||
}
|
||||
|
||||
export interface TtsCache {
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** API responses/payloads */
|
||||
|
||||
export interface User {
|
||||
@@ -172,6 +192,7 @@ export interface ServiceProvider {
|
||||
name: string;
|
||||
ms_teams_fqdn: null | string;
|
||||
service_provider_sid: string;
|
||||
lcr_sid: null | string;
|
||||
}
|
||||
|
||||
export interface Limit {
|
||||
@@ -231,6 +252,23 @@ export interface Account {
|
||||
registration_hook: null | WebHook;
|
||||
service_provider_sid: string;
|
||||
device_calling_application_sid: null | string;
|
||||
record_all_calls: number;
|
||||
record_format?: null | string;
|
||||
bucket_credential: null | BucketCredential;
|
||||
}
|
||||
|
||||
export interface AwsTag {
|
||||
Key: string;
|
||||
Value: string;
|
||||
}
|
||||
|
||||
export interface BucketCredential {
|
||||
vendor: null | string;
|
||||
region?: null | string;
|
||||
name?: null | string;
|
||||
access_key_id?: null | string;
|
||||
secret_access_key?: null | string;
|
||||
tags?: null | AwsTag[];
|
||||
}
|
||||
|
||||
export interface Application {
|
||||
@@ -246,6 +284,7 @@ export interface Application {
|
||||
speech_synthesis_language: null | string;
|
||||
speech_recognizer_vendor: null | Lowercase<Vendor>;
|
||||
speech_recognizer_language: null | string;
|
||||
record_all_calls: number;
|
||||
}
|
||||
|
||||
export interface PhoneNumber {
|
||||
@@ -281,6 +320,8 @@ export interface RecentCall {
|
||||
remote_host: string;
|
||||
direction: string;
|
||||
trunk: string;
|
||||
trace_id: string;
|
||||
recording_url?: string;
|
||||
}
|
||||
|
||||
export interface SpeechCredential {
|
||||
@@ -303,12 +344,17 @@ export interface SpeechCredential {
|
||||
custom_stt_endpoint: null | string;
|
||||
client_id: null | string;
|
||||
secret: null | string;
|
||||
nuance_tts_uri: null | string;
|
||||
nuance_stt_uri: null | string;
|
||||
tts_api_key: null | string;
|
||||
tts_region: null | string;
|
||||
stt_api_key: null | string;
|
||||
stt_region: null | string;
|
||||
instance_id: null | string;
|
||||
riva_server_uri: null | string;
|
||||
auth_token: null | string;
|
||||
custom_stt_url: null | string;
|
||||
custom_tts_url: null | string;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
@@ -319,6 +365,13 @@ export interface Alert {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface CarrierRegisterStatus {
|
||||
status: null | string;
|
||||
reason: null | string;
|
||||
cseq: null | string;
|
||||
callId: null | string;
|
||||
}
|
||||
|
||||
export interface Carrier {
|
||||
voip_carrier_sid: string;
|
||||
name: string;
|
||||
@@ -344,6 +397,7 @@ export interface Carrier {
|
||||
smpp_inbound_system_id: null | string;
|
||||
smpp_inbound_password: null | string;
|
||||
smpp_enquire_link_interval: number;
|
||||
register_status: CarrierRegisterStatus;
|
||||
}
|
||||
|
||||
export interface PredefinedCarrier extends Carrier {
|
||||
@@ -363,6 +417,7 @@ export interface Gateway {
|
||||
export interface SipGateway extends Gateway {
|
||||
sip_gateway_sid?: null | string;
|
||||
is_active: boolean;
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
export interface SmppGateway extends Gateway {
|
||||
@@ -371,6 +426,41 @@ export interface SmppGateway extends Gateway {
|
||||
use_tls: boolean;
|
||||
}
|
||||
|
||||
export interface Lcr {
|
||||
lcr_sid?: null | string;
|
||||
is_active: boolean;
|
||||
name: null | string;
|
||||
default_carrier_set_entry_sid?: null | string;
|
||||
account_sid: null | string;
|
||||
service_provider_sid: null | string;
|
||||
number_routes?: number;
|
||||
}
|
||||
|
||||
export interface LcrRoute {
|
||||
lcr_route_sid?: null | string;
|
||||
lcr_sid: null | string;
|
||||
regex: null | string;
|
||||
desciption?: null | string;
|
||||
priority: number;
|
||||
lcr_carrier_set_entries?: LcrCarrierSetEntry[];
|
||||
}
|
||||
|
||||
export interface LcrCarrierSetEntry {
|
||||
lcr_carrier_set_entry_sid?: null | string;
|
||||
workload?: number;
|
||||
lcr_route_sid: null | string;
|
||||
voip_carrier_sid: null | string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
client_sid?: null | string;
|
||||
account_sid: null | string;
|
||||
username: null | string;
|
||||
password?: null | string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface PageQuery {
|
||||
page: number;
|
||||
count: number;
|
||||
|
||||
@@ -38,7 +38,7 @@ export const AccountFilter = ({
|
||||
|
||||
return (
|
||||
<div className={classNames(classes)}>
|
||||
<label htmlFor="account_filter">{label}:</label>
|
||||
{label && <label htmlFor="account_filter">{label}:</label>}
|
||||
<div>
|
||||
<select
|
||||
id="account_filter"
|
||||
|
||||
@@ -63,7 +63,7 @@ export const Checkzone = forwardRef<CheckzoneRef, CheckzoneProps>(
|
||||
/>
|
||||
<div>{label}</div>
|
||||
</label>
|
||||
<div className={classesIn}>{children}</div>
|
||||
{checked && <div className={classesIn}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,15 @@ import {
|
||||
PhoneOutgoing,
|
||||
PhoneIncoming,
|
||||
MoreHorizontal,
|
||||
Share2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Play,
|
||||
Pause,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Download,
|
||||
Smartphone,
|
||||
} from "react-feather";
|
||||
|
||||
import type { Icon } from "react-feather";
|
||||
@@ -88,4 +97,13 @@ export const Icons: IconMap = {
|
||||
PhoneOutgoing,
|
||||
PhoneIncoming,
|
||||
MoreHorizontal,
|
||||
Share2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Play,
|
||||
Pause,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Download,
|
||||
Smartphone,
|
||||
};
|
||||
|
||||
@@ -91,9 +91,19 @@ export const ModalForm = ({
|
||||
|
||||
export const ModalClose = ({ children, handleClose }: CloseProps) => {
|
||||
return ReactDOM.createPortal(
|
||||
<div className="modal">
|
||||
<div className="modal__box">
|
||||
<div className="modal__stuff">{children}</div>
|
||||
<div className="modal" role="presentation" onClick={handleClose}>
|
||||
<div
|
||||
className="modal__box"
|
||||
role="presentation"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="modal__stuff"
|
||||
role="presentation"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<ButtonGroup right>
|
||||
<Button type="button" small subStyle="grey" onClick={handleClose}>
|
||||
Close
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
import { classNames } from "@jambonz/ui-kit";
|
||||
|
||||
import { Icons } from "src/components/icons";
|
||||
@@ -7,14 +7,18 @@ import "./styles.scss";
|
||||
|
||||
type SearchFilterProps = JSX.IntrinsicElements["input"] & {
|
||||
filter: [string, React.Dispatch<React.SetStateAction<string>>];
|
||||
delay?: number | null;
|
||||
};
|
||||
|
||||
export const SearchFilter = ({
|
||||
placeholder,
|
||||
filter: [filterValue, setFilterValue],
|
||||
delay,
|
||||
}: SearchFilterProps) => {
|
||||
const [focus, setFocus] = useState(false);
|
||||
const [tmpFilterValue, setTmpFilterValue] = useState(filterValue);
|
||||
const [appearance, setAppearance] = useState(false);
|
||||
const typingTimeoutRef = useRef<number | null>(null);
|
||||
const classes = {
|
||||
"search-filter": true,
|
||||
focused: focus,
|
||||
@@ -23,7 +27,18 @@ export const SearchFilter = ({
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFilterValue(e.target.value.toLowerCase());
|
||||
setTmpFilterValue(e.target.value.toLowerCase());
|
||||
if (delay) {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
setFilterValue(e.target.value.toLowerCase());
|
||||
}, delay);
|
||||
} else {
|
||||
setFilterValue(e.target.value.toLowerCase());
|
||||
}
|
||||
|
||||
if (e.target.value) {
|
||||
setAppearance(true);
|
||||
@@ -51,7 +66,7 @@ export const SearchFilter = ({
|
||||
type="search"
|
||||
name="search_filter"
|
||||
placeholder={placeholder}
|
||||
value={filterValue}
|
||||
value={tmpFilterValue}
|
||||
onChange={handleChange}
|
||||
onFocus={() => {
|
||||
setFocus(true);
|
||||
|
||||
@@ -9,14 +9,15 @@ import "./styles.scss";
|
||||
type TooltipProps = {
|
||||
text: IMessage;
|
||||
children: React.ReactNode;
|
||||
subStyle?: string;
|
||||
};
|
||||
|
||||
export const Tooltip = ({ text, children }: TooltipProps) => {
|
||||
export const Tooltip = ({ text, children, subStyle }: TooltipProps) => {
|
||||
return (
|
||||
<div className="tooltip">
|
||||
<div className="tooltip__reveal">{text}</div>
|
||||
{children}
|
||||
<Icons.HelpCircle />
|
||||
{subStyle === "info" ? <Icons.Info /> : <Icons.HelpCircle />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,16 +13,6 @@ export const MSG_PASSWD_MATCH = "Passwords do not match";
|
||||
export const MSG_SERVER_DOWN = "The server cannot be reached";
|
||||
export const MSG_LOGGED_OUT = "You've successfully logged out.";
|
||||
export const MSG_MUST_LOGIN = "You must log in to view that page";
|
||||
export const MSG_PASSWD_CRITERIA = (
|
||||
<>
|
||||
Password must:
|
||||
<ul>
|
||||
<li>Be at least 6 characters</li>
|
||||
<li>Contain at least one letter</li>
|
||||
<li>Contain at least one number</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
export const MSG_REQUIRED_FIELDS = (
|
||||
<>
|
||||
Fields marked with an asterisk<span>*</span> are required.
|
||||
@@ -34,3 +24,4 @@ export const MSG_WEBHOOK_FIELDS = (
|
||||
<span>password</span> fields are required.
|
||||
</>
|
||||
);
|
||||
export const NOT_AVAILABLE_PREFIX = "NotAvalable";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { classNames, M, Icon, Button } from "@jambonz/ui-kit";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Icons, ModalForm } from "src/components";
|
||||
import { naviTop, naviByo } from "./items";
|
||||
@@ -20,6 +20,8 @@ import "./styles.scss";
|
||||
import { ScopedAccess } from "src/components/scoped-access";
|
||||
import { Scope, UserData } from "src/store/types";
|
||||
import { USER_ADMIN } from "src/api/constants";
|
||||
import { ROUTE_LOGIN } from "src/router/routes";
|
||||
import { Lcr } from "src/api/types";
|
||||
|
||||
type CommonProps = {
|
||||
handleMenu: () => void;
|
||||
@@ -34,16 +36,17 @@ type NaviProps = CommonProps & {
|
||||
type ItemProps = CommonProps & {
|
||||
item: NaviItem;
|
||||
user?: UserData;
|
||||
lcr?: Lcr;
|
||||
};
|
||||
|
||||
const Item = ({ item, user, handleMenu }: ItemProps) => {
|
||||
const Item = ({ item, user, lcr, handleMenu }: ItemProps) => {
|
||||
const location = useLocation();
|
||||
const active = location.pathname.includes(item.route(user));
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
to={item.route(user)}
|
||||
to={item.route(user, lcr)}
|
||||
className={classNames({ navi__link: true, "txt--jean": true, active })}
|
||||
onClick={handleMenu}
|
||||
>
|
||||
@@ -61,7 +64,9 @@ export const Navi = ({
|
||||
handleLogout,
|
||||
}: NaviProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const user = useSelectState("user");
|
||||
const lcr = useSelectState("lcr");
|
||||
const accessControl = useSelectState("accessControl");
|
||||
const serviceProviders = useSelectState("serviceProviders");
|
||||
const currentServiceProvider = useSelectState("currentServiceProvider");
|
||||
@@ -131,6 +136,7 @@ export const Navi = ({
|
||||
useEffect(() => {
|
||||
dispatch({ type: "user" });
|
||||
dispatch({ type: "serviceProviders" });
|
||||
dispatch({ type: "lcr" });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -160,6 +166,7 @@ export const Navi = ({
|
||||
onChange={(e) => {
|
||||
setSid(e.target.value);
|
||||
setActiveSP(e.target.value);
|
||||
navigate(ROUTE_LOGIN);
|
||||
}}
|
||||
disabled={user?.scope !== USER_ADMIN}
|
||||
>
|
||||
@@ -218,6 +225,7 @@ export const Navi = ({
|
||||
<Item
|
||||
key={item.label}
|
||||
user={user}
|
||||
lcr={lcr}
|
||||
item={item}
|
||||
handleMenu={handleMenu}
|
||||
/>
|
||||
|
||||
@@ -9,17 +9,21 @@ import {
|
||||
ROUTE_INTERNAL_SPEECH,
|
||||
ROUTE_INTERNAL_PHONE_NUMBERS,
|
||||
ROUTE_INTERNAL_MS_TEAMS_TENANTS,
|
||||
ROUTE_INTERNAL_LEST_COST_ROUTING,
|
||||
ROUTE_INTERNAL_CLIENTS,
|
||||
} from "src/router/routes";
|
||||
import { Icons } from "src/components";
|
||||
import { Scope, UserData } from "src/store/types";
|
||||
|
||||
import type { Icon } from "react-feather";
|
||||
import type { ACL } from "src/store/types";
|
||||
import { Lcr } from "src/api/types";
|
||||
import { DISABLE_LCR } from "src/api/constants";
|
||||
|
||||
export interface NaviItem {
|
||||
label: string;
|
||||
icon: Icon;
|
||||
route: (user?: UserData) => string;
|
||||
route: (user?: UserData, lcr?: Lcr) => string;
|
||||
acl?: keyof ACL;
|
||||
scope?: Scope;
|
||||
restrict?: boolean;
|
||||
@@ -50,6 +54,11 @@ export const naviTop: NaviItem[] = [
|
||||
scope: Scope.account,
|
||||
restrict: true,
|
||||
},
|
||||
{
|
||||
label: "Clients",
|
||||
icon: Icons.Smartphone,
|
||||
route: () => ROUTE_INTERNAL_CLIENTS,
|
||||
},
|
||||
{
|
||||
label: "Applications",
|
||||
icon: Icons.Grid,
|
||||
@@ -89,4 +98,21 @@ export const naviByo: NaviItem[] = [
|
||||
route: () => ROUTE_INTERNAL_MS_TEAMS_TENANTS,
|
||||
acl: "hasMSTeamsFqdn",
|
||||
},
|
||||
...(DISABLE_LCR === false
|
||||
? [
|
||||
{
|
||||
label: "Outbound Call Routing",
|
||||
icon: Icons.Share2,
|
||||
route: (user, lcr) => {
|
||||
if (user?.access === Scope.admin) {
|
||||
return ROUTE_INTERNAL_LEST_COST_ROUTING;
|
||||
}
|
||||
if (lcr && lcr.lcr_sid) {
|
||||
return `${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr.lcr_sid}/edit`;
|
||||
}
|
||||
return `${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`;
|
||||
},
|
||||
} as NaviItem,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useApiData } from "src/api";
|
||||
import { toastError, useSelectState } from "src/store";
|
||||
import { AccountForm } from "./form";
|
||||
|
||||
import type { Account, Application, Limit } from "src/api/types";
|
||||
import type { Account, Application, Limit, TtsCache } from "src/api/types";
|
||||
import {
|
||||
ROUTE_INTERNAL_ACCOUNTS,
|
||||
ROUTE_INTERNAL_APPLICATIONS,
|
||||
@@ -25,6 +25,9 @@ export const EditAccount = () => {
|
||||
`Accounts/${params.account_sid}/Limits`
|
||||
);
|
||||
const [apps] = useApiData<Application[]>("Applications");
|
||||
const [ttsCache, ttsCacheFetcher] = useApiData<TtsCache>(
|
||||
`Accounts/${params.account_sid}/TtsCache`
|
||||
);
|
||||
|
||||
useScopedRedirect(
|
||||
Scope.account,
|
||||
@@ -50,6 +53,7 @@ export const EditAccount = () => {
|
||||
apps={apps}
|
||||
account={{ data, refetch, error }}
|
||||
limits={{ data: limitsData, refetch: refetchLimits }}
|
||||
ttsCache={{ data: ttsCache, refetch: ttsCacheFetcher }}
|
||||
/>
|
||||
<ApiKeys
|
||||
path={`Accounts/${params.account_sid}/ApiKeys`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { P, Button, ButtonGroup, MS } from "@jambonz/ui-kit";
|
||||
import { P, Button, ButtonGroup, MS, Icon } from "@jambonz/ui-kit";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { toastError, toastSuccess, useSelectState } from "src/store";
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
useApiData,
|
||||
postAccountLimit,
|
||||
deleteAccountLimit,
|
||||
deleteAccountTtsCache,
|
||||
postAccountBucketCredentialTest,
|
||||
} from "src/api";
|
||||
import { ClipBoard, Icons, Modal, Section, Tooltip } from "src/components";
|
||||
import {
|
||||
@@ -22,7 +24,11 @@ import {
|
||||
} from "src/components/forms";
|
||||
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
|
||||
import {
|
||||
AUDIO_FORMAT_OPTIONS,
|
||||
BUCKET_VENDOR_OPTIONS,
|
||||
CRED_OK,
|
||||
DEFAULT_WEBHOOK,
|
||||
DISABLE_CALL_RECORDING,
|
||||
USER_ACCOUNT,
|
||||
WEBHOOK_METHODS,
|
||||
} from "src/api/constants";
|
||||
@@ -35,16 +41,26 @@ import type {
|
||||
WebhookMethod,
|
||||
UseApiDataMap,
|
||||
Limit,
|
||||
TtsCache,
|
||||
BucketCredential,
|
||||
AwsTag,
|
||||
} from "src/api/types";
|
||||
import { hasLength } from "src/utils";
|
||||
import { hasLength, hasValue } from "src/utils";
|
||||
import { useRegionVendors } from "src/vendor";
|
||||
|
||||
type AccountFormProps = {
|
||||
apps?: Application[];
|
||||
limits?: UseApiDataMap<Limit[]>;
|
||||
account?: UseApiDataMap<Account>;
|
||||
ttsCache?: UseApiDataMap<TtsCache>;
|
||||
};
|
||||
|
||||
export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
|
||||
export const AccountForm = ({
|
||||
apps,
|
||||
limits,
|
||||
account,
|
||||
ttsCache,
|
||||
}: AccountFormProps) => {
|
||||
const navigate = useNavigate();
|
||||
const user = useSelectState("user");
|
||||
const currentServiceProvider = useSelectState("currentServiceProvider");
|
||||
@@ -60,6 +76,19 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
|
||||
const [initialRegHook, setInitialRegHook] = useState(false);
|
||||
const [initialQueueHook, setInitialQueueHook] = useState(false);
|
||||
const [localLimits, setLocalLimits] = useState<Limit[]>([]);
|
||||
const [clearTtsCacheFlag, setClearTtsCacheFlag] = useState(false);
|
||||
const [recordAllCalls, setRecordAllCalls] = useState(false);
|
||||
const [initialCheckRecordAllCall, setInitialCheckRecordAllCall] =
|
||||
useState(false);
|
||||
const [bucketVendor, setBucketVendor] = useState("");
|
||||
const [recordFormat, setRecordFormat] = useState("mp3");
|
||||
const [bucketRegion, setBucketRegion] = useState("us-east-1");
|
||||
const [bucketName, setBucketName] = useState("");
|
||||
const [bucketAccessKeyId, setBucketAccessKeyId] = useState("");
|
||||
const [bucketSecretAccessKey, setBucketSecretAccessKey] = useState("");
|
||||
const [bucketCredentialChecked, setBucketCredentialChecked] = useState(false);
|
||||
const [bucketTags, setBucketTags] = useState<AwsTag[]>([]);
|
||||
const regions = useRegionVendors();
|
||||
|
||||
/** This lets us map and render the same UI for each... */
|
||||
const webhooks = [
|
||||
@@ -105,6 +134,28 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
|
||||
setModal(false);
|
||||
};
|
||||
|
||||
const handleTestBucketCredential = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!account || !account.data) return;
|
||||
const cred: BucketCredential = {
|
||||
vendor: bucketVendor,
|
||||
region: bucketRegion,
|
||||
name: bucketName,
|
||||
access_key_id: bucketAccessKeyId,
|
||||
secret_access_key: bucketSecretAccessKey,
|
||||
};
|
||||
|
||||
postAccountBucketCredentialTest(account?.data?.account_sid, cred).then(
|
||||
({ json }) => {
|
||||
if (json.status === CRED_OK) {
|
||||
toastSuccess("Bucket Credential is valid.");
|
||||
} else {
|
||||
toastError(json.reason);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (account && account.data) {
|
||||
getAccountWebhook(account.data.account_sid)
|
||||
@@ -139,6 +190,20 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCache = () => {
|
||||
deleteAccountTtsCache(account?.data?.account_sid || "")
|
||||
.then(() => {
|
||||
if (ttsCache) {
|
||||
ttsCache.refetch();
|
||||
}
|
||||
setClearTtsCacheFlag(false);
|
||||
toastSuccess("Tts Cache successfully cleaned");
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -158,7 +223,14 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
|
||||
? accounts.filter((a) => a.account_sid !== account.data!.account_sid)
|
||||
: accounts;
|
||||
|
||||
if (filtered.find((a) => a.name === name)) {
|
||||
if (
|
||||
account &&
|
||||
filtered.find(
|
||||
(a) =>
|
||||
a.service_provider_sid !== account.data!.service_provider_sid &&
|
||||
a.name === name
|
||||
)
|
||||
) {
|
||||
setMessage(
|
||||
"The name you have entered is already in use on another one of your accounts."
|
||||
);
|
||||
@@ -182,6 +254,24 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
|
||||
queue_event_hook: queueHook || account.data.queue_event_hook,
|
||||
registration_hook: regHook || account.data.registration_hook,
|
||||
device_calling_application_sid: appId || null,
|
||||
record_all_calls: recordAllCalls ? 1 : 0,
|
||||
record_format: recordFormat ? recordFormat : "mp3",
|
||||
...(bucketVendor === "aws_s3" && {
|
||||
bucket_credential: {
|
||||
vendor: bucketVendor || null,
|
||||
region: bucketRegion || "us-east-1",
|
||||
name: bucketName || null,
|
||||
access_key_id: bucketAccessKeyId || null,
|
||||
secret_access_key: bucketSecretAccessKey || null,
|
||||
...(hasLength(bucketTags) && { tags: bucketTags }),
|
||||
},
|
||||
}),
|
||||
...(!bucketCredentialChecked && {
|
||||
record_all_calls: 0,
|
||||
bucket_credential: {
|
||||
vendor: "none",
|
||||
},
|
||||
}),
|
||||
})
|
||||
.then(() => {
|
||||
account.refetch();
|
||||
@@ -256,9 +346,64 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
|
||||
setInitialQueueHook(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (account.data.bucket_credential?.vendor) {
|
||||
setBucketVendor(account.data.bucket_credential?.vendor);
|
||||
}
|
||||
|
||||
if (account.data.bucket_credential?.name) {
|
||||
setBucketName(account.data.bucket_credential?.name);
|
||||
}
|
||||
|
||||
if (account.data.bucket_credential?.access_key_id) {
|
||||
setBucketAccessKeyId(account.data.bucket_credential?.access_key_id);
|
||||
}
|
||||
if (account.data.bucket_credential?.secret_access_key) {
|
||||
setBucketSecretAccessKey(
|
||||
account.data.bucket_credential?.secret_access_key
|
||||
);
|
||||
}
|
||||
if (account.data.bucket_credential?.region) {
|
||||
setBucketRegion(account.data.bucket_credential?.region);
|
||||
}
|
||||
if (account.data.record_all_calls) {
|
||||
setRecordAllCalls(account.data.record_all_calls ? true : false);
|
||||
}
|
||||
setBucketCredentialChecked(
|
||||
hasValue(bucketVendor) && bucketVendor.length !== 0
|
||||
);
|
||||
if (account.data.bucket_credential?.tags) {
|
||||
setBucketTags(account.data.bucket_credential?.tags);
|
||||
}
|
||||
if (account.data.record_format) {
|
||||
setRecordFormat(account.data.record_format || "mp3");
|
||||
}
|
||||
setInitialCheckRecordAllCall(
|
||||
hasValue(bucketVendor) && bucketVendor.length !== 0
|
||||
);
|
||||
}
|
||||
}, [account]);
|
||||
|
||||
const updateBucketTags = (
|
||||
index: number,
|
||||
key: string,
|
||||
value: typeof bucketTags[number][keyof AwsTag]
|
||||
) => {
|
||||
setBucketTags(
|
||||
bucketTags.map((b, i) => (i === index ? { ...b, [key]: value } : b))
|
||||
);
|
||||
};
|
||||
|
||||
const addBucketTag = () => {
|
||||
setBucketTags((curr) => [
|
||||
...curr,
|
||||
{
|
||||
Key: "",
|
||||
Value: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section slim>
|
||||
@@ -418,11 +563,6 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
|
||||
username: e.target.value,
|
||||
});
|
||||
}}
|
||||
required={
|
||||
webhook.stateVal.password && !webhook.stateVal.username
|
||||
? true
|
||||
: false
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`${webhook.prefix}_password`}>
|
||||
Password
|
||||
@@ -438,17 +578,234 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
|
||||
password: e.target.value,
|
||||
});
|
||||
}}
|
||||
required={
|
||||
webhook.stateVal.username && !webhook.stateVal.password
|
||||
? true
|
||||
: false
|
||||
}
|
||||
/>
|
||||
</Checkzone>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
{ttsCache && (
|
||||
<fieldset>
|
||||
<ButtonGroup left>
|
||||
<Button
|
||||
onClick={(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setClearTtsCacheFlag(true);
|
||||
}}
|
||||
small
|
||||
disabled={ttsCache.data?.size === 0}
|
||||
>
|
||||
Clear TTS Cache
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<MS>{`There are ${
|
||||
ttsCache.data ? ttsCache.data.size : 0
|
||||
} cached TTS prompts`}</MS>
|
||||
</fieldset>
|
||||
)}
|
||||
{!DISABLE_CALL_RECORDING && (
|
||||
<>
|
||||
<fieldset>
|
||||
<Checkzone
|
||||
hidden
|
||||
name="bucket_credential"
|
||||
label="Enable call recording"
|
||||
initialCheck={initialCheckRecordAllCall}
|
||||
handleChecked={(e) => {
|
||||
setBucketCredentialChecked(e.target.checked);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="audio_format">Audio Format</label>
|
||||
<Selector
|
||||
id={"audio_format"}
|
||||
name={"audio_format"}
|
||||
value={recordFormat}
|
||||
options={AUDIO_FORMAT_OPTIONS}
|
||||
onChange={(e) => {
|
||||
setRecordFormat(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="vendor">
|
||||
Bucket Vendor{recordAllCalls && <span>*</span>}
|
||||
</label>
|
||||
<Selector
|
||||
required={recordAllCalls}
|
||||
id={"record_bucket_vendor"}
|
||||
name={"record_bucket_vendor"}
|
||||
value={bucketVendor}
|
||||
options={BUCKET_VENDOR_OPTIONS}
|
||||
onChange={(e) => {
|
||||
setBucketVendor(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{bucketVendor === "aws_s3" && (
|
||||
<>
|
||||
<label htmlFor="bucket_name">
|
||||
Bucket Name<span>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="bucket_name"
|
||||
required
|
||||
type="text"
|
||||
name="bucket_name"
|
||||
placeholder="Bucket"
|
||||
value={bucketName}
|
||||
onChange={(e) => {
|
||||
setBucketName(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{regions && regions["aws"] && (
|
||||
<>
|
||||
<label htmlFor="bucket_aws_region">
|
||||
Region<span>*</span>
|
||||
</label>
|
||||
<Selector
|
||||
id="region"
|
||||
name="region"
|
||||
value={bucketRegion}
|
||||
required
|
||||
options={[
|
||||
{
|
||||
name: "Select a region",
|
||||
value: "",
|
||||
},
|
||||
].concat(regions["aws"])}
|
||||
onChange={(e) => setBucketRegion(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<label htmlFor="bucket_aws_access_key">
|
||||
Access key ID<span>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="bucket_aws_access_key"
|
||||
required
|
||||
type="text"
|
||||
name="bucket_aws_access_key"
|
||||
placeholder="Access Key ID"
|
||||
value={bucketAccessKeyId}
|
||||
onChange={(e) => {
|
||||
setBucketAccessKeyId(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="bucket_aws_secret_key">
|
||||
Secret access key<span>*</span>
|
||||
</label>
|
||||
<Passwd
|
||||
id="bucket_aws_secret_key"
|
||||
required
|
||||
name="bucketaws_secret_key"
|
||||
placeholder="Secret Access Key"
|
||||
value={bucketSecretAccessKey}
|
||||
onChange={(e) => {
|
||||
setBucketSecretAccessKey(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="aws_s3_tags">S3 Tags</label>
|
||||
{hasLength(bucketTags) &&
|
||||
bucketTags.map((b, i) => (
|
||||
<div key={`s3_tags_${i}`} className="bucket_tag">
|
||||
<div>
|
||||
<div>
|
||||
<input
|
||||
id={`bucket_tag_name_${i}`}
|
||||
name={`bucket_tag_name_${i}`}
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
required
|
||||
value={b.Key}
|
||||
onChange={(e) => {
|
||||
updateBucketTags(i, "Key", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
id={`bucket_tag_value_${i}`}
|
||||
name={`bucket_tag_value_${i}`}
|
||||
type="text"
|
||||
placeholder="Value"
|
||||
required
|
||||
value={b.Value}
|
||||
onChange={(e) => {
|
||||
updateBucketTags(
|
||||
i,
|
||||
"Value",
|
||||
e.target.value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btnty"
|
||||
title="Delete Aws Tag"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBucketTags(
|
||||
bucketTags.filter((g2, i2) => i2 !== i)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icon>
|
||||
<Icons.Trash2 />
|
||||
</Icon>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<ButtonGroup left>
|
||||
<button
|
||||
className="btnty"
|
||||
type="button"
|
||||
onClick={addBucketTag}
|
||||
title="Add S3 Tags"
|
||||
>
|
||||
<Icon subStyle="teal">
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
</button>
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup left>
|
||||
<Button
|
||||
onClick={handleTestBucketCredential}
|
||||
small
|
||||
disabled={
|
||||
!bucketName ||
|
||||
!bucketAccessKeyId ||
|
||||
!bucketSecretAccessKey
|
||||
}
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</>
|
||||
)}
|
||||
<label htmlFor="record_all_call" className="chk">
|
||||
<input
|
||||
id="record_all_call"
|
||||
name="record_all_call"
|
||||
type="checkbox"
|
||||
onChange={(e) => setRecordAllCalls(e.target.checked)}
|
||||
checked={recordAllCalls}
|
||||
/>
|
||||
<div></div>
|
||||
<Tooltip
|
||||
text="You can also record calls only to specific applications"
|
||||
subStyle="info"
|
||||
>
|
||||
Record all calls for this account
|
||||
</Tooltip>
|
||||
</label>
|
||||
</Checkzone>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<fieldset>
|
||||
<Message message={message} />
|
||||
@@ -479,6 +836,14 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
|
||||
</P>
|
||||
</Modal>
|
||||
)}
|
||||
{clearTtsCacheFlag && (
|
||||
<Modal
|
||||
handleSubmit={handleClearCache}
|
||||
handleCancel={() => setClearTtsCacheFlag(false)}
|
||||
>
|
||||
<P>Are you sure you want to clean TTS cache for this account?</P>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
VENDOR_WELLSAID,
|
||||
useSpeechVendors,
|
||||
VENDOR_DEEPGRAM,
|
||||
VENDOR_SONIOX,
|
||||
VENDOR_CUSTOM,
|
||||
} from "src/vendor";
|
||||
import {
|
||||
postApplication,
|
||||
@@ -31,7 +33,11 @@ import {
|
||||
ROUTE_INTERNAL_ACCOUNTS,
|
||||
ROUTE_INTERNAL_APPLICATIONS,
|
||||
} from "src/router/routes";
|
||||
import { DEFAULT_WEBHOOK, WEBHOOK_METHODS } from "src/api/constants";
|
||||
import {
|
||||
DEFAULT_WEBHOOK,
|
||||
DISABLE_CALL_RECORDING,
|
||||
WEBHOOK_METHODS,
|
||||
} from "src/api/constants";
|
||||
|
||||
import type {
|
||||
RecognizerVendors,
|
||||
@@ -39,6 +45,7 @@ import type {
|
||||
Voice,
|
||||
VoiceLanguage,
|
||||
Language,
|
||||
VendorOptions,
|
||||
} from "src/vendor/types";
|
||||
|
||||
import type {
|
||||
@@ -47,9 +54,10 @@ import type {
|
||||
Application,
|
||||
WebhookMethod,
|
||||
UseApiDataMap,
|
||||
SpeechCredential,
|
||||
} from "src/api/types";
|
||||
import { MSG_REQUIRED_FIELDS, MSG_WEBHOOK_FIELDS } from "src/constants";
|
||||
import { isUserAccountScope, useRedirect } from "src/utils";
|
||||
import { hasLength, isUserAccountScope, useRedirect } from "src/utils";
|
||||
import { setAccountFilter, setLocation } from "src/store/localStore";
|
||||
|
||||
type ApplicationFormProps = {
|
||||
@@ -68,11 +76,17 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
const [initialApplicationJson, setInitialApplicationJson] = useState(false);
|
||||
const [accountSid, setAccountSid] = useState("");
|
||||
const [callWebhook, setCallWebhook] = useState<WebHook>(DEFAULT_WEBHOOK);
|
||||
const [tmpCallWebhook, setTmpCallWebhook] =
|
||||
useState<WebHook>(DEFAULT_WEBHOOK);
|
||||
const [initialCallWebhook, setInitialCallWebhook] = useState(false);
|
||||
const [statusWebhook, setStatusWebhook] = useState<WebHook>(DEFAULT_WEBHOOK);
|
||||
const [tmpStatusWebhook, setTmpStatusWebhook] =
|
||||
useState<WebHook>(DEFAULT_WEBHOOK);
|
||||
const [initialStatusWebhook, setInitialStatusWebhook] = useState(false);
|
||||
const [messageWebhook, setMessageWebhook] =
|
||||
useState<WebHook>(DEFAULT_WEBHOOK);
|
||||
const [tmpMessageWebhook, setTmpMessageWebhook] =
|
||||
useState<WebHook>(DEFAULT_WEBHOOK);
|
||||
const [initialMessageWebhook, setInitialMessageWebhook] = useState(false);
|
||||
const [synthVendor, setSynthVendor] =
|
||||
useState<keyof SynthesisVendors>(VENDOR_GOOGLE);
|
||||
@@ -82,6 +96,11 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
useState<keyof RecognizerVendors>(VENDOR_GOOGLE);
|
||||
const [recogLang, setRecogLang] = useState(LANG_EN_US);
|
||||
const [message, setMessage] = useState("");
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [credentials] = useApiData<SpeechCredential[]>(apiUrl);
|
||||
const [softTtsVendor, setSoftTtsVendor] = useState<VendorOptions[]>(vendors);
|
||||
const [softSttVendor, setSoftSttVendor] = useState<VendorOptions[]>(vendors);
|
||||
const [recordAllCalls, setRecordAllCalls] = useState(false);
|
||||
|
||||
/** This lets us map and render the same UI for each... */
|
||||
const webhooks = [
|
||||
@@ -89,7 +108,9 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
label: "Calling",
|
||||
prefix: "call_webhook",
|
||||
stateVal: callWebhook,
|
||||
tmpStateVal: tmpCallWebhook,
|
||||
stateSet: setCallWebhook,
|
||||
tmpStateSet: setTmpCallWebhook,
|
||||
initialCheck: initialCallWebhook,
|
||||
required: true,
|
||||
},
|
||||
@@ -97,7 +118,9 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
label: "Call status",
|
||||
prefix: "status_webhook",
|
||||
stateVal: statusWebhook,
|
||||
tmpStateVal: tmpStatusWebhook,
|
||||
stateSet: setStatusWebhook,
|
||||
tmpStateSet: setTmpStatusWebhook,
|
||||
initialCheck: initialStatusWebhook,
|
||||
required: true,
|
||||
},
|
||||
@@ -105,7 +128,9 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
label: "Messaging",
|
||||
prefix: "message_webhook",
|
||||
stateVal: messageWebhook,
|
||||
tmpStateVal: tmpMessageWebhook,
|
||||
stateSet: setMessageWebhook,
|
||||
tmpStateSet: setTmpMessageWebhook,
|
||||
initialCheck: initialMessageWebhook,
|
||||
required: false,
|
||||
},
|
||||
@@ -158,6 +183,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
speech_synthesis_voice: synthVoice || null,
|
||||
speech_recognizer_vendor: recogVendor || null,
|
||||
speech_recognizer_language: recogLang || null,
|
||||
record_all_calls: recordAllCalls ? 1 : 0,
|
||||
};
|
||||
|
||||
if (application && application.data) {
|
||||
@@ -185,10 +211,45 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (credentials && hasLength(credentials)) {
|
||||
const v = credentials
|
||||
.filter((tv) => tv.vendor.startsWith(VENDOR_CUSTOM) && tv.use_for_tts)
|
||||
.map((tv) =>
|
||||
Object.assign({
|
||||
name:
|
||||
tv.vendor.substring(VENDOR_CUSTOM.length + 1) +
|
||||
` (${VENDOR_CUSTOM})`,
|
||||
value: tv.vendor,
|
||||
})
|
||||
);
|
||||
setSoftTtsVendor(vendors.concat(v));
|
||||
|
||||
const v2 = credentials
|
||||
.filter((tv) => tv.vendor.startsWith(VENDOR_CUSTOM) && tv.use_for_stt)
|
||||
.map((tv) =>
|
||||
Object.assign({
|
||||
name:
|
||||
tv.vendor.substring(VENDOR_CUSTOM.length + 1) +
|
||||
` (${VENDOR_CUSTOM})`,
|
||||
value: tv.vendor,
|
||||
})
|
||||
);
|
||||
setSoftSttVendor(vendors.concat(v2));
|
||||
}
|
||||
}, [credentials]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountSid) {
|
||||
setApiUrl(`Accounts/${accountSid}/SpeechCredentials`);
|
||||
}
|
||||
}, [accountSid]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocation();
|
||||
if (application && application.data) {
|
||||
setApplicationName(application.data.name);
|
||||
setRecordAllCalls(application.data.record_all_calls ? true : false);
|
||||
if (!applicationJson) {
|
||||
setApplicationJson(application.data.app_json || "");
|
||||
}
|
||||
@@ -200,6 +261,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
|
||||
if (application.data.call_hook) {
|
||||
setCallWebhook(application.data.call_hook);
|
||||
setTmpCallWebhook(application.data.call_hook);
|
||||
|
||||
if (
|
||||
application.data.call_hook.username ||
|
||||
@@ -211,6 +273,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
|
||||
if (application.data.call_status_hook) {
|
||||
setStatusWebhook(application.data.call_status_hook);
|
||||
setTmpStatusWebhook(application.data.call_status_hook);
|
||||
|
||||
if (
|
||||
application.data.call_status_hook.username ||
|
||||
@@ -222,6 +285,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
|
||||
if (application.data.messaging_hook) {
|
||||
setMessageWebhook(application.data.messaging_hook);
|
||||
setTmpMessageWebhook(application.data.messaging_hook);
|
||||
|
||||
if (
|
||||
application.data.messaging_hook.username ||
|
||||
@@ -341,6 +405,18 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
name={webhook.prefix}
|
||||
label="Use HTTP basic authentication"
|
||||
initialCheck={webhook.initialCheck}
|
||||
handleChecked={(e) => {
|
||||
if (e.target.checked) {
|
||||
webhook.stateSet(webhook.tmpStateVal);
|
||||
} else {
|
||||
webhook.tmpStateSet(webhook.stateVal);
|
||||
webhook.stateSet({
|
||||
...webhook.stateVal,
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MS>{MSG_WEBHOOK_FIELDS}</MS>
|
||||
<label htmlFor={`${webhook.prefix}_username`}>Username</label>
|
||||
@@ -356,13 +432,6 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
username: e.target.value,
|
||||
});
|
||||
}}
|
||||
required={
|
||||
webhook.required &&
|
||||
!webhook.stateVal.username &&
|
||||
webhook.stateVal.password
|
||||
? true
|
||||
: false
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`${webhook.prefix}_password`}>Password</label>
|
||||
<Passwd
|
||||
@@ -376,13 +445,6 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
password: e.target.value,
|
||||
});
|
||||
}}
|
||||
required={
|
||||
webhook.required &&
|
||||
webhook.stateVal.username &&
|
||||
!webhook.stateVal.password
|
||||
? true
|
||||
: false
|
||||
}
|
||||
/>
|
||||
</Checkzone>
|
||||
</fieldset>
|
||||
@@ -395,13 +457,22 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
id="synthesis_vendor"
|
||||
name="synthesis_vendor"
|
||||
value={synthVendor}
|
||||
options={vendors.filter(
|
||||
(vendor) => vendor.value != VENDOR_DEEPGRAM
|
||||
options={softTtsVendor.filter(
|
||||
(vendor) =>
|
||||
vendor.value != VENDOR_DEEPGRAM &&
|
||||
vendor.value != VENDOR_SONIOX &&
|
||||
vendor.value !== VENDOR_CUSTOM
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const vendor = e.target.value as keyof SynthesisVendors;
|
||||
setSynthVendor(vendor);
|
||||
|
||||
/** When Custom Vendor is used, user you have to input the lange and voice. */
|
||||
if (vendor.toString().startsWith(VENDOR_CUSTOM)) {
|
||||
setSynthVoice("");
|
||||
return;
|
||||
}
|
||||
|
||||
/** When using Google and en-US, ensure "Standard-C" is used as default */
|
||||
if (
|
||||
e.target.value === VENDOR_GOOGLE &&
|
||||
@@ -430,55 +501,88 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
setSynthVoice(newLang!.voices[0].value);
|
||||
}}
|
||||
/>
|
||||
{synthVendor && synthLang && (
|
||||
<>
|
||||
<label htmlFor="synthesis_lang">Language</label>
|
||||
<Selector
|
||||
id="synthesis_lang"
|
||||
name="synthesis_lang"
|
||||
value={synthLang}
|
||||
options={synthesis[synthVendor as keyof SynthesisVendors].map(
|
||||
(lang: VoiceLanguage) => ({
|
||||
{synthVendor &&
|
||||
!synthVendor.toString().startsWith(VENDOR_CUSTOM) &&
|
||||
synthLang && (
|
||||
<>
|
||||
<label htmlFor="synthesis_lang">Language</label>
|
||||
<Selector
|
||||
id="synthesis_lang"
|
||||
name="synthesis_lang"
|
||||
value={synthLang}
|
||||
options={synthesis[
|
||||
synthVendor as keyof SynthesisVendors
|
||||
].map((lang: VoiceLanguage) => ({
|
||||
name: lang.name,
|
||||
value: lang.code,
|
||||
})
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const language = e.target.value;
|
||||
setSynthLang(language);
|
||||
}))}
|
||||
onChange={(e) => {
|
||||
const language = e.target.value;
|
||||
setSynthLang(language);
|
||||
|
||||
/** When using Google and en-US, ensure "Standard-C" is used as default */
|
||||
if (
|
||||
synthVendor === VENDOR_GOOGLE &&
|
||||
language === LANG_EN_US
|
||||
) {
|
||||
setSynthVoice(LANG_EN_US_STANDARD_C);
|
||||
return;
|
||||
/** When using Google and en-US, ensure "Standard-C" is used as default */
|
||||
if (
|
||||
synthVendor === VENDOR_GOOGLE &&
|
||||
language === LANG_EN_US
|
||||
) {
|
||||
setSynthVoice(LANG_EN_US_STANDARD_C);
|
||||
return;
|
||||
}
|
||||
|
||||
const newLang = synthesis[
|
||||
synthVendor as keyof SynthesisVendors
|
||||
].find((lang) => lang.code === language);
|
||||
|
||||
setSynthVoice(newLang!.voices[0].value);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="synthesis_voice">Voice</label>
|
||||
<Selector
|
||||
id="synthesis_voice"
|
||||
name="synthesis_voice"
|
||||
value={synthVoice}
|
||||
options={
|
||||
synthesis[synthVendor as keyof SynthesisVendors]
|
||||
.filter(
|
||||
(lang: VoiceLanguage) => lang.code === synthLang
|
||||
)
|
||||
.flatMap((lang: VoiceLanguage) =>
|
||||
lang.voices.map((voice: Voice) => ({
|
||||
name: voice.name,
|
||||
value: voice.value,
|
||||
}))
|
||||
) as Voice[]
|
||||
}
|
||||
|
||||
const newLang = synthesis[
|
||||
synthVendor as keyof SynthesisVendors
|
||||
].find((lang) => lang.code === language);
|
||||
|
||||
setSynthVoice(newLang!.voices[0].value);
|
||||
onChange={(e) => setSynthVoice(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{synthVendor.toString().startsWith(VENDOR_CUSTOM) && (
|
||||
<>
|
||||
<label htmlFor="custom_vendor_synthesis_lang">Language</label>
|
||||
<input
|
||||
id="custom_vendor_synthesis_lang"
|
||||
type="text"
|
||||
name="custom_vendor_synthesis_lang"
|
||||
placeholder="Required"
|
||||
required
|
||||
value={synthLang}
|
||||
onChange={(e) => {
|
||||
setSynthLang(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="synthesis_voice">Voice</label>
|
||||
<Selector
|
||||
id="synthesis_voice"
|
||||
name="synthesis_voice"
|
||||
|
||||
<label htmlFor="custom_vendor_synthesis_voice">Voice</label>
|
||||
<input
|
||||
id="custom_vendor_synthesis_voice"
|
||||
type="text"
|
||||
name="custom_vendor_synthesis_voice"
|
||||
placeholder="Required"
|
||||
required
|
||||
value={synthVoice}
|
||||
options={
|
||||
synthesis[synthVendor as keyof SynthesisVendors]
|
||||
.filter((lang: VoiceLanguage) => lang.code === synthLang)
|
||||
.flatMap((lang: VoiceLanguage) =>
|
||||
lang.voices.map((voice: Voice) => ({
|
||||
name: voice.name,
|
||||
value: voice.value,
|
||||
}))
|
||||
) as Voice[]
|
||||
}
|
||||
onChange={(e) => setSynthVoice(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSynthVoice(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -491,13 +595,18 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
id="recognizer_vendor"
|
||||
name="recognizer_vendor"
|
||||
value={recogVendor}
|
||||
options={vendors.filter(
|
||||
(vendor) => vendor.value != VENDOR_WELLSAID
|
||||
options={softSttVendor.filter(
|
||||
(vendor) =>
|
||||
vendor.value != VENDOR_WELLSAID &&
|
||||
vendor.value !== VENDOR_CUSTOM
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const vendor = e.target.value as keyof RecognizerVendors;
|
||||
setRecogVendor(vendor);
|
||||
|
||||
/**When vendor is custom, Language is input by user */
|
||||
if (vendor.toString() === VENDOR_CUSTOM) return;
|
||||
|
||||
/** Google and AWS have different language lists */
|
||||
/** If the new language doesn't map then default to "en-US" */
|
||||
const newLang = recognizers[vendor].find(
|
||||
@@ -512,19 +621,37 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{recogVendor && recogLang && (
|
||||
{recogVendor &&
|
||||
!recogVendor.toString().startsWith(VENDOR_CUSTOM) &&
|
||||
recogLang && (
|
||||
<>
|
||||
<label htmlFor="recognizer_lang">Language</label>
|
||||
<Selector
|
||||
id="recognizer_lang"
|
||||
name="recognizer_lang"
|
||||
value={recogLang}
|
||||
options={recognizers[
|
||||
recogVendor as keyof RecognizerVendors
|
||||
].map((lang: Language) => ({
|
||||
name: lang.name,
|
||||
value: lang.code,
|
||||
}))}
|
||||
onChange={(e) => {
|
||||
setRecogLang(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{recogVendor.toString().startsWith(VENDOR_CUSTOM) && (
|
||||
<>
|
||||
<label htmlFor="recognizer_lang">Language</label>
|
||||
<Selector
|
||||
id="recognizer_lang"
|
||||
name="recognizer_lang"
|
||||
<label htmlFor="custom_vendor_recognizer_voice">Language</label>
|
||||
<input
|
||||
id="custom_vendor_recognizer_voice"
|
||||
type="text"
|
||||
name="custom_vendor_recognizer_voice"
|
||||
placeholder="Required"
|
||||
required
|
||||
value={recogLang}
|
||||
options={recognizers[
|
||||
recogVendor as keyof RecognizerVendors
|
||||
].map((lang: Language) => ({
|
||||
name: lang.name,
|
||||
value: lang.code,
|
||||
}))}
|
||||
onChange={(e) => {
|
||||
setRecogLang(e.target.value);
|
||||
}}
|
||||
@@ -563,6 +690,23 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
||||
</Checkzone>
|
||||
</fieldset>
|
||||
)}
|
||||
{!DISABLE_CALL_RECORDING &&
|
||||
accounts?.filter((a) => a.account_sid === accountSid).length &&
|
||||
!accounts?.filter((a) => a.account_sid === accountSid)[0]
|
||||
.record_all_calls && (
|
||||
<fieldset>
|
||||
<label htmlFor="record_all_call" className="chk">
|
||||
<input
|
||||
id="record_all_call"
|
||||
name="record_all_call"
|
||||
type="checkbox"
|
||||
onChange={(e) => setRecordAllCalls(e.target.checked)}
|
||||
checked={recordAllCalls}
|
||||
/>
|
||||
<div>Record all calls</div>
|
||||
</label>
|
||||
</fieldset>
|
||||
)}
|
||||
{message && <fieldset>{<Message message={message} />}</fieldset>}
|
||||
<fieldset>
|
||||
<ButtonGroup left>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
FQDN_TOP_LEVEL,
|
||||
INVALID,
|
||||
NETMASK_OPTIONS,
|
||||
SIP_GATEWAY_PROTOCOL_OPTIONS,
|
||||
TCP_MAX_PORT,
|
||||
TECH_PREFIX_MINLENGTH,
|
||||
USER_ACCOUNT,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
isUserAccountScope,
|
||||
hasLength,
|
||||
isValidPort,
|
||||
disableDefaultTrunkRouting,
|
||||
} from "src/utils";
|
||||
|
||||
import type {
|
||||
@@ -59,6 +61,7 @@ import type {
|
||||
Application,
|
||||
} from "src/api/types";
|
||||
import { setAccountFilter, setLocation } from "src/store/localStore";
|
||||
import { RegisterStatus } from "./register-status";
|
||||
|
||||
type CarrierFormProps = {
|
||||
carrier?: UseApiDataMap<Carrier>;
|
||||
@@ -135,6 +138,7 @@ export const CarrierForm = ({
|
||||
|
||||
const setCarrierStates = (obj: Carrier) => {
|
||||
if (obj) {
|
||||
setIsActive(obj.is_active);
|
||||
if (obj.name) {
|
||||
setCarrierName(obj.name);
|
||||
}
|
||||
@@ -618,6 +622,15 @@ export const CarrierForm = ({
|
||||
<fieldset>
|
||||
<MS>{MSG_REQUIRED_FIELDS}</MS>
|
||||
</fieldset>
|
||||
{carrier &&
|
||||
carrier.data &&
|
||||
Boolean(carrier.data.requires_register) &&
|
||||
carrier.data.register_status && (
|
||||
<fieldset>
|
||||
<div className="m med">Register status</div>
|
||||
<RegisterStatus carrier={carrier.data} />
|
||||
</fieldset>
|
||||
)}
|
||||
<fieldset>
|
||||
<div className="multi">
|
||||
<div className="inp">
|
||||
@@ -725,18 +738,21 @@ export const CarrierForm = ({
|
||||
: false
|
||||
}
|
||||
/>
|
||||
{accountSid && hasLength(applications) && (
|
||||
<>
|
||||
<ApplicationSelect
|
||||
label="Default Application"
|
||||
defaultOption="None"
|
||||
application={[applicationSid, setApplicationSid]}
|
||||
applications={applications.filter(
|
||||
(application) => application.account_sid === accountSid
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{user &&
|
||||
disableDefaultTrunkRouting(user?.scope) &&
|
||||
accountSid &&
|
||||
hasLength(applications) && (
|
||||
<>
|
||||
<ApplicationSelect
|
||||
label="Default Application"
|
||||
defaultOption="None"
|
||||
application={[applicationSid, setApplicationSid]}
|
||||
applications={applications.filter(
|
||||
(application) => application.account_sid === accountSid
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<Checkzone
|
||||
@@ -960,20 +976,56 @@ export const CarrierForm = ({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Selector
|
||||
id={`sip_netmask_${i}`}
|
||||
name={`sip_netmask${i}`}
|
||||
placeholder="32"
|
||||
value={g.netmask}
|
||||
options={NETMASK_OPTIONS}
|
||||
onChange={(e) => {
|
||||
updateSipGateways(i, "netmask", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{g.outbound ? (
|
||||
<div>
|
||||
<Selector
|
||||
id={`sip_protocol_${i}`}
|
||||
name={`sip_protocol${i}`}
|
||||
placeholder=""
|
||||
value={g.protocol}
|
||||
options={SIP_GATEWAY_PROTOCOL_OPTIONS}
|
||||
onChange={(e) => {
|
||||
updateSipGateways(i, "protocol", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Selector
|
||||
id={`sip_netmask_${i}`}
|
||||
name={`sip_netmask${i}`}
|
||||
placeholder="32"
|
||||
value={g.netmask}
|
||||
options={NETMASK_OPTIONS}
|
||||
onChange={(e) => {
|
||||
updateSipGateways(i, "netmask", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`sip__gw_is_active_${i}`}
|
||||
className="chk"
|
||||
>
|
||||
<input
|
||||
id={`sip__gw_is_active_${i}`}
|
||||
name={`sip__gw_is_active_${i}`}
|
||||
type="checkbox"
|
||||
checked={g.is_active ? true : false}
|
||||
onChange={(e) => {
|
||||
updateSipGateways(
|
||||
i,
|
||||
"is_active",
|
||||
e.target.checked ? 1 : 0
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div>Active</div>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`sip_inbound_${i}`} className="chk">
|
||||
<input
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import {
|
||||
API_SIP_GATEWAY,
|
||||
API_SMPP_GATEWAY,
|
||||
CARRIER_REG_OK,
|
||||
USER_ACCOUNT,
|
||||
} from "src/api/constants";
|
||||
import { DeleteCarrier } from "./delete";
|
||||
@@ -51,7 +52,6 @@ export const Carriers = () => {
|
||||
setAccountSid(getAccountFilter());
|
||||
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
|
||||
setAccountSid(user?.account_sid);
|
||||
return carriers;
|
||||
}
|
||||
|
||||
return carriers
|
||||
@@ -197,6 +197,26 @@ export const Carriers = () => {
|
||||
<span>{carrier.is_active ? "Active" : "Inactive"}</span>
|
||||
</div>
|
||||
</div>
|
||||
{Boolean(carrier.requires_register) && (
|
||||
<div
|
||||
className={`i txt--${
|
||||
carrier.register_status.status === CARRIER_REG_OK
|
||||
? "teal"
|
||||
: "jam"
|
||||
}`}
|
||||
>
|
||||
{carrier.register_status.status === CARRIER_REG_OK ? (
|
||||
<Icons.CheckCircle />
|
||||
) : (
|
||||
<Icons.XCircle />
|
||||
)}
|
||||
<span>
|
||||
{carrier.register_status.status === CARRIER_REG_OK
|
||||
? "Registered"
|
||||
: "Unregistered"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Gateways carrier={carrier} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
65
src/containers/internal/views/carriers/pcap.tsx
Normal file
65
src/containers/internal/views/carriers/pcap.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
getRecentCall,
|
||||
getServiceProviderRecentCall,
|
||||
getPcap,
|
||||
getServiceProviderPcap,
|
||||
} from "src/api";
|
||||
import { toastError } from "src/store";
|
||||
|
||||
import type { DownloadedBlob } from "src/api/types";
|
||||
|
||||
type PcapButtonProps = {
|
||||
accountSid: string;
|
||||
serviceProviderSid: string;
|
||||
sipCallId: string;
|
||||
};
|
||||
|
||||
export const PcapButton = ({
|
||||
accountSid,
|
||||
serviceProviderSid,
|
||||
sipCallId,
|
||||
}: PcapButtonProps) => {
|
||||
const [pcap, setPcap] = useState<DownloadedBlob>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!sipCallId) return;
|
||||
const p = accountSid
|
||||
? getRecentCall(accountSid, sipCallId)
|
||||
: getServiceProviderRecentCall(serviceProviderSid, sipCallId);
|
||||
p.then(({ json }) => {
|
||||
if (json.total > 0) {
|
||||
const p1 = accountSid
|
||||
? getPcap(accountSid, sipCallId, "register")
|
||||
: getServiceProviderPcap(serviceProviderSid, sipCallId, "register");
|
||||
p1.then(({ blob }) => {
|
||||
if (blob) {
|
||||
setPcap({
|
||||
data_url: URL.createObjectURL(blob),
|
||||
file_name: `callid-${sipCallId}.pcap`,
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (pcap) {
|
||||
return (
|
||||
<a
|
||||
href={pcap.data_url}
|
||||
download={pcap.file_name}
|
||||
className="btn btn--small pcap"
|
||||
>
|
||||
Download pcap
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
53
src/containers/internal/views/carriers/register-status.tsx
Normal file
53
src/containers/internal/views/carriers/register-status.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { Carrier } from "src/api/types";
|
||||
import { Icons } from "src/components";
|
||||
import { CARRIER_REG_OK } from "src/api/constants";
|
||||
import { MS } from "@jambonz/ui-kit";
|
||||
import { PcapButton } from "./pcap";
|
||||
|
||||
type CarrierProps = {
|
||||
carrier: Carrier;
|
||||
};
|
||||
|
||||
export const RegisterStatus = ({ carrier }: CarrierProps) => {
|
||||
const renderStatus = () => {
|
||||
return (
|
||||
<div
|
||||
className={`i txt--${
|
||||
carrier.register_status.status
|
||||
? carrier.register_status.status === CARRIER_REG_OK
|
||||
? "teal"
|
||||
: "jam"
|
||||
: "jean"
|
||||
}`}
|
||||
title={carrier.register_status.reason || "Not Started"}
|
||||
>
|
||||
{carrier.register_status.status === CARRIER_REG_OK ? (
|
||||
<Icons.CheckCircle />
|
||||
) : (
|
||||
<Icons.XCircle />
|
||||
)}
|
||||
<span>
|
||||
{carrier.register_status.status
|
||||
? `Status ${carrier.register_status.status}`
|
||||
: "Not Started"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<details className={carrier.register_status.status || "not-tested"}>
|
||||
<summary>{renderStatus()}</summary>
|
||||
<MS>
|
||||
<strong>Reason:</strong>{" "}
|
||||
{carrier.register_status.reason || "Not Started"}
|
||||
</MS>
|
||||
<PcapButton
|
||||
accountSid={carrier.account_sid || ""}
|
||||
serviceProviderSid={carrier.service_provider_sid}
|
||||
sipCallId={carrier.register_status.callId || ""}
|
||||
/>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
14
src/containers/internal/views/clients/add.tsx
Normal file
14
src/containers/internal/views/clients/add.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { H1 } from "@jambonz/ui-kit";
|
||||
import React from "react";
|
||||
import ClientsForm from "./form";
|
||||
|
||||
export const ClientsAdd = () => {
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Add client</H1>
|
||||
<ClientsForm />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsAdd;
|
||||
28
src/containers/internal/views/clients/delete.tsx
Normal file
28
src/containers/internal/views/clients/delete.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { P } from "@jambonz/ui-kit";
|
||||
import React from "react";
|
||||
import { Client } from "src/api/types";
|
||||
import { Modal } from "src/components";
|
||||
|
||||
type ClientsDeleteProps = {
|
||||
client: Client;
|
||||
handleCancel: () => void;
|
||||
handleSubmit: () => void;
|
||||
};
|
||||
export const ClientsDelete = ({
|
||||
client,
|
||||
handleCancel,
|
||||
handleSubmit,
|
||||
}: ClientsDeleteProps) => {
|
||||
return (
|
||||
<>
|
||||
<Modal handleCancel={handleCancel} handleSubmit={handleSubmit}>
|
||||
<P>
|
||||
Are you sure you want to delete the client{" "}
|
||||
<strong>{client.username}</strong>?
|
||||
</P>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsDelete;
|
||||
30
src/containers/internal/views/clients/edit.tsx
Normal file
30
src/containers/internal/views/clients/edit.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { H1 } from "@jambonz/ui-kit";
|
||||
import React, { useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useApiData } from "src/api";
|
||||
import { Client } from "src/api/types";
|
||||
import { toastError } from "src/store";
|
||||
import ClientsForm from "./form";
|
||||
|
||||
export const ClientsEdit = () => {
|
||||
const params = useParams();
|
||||
const [data, refetch, error] = useApiData<Client>(
|
||||
`Clients/${params.client_sid}`
|
||||
);
|
||||
|
||||
/** Handle error toast at top level... */
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toastError(error.msg);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Edit client</H1>
|
||||
<ClientsForm client={{ data, refetch, error }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsEdit;
|
||||
229
src/containers/internal/views/clients/form.tsx
Normal file
229
src/containers/internal/views/clients/form.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
deleteClient,
|
||||
postClient,
|
||||
putClient,
|
||||
useApiData,
|
||||
useServiceProviderData,
|
||||
} from "src/api";
|
||||
import { DEFAULT_PSWD_SETTINGS, USER_ACCOUNT } from "src/api/constants";
|
||||
import {
|
||||
Account,
|
||||
Client,
|
||||
PasswordSettings,
|
||||
UseApiDataMap,
|
||||
} from "src/api/types";
|
||||
import { Section } from "src/components";
|
||||
import { AccountSelect, Message, Passwd } from "src/components/forms";
|
||||
import { MSG_REQUIRED_FIELDS } from "src/constants";
|
||||
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
|
||||
import { toastError, toastSuccess, useSelectState } from "src/store";
|
||||
import ClientsDelete from "./delete";
|
||||
import { hasValue, isValidPasswd } from "src/utils";
|
||||
import { IMessage } from "src/store/types";
|
||||
|
||||
type ClientsFormProps = {
|
||||
client?: UseApiDataMap<Client>;
|
||||
};
|
||||
|
||||
export const ClientsForm = ({ client }: ClientsFormProps) => {
|
||||
const user = useSelectState("user");
|
||||
const [accounts] = useServiceProviderData<Account[]>("Accounts");
|
||||
const [pwdSettings] =
|
||||
useApiData<PasswordSettings>("PasswordSettings") || DEFAULT_PSWD_SETTINGS;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [accountSid, setAccountSid] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [modal, setModal] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!client) {
|
||||
if (!passwdCheck()) return;
|
||||
|
||||
postClient({
|
||||
account_sid: accountSid,
|
||||
username: username,
|
||||
password: password,
|
||||
is_active: isActive,
|
||||
})
|
||||
.then(() => {
|
||||
toastSuccess("Client created successfully");
|
||||
navigate(ROUTE_INTERNAL_CLIENTS);
|
||||
})
|
||||
.catch((error: { msg: IMessage }) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
} else {
|
||||
putClient(client.data?.client_sid || "", {
|
||||
account_sid: accountSid,
|
||||
username: username,
|
||||
...(password && { password: password }),
|
||||
is_active: isActive,
|
||||
})
|
||||
.then(() => {
|
||||
toastSuccess("Client updated successfully");
|
||||
navigate(ROUTE_INTERNAL_CLIENTS);
|
||||
})
|
||||
.catch((error: { msg: IMessage }) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const passwdCheck = () => {
|
||||
if (pwdSettings && !isValidPasswd(password, pwdSettings)) {
|
||||
toastError("Invalid password.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setModal(false);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (client) {
|
||||
deleteClient(client.data?.client_sid || "")
|
||||
.then(() => {
|
||||
toastSuccess("Client deleted successfully");
|
||||
navigate(ROUTE_INTERNAL_CLIENTS);
|
||||
})
|
||||
.catch((error: { msg: IMessage }) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (client && client.data) {
|
||||
if (client.data.username) {
|
||||
setUsername(client.data.username);
|
||||
}
|
||||
|
||||
if (client.data.account_sid) {
|
||||
setAccountSid(client.data.account_sid);
|
||||
}
|
||||
|
||||
setIsActive(client.data.is_active);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
const acc = accounts?.find((a) => a.account_sid === accountSid);
|
||||
if (!accountSid || !accounts || !acc) return;
|
||||
if (!acc?.sip_realm) {
|
||||
setErrorMessage(`Sip realm is not set for the account.`);
|
||||
} else if (!acc?.device_calling_application_sid) {
|
||||
setErrorMessage(`Device calling application is not set for the account.`);
|
||||
} else {
|
||||
setErrorMessage("");
|
||||
}
|
||||
}, [accountSid]);
|
||||
return (
|
||||
<>
|
||||
<Section slim>
|
||||
<form className="form form--internal" onSubmit={handleSubmit}>
|
||||
<fieldset>
|
||||
<MS>{MSG_REQUIRED_FIELDS}</MS>
|
||||
{errorMessage && <Message message={errorMessage} />}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<div className="multi">
|
||||
<div className="inp">
|
||||
<label htmlFor="lcr_name">
|
||||
User Name<span>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="client_username"
|
||||
name="client_username"
|
||||
type="text"
|
||||
placeholder="user name"
|
||||
value={username}
|
||||
required={true}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label htmlFor="is_active" className="chk">
|
||||
<input
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
/>
|
||||
<div>Active</div>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<label htmlFor="password">
|
||||
Password{!hasValue(client) && <span>*</span>}
|
||||
</label>
|
||||
<Passwd
|
||||
id="password"
|
||||
required={!hasValue(client)}
|
||||
name="password"
|
||||
value={password}
|
||||
placeholder="Password"
|
||||
setValue={setPassword}
|
||||
/>
|
||||
</fieldset>
|
||||
{user?.scope !== USER_ACCOUNT && (
|
||||
<fieldset>
|
||||
<AccountSelect
|
||||
accounts={accounts}
|
||||
account={[accountSid, setAccountSid]}
|
||||
label="Used by"
|
||||
required={true}
|
||||
defaultOption={false}
|
||||
disabled={hasValue(client)}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
<fieldset>
|
||||
<ButtonGroup left className={client && "btns--spaced"}>
|
||||
<Button
|
||||
small
|
||||
subStyle="grey"
|
||||
as={Link}
|
||||
to={ROUTE_INTERNAL_CLIENTS}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" small disabled={errorMessage !== ""}>
|
||||
Save
|
||||
</Button>
|
||||
{client && client.data && (
|
||||
<Button
|
||||
small
|
||||
type="button"
|
||||
subStyle="grey"
|
||||
onClick={() => setModal(true)}
|
||||
>
|
||||
Delete User
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Section>
|
||||
{client && client.data && modal && (
|
||||
<ClientsDelete
|
||||
client={client.data}
|
||||
handleCancel={handleCancel}
|
||||
handleSubmit={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsForm;
|
||||
175
src/containers/internal/views/clients/index.tsx
Normal file
175
src/containers/internal/views/clients/index.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Button, H1, Icon, M } from "@jambonz/ui-kit";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { deleteClient, useApiData, useServiceProviderData } from "src/api";
|
||||
import { Account, Client } from "src/api/types";
|
||||
import {
|
||||
AccountFilter,
|
||||
Icons,
|
||||
ScopedAccess,
|
||||
SearchFilter,
|
||||
Section,
|
||||
Spinner,
|
||||
} from "src/components";
|
||||
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
|
||||
import { toastError, toastSuccess, useSelectState } from "src/store";
|
||||
import { Scope } from "src/store/types";
|
||||
import { hasLength, hasValue, useFilteredResults } from "src/utils";
|
||||
import ClientsDelete from "./delete";
|
||||
import { USER_ACCOUNT } from "src/api/constants";
|
||||
|
||||
export const Clients = () => {
|
||||
const user = useSelectState("user");
|
||||
const [accounts] = useServiceProviderData<Account[]>("Accounts");
|
||||
const [clients, refetch] = useApiData<Client[]>("Clients");
|
||||
|
||||
const [accountSid, setAccountSid] = useState("");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [client, setClient] = useState<Client | null>();
|
||||
|
||||
const tmpFilteredClients = useMemo(() => {
|
||||
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
|
||||
setAccountSid(user?.account_sid);
|
||||
return clients;
|
||||
}
|
||||
|
||||
return clients
|
||||
? clients.filter((c) => {
|
||||
return accountSid ? c.account_sid === accountSid : true;
|
||||
})
|
||||
: [];
|
||||
}, [accountSid, clients]);
|
||||
|
||||
const filteredClients = useFilteredResults(filter, tmpFilteredClients);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (client) {
|
||||
deleteClient(client.client_sid || "")
|
||||
.then(() => {
|
||||
toastSuccess(
|
||||
<>
|
||||
Deleted outbound call route <strong>{client.username}</strong>
|
||||
</>
|
||||
);
|
||||
setClient(null);
|
||||
refetch();
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="mast">
|
||||
<H1 className="h2">Clients</H1>
|
||||
<Link to={`${ROUTE_INTERNAL_CLIENTS}/add`} title="Add a client">
|
||||
{" "}
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="filters filters--spaced">
|
||||
<SearchFilter
|
||||
placeholder="Filter clients"
|
||||
filter={[filter, setFilter]}
|
||||
/>
|
||||
<ScopedAccess user={user} scope={Scope.admin}>
|
||||
<AccountFilter
|
||||
account={[accountSid, setAccountSid]}
|
||||
accounts={accounts}
|
||||
label=""
|
||||
defaultOption
|
||||
/>
|
||||
</ScopedAccess>
|
||||
</section>
|
||||
<Section {...(hasLength(filteredClients) && { slim: true })}>
|
||||
<div className="list">
|
||||
{!hasValue(filteredClients) && hasLength(accounts) ? (
|
||||
<Spinner />
|
||||
) : hasLength(filteredClients) ? (
|
||||
filteredClients.map((c) => (
|
||||
<div className="item" key={c.client_sid}>
|
||||
<div className="item__info">
|
||||
<div className="item__title">
|
||||
<Link
|
||||
to={`${ROUTE_INTERNAL_CLIENTS}/${c.client_sid}/edit`}
|
||||
title="Edit outbound call routes"
|
||||
className="i"
|
||||
>
|
||||
<strong>{c.username}</strong>
|
||||
<Icons.ArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="item__meta">
|
||||
<div>
|
||||
<div
|
||||
className={`i txt--${c.is_active ? "teal" : "grey"}`}
|
||||
>
|
||||
{c.is_active ? (
|
||||
<Icons.CheckCircle />
|
||||
) : (
|
||||
<Icons.XCircle />
|
||||
)}
|
||||
<span>{c.is_active ? "Active" : "Inactive"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={`i txt--${c.account_sid ? "teal" : "grey"}`}
|
||||
>
|
||||
<Icons.Activity />
|
||||
<span>
|
||||
{
|
||||
accounts?.find(
|
||||
(acct) => acct.account_sid === c.account_sid
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item__actions">
|
||||
<Link
|
||||
to={`${ROUTE_INTERNAL_CLIENTS}/${c.client_sid}/edit`}
|
||||
title="Edit Client"
|
||||
>
|
||||
<Icons.Edit3 />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title="Delete client"
|
||||
onClick={() => setClient(c)}
|
||||
className="btnty"
|
||||
>
|
||||
<Icons.Trash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<M>No Clients.</M>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
<Section clean>
|
||||
<Button small as={Link} to={`${ROUTE_INTERNAL_CLIENTS}/add`}>
|
||||
Add client
|
||||
</Button>
|
||||
</Section>
|
||||
{client && (
|
||||
<ClientsDelete
|
||||
client={client}
|
||||
handleCancel={() => setClient(null)}
|
||||
handleSubmit={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Clients;
|
||||
21
src/containers/internal/views/least-cost-routing/add.tsx
Normal file
21
src/containers/internal/views/least-cost-routing/add.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import { H1, M } from "@jambonz/ui-kit";
|
||||
|
||||
import { LcrForm } from "./form";
|
||||
|
||||
export const AddLcr = () => {
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Add outbound call routes</H1>
|
||||
<section>
|
||||
<M>
|
||||
Outbound call routing is used to select a carrier when there are
|
||||
multiple carriers available.
|
||||
</M>
|
||||
</section>
|
||||
<LcrForm />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddLcr;
|
||||
176
src/containers/internal/views/least-cost-routing/card.tsx
Normal file
176
src/containers/internal/views/least-cost-routing/card.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React from "react";
|
||||
import { Icon } from "@jambonz/ui-kit";
|
||||
import { Identifier, XYCoord } from "dnd-core";
|
||||
import { useRef } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { LcrRoute } from "src/api/types";
|
||||
import { Icons } from "src/components";
|
||||
import { Selector } from "src/components/forms";
|
||||
import { SelectorOption } from "src/components/forms/selector";
|
||||
import "./styles.scss";
|
||||
|
||||
interface DragItem {
|
||||
index: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const ItemTypes = {
|
||||
CARD: "card",
|
||||
};
|
||||
|
||||
type CardProps = {
|
||||
lr: LcrRoute;
|
||||
index: number;
|
||||
moveCard: (dragIndex: number, hoverIndex: number) => void;
|
||||
updateLcrRoute: (index: number, key: string, value: unknown) => void;
|
||||
updateLcrCarrierSetEntries: (
|
||||
index1: number,
|
||||
index2: number,
|
||||
key: string,
|
||||
value: unknown
|
||||
) => void;
|
||||
handleRouteDelete: (lr: LcrRoute, index: number) => void;
|
||||
carrierSelectorOptions: SelectorOption[];
|
||||
};
|
||||
|
||||
export const Card = ({
|
||||
lr,
|
||||
index,
|
||||
moveCard,
|
||||
updateLcrRoute,
|
||||
updateLcrCarrierSetEntries,
|
||||
handleRouteDelete,
|
||||
carrierSelectorOptions,
|
||||
}: CardProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [{ handlerId }, drop] = useDrop<
|
||||
DragItem,
|
||||
void,
|
||||
{ handlerId: Identifier | null }
|
||||
>({
|
||||
accept: ItemTypes.CARD,
|
||||
collect(monitor) {
|
||||
return {
|
||||
handlerId: monitor.getHandlerId(),
|
||||
};
|
||||
},
|
||||
hover(item: DragItem, monitor) {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
|
||||
// Don't replace items with themselves
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine rectangle on screen
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
|
||||
// Get vertical middle
|
||||
const hoverMiddleY =
|
||||
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
|
||||
// Determine mouse position
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
|
||||
// Get pixels to the top
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
||||
|
||||
// Only perform the move when the mouse has crossed half of the items height
|
||||
// When dragging downwards, only move when the cursor is below 50%
|
||||
// When dragging upwards, only move when the cursor is above 50%
|
||||
|
||||
// Dragging downwards
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dragging upwards
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Time to actually perform the action
|
||||
moveCard(dragIndex, hoverIndex);
|
||||
|
||||
// Note: we're mutating the monitor item here!
|
||||
// Generally it's better to avoid mutations,
|
||||
// but it's good here for the sake of performance
|
||||
// to avoid expensive index searches.
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: ItemTypes.CARD,
|
||||
item: () => {
|
||||
return { index };
|
||||
},
|
||||
collect: (monitor) => {
|
||||
return { isDragging: monitor.isDragging() };
|
||||
},
|
||||
});
|
||||
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`lcr lcr--route lcr-card lcr-card-${
|
||||
isDragging ? "disappear" : "appear"
|
||||
}`}
|
||||
handler-id={handlerId}
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
id={`lcr_route_regex_${index}`}
|
||||
name={`lcr_route_regex_${index}`}
|
||||
type="text"
|
||||
placeholder="Digit prefix or regex"
|
||||
required
|
||||
value={lr.regex || ""}
|
||||
onChange={(e) => {
|
||||
updateLcrRoute(index, "regex", e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Selector
|
||||
id={`lcr_carrier_set_entry_carrier_${index}`}
|
||||
name={`lcr_carrier_set_entry_carrier_${index}`}
|
||||
placeholder="Carrier"
|
||||
value={
|
||||
lr.lcr_carrier_set_entries && lr.lcr_carrier_set_entries.length > 0
|
||||
? lr.lcr_carrier_set_entries[0].voip_carrier_sid
|
||||
? lr.lcr_carrier_set_entries[0].voip_carrier_sid
|
||||
: ""
|
||||
: ""
|
||||
}
|
||||
required
|
||||
options={carrierSelectorOptions}
|
||||
onChange={(e) => {
|
||||
updateLcrCarrierSetEntries(
|
||||
index,
|
||||
0,
|
||||
"voip_carrier_sid",
|
||||
e.target.value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btnty btn__delete"
|
||||
title="Delete route"
|
||||
type="button"
|
||||
onClick={() => handleRouteDelete(lr, index)}
|
||||
>
|
||||
<Icon>
|
||||
<Icons.Trash2 />
|
||||
</Icon>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Card;
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from "react";
|
||||
import { LcrRoute } from "src/api/types";
|
||||
import Card from "./card";
|
||||
import { hasLength } from "src/utils";
|
||||
import update from "immutability-helper";
|
||||
import { deleteLcrRoute } from "src/api";
|
||||
import { toastError, toastSuccess } from "src/store";
|
||||
import { SelectorOption } from "src/components/forms/selector";
|
||||
import { NOT_AVAILABLE_PREFIX } from "src/constants";
|
||||
|
||||
type ContainerProps = {
|
||||
lcrRoute: [LcrRoute[], React.Dispatch<React.SetStateAction<LcrRoute[]>>];
|
||||
carrierSelectorOptions: SelectorOption[];
|
||||
};
|
||||
|
||||
export const Container = ({
|
||||
lcrRoute: [lcrRoutes, setLcrRoutes],
|
||||
carrierSelectorOptions,
|
||||
}: ContainerProps) => {
|
||||
const moveCard = (dragIndex: number, hoverIndex: number) => {
|
||||
setLcrRoutes((prevCards) =>
|
||||
update(prevCards, {
|
||||
$splice: [
|
||||
[dragIndex, 1],
|
||||
[hoverIndex, 0, prevCards[dragIndex]],
|
||||
],
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const updateLcrRoute = (index: number, key: string, value: unknown) => {
|
||||
setLcrRoutes(
|
||||
lcrRoutes.map((lr, i) => (i === index ? { ...lr, [key]: value } : lr))
|
||||
);
|
||||
};
|
||||
|
||||
const updateLcrCarrierSetEntries = (
|
||||
index1: number,
|
||||
index2: number,
|
||||
key: string,
|
||||
value: unknown
|
||||
) => {
|
||||
setLcrRoutes(
|
||||
lcrRoutes.map((lr, i) =>
|
||||
i === index1
|
||||
? {
|
||||
...lr,
|
||||
lcr_carrier_set_entries: lr.lcr_carrier_set_entries?.map(
|
||||
(entry, j) =>
|
||||
j === index2
|
||||
? {
|
||||
...entry,
|
||||
[key]: value,
|
||||
}
|
||||
: entry
|
||||
),
|
||||
}
|
||||
: lr
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleRouteDelete = (r: LcrRoute | undefined, index: number) => {
|
||||
if (
|
||||
r &&
|
||||
r.lcr_route_sid &&
|
||||
!r.lcr_route_sid.startsWith(NOT_AVAILABLE_PREFIX)
|
||||
) {
|
||||
deleteLcrRoute(r.lcr_route_sid)
|
||||
.then(() => {
|
||||
toastSuccess("Least cost routing rule successfully deleted");
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error);
|
||||
});
|
||||
}
|
||||
setLcrRoutes(lcrRoutes.filter((g2, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasLength(lcrRoutes) &&
|
||||
lcrRoutes.map((lr, i) => (
|
||||
<Card
|
||||
key={lr.lcr_route_sid}
|
||||
lr={lr}
|
||||
index={i}
|
||||
moveCard={moveCard}
|
||||
updateLcrRoute={updateLcrRoute}
|
||||
updateLcrCarrierSetEntries={updateLcrCarrierSetEntries}
|
||||
handleRouteDelete={handleRouteDelete}
|
||||
carrierSelectorOptions={carrierSelectorOptions}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Container;
|
||||
25
src/containers/internal/views/least-cost-routing/delete.tsx
Normal file
25
src/containers/internal/views/least-cost-routing/delete.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import { P } from "@jambonz/ui-kit";
|
||||
import { Modal } from "src/components";
|
||||
import { Lcr } from "src/api/types";
|
||||
|
||||
type DeleteProps = {
|
||||
lcr: Lcr;
|
||||
handleCancel: () => void;
|
||||
handleSubmit: () => void;
|
||||
};
|
||||
|
||||
export const DeleteLcr = ({ lcr, handleCancel, handleSubmit }: DeleteProps) => {
|
||||
return (
|
||||
<>
|
||||
<Modal handleCancel={handleCancel} handleSubmit={handleSubmit}>
|
||||
<P>
|
||||
Are you sure you want to delete least cost routing{" "}
|
||||
<strong>{lcr.name}</strong>?
|
||||
</P>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteLcr;
|
||||
36
src/containers/internal/views/least-cost-routing/edit.tsx
Normal file
36
src/containers/internal/views/least-cost-routing/edit.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { H1, M } from "@jambonz/ui-kit";
|
||||
import LcrForm from "./form";
|
||||
import { useApiData } from "src/api";
|
||||
import { Lcr, LcrRoute } from "src/api/types";
|
||||
import { useParams } from "react-router-dom";
|
||||
export const EditLcr = () => {
|
||||
const params = useParams();
|
||||
const [lcrData, lcrRefect, lcrError] = useApiData<Lcr>(
|
||||
`Lcrs/${params.lcr_sid}`
|
||||
);
|
||||
const [lcrRouteData, lcrRouteRefect, lcrRouteError] = useApiData<LcrRoute[]>(
|
||||
`LcrRoutes?lcr_sid=${params.lcr_sid}`
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Edit outbound call routes</H1>
|
||||
<section>
|
||||
<M>
|
||||
Outbound call routing is used to select a carrier when there are
|
||||
multiple carriers available.
|
||||
</M>
|
||||
</section>
|
||||
<LcrForm
|
||||
lcrDataMap={{ data: lcrData, refetch: lcrRefect, error: lcrError }}
|
||||
lcrRouteDataMap={{
|
||||
data: lcrRouteData,
|
||||
refetch: lcrRouteRefect,
|
||||
error: lcrRouteError,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditLcr;
|
||||
598
src/containers/internal/views/least-cost-routing/form.tsx
Normal file
598
src/containers/internal/views/least-cost-routing/form.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Button, ButtonGroup, Icon, MS, MXS } from "@jambonz/ui-kit";
|
||||
import { Icons, Section } from "src/components";
|
||||
import {
|
||||
toastError,
|
||||
toastSuccess,
|
||||
useDispatch,
|
||||
useSelectState,
|
||||
} from "src/store";
|
||||
import { MSG_REQUIRED_FIELDS, NOT_AVAILABLE_PREFIX } from "src/constants";
|
||||
import { setLocation } from "src/store/localStore";
|
||||
import { AccountSelect, Message, Selector } from "src/components/forms";
|
||||
import type {
|
||||
Account,
|
||||
Carrier,
|
||||
Lcr,
|
||||
LcrCarrierSetEntry,
|
||||
LcrRoute,
|
||||
UseApiDataMap,
|
||||
} from "src/api/types";
|
||||
import { ROUTE_INTERNAL_LEST_COST_ROUTING } from "src/router/routes";
|
||||
import {
|
||||
deleteLcr,
|
||||
postLcrCarrierSetEntry,
|
||||
putLcrCarrierSetEntries,
|
||||
putLcrRoutes,
|
||||
putLcr,
|
||||
useApiData,
|
||||
useServiceProviderData,
|
||||
getLcrRoute,
|
||||
} from "src/api";
|
||||
import { USER_ACCOUNT, USER_ADMIN } from "src/api/constants";
|
||||
import { postLcr } from "src/api";
|
||||
import { postLcrRoute } from "src/api";
|
||||
import DeleteLcr from "./delete";
|
||||
import { Scope } from "src/store/types";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import Container from "./container";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
type LcrFormProps = {
|
||||
lcrDataMap?: UseApiDataMap<Lcr>;
|
||||
lcrRouteDataMap?: UseApiDataMap<LcrRoute[]>;
|
||||
};
|
||||
|
||||
export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
|
||||
const LCR_ROUTE_TEMPLATE: LcrRoute = {
|
||||
lcr_route_sid: `${NOT_AVAILABLE_PREFIX}${v4()}`,
|
||||
regex: "",
|
||||
lcr_sid: "",
|
||||
priority: 0,
|
||||
lcr_carrier_set_entries: [
|
||||
{
|
||||
lcr_route_sid: "",
|
||||
voip_carrier_sid: "",
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [lcrName, setLcrName] = useState("");
|
||||
const [defaultLcrCarrier, setDefaultLcrCarrier] = useState("");
|
||||
const [defaultLcrCarrierSetEntrySid, setDefaultLcrCarrierSetEntrySid] =
|
||||
useState<string | null>();
|
||||
const [defaultLcrRouteSid, setDefaultLcrRouteSid] = useState("");
|
||||
const [defaultCarrier, setDefaultCarrier] = useState("");
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [accountSid, setAccountSid] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [lcrRoutes, setLcrRoutes] = useState<LcrRoute[]>([LCR_ROUTE_TEMPLATE]);
|
||||
const [previousLcrRoutes, setPreviousLcrRoutes] = useState<LcrRoute[]>([
|
||||
LCR_ROUTE_TEMPLATE,
|
||||
]);
|
||||
const [previouseLcr, setPreviousLcr] = useState<Lcr | null>();
|
||||
const [accounts] = useServiceProviderData<Account[]>("Accounts");
|
||||
const [lcrForDelete, setLcrForDelete] = useState<Lcr | null>();
|
||||
|
||||
const user = useSelectState("user");
|
||||
const currentServiceProvider = useSelectState("currentServiceProvider");
|
||||
const [carriers] = useApiData<Carrier[]>(apiUrl);
|
||||
|
||||
useEffect(() => {
|
||||
setLocation();
|
||||
if (currentServiceProvider) {
|
||||
setApiUrl(
|
||||
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`
|
||||
);
|
||||
}
|
||||
}, [user, currentServiceProvider, accountSid]);
|
||||
|
||||
const carrierSelectorOptions = useMemo(() => {
|
||||
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
|
||||
setAccountSid(user?.account_sid);
|
||||
}
|
||||
|
||||
const carriersFiltered = carriers
|
||||
? carriers.filter((carrier) =>
|
||||
accountSid
|
||||
? carrier.account_sid === accountSid
|
||||
: carrier.account_sid === null
|
||||
)
|
||||
: [];
|
||||
|
||||
const ret = carriersFiltered
|
||||
? carriersFiltered.map((c: Carrier, i) => {
|
||||
if (i === 0) {
|
||||
setDefaultCarrier(c.voip_carrier_sid);
|
||||
}
|
||||
return {
|
||||
name: c.name,
|
||||
value: c.voip_carrier_sid,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
if (carriers && ret.length === 0) {
|
||||
setErrorMessage(
|
||||
accountSid
|
||||
? "There are no available carriers defined for this account"
|
||||
: "There are no available carriers"
|
||||
);
|
||||
} else {
|
||||
setErrorMessage("");
|
||||
}
|
||||
return ret;
|
||||
}, [accountSid, carriers]);
|
||||
|
||||
if (lcrDataMap && lcrDataMap.data && lcrDataMap.data !== previouseLcr) {
|
||||
setLcrName(lcrDataMap.data.name || "");
|
||||
setIsActive(lcrDataMap.data.is_active);
|
||||
setPreviousLcr(lcrDataMap.data);
|
||||
}
|
||||
|
||||
if (
|
||||
lcrRouteDataMap &&
|
||||
lcrRouteDataMap.data &&
|
||||
lcrRouteDataMap.data !== previousLcrRoutes
|
||||
) {
|
||||
setPreviousLcrRoutes(lcrRouteDataMap.data);
|
||||
// Find default carrier
|
||||
lcrRouteDataMap.data.forEach((lr) => {
|
||||
lr.lcr_carrier_set_entries?.forEach((entry) => {
|
||||
if (
|
||||
entry.lcr_carrier_set_entry_sid ===
|
||||
lcrDataMap?.data?.default_carrier_set_entry_sid
|
||||
) {
|
||||
setDefaultLcrCarrier(entry.voip_carrier_sid || defaultCarrier);
|
||||
setDefaultLcrCarrierSetEntrySid(
|
||||
entry.lcr_carrier_set_entry_sid || null
|
||||
);
|
||||
setDefaultLcrRouteSid(entry.lcr_route_sid || "");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
useMemo(() => {
|
||||
if (lcrRouteDataMap && lcrRouteDataMap.data)
|
||||
setLcrRoutes(
|
||||
lcrRouteDataMap.data.filter(
|
||||
(route) => route.lcr_route_sid !== defaultLcrRouteSid
|
||||
)
|
||||
);
|
||||
}, [defaultLcrRouteSid]);
|
||||
|
||||
const addLcrRoutes = () => {
|
||||
const ls = [
|
||||
...lcrRoutes,
|
||||
{
|
||||
...LCR_ROUTE_TEMPLATE,
|
||||
priority: lcrRoutes.length,
|
||||
},
|
||||
];
|
||||
setLcrRoutes(ls);
|
||||
};
|
||||
|
||||
const getLcrPayload = (): Lcr => {
|
||||
return {
|
||||
name: lcrName,
|
||||
is_active: isActive,
|
||||
account_sid: accountSid,
|
||||
service_provider_sid:
|
||||
currentServiceProvider?.service_provider_sid || null,
|
||||
default_carrier_set_entry_sid: defaultLcrCarrierSetEntrySid,
|
||||
};
|
||||
};
|
||||
|
||||
const handleLcrPost = () => {
|
||||
const lcrPayload: Lcr = getLcrPayload();
|
||||
postLcr(lcrPayload)
|
||||
.then(({ json }) => {
|
||||
Promise.all(
|
||||
lcrRoutes.map((route, i) => handleLcrRoutePost(json.sid, route, i))
|
||||
)
|
||||
.then(() => {
|
||||
handleLcrDefaultCarrierPost(json.sid);
|
||||
})
|
||||
.catch(({ msg }) => {
|
||||
toastError(msg);
|
||||
});
|
||||
})
|
||||
.catch(({ msg }) => {
|
||||
toastError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLcrDefaultCarrierPost = (lcr_sid: string) => {
|
||||
const defaultRoute = {
|
||||
lcr_sid: lcr_sid,
|
||||
regex: ".*",
|
||||
desciption: "System Default Route",
|
||||
priority: 9999,
|
||||
lcr_carrier_set_entries: [
|
||||
{
|
||||
lcr_route_sid: "",
|
||||
voip_carrier_sid: defaultLcrCarrier,
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
handleLcrRoutePost(lcr_sid, defaultRoute, 9999).then((lcr_route_sid) => {
|
||||
// There is small hack here to wait the data commited in bd for lcr entries
|
||||
new Promise(async (r) => setTimeout(() => r(0), 300)).then(() => {
|
||||
getLcrRoute(lcr_route_sid).then(({ json }) => {
|
||||
if (json.lcr_carrier_set_entries?.length) {
|
||||
const lcr_carrier_set_entry_sid =
|
||||
json.lcr_carrier_set_entries[0].lcr_carrier_set_entry_sid;
|
||||
putLcr(lcr_sid, {
|
||||
default_carrier_set_entry_sid: lcr_carrier_set_entry_sid,
|
||||
})
|
||||
.then(() => {
|
||||
if (lcrDataMap) {
|
||||
toastSuccess("Least cost routing successfully updated");
|
||||
} else {
|
||||
toastSuccess("Least cost routing successfully created");
|
||||
if (user?.access === Scope.admin) {
|
||||
navigate(ROUTE_INTERNAL_LEST_COST_ROUTING);
|
||||
} else {
|
||||
navigate(
|
||||
`${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr_sid}/edit`
|
||||
);
|
||||
}
|
||||
// Update global state
|
||||
dispatch({ type: "lcr" });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleLcrRoutePost = (
|
||||
lcr_sid: string,
|
||||
route: LcrRoute,
|
||||
priority: number
|
||||
): Promise<string> => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const lcrRoutePayload: LcrRoute = {
|
||||
lcr_sid,
|
||||
regex: route.regex,
|
||||
priority,
|
||||
};
|
||||
postLcrRoute(lcrRoutePayload)
|
||||
.then(({ json }) => {
|
||||
if (route.lcr_carrier_set_entries) {
|
||||
Promise.all(
|
||||
route.lcr_carrier_set_entries.map((entry) => {
|
||||
handleLcrCarrierSetEntryPost(json.sid, entry);
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
resolve(json.sid);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleLcrCarrierSetEntryPost = (
|
||||
lcr_route_sid: string,
|
||||
entry: LcrCarrierSetEntry
|
||||
): Promise<string> => {
|
||||
const lcrCarrierSetEntryPayload: LcrCarrierSetEntry = {
|
||||
...entry,
|
||||
voip_carrier_sid: entry.voip_carrier_sid || defaultCarrier,
|
||||
lcr_route_sid,
|
||||
};
|
||||
return new Promise<string>(async (r, e) => {
|
||||
postLcrCarrierSetEntry(lcrCarrierSetEntryPayload)
|
||||
.then(({ json }) => r(json.sid))
|
||||
.catch((error) => e(error));
|
||||
});
|
||||
};
|
||||
|
||||
const handleLcrPut = () => {
|
||||
if (lcrDataMap && lcrDataMap.data && lcrDataMap.data.lcr_sid) {
|
||||
// update LCR
|
||||
const lcrPayload: Lcr = getLcrPayload();
|
||||
putLcr(lcrDataMap.data.lcr_sid, lcrPayload).then(() => {
|
||||
Promise.all(
|
||||
lcrRoutes.map((route, i) => {
|
||||
if (
|
||||
route.lcr_route_sid &&
|
||||
!route.lcr_route_sid?.startsWith(NOT_AVAILABLE_PREFIX)
|
||||
) {
|
||||
handleLcrRoutePut(
|
||||
lcrDataMap.data?.lcr_sid || "",
|
||||
route.lcr_route_sid,
|
||||
route,
|
||||
i
|
||||
);
|
||||
} else {
|
||||
handleLcrRoutePost(lcrDataMap.data?.lcr_sid || "", route, i);
|
||||
}
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
if (defaultLcrCarrierSetEntrySid) {
|
||||
const defaultEntry: LcrCarrierSetEntry = {
|
||||
lcr_route_sid: defaultLcrRouteSid,
|
||||
voip_carrier_sid: defaultLcrCarrier,
|
||||
priority: 0,
|
||||
};
|
||||
|
||||
handleLcrCarrierEntryPut(
|
||||
defaultLcrRouteSid,
|
||||
defaultLcrCarrierSetEntrySid,
|
||||
defaultEntry
|
||||
).then(() => {
|
||||
toastSuccess("Least cost routing rule successfully updated");
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => toastError(error));
|
||||
});
|
||||
}
|
||||
|
||||
const handleLcrRoutePut = (
|
||||
lcr_sid: string,
|
||||
lcr_route_sid: string,
|
||||
route: LcrRoute,
|
||||
priority: number
|
||||
): Promise<string> => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const lcrRoutePayload: LcrRoute = {
|
||||
lcr_sid,
|
||||
regex: route.regex,
|
||||
priority,
|
||||
};
|
||||
putLcrRoutes(lcr_route_sid, lcrRoutePayload).then(() => {
|
||||
if (
|
||||
route.lcr_carrier_set_entries &&
|
||||
route.lcr_carrier_set_entries.length > 0
|
||||
) {
|
||||
Promise.all(
|
||||
route.lcr_carrier_set_entries.map((entry) => {
|
||||
if (entry.lcr_carrier_set_entry_sid) {
|
||||
return handleLcrCarrierEntryPut(
|
||||
entry.lcr_route_sid || lcr_route_sid,
|
||||
entry.lcr_carrier_set_entry_sid,
|
||||
entry
|
||||
);
|
||||
} else {
|
||||
return handleLcrCarrierSetEntryPost(lcr_route_sid, entry);
|
||||
}
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
resolve("Least cost routing rule successfully updated");
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleLcrCarrierEntryPut = (
|
||||
lcr_route_sid: string,
|
||||
lcr_carrier_set_entry_sid: string,
|
||||
entry: LcrCarrierSetEntry
|
||||
): Promise<string> => {
|
||||
const lcrCarrierSetEntryPayload: LcrCarrierSetEntry = {
|
||||
lcr_route_sid,
|
||||
workload: entry.workload,
|
||||
voip_carrier_sid: entry.voip_carrier_sid || defaultCarrier,
|
||||
priority: entry.priority,
|
||||
};
|
||||
return new Promise<string>(async (r, e) => {
|
||||
putLcrCarrierSetEntries(
|
||||
lcr_carrier_set_entry_sid,
|
||||
lcrCarrierSetEntryPayload
|
||||
)
|
||||
.then(() => r("success"))
|
||||
.catch((error) => e(error));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (lcrDataMap) {
|
||||
handleLcrPut();
|
||||
} else {
|
||||
handleLcrPost();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (lcrForDelete) {
|
||||
deleteLcr(lcrForDelete.lcr_sid || "")
|
||||
.then(() => {
|
||||
toastSuccess(
|
||||
<>
|
||||
Deleted least cost routing <strong>{lcrForDelete?.name}</strong>
|
||||
</>
|
||||
);
|
||||
setLcrForDelete(null);
|
||||
if (user?.access === Scope.admin) {
|
||||
navigate(ROUTE_INTERNAL_LEST_COST_ROUTING);
|
||||
} else {
|
||||
navigate(`${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`);
|
||||
}
|
||||
dispatch({ type: "lcr" });
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section slim>
|
||||
<form className="form form--internal" onSubmit={handleSubmit}>
|
||||
<fieldset>
|
||||
<MS>{MSG_REQUIRED_FIELDS}</MS>
|
||||
{errorMessage && <Message message={errorMessage} />}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<div className="multi">
|
||||
<div className="inp">
|
||||
<label htmlFor="lcr_name">Name</label>
|
||||
<input
|
||||
id="lcr_name"
|
||||
name="lcr_name"
|
||||
type="text"
|
||||
placeholder="name"
|
||||
value={lcrName}
|
||||
onChange={(e) => setLcrName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label htmlFor="is_active" className="chk">
|
||||
<input
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
/>
|
||||
<div>Active</div>
|
||||
</label>
|
||||
<div className="sel sel--preset">
|
||||
<label htmlFor="predefined_select">
|
||||
Select a default outbound carrier<span>*</span>
|
||||
</label>
|
||||
<Selector
|
||||
id="defailt_carrier"
|
||||
name="defailt_carrier"
|
||||
value={defaultLcrCarrier}
|
||||
options={carrierSelectorOptions}
|
||||
required
|
||||
onChange={(e) => {
|
||||
setDefaultLcrCarrier(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
{user?.scope === USER_ADMIN && (
|
||||
<fieldset>
|
||||
<AccountSelect
|
||||
accounts={accounts}
|
||||
account={[accountSid, setAccountSid]}
|
||||
label="Used by"
|
||||
required={false}
|
||||
defaultOption={true}
|
||||
disabled={lcrDataMap !== undefined}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
<fieldset>
|
||||
<label htmlFor="lcr_route">
|
||||
Route based on first match<span>*</span>
|
||||
</label>
|
||||
<MXS>
|
||||
<em>Drag and drop to rearrange the order.</em>
|
||||
</MXS>
|
||||
<label htmlFor="sip_gateways">Digit pattern / Carrier</label>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Container
|
||||
lcrRoute={[lcrRoutes, setLcrRoutes]}
|
||||
carrierSelectorOptions={carrierSelectorOptions}
|
||||
/>
|
||||
</DndProvider>
|
||||
<ButtonGroup left>
|
||||
<button
|
||||
className="btnty"
|
||||
type="button"
|
||||
title="Add route"
|
||||
onClick={() => {
|
||||
addLcrRoutes();
|
||||
}}
|
||||
>
|
||||
<Icon subStyle="teal">
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
</button>
|
||||
</ButtonGroup>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<div className="grid grid--col3">
|
||||
<div className="grid__row">
|
||||
<div>
|
||||
<ButtonGroup left>
|
||||
{user?.access === Scope.admin && (
|
||||
<Button
|
||||
small
|
||||
subStyle="grey"
|
||||
as={Link}
|
||||
to={ROUTE_INTERNAL_LEST_COST_ROUTING}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
small
|
||||
disabled={carrierSelectorOptions.length === 0}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div />
|
||||
<div>
|
||||
{user?.scope !== USER_ADMIN &&
|
||||
lcrDataMap &&
|
||||
lcrDataMap.data &&
|
||||
lcrDataMap.data.lcr_sid && (
|
||||
<ButtonGroup right>
|
||||
<Button
|
||||
type="button"
|
||||
small
|
||||
subStyle="grey"
|
||||
onClick={() => {
|
||||
setLcrForDelete(lcrDataMap.data);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Section>
|
||||
{lcrForDelete && (
|
||||
<DeleteLcr
|
||||
lcr={lcrForDelete}
|
||||
handleCancel={() => setLcrForDelete(null)}
|
||||
handleSubmit={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LcrForm;
|
||||
223
src/containers/internal/views/least-cost-routing/index.tsx
Normal file
223
src/containers/internal/views/least-cost-routing/index.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Button, H1, Icon, M } from "@jambonz/ui-kit";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { deleteLcr, useApiData, useServiceProviderData } from "src/api";
|
||||
// import { USER_ACCOUNT } from "src/api/constants";
|
||||
import type { Account, Lcr } from "src/api/types";
|
||||
import {
|
||||
AccountFilter,
|
||||
Icons,
|
||||
SearchFilter,
|
||||
Section,
|
||||
Spinner,
|
||||
} from "src/components";
|
||||
import { ScopedAccess } from "src/components/scoped-access";
|
||||
import { ROUTE_INTERNAL_LEST_COST_ROUTING } from "src/router/routes";
|
||||
import { toastSuccess, toastError, useSelectState } from "src/store";
|
||||
// import { getAccountFilter, setLocation } from "src/store/localStore";
|
||||
import { Scope } from "src/store/types";
|
||||
import {
|
||||
hasLength,
|
||||
hasValue,
|
||||
useFilteredResults,
|
||||
useScopedRedirect,
|
||||
} from "src/utils";
|
||||
import { USER_ACCOUNT } from "src/api/constants";
|
||||
import DeleteLcr from "./delete";
|
||||
|
||||
export const Lcrs = () => {
|
||||
const user = useSelectState("user");
|
||||
useScopedRedirect(
|
||||
Scope.admin,
|
||||
`${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`,
|
||||
user,
|
||||
"You do not have permissions to manage all outbound call routes"
|
||||
);
|
||||
const [lcrs, refetch] = useApiData<Lcr[]>("Lcrs");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [accountSid, setAccountSid] = useState("");
|
||||
|
||||
const currentServiceProvider = useSelectState("currentServiceProvider");
|
||||
const [lcr, setLcr] = useState<Lcr | null>();
|
||||
const [accounts] = useServiceProviderData<Account[]>("Accounts");
|
||||
|
||||
const lcrsFiltered = useMemo(() => {
|
||||
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
|
||||
setAccountSid(user?.account_sid);
|
||||
return lcrs;
|
||||
}
|
||||
|
||||
return lcrs
|
||||
? lcrs.filter((lcr) =>
|
||||
accountSid
|
||||
? lcr.account_sid === accountSid
|
||||
: currentServiceProvider?.service_provider_sid
|
||||
? lcr.service_provider_sid ==
|
||||
currentServiceProvider.service_provider_sid
|
||||
: lcr.account_sid === null
|
||||
)
|
||||
: [];
|
||||
}, [accountSid, lcrs]);
|
||||
const filteredLcrs = useFilteredResults<Lcr>(filter, lcrsFiltered);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (lcr) {
|
||||
deleteLcr(lcr.lcr_sid || "")
|
||||
.then(() => {
|
||||
toastSuccess(
|
||||
<>
|
||||
Deleted outbound call route <strong>{lcr?.name}</strong>
|
||||
</>
|
||||
);
|
||||
setLcr(null);
|
||||
refetch();
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="mast">
|
||||
<H1 className="h2">Outbound call routing</H1>
|
||||
<Link
|
||||
to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`}
|
||||
title="Add a Least cost routing"
|
||||
>
|
||||
{" "}
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
</Link>
|
||||
</section>
|
||||
<section>
|
||||
<M>
|
||||
Outbound call routing is used to select a carrier when there are
|
||||
multiple carriers available.
|
||||
</M>
|
||||
</section>
|
||||
<section className="filters filters--spaced">
|
||||
<SearchFilter placeholder="Filter lcrs" filter={[filter, setFilter]} />
|
||||
<ScopedAccess user={user} scope={Scope.admin}>
|
||||
<AccountFilter
|
||||
account={[accountSid, setAccountSid]}
|
||||
accounts={accounts}
|
||||
label="Used by"
|
||||
defaultOption
|
||||
/>
|
||||
</ScopedAccess>
|
||||
</section>
|
||||
<Section {...(hasLength(filteredLcrs) && { slim: true })}>
|
||||
<div className="list">
|
||||
{!hasValue(filteredLcrs) && hasLength(accounts) ? (
|
||||
<Spinner />
|
||||
) : hasLength(filteredLcrs) ? (
|
||||
filteredLcrs.map((lcr) => (
|
||||
<div className="item" key={lcr.lcr_sid}>
|
||||
<div className="item__info">
|
||||
<div className="item__title">
|
||||
<ScopedAccess
|
||||
user={user}
|
||||
scope={
|
||||
!lcr.account_sid
|
||||
? Scope.service_provider
|
||||
: Scope.account
|
||||
}
|
||||
>
|
||||
<Link
|
||||
to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr.lcr_sid}/edit`}
|
||||
title="Edit outbound call routes"
|
||||
className="i"
|
||||
>
|
||||
<strong>{lcr.name}</strong>
|
||||
<Icons.ArrowRight />
|
||||
</Link>
|
||||
</ScopedAccess>
|
||||
</div>
|
||||
<div className="item__meta">
|
||||
<div>
|
||||
<div
|
||||
className={`i txt--${lcr.is_active ? "teal" : "grey"}`}
|
||||
>
|
||||
{lcr.is_active ? (
|
||||
<Icons.CheckCircle />
|
||||
) : (
|
||||
<Icons.XCircle />
|
||||
)}
|
||||
<span>{lcr.is_active ? "Active" : "Inactive"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={`i txt--teal`}>
|
||||
<Icons.Activity />
|
||||
<span>
|
||||
{lcr.account_sid
|
||||
? accounts?.find(
|
||||
(acct) => acct.account_sid === lcr.account_sid
|
||||
)?.name
|
||||
: currentServiceProvider?.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={`i txt--teal`}>
|
||||
<Icons.Share2 />
|
||||
<span>{`${
|
||||
lcr.number_routes && lcr.number_routes > 1
|
||||
? lcr.number_routes - 1
|
||||
: 0
|
||||
} routes`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScopedAccess
|
||||
user={user}
|
||||
scope={
|
||||
!lcr.account_sid ? Scope.service_provider : Scope.account
|
||||
}
|
||||
>
|
||||
<div className="item__actions">
|
||||
<Link
|
||||
to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr.lcr_sid}/edit`}
|
||||
title="Edit Client"
|
||||
>
|
||||
<Icons.Edit3 />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title="Delete outbound call route"
|
||||
onClick={() => setLcr(lcr)}
|
||||
className="btnty"
|
||||
>
|
||||
<Icons.Trash />
|
||||
</button>
|
||||
</div>
|
||||
</ScopedAccess>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<M>No outbound call routes.</M>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
<Section clean>
|
||||
<Button small as={Link} to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`}>
|
||||
Add outbound call routes
|
||||
</Button>
|
||||
</Section>
|
||||
{lcr && (
|
||||
<DeleteLcr
|
||||
lcr={lcr}
|
||||
handleCancel={() => setLcr(null)}
|
||||
handleSubmit={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Lcrs;
|
||||
17
src/containers/internal/views/least-cost-routing/styles.scss
Normal file
17
src/containers/internal/views/least-cost-routing/styles.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
.lcr-card {
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lcr-card:hover {
|
||||
box-shadow: -7px 7px 5px #d5d7db, -5px -5px 10px #ffffff;
|
||||
transform: translateY(-3px) translateX(-3px);
|
||||
}
|
||||
|
||||
.lcr-card-appear {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lcr-card-disappear {
|
||||
opacity: 0;
|
||||
}
|
||||
29
src/containers/internal/views/recent-calls/call-detail.tsx
Normal file
29
src/containers/internal/views/recent-calls/call-detail.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { RecentCall } from "src/api/types";
|
||||
|
||||
export type CallDetailProps = {
|
||||
call: RecentCall;
|
||||
};
|
||||
|
||||
export const CallDetail = ({ call }: CallDetailProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="item__details">
|
||||
<div className="pre-grid">
|
||||
{Object.keys(call).map((key) => (
|
||||
<React.Fragment key={key}>
|
||||
<div>{key}:</div>
|
||||
<div>
|
||||
{call[key as keyof typeof call]
|
||||
? String(call[key as keyof typeof call])
|
||||
: "null"}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CallDetail;
|
||||
150
src/containers/internal/views/recent-calls/call-tracing.tsx
Normal file
150
src/containers/internal/views/recent-calls/call-tracing.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Bar } from "./jaeger/bar";
|
||||
import { JaegerGroup, JaegerRoot, JaegerSpan } from "src/api/jaeger-types";
|
||||
import { getJaegerTrace } from "src/api";
|
||||
import { RecentCall } from "src/api/types";
|
||||
import { getSpansFromJaegerRoot } from "./utils";
|
||||
|
||||
function useWindowSize() {
|
||||
const [windowSize, setWindowSize] = useState({
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
handleResize();
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
return windowSize;
|
||||
}
|
||||
|
||||
export type CallTracingProps = {
|
||||
call: RecentCall;
|
||||
};
|
||||
|
||||
export const CallTracing = ({ call }: CallTracingProps) => {
|
||||
const [jaegerRoot, setJaegerRoot] = useState<JaegerRoot>();
|
||||
const [jaegerGroup, setJaegerGroup] = useState<JaegerGroup>();
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
const getGroupsByParent = (spanId: string, groups: JaegerGroup[]) => {
|
||||
groups.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
|
||||
return groups.filter((value) => value.parentSpanId === spanId);
|
||||
};
|
||||
|
||||
const getRootSpan = (spans: JaegerSpan[]) => {
|
||||
const spanIds = spans.map((value) => value.spanId);
|
||||
return spans.find((value) => spanIds.indexOf(value.parentSpanId) == -1);
|
||||
};
|
||||
|
||||
const getRootGroup = (grps: JaegerGroup[]) => {
|
||||
const spanIds = grps.map((value) => value.spanId);
|
||||
return grps.find((value) => spanIds.indexOf(value.parentSpanId) == -1);
|
||||
};
|
||||
|
||||
const calculateRatio = (span: JaegerSpan) => {
|
||||
const { innerWidth } = window;
|
||||
const durationMs =
|
||||
(span.endTimeUnixNano - span.startTimeUnixNano) / 1_000_000;
|
||||
|
||||
if (durationMs > innerWidth) {
|
||||
const offset = innerWidth > 1200 ? 3 : innerWidth > 800 ? 2.5 : 2;
|
||||
return durationMs / (innerWidth - innerWidth / offset);
|
||||
}
|
||||
|
||||
return 1;
|
||||
};
|
||||
|
||||
const buildSpans = (root: JaegerRoot) => {
|
||||
setJaegerRoot(root);
|
||||
const spans = getSpansFromJaegerRoot(root);
|
||||
const rootSpan = getRootSpan(spans);
|
||||
if (rootSpan) {
|
||||
const startTime = rootSpan.startTimeUnixNano;
|
||||
const ratio = calculateRatio(rootSpan);
|
||||
calculateRatio(rootSpan);
|
||||
const groups: JaegerGroup[] = spans.map((span) => {
|
||||
const level = 0;
|
||||
const children: JaegerGroup[] = [];
|
||||
const startMs = (span.startTimeUnixNano - startTime) / 1_000_000;
|
||||
const durationMs =
|
||||
(span.endTimeUnixNano - span.startTimeUnixNano) / 1_000_000;
|
||||
const startPx = startMs / ratio;
|
||||
const durationPx = durationMs / ratio;
|
||||
const endPx = startPx + durationPx;
|
||||
const endMs = startMs + durationMs;
|
||||
return {
|
||||
level,
|
||||
children,
|
||||
startPx,
|
||||
endPx,
|
||||
durationPx,
|
||||
startMs,
|
||||
endMs,
|
||||
durationMs,
|
||||
...span,
|
||||
};
|
||||
});
|
||||
|
||||
const rootGroup = getRootGroup(groups);
|
||||
if (rootGroup) {
|
||||
rootGroup.children = buildChildren(
|
||||
rootGroup.level + 1,
|
||||
rootGroup,
|
||||
groups
|
||||
);
|
||||
setJaegerGroup(rootGroup);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buildChildren = (
|
||||
level: number,
|
||||
rootGroup: JaegerGroup,
|
||||
groups: JaegerGroup[]
|
||||
): JaegerGroup[] => {
|
||||
return getGroupsByParent(rootGroup.spanId, groups).map((group) => {
|
||||
group.level = level;
|
||||
group.children = buildChildren(group.level + 1, group, groups);
|
||||
return group;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (call.trace_id && call.trace_id != "00000000000000000000000000000000") {
|
||||
getJaegerTrace(call.account_sid, call.trace_id).then(({ json }) => {
|
||||
if (json) {
|
||||
buildSpans(json);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (jaegerRoot) {
|
||||
buildSpans(jaegerRoot);
|
||||
}
|
||||
}, [windowSize]);
|
||||
|
||||
if (jaegerGroup) {
|
||||
return (
|
||||
<>
|
||||
<div className="item__details">
|
||||
<div className="barGroup">
|
||||
<Bar group={jaegerGroup} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CallTracing;
|
||||
@@ -2,10 +2,15 @@ import React, { useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { Icons } from "src/components";
|
||||
import { formatPhoneNumber } from "src/utils";
|
||||
import { formatPhoneNumber, hasValue } from "src/utils";
|
||||
import { PcapButton } from "./pcap";
|
||||
|
||||
import type { RecentCall } from "src/api/types";
|
||||
import { Tabs, Tab } from "@jambonz/ui-kit";
|
||||
import CallDetail from "./call-detail";
|
||||
import CallTracing from "./call-tracing";
|
||||
import { DISABLE_JAEGER_TRACING } from "src/api/constants";
|
||||
import { Player } from "./player";
|
||||
import "./styles.scss";
|
||||
|
||||
type DetailsItemProps = {
|
||||
call: RecentCall;
|
||||
@@ -13,6 +18,13 @@ type DetailsItemProps = {
|
||||
|
||||
export const DetailsItem = ({ call }: DetailsItemProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("");
|
||||
|
||||
const transformRecentCall = (call: RecentCall): RecentCall => {
|
||||
const newCall = { ...call };
|
||||
delete newCall.recording_url;
|
||||
return newCall;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="item">
|
||||
@@ -28,7 +40,7 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
|
||||
<div className="item__info">
|
||||
<div className="item__title">
|
||||
<strong>
|
||||
{dayjs(call.attempted_at).format("YYYY MM.DD hh:mm a")}
|
||||
{dayjs(call.attempted_at).format("YYYY MM.DD hh:mm:ss a")}
|
||||
</strong>
|
||||
<span className="i txt--dark">
|
||||
{call.direction === "inbound" ? (
|
||||
@@ -55,21 +67,29 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div className="item__details">
|
||||
<div className="pre-grid">
|
||||
{Object.keys(call).map((key) => (
|
||||
<React.Fragment key={key}>
|
||||
<div>{key}:</div>
|
||||
<div>
|
||||
{call[key as keyof typeof call]
|
||||
? call[key as keyof typeof call].toString()
|
||||
: "null"}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{open && <PcapButton call={call} />}
|
||||
</div>
|
||||
{call.trace_id === "00000000000000000000000000000000" ||
|
||||
DISABLE_JAEGER_TRACING ? (
|
||||
<CallDetail call={transformRecentCall(call)} />
|
||||
) : (
|
||||
<Tabs active={[activeTab, setActiveTab]}>
|
||||
<Tab id="details" label="Details">
|
||||
<CallDetail call={transformRecentCall(call)} />
|
||||
</Tab>
|
||||
<Tab id="tracing" label="Tracing">
|
||||
<CallTracing call={call} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
)}
|
||||
{open && (
|
||||
<>
|
||||
<div className="footer">
|
||||
{hasValue(call.recording_url) && <Player call={call} />}
|
||||
<div className="footer__buttons">
|
||||
<PcapButton call={call} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Spinner,
|
||||
Pagination,
|
||||
SelectFilter,
|
||||
SearchFilter,
|
||||
} from "src/components";
|
||||
import { hasLength, hasValue } from "src/utils";
|
||||
import { DetailsItem } from "./details";
|
||||
@@ -47,6 +48,8 @@ export const RecentCalls = () => {
|
||||
const [dateFilter, setDateFilter] = useState("today");
|
||||
const [directionFilter, setDirectionFilter] = useState("io");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [fromFilter, setFromFilter] = useState("");
|
||||
const [toFilter, setToFilter] = useState("");
|
||||
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [perPageFilter, setPerPageFilter] = useState("25");
|
||||
@@ -64,6 +67,8 @@ export const RecentCalls = () => {
|
||||
: { days: Number(dateFilter) }),
|
||||
...(statusFilter !== "all" && { answered: statusFilter }),
|
||||
...(directionFilter !== "io" && { direction: directionFilter }),
|
||||
...(fromFilter && { from: fromFilter }),
|
||||
...(toFilter && { to: toFilter }),
|
||||
};
|
||||
|
||||
getRecentCalls(accountSid, payload)
|
||||
@@ -94,7 +99,15 @@ export const RecentCalls = () => {
|
||||
if (accountSid) {
|
||||
handleFilterChange();
|
||||
}
|
||||
}, [accountSid, pageNumber, dateFilter, directionFilter, statusFilter]);
|
||||
}, [
|
||||
accountSid,
|
||||
pageNumber,
|
||||
dateFilter,
|
||||
directionFilter,
|
||||
statusFilter,
|
||||
fromFilter,
|
||||
toFilter,
|
||||
]);
|
||||
|
||||
/** Reset page number when filters change */
|
||||
useEffect(() => {
|
||||
@@ -136,6 +149,16 @@ export const RecentCalls = () => {
|
||||
filter={[statusFilter, setStatusFilter]}
|
||||
options={statusSelection}
|
||||
/>
|
||||
<SearchFilter
|
||||
placeholder="Filter From"
|
||||
filter={[fromFilter, setFromFilter]}
|
||||
delay={1000}
|
||||
/>
|
||||
<SearchFilter
|
||||
placeholder="Filter To"
|
||||
filter={[toFilter, setToFilter]}
|
||||
delay={1000}
|
||||
/>
|
||||
</section>
|
||||
<Section {...(hasLength(calls) && { slim: true })}>
|
||||
<div className="list">
|
||||
|
||||
66
src/containers/internal/views/recent-calls/jaeger/bar.tsx
Normal file
66
src/containers/internal/views/recent-calls/jaeger/bar.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useState } from "react";
|
||||
import { JaegerGroup } from "src/api/jaeger-types";
|
||||
|
||||
import "./styles.scss";
|
||||
import { formattedDuration } from "./utils";
|
||||
import { JaegerDetail } from "./detail";
|
||||
import { ModalClose } from "src/components";
|
||||
import { P } from "@jambonz/ui-kit";
|
||||
|
||||
type BarProps = {
|
||||
group: JaegerGroup;
|
||||
};
|
||||
|
||||
export const Bar = ({ group }: BarProps) => {
|
||||
const [jaegerDetail, setJaegerDetail] = useState<JaegerGroup | null>(null);
|
||||
const titleMargin = group.level * 30;
|
||||
|
||||
const truncate = (str: string) => {
|
||||
if (str.length > 36) {
|
||||
return str.substring(0, 36) + "...";
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="barWrapper"
|
||||
role={"presentation"}
|
||||
onClick={() => setJaegerDetail(group)}
|
||||
>
|
||||
<div role="presentation" className="barWrapper__row">
|
||||
<div
|
||||
className="barWrapper__header"
|
||||
style={{ paddingLeft: `${titleMargin}px` }}
|
||||
>
|
||||
{truncate(group.name)}
|
||||
</div>
|
||||
<button
|
||||
className="barWrapper__span"
|
||||
style={{
|
||||
marginLeft: `${group.startPx}px`,
|
||||
width: group.durationPx,
|
||||
}}
|
||||
/>
|
||||
<div className="barWrapper__duration">
|
||||
{formattedDuration(group.durationMs)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{jaegerDetail && (
|
||||
<ModalClose handleClose={() => setJaegerDetail(null)}>
|
||||
<div className="spanDetailsWrapper__header">
|
||||
<P>
|
||||
<strong>Span:</strong> {group.name.replaceAll(",", ", ")}
|
||||
</P>
|
||||
</div>
|
||||
<JaegerDetail group={jaegerDetail} />
|
||||
</ModalClose>
|
||||
)}
|
||||
{group.children.map((value) => (
|
||||
<Bar key={value.spanId} group={value} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
71
src/containers/internal/views/recent-calls/jaeger/detail.tsx
Normal file
71
src/containers/internal/views/recent-calls/jaeger/detail.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import { JaegerGroup, JaegerValue } from "src/api/jaeger-types";
|
||||
import dayjs from "dayjs";
|
||||
import "./styles.scss";
|
||||
import { formattedDuration } from "./utils";
|
||||
|
||||
type JaegerDetailProps = {
|
||||
group: JaegerGroup;
|
||||
};
|
||||
|
||||
const extractSpanGroupValue = (value: JaegerValue): string => {
|
||||
const ret = String(value.stringValue || value.doubleValue || value.boolValue);
|
||||
// add white space for wrap the line
|
||||
return ret.replaceAll(",", ", ");
|
||||
};
|
||||
|
||||
export const JaegerDetail = ({ group }: JaegerDetailProps) => {
|
||||
return (
|
||||
<div className="spanDetailsWrapper">
|
||||
<div className="spanDetailsWrapper__detailsWrapper">
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Span ID:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">{group.spanId}</div>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Span Start:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">
|
||||
{dayjs
|
||||
.unix(group.startTimeUnixNano / 1000000000)
|
||||
.format("DD/MM/YY HH:mm:ss.SSS")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Span End:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">
|
||||
{dayjs
|
||||
.unix(group.endTimeUnixNano / 1000000000)
|
||||
.format("DD/MM/YY HH:mm:ss.SSS")}
|
||||
</div>
|
||||
</div>
|
||||
{!(group.name && group.name.startsWith("dtmf:")) && (
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Duration:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">
|
||||
{formattedDuration(group.durationMs)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.attributes.map((attribute) => (
|
||||
<div key={attribute.key} className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>{attribute.key}</strong>:
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">
|
||||
{extractSpanGroupValue(attribute.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
124
src/containers/internal/views/recent-calls/jaeger/styles.scss
Normal file
124
src/containers/internal/views/recent-calls/jaeger/styles.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
@use "src/styles/vars";
|
||||
@use "src/styles/mixins";
|
||||
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
|
||||
|
||||
.barGroup {
|
||||
border-radius: ui-vars.$px01;
|
||||
@include mixins.code();
|
||||
text-align: left;
|
||||
padding: ui-vars.$px03;
|
||||
color: ui-vars.$pink;
|
||||
background-color: ui-vars.$dark;
|
||||
border-radius: ui-vars.$px01;
|
||||
margin-top: ui-vars.$px02;
|
||||
overflow-x: auto;
|
||||
overflow-y: scroll;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 15px;
|
||||
height: 40vh;
|
||||
}
|
||||
}
|
||||
|
||||
.barWrapper {
|
||||
width: 100%;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__row:hover {
|
||||
font-weight: bolder;
|
||||
color: ui-vars.$purple;
|
||||
}
|
||||
|
||||
&__row:hover button {
|
||||
font-weight: bolder;
|
||||
background-color: ui-vars.$purple;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__header {
|
||||
min-width: 400px;
|
||||
height: 15px;
|
||||
padding-top: 4px;
|
||||
font-size: small;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding-top: 4px;
|
||||
min-width: 250px;
|
||||
font-size: x-small;
|
||||
}
|
||||
}
|
||||
|
||||
&__span {
|
||||
padding-top: 4px;
|
||||
height: 5px;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
border: 3px solid #444;
|
||||
border-radius: 8px;
|
||||
background-color: ui-vars.$jambonz;
|
||||
min-width: 6px;
|
||||
}
|
||||
|
||||
&__duration {
|
||||
margin: 5px;
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.spanDetailsWrapper {
|
||||
border-radius: ui-vars.$px01;
|
||||
@include mixins.code();
|
||||
text-align: left;
|
||||
padding: ui-vars.$px01;
|
||||
background-color: ui-vars.$white;
|
||||
border-radius: ui-vars.$px01;
|
||||
color: ui-vars.$dark;
|
||||
font-size: ui-vars.$mxs-size;
|
||||
max-width: ui-vars.$width-tablet-2;
|
||||
max-height: 500px;
|
||||
overflow-y: scroll;
|
||||
|
||||
&__detailsWrapper {
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
font-size: 0.9em;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
padding: 5px;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
padding: 15px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
&__details {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__details_header {
|
||||
padding: 3px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
&__details_body {
|
||||
flex-grow: 1;
|
||||
white-space: pre-line;
|
||||
padding: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__header {
|
||||
background-color: ui-vars.$white;
|
||||
color: ui-vars.$jambonz;
|
||||
padding: 5px 10px 10px 10px;
|
||||
border-bottom: thin solid ui-vars.$grey;
|
||||
}
|
||||
}
|
||||
16
src/containers/internal/views/recent-calls/jaeger/utils.tsx
Normal file
16
src/containers/internal/views/recent-calls/jaeger/utils.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export const formattedDuration = (duration: number) => {
|
||||
if (duration < 1) {
|
||||
return (Math.round(duration * 100) / 100).toFixed(2) + "ms";
|
||||
} else if (duration < 1000) {
|
||||
return (Math.round(duration * 100) / 100).toFixed(0) + "ms";
|
||||
} else if (duration >= 1000) {
|
||||
const min = Math.floor((duration / 1000 / 60) << 0);
|
||||
if (min == 0) {
|
||||
const secs = parseFloat(`${duration / 1000}`).toFixed(2);
|
||||
return `${secs}s`;
|
||||
} else {
|
||||
const sec = Math.floor((duration / 1000) % 60);
|
||||
return `${min}m ${sec}s`;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,38 +1,32 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { getPcap, getRecentCall } from "src/api";
|
||||
import { getPcap } from "src/api";
|
||||
import { toastError } from "src/store";
|
||||
|
||||
import type { Pcap, RecentCall } from "src/api/types";
|
||||
import type { DownloadedBlob, RecentCall } from "src/api/types";
|
||||
|
||||
type PcapButtonProps = {
|
||||
call: RecentCall;
|
||||
};
|
||||
|
||||
export const PcapButton = ({ call }: PcapButtonProps) => {
|
||||
const [pcap, setPcap] = useState<Pcap>();
|
||||
const [pcap, setPcap] = useState<DownloadedBlob | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getRecentCall(call.account_sid, call.sip_callid)
|
||||
.then(({ json }) => {
|
||||
if (json.total > 0) {
|
||||
getPcap(call.account_sid, call.sip_callid)
|
||||
.then(({ blob }) => {
|
||||
if (blob) {
|
||||
setPcap({
|
||||
data_url: URL.createObjectURL(blob),
|
||||
file_name: `callid-${call.sip_callid}.pcap`,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
if (!pcap) {
|
||||
getPcap(call.account_sid, call.sip_callid, "invite")
|
||||
.then(({ blob }) => {
|
||||
if (blob) {
|
||||
setPcap({
|
||||
data_url: URL.createObjectURL(blob),
|
||||
file_name: `callid-${call.sip_callid}.pcap`,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (pcap) {
|
||||
|
||||
469
src/containers/internal/views/recent-calls/player.tsx
Normal file
469
src/containers/internal/views/recent-calls/player.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
import React from "react";
|
||||
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Icon, P } from "@jambonz/ui-kit";
|
||||
import { Icons, ModalClose } from "src/components";
|
||||
import { getBlob, getJaegerTrace } from "src/api";
|
||||
import { DownloadedBlob, RecentCall } from "src/api/types";
|
||||
import RegionsPlugin, { Region } from "wavesurfer.js/src/plugin/regions";
|
||||
import TimelinePlugin from "wavesurfer.js/src/plugin/timeline";
|
||||
import { API_BASE_URL } from "src/api/constants";
|
||||
import {
|
||||
JaegerRoot,
|
||||
JaegerSpan,
|
||||
WaveSufferDtmfResult,
|
||||
WaveSufferSttResult,
|
||||
} from "src/api/jaeger-types";
|
||||
import {
|
||||
getSpanAttributeByName,
|
||||
getSpansByName,
|
||||
getSpansByNameRegex,
|
||||
getSpansFromJaegerRoot,
|
||||
} from "./utils";
|
||||
|
||||
type PlayerProps = {
|
||||
call: RecentCall;
|
||||
};
|
||||
|
||||
export const Player = ({ call }: PlayerProps) => {
|
||||
const { recording_url, call_sid } = call;
|
||||
const url =
|
||||
recording_url && recording_url.startsWith("http://")
|
||||
? recording_url
|
||||
: `${API_BASE_URL}${recording_url}`;
|
||||
const JUMP_DURATION = 15; //seconds
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [playBackTime, setPlayBackTime] = useState("");
|
||||
const [jaegerRoot, setJeagerRoot] = useState<JaegerRoot>();
|
||||
const [waveSufferRegionData, setWaveSufferRegionData] =
|
||||
useState<WaveSufferSttResult | null>();
|
||||
const [waveSufferDtmfData, setWaveSufferDtmfData] =
|
||||
useState<WaveSufferDtmfResult | null>();
|
||||
const [regionChecked, setRegionChecked] = useState(false);
|
||||
|
||||
const wavesurferId = `wavesurfer--${call_sid}`;
|
||||
const wavesurferTimelineId = `timeline-${wavesurferId}`;
|
||||
const waveSufferRef = useRef<WaveSurfer | null>(null);
|
||||
|
||||
const [record, setRecord] = useState<DownloadedBlob | null>(null);
|
||||
|
||||
const drawDtmfRegionForSpan = (s: JaegerSpan, startPoint: JaegerSpan) => {
|
||||
if (waveSufferRef.current) {
|
||||
const r = waveSufferRef.current.regions.list[s.spanId];
|
||||
if (!r) {
|
||||
const [dtmfValue] = getSpanAttributeByName(s.attributes, "dtmf");
|
||||
const [durationValue] = getSpanAttributeByName(
|
||||
s.attributes,
|
||||
"duration"
|
||||
);
|
||||
if (dtmfValue && durationValue) {
|
||||
const start =
|
||||
(s.startTimeUnixNano - startPoint.startTimeUnixNano) /
|
||||
1_000_000_000;
|
||||
const duration =
|
||||
Number(durationValue.value.stringValue.replace("ms", "")) / 1_000;
|
||||
// as duration of DTMF is short, cannot be shown in wavesuffer,
|
||||
// adjust region width here.
|
||||
const delta = duration <= 0.1 ? 0.1 : duration;
|
||||
const end = start + delta;
|
||||
|
||||
const region = waveSufferRef.current.addRegion({
|
||||
id: s.spanId,
|
||||
start,
|
||||
end,
|
||||
color: "rgba(138, 43, 226, 0.15)",
|
||||
drag: false,
|
||||
loop: false,
|
||||
resize: false,
|
||||
});
|
||||
changeRegionMouseStyle(region);
|
||||
|
||||
const att: WaveSufferDtmfResult = {
|
||||
dtmf: dtmfValue.value.stringValue,
|
||||
duration: durationValue.value.stringValue,
|
||||
};
|
||||
|
||||
region.on("click", () => {
|
||||
setWaveSufferDtmfData(att);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const changeRegionMouseStyle = (region: Region, channel = 0) => {
|
||||
region.element.style.display = regionChecked ? "" : "none";
|
||||
region.element.style.height = "49%";
|
||||
region.element.style.top = channel === 0 ? "0" : "51%";
|
||||
|
||||
region.element.addEventListener("mouseenter", () => {
|
||||
region.element.style.cursor = "pointer"; // Change to your desired cursor style
|
||||
});
|
||||
|
||||
region.element.addEventListener("mouseleave", () => {
|
||||
region.element.style.cursor = "default";
|
||||
});
|
||||
};
|
||||
|
||||
const drawSttRegionForSpan = (
|
||||
s: JaegerSpan,
|
||||
startPoint: JaegerSpan,
|
||||
channel = 0
|
||||
) => {
|
||||
if (waveSufferRef.current) {
|
||||
const r = waveSufferRef.current.regions.list[s.spanId];
|
||||
if (!r) {
|
||||
const start =
|
||||
(s.startTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000 +
|
||||
0.05; // add magic 0.01 second in each region start time to isolate 2 near regions
|
||||
const end =
|
||||
(s.endTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
|
||||
|
||||
const region = waveSufferRef.current.addRegion({
|
||||
id: s.spanId,
|
||||
start,
|
||||
end,
|
||||
color: "rgba(255, 0, 0, 0.15)",
|
||||
drag: false,
|
||||
loop: false,
|
||||
resize: false,
|
||||
});
|
||||
changeRegionMouseStyle(region, channel);
|
||||
const [sttResult] = getSpanAttributeByName(s.attributes, "stt.result");
|
||||
let att: WaveSufferSttResult;
|
||||
if (sttResult) {
|
||||
const data = JSON.parse(sttResult.value.stringValue);
|
||||
att = {
|
||||
vendor: data.vendor.name,
|
||||
transcript: data.alternatives[0].transcript,
|
||||
confidence: data.alternatives[0].confidence,
|
||||
language_code: data.language_code,
|
||||
};
|
||||
} else {
|
||||
const [sttResolve] = getSpanAttributeByName(
|
||||
s.attributes,
|
||||
"stt.resolve"
|
||||
);
|
||||
if (sttResolve && sttResolve.value.stringValue === "timeout") {
|
||||
att = {
|
||||
vendor: "",
|
||||
transcript: "None (speech session timeout)",
|
||||
confidence: 0,
|
||||
language_code: "",
|
||||
};
|
||||
} else {
|
||||
att = {
|
||||
vendor: "",
|
||||
transcript:
|
||||
"None (call disconnected or speech session terminated)",
|
||||
confidence: 0,
|
||||
language_code: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
region.on("click", () => {
|
||||
setWaveSufferRegionData(att);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buildWavesufferRegion = () => {
|
||||
if (jaegerRoot) {
|
||||
const spans = getSpansFromJaegerRoot(jaegerRoot);
|
||||
const [startPoint] = getSpansByName(spans, "background-listen:listen");
|
||||
// there should be only one startPoint for background listen
|
||||
if (startPoint) {
|
||||
const gatherSpans = getSpansByNameRegex(spans, /:gather{/);
|
||||
gatherSpans.forEach((s) => {
|
||||
drawSttRegionForSpan(s, startPoint);
|
||||
});
|
||||
|
||||
const transcribeSpans = getSpansByNameRegex(spans, /stt-listen:/);
|
||||
transcribeSpans.forEach((cs) => {
|
||||
// Channel start from 0
|
||||
const channel = Number(cs.name.split(":")[1]);
|
||||
drawSttRegionForSpan(
|
||||
cs,
|
||||
startPoint,
|
||||
channel > 0 ? channel - 1 : channel
|
||||
);
|
||||
});
|
||||
|
||||
const dtmfSpans = getSpansByNameRegex(spans, /dtmf:/);
|
||||
dtmfSpans.forEach((ds) => {
|
||||
drawDtmfRegionForSpan(ds, startPoint);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
buildWavesufferRegion();
|
||||
}, [jaegerRoot, isReady]);
|
||||
|
||||
useEffect(() => {
|
||||
getBlob(url).then(({ blob, headers }) => {
|
||||
if (blob) {
|
||||
const ext = headers.get("Content-Type") === "audio/wav" ? "wav" : "mp3";
|
||||
setRecord({
|
||||
data_url: URL.createObjectURL(blob),
|
||||
file_name: `callid-${call_sid}.${ext}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (call.trace_id && call.trace_id != "00000000000000000000000000000000") {
|
||||
getJaegerTrace(call.account_sid, call.trace_id).then(({ json }) => {
|
||||
if (json) {
|
||||
setJeagerRoot(json);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
|
||||
return `${minutes.toString().padStart(2, "0")}:${remainingSeconds
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (waveSufferRef.current !== null || !record) return;
|
||||
waveSufferRef.current = WaveSurfer.create({
|
||||
container: `#${wavesurferId}`,
|
||||
waveColor: "#da1c5c",
|
||||
progressColor: "grey",
|
||||
height: 50,
|
||||
cursorWidth: 1,
|
||||
cursorColor: "lightgray",
|
||||
normalize: true,
|
||||
responsive: true,
|
||||
fillParent: true,
|
||||
splitChannels: true,
|
||||
scrollParent: true,
|
||||
plugins: [
|
||||
RegionsPlugin.create({}),
|
||||
TimelinePlugin.create({
|
||||
container: `#${wavesurferTimelineId}`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
waveSufferRef.current.load(record?.data_url);
|
||||
// All event should be after load
|
||||
waveSufferRef.current.on("finish", () => {
|
||||
setIsPlaying(false);
|
||||
});
|
||||
|
||||
waveSufferRef.current.on("play", () => {
|
||||
setIsPlaying(true);
|
||||
});
|
||||
|
||||
waveSufferRef.current.on("pause", () => {
|
||||
setIsPlaying(false);
|
||||
});
|
||||
|
||||
waveSufferRef.current.on("ready", () => {
|
||||
setIsReady(true);
|
||||
setPlayBackTime(formatTime(waveSufferRef.current?.getDuration() || 0));
|
||||
});
|
||||
|
||||
waveSufferRef.current.on("audioprocess", () => {
|
||||
setPlayBackTime(formatTime(waveSufferRef.current?.getCurrentTime() || 0));
|
||||
});
|
||||
}, [record]);
|
||||
|
||||
const togglePlayback = () => {
|
||||
if (waveSufferRef.current) {
|
||||
if (!isPlaying) {
|
||||
waveSufferRef.current.play();
|
||||
} else {
|
||||
waveSufferRef.current.pause();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setPlaybackJump = (delta: number) => {
|
||||
if (waveSufferRef.current) {
|
||||
const idx = waveSufferRef.current.getCurrentTime() + delta;
|
||||
const value =
|
||||
idx <= 0
|
||||
? 0
|
||||
: idx >= waveSufferRef.current.getDuration()
|
||||
? waveSufferRef.current.getDuration() - 1
|
||||
: idx;
|
||||
waveSufferRef.current.setCurrentTime(value);
|
||||
setPlayBackTime(formatTime(value));
|
||||
}
|
||||
};
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="media-container">
|
||||
<div id={wavesurferId} />
|
||||
<div id={wavesurferTimelineId} />
|
||||
<div className="media-container__center">
|
||||
<strong>{playBackTime}</strong>
|
||||
</div>
|
||||
<div className="media-container__center">
|
||||
<button
|
||||
className="btnty"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlaybackJump(-JUMP_DURATION);
|
||||
}}
|
||||
title="Jump left"
|
||||
disabled={!isReady}
|
||||
>
|
||||
<Icon>
|
||||
<Icons.ChevronsLeft />
|
||||
</Icon>
|
||||
</button>
|
||||
<button
|
||||
className="btnty"
|
||||
type="button"
|
||||
onClick={togglePlayback}
|
||||
title="play/pause"
|
||||
disabled={!isReady}
|
||||
>
|
||||
<Icon>{isPlaying ? <Icons.Pause /> : <Icons.Play />}</Icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btnty"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPlaybackJump(JUMP_DURATION);
|
||||
}}
|
||||
title="Jump right"
|
||||
disabled={!isReady}
|
||||
>
|
||||
<Icon>
|
||||
<Icons.ChevronsRight />
|
||||
</Icon>
|
||||
</button>
|
||||
<a
|
||||
href={record.data_url}
|
||||
download={record.file_name}
|
||||
className="btnty"
|
||||
title="Download record file"
|
||||
>
|
||||
<Icon>
|
||||
<Icons.Download />
|
||||
</Icon>
|
||||
</a>
|
||||
</div>
|
||||
<label htmlFor="is_active" className="chk">
|
||||
<input
|
||||
id={`is_active${call.call_sid}`}
|
||||
name="is_active"
|
||||
type="checkbox"
|
||||
checked={regionChecked}
|
||||
onChange={(e) => {
|
||||
setRegionChecked(e.target.checked);
|
||||
if (waveSufferRef.current) {
|
||||
const regionsList = waveSufferRef.current.regions.list;
|
||||
for (const [, region] of Object.entries(regionsList)) {
|
||||
region.element.style.display = e.target.checked ? "" : "none";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>Overlay STT and DTMF events</div>
|
||||
</label>
|
||||
</div>
|
||||
{waveSufferRegionData && (
|
||||
<ModalClose handleClose={() => setWaveSufferRegionData(null)}>
|
||||
<div className="spanDetailsWrapper__header">
|
||||
<P>
|
||||
<strong>Speech to text result</strong>
|
||||
</P>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper">
|
||||
<div className="spanDetailsWrapper__detailsWrapper">
|
||||
{waveSufferRegionData.vendor && (
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Vendor:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">
|
||||
{waveSufferRegionData.vendor}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{waveSufferRegionData.confidence !== 0 && (
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Confidence:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">
|
||||
{waveSufferRegionData.confidence}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{waveSufferRegionData.language_code && (
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Language code:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">
|
||||
{waveSufferRegionData.language_code}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{waveSufferRegionData.transcript && (
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Transcript:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">
|
||||
{waveSufferRegionData.transcript}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalClose>
|
||||
)}
|
||||
{waveSufferDtmfData && (
|
||||
<ModalClose handleClose={() => setWaveSufferDtmfData(null)}>
|
||||
<div className="spanDetailsWrapper__header">
|
||||
<P>
|
||||
<strong>Dtmf result</strong>
|
||||
</P>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper">
|
||||
<div className="spanDetailsWrapper__detailsWrapper">
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Dtmf:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">
|
||||
{waveSufferDtmfData.dtmf}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spanDetailsWrapper__details">
|
||||
<div className="spanDetailsWrapper__details_header">
|
||||
<strong>Duration:</strong>
|
||||
</div>
|
||||
<div className="spanDetailsWrapper__details_body">
|
||||
{waveSufferDtmfData.duration}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalClose>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
48
src/containers/internal/views/recent-calls/styles.scss
Normal file
48
src/containers/internal/views/recent-calls/styles.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
@use "src/styles/vars";
|
||||
@use "src/styles/mixins";
|
||||
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
|
||||
|
||||
.wavesuffer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
margin-top: ui-vars.$px02;
|
||||
|
||||
&__buttons {
|
||||
padding: ui-vars.$px02;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ui-vars.$px02;
|
||||
}
|
||||
}
|
||||
|
||||
.media-container {
|
||||
border: 1px solid black;
|
||||
border-radius: ui-vars.$px01;
|
||||
padding: 13px;
|
||||
position: relative;
|
||||
|
||||
&__center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
grid-gap: ui-vars.$px01;
|
||||
margin-top: ui-vars.$px01;
|
||||
|
||||
button {
|
||||
background-color: transparent;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ico {
|
||||
color: ui-vars.$white;
|
||||
@include mixins.icosize();
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/containers/internal/views/recent-calls/utils.ts
Normal file
46
src/containers/internal/views/recent-calls/utils.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { JaegerAttribute, JaegerRoot, JaegerSpan } from "src/api/jaeger-types";
|
||||
|
||||
export const getSpansFromJaegerRoot = (trace: JaegerRoot) => {
|
||||
const spans: JaegerSpan[] = [];
|
||||
trace.resourceSpans.forEach((resourceSpan) => {
|
||||
resourceSpan.instrumentationLibrarySpans.forEach(
|
||||
(instrumentationLibrarySpan) => {
|
||||
instrumentationLibrarySpan.spans.forEach((value) => {
|
||||
const attrs = value.attributes.filter(
|
||||
(attr) =>
|
||||
!(
|
||||
attr.key.startsWith("telemetry") ||
|
||||
attr.key.startsWith("internal")
|
||||
)
|
||||
);
|
||||
value.attributes = attrs;
|
||||
spans.push(value);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
spans.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
|
||||
return spans;
|
||||
};
|
||||
|
||||
export const getSpansByName = (
|
||||
spans: JaegerSpan[],
|
||||
name: string
|
||||
): JaegerSpan[] => {
|
||||
return spans.filter((s) => s.name === name);
|
||||
};
|
||||
|
||||
export const getSpansByNameRegex = (
|
||||
spans: JaegerSpan[],
|
||||
pattern: RegExp
|
||||
): JaegerSpan[] => {
|
||||
const matcher = new RegExp(pattern);
|
||||
return spans.filter((s) => matcher.test(s.name));
|
||||
};
|
||||
|
||||
export const getSpanAttributeByName = (
|
||||
attr: JaegerAttribute[],
|
||||
name: string
|
||||
): JaegerAttribute[] => {
|
||||
return attr.filter((a) => a.key === name);
|
||||
};
|
||||
@@ -1,31 +1,66 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { ButtonGroup, Button } from "@jambonz/ui-kit";
|
||||
import { useApiData, postPasswordSettings } from "src/api";
|
||||
import { PasswordSettings } from "src/api/types";
|
||||
import { ButtonGroup, Button, MS, P } from "@jambonz/ui-kit";
|
||||
import {
|
||||
useApiData,
|
||||
postPasswordSettings,
|
||||
postSystemInformation,
|
||||
deleteTtsCache,
|
||||
} from "src/api";
|
||||
import { PasswordSettings, SystemInformation, TtsCache } from "src/api/types";
|
||||
import { toastError, toastSuccess } from "src/store";
|
||||
import { Selector } from "src/components/forms";
|
||||
import { hasValue } from "src/utils";
|
||||
import { PASSWORD_LENGTHS_OPTIONS, PASSWORD_MIN } from "src/api/constants";
|
||||
import { Modal } from "src/components";
|
||||
|
||||
export const AdminSettings = () => {
|
||||
const [passwordSettings, passwordSettingsFetcher] =
|
||||
useApiData<PasswordSettings>("PasswordSettings");
|
||||
const [systemInformation, systemInformationFetcher] =
|
||||
useApiData<SystemInformation>("SystemInformation");
|
||||
const [ttsCache, ttsCacheFetcher] = useApiData<TtsCache>("TtsCache");
|
||||
// Min value is 8
|
||||
const [minPasswordLength, setMinPasswordLength] = useState(PASSWORD_MIN);
|
||||
const [requireDigit, setRequireDigit] = useState(false);
|
||||
const [requireSpecialCharacter, setRequireSpecialCharacter] = useState(false);
|
||||
const [domainName, setDomainName] = useState("");
|
||||
const [sipDomainName, setSipDomainName] = useState("");
|
||||
const [monitoringDomainName, setMonitoringDomainName] = useState("");
|
||||
const [clearTtsCacheFlag, setClearTtsCacheFlag] = useState(false);
|
||||
|
||||
const handleClearCache = () => {
|
||||
deleteTtsCache()
|
||||
.then(() => {
|
||||
ttsCacheFetcher();
|
||||
setClearTtsCacheFlag(false);
|
||||
toastSuccess("Tts Cache successfully cleaned");
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const payload: Partial<PasswordSettings> = {
|
||||
|
||||
const systemInformationPayload: Partial<SystemInformation> = {
|
||||
domain_name: domainName,
|
||||
sip_domain_name: sipDomainName,
|
||||
monitoring_domain_name: monitoringDomainName,
|
||||
};
|
||||
const passwordSettingsPayload: Partial<PasswordSettings> = {
|
||||
min_password_length: minPasswordLength,
|
||||
require_digit: requireDigit ? 1 : 0,
|
||||
require_special_character: requireSpecialCharacter ? 1 : 0,
|
||||
};
|
||||
postPasswordSettings(payload)
|
||||
Promise.all([
|
||||
postSystemInformation(systemInformationPayload),
|
||||
postPasswordSettings(passwordSettingsPayload),
|
||||
])
|
||||
.then(() => {
|
||||
passwordSettingsFetcher();
|
||||
systemInformationFetcher();
|
||||
toastSuccess("Password settings successfully updated");
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -43,10 +78,45 @@ export const AdminSettings = () => {
|
||||
setMinPasswordLength(passwordSettings.min_password_length);
|
||||
}
|
||||
}
|
||||
}, [passwordSettings]);
|
||||
if (hasValue(systemInformation)) {
|
||||
setDomainName(systemInformation.domain_name);
|
||||
setSipDomainName(systemInformation.sip_domain_name);
|
||||
setMonitoringDomainName(systemInformation.monitoring_domain_name);
|
||||
}
|
||||
}, [passwordSettings, systemInformation]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<fieldset>
|
||||
<label htmlFor="system_information">System Information</label>
|
||||
<label htmlFor="name">Domain Name</label>
|
||||
<input
|
||||
id="domain_name"
|
||||
type="text"
|
||||
name="domain_name"
|
||||
placeholder="Domain name"
|
||||
value={domainName}
|
||||
onChange={(e) => setDomainName(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="name">Sip Domain Name</label>
|
||||
<input
|
||||
id="sip_domain_name"
|
||||
type="text"
|
||||
name="sip_domain_name"
|
||||
placeholder="Sip domain name"
|
||||
value={sipDomainName}
|
||||
onChange={(e) => setSipDomainName(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="name">Monitoring Domain Name</label>
|
||||
<input
|
||||
id="monitor_domain_name"
|
||||
type="text"
|
||||
name="monitor_domain_name"
|
||||
placeholder="Monitoring domain name"
|
||||
value={monitoringDomainName}
|
||||
onChange={(e) => setMonitoringDomainName(e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<label htmlFor="min_password_length">Min password length</label>
|
||||
<Selector
|
||||
@@ -78,6 +148,23 @@ export const AdminSettings = () => {
|
||||
<div>Password require special character</div>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<ButtonGroup left>
|
||||
<Button
|
||||
onClick={(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setClearTtsCacheFlag(true);
|
||||
}}
|
||||
small
|
||||
disabled={!ttsCache || ttsCache.size === 0}
|
||||
>
|
||||
Clear TTS Cache
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<MS>{`There are ${
|
||||
ttsCache ? ttsCache.size : 0
|
||||
} cached TTS prompts`}</MS>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<ButtonGroup left>
|
||||
<Button onClick={handleSubmit} small>
|
||||
@@ -85,6 +172,14 @@ export const AdminSettings = () => {
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</fieldset>
|
||||
{clearTtsCacheFlag && (
|
||||
<Modal
|
||||
handleSubmit={handleClearCache}
|
||||
handleCancel={() => setClearTtsCacheFlag(false)}
|
||||
>
|
||||
<P>Are you sure you want to clean TTS cache?</P>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { Fragment, useEffect, useState } from "react";
|
||||
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Selector,
|
||||
Passwd,
|
||||
AccountSelect,
|
||||
Checkzone,
|
||||
} from "src/components/forms";
|
||||
import { toastError, toastSuccess, useSelectState } from "src/store";
|
||||
import {
|
||||
@@ -27,6 +28,8 @@ import {
|
||||
VENDOR_DEEPGRAM,
|
||||
VENDOR_IBM,
|
||||
VENDOR_NVIDIA,
|
||||
VENDOR_SONIOX,
|
||||
VENDOR_CUSTOM,
|
||||
} from "src/vendor";
|
||||
import { MSG_REQUIRED_FIELDS } from "src/constants";
|
||||
import {
|
||||
@@ -40,6 +43,7 @@ import { CredentialStatus } from "./status";
|
||||
import type { RegionVendors, GoogleServiceKey, Vendor } from "src/vendor/types";
|
||||
import type { Account, SpeechCredential, UseApiDataMap } from "src/api/types";
|
||||
import { setAccountFilter, setLocation } from "src/store/localStore";
|
||||
import { DISABLE_CUSTOM_SPEECH } from "src/api/constants";
|
||||
|
||||
type SpeechServiceFormProps = {
|
||||
credential?: UseApiDataMap<SpeechCredential>;
|
||||
@@ -52,7 +56,9 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
const regions = useRegionVendors();
|
||||
const [accounts] = useServiceProviderData<Account[]>("Accounts");
|
||||
const [accountSid, setAccountSid] = useState("");
|
||||
const [initialTtsCheck, setInitialTtsCheck] = useState(false);
|
||||
const [ttsCheck, setTtsCheck] = useState(false);
|
||||
const [initialSttCheck, setInitialSttCheck] = useState(false);
|
||||
const [sttCheck, setSttCheck] = useState(false);
|
||||
const [vendor, setVendor] = useState<Lowercase<Vendor>>(
|
||||
"" as Lowercase<Vendor>
|
||||
@@ -77,6 +83,22 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
const [customSttEndpoint, setCustomSttEndpoint] = useState("");
|
||||
const [tmpCustomSttEndpoint, setTmpCustomSttEndpoint] = useState("");
|
||||
const [rivaServerUri, setRivaServerUri] = useState("");
|
||||
const [customVendorName, setCustomVendorName] = useState("");
|
||||
const [customVendorAuthToken, setCustomVendorAuthToken] = useState("");
|
||||
const [tmpCustomVendorTtsUrl, setTmpCustomVendorTtsUrl] = useState("");
|
||||
const [customVendorTtsUrl, setCustomVendorTtsUrl] = useState("");
|
||||
const [tmpCustomVendorSttUrl, setTmpCustomVendorSttUrl] = useState("");
|
||||
const [customVendorSttUrl, setCustomVendorSttUrl] = useState("");
|
||||
const [initialOnPremNuanceTtsCheck, setInitialOnPremNuanceTtsCheck] =
|
||||
useState(false);
|
||||
const [onPremNuanceTtsCheck, setOnPremNuanceTtsCheck] = useState(false);
|
||||
const [onPremNuanceTtsUrl, setOnPremNuanceTtsUrl] = useState("");
|
||||
const [tmpOnPremNuanceTtsUrl, setTmpOnPremNuanceTtsUrl] = useState("");
|
||||
const [initialOnPremNuanceSttCheck, setInitialOnPremNuanceSttCheck] =
|
||||
useState(false);
|
||||
const [onPremNuanceSttCheck, setOnPremNuanceSttCheck] = useState(false);
|
||||
const [tmpOnPremNuanceSttUrl, setTmpOnPremNuanceSttUrl] = useState("");
|
||||
const [onPremNuanceSttUrl, setOnPremNuanceSttUrl] = useState("");
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
const handleError = () => {
|
||||
@@ -141,6 +163,20 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
...(vendor === VENDOR_NVIDIA && {
|
||||
riva_server_uri: rivaServerUri || null,
|
||||
}),
|
||||
...(vendor === VENDOR_CUSTOM && {
|
||||
vendor: (vendor + ":" + customVendorName) as Lowercase<Vendor>,
|
||||
use_for_tts: ttsCheck ? 1 : 0,
|
||||
use_for_stt: sttCheck ? 1 : 0,
|
||||
custom_tts_url: customVendorTtsUrl || null,
|
||||
custom_stt_url: customVendorSttUrl || null,
|
||||
auth_token: customVendorAuthToken || null,
|
||||
}),
|
||||
...(vendor === VENDOR_NUANCE && {
|
||||
client_id: clientId || null,
|
||||
secret: secretKey || null,
|
||||
nuance_tts_uri: onPremNuanceTtsUrl || null,
|
||||
nuance_stt_uri: onPremNuanceSttUrl || null,
|
||||
}),
|
||||
};
|
||||
|
||||
if (credential && credential.data) {
|
||||
@@ -173,11 +209,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
api_key:
|
||||
vendor === VENDOR_MICROSOFT ||
|
||||
vendor === VENDOR_WELLSAID ||
|
||||
vendor === VENDOR_DEEPGRAM
|
||||
vendor === VENDOR_DEEPGRAM ||
|
||||
vendor === VENDOR_SONIOX
|
||||
? apiKey
|
||||
: null,
|
||||
client_id: vendor === VENDOR_NUANCE ? clientId : null,
|
||||
secret: vendor === VENDOR_NUANCE ? secretKey : null,
|
||||
riva_server_uri: vendor == VENDOR_NVIDIA ? rivaServerUri : null,
|
||||
})
|
||||
.then(() => {
|
||||
@@ -196,7 +231,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
setLocation();
|
||||
if (credential && credential.data) {
|
||||
if (credential.data.vendor) {
|
||||
setVendor(credential.data.vendor);
|
||||
const v = credential.data.vendor.startsWith(VENDOR_CUSTOM)
|
||||
? VENDOR_CUSTOM
|
||||
: credential.data.vendor;
|
||||
setVendor(v);
|
||||
}
|
||||
|
||||
if (credential.data.account_sid) {
|
||||
@@ -205,14 +243,18 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
|
||||
if (credential.data.use_for_stt) {
|
||||
setSttCheck(true);
|
||||
setInitialSttCheck(true);
|
||||
} else {
|
||||
setSttCheck(false);
|
||||
setInitialSttCheck(false);
|
||||
}
|
||||
|
||||
if (credential.data.use_for_tts) {
|
||||
setTtsCheck(true);
|
||||
setInitialTtsCheck(true);
|
||||
} else {
|
||||
setTtsCheck(false);
|
||||
setInitialTtsCheck(false);
|
||||
}
|
||||
|
||||
if (credential.data.service_key) {
|
||||
@@ -247,6 +289,24 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
setSecretKey(credential.data.secret);
|
||||
}
|
||||
|
||||
if (credential.data.nuance_tts_uri) {
|
||||
setOnPremNuanceTtsUrl(credential.data.nuance_tts_uri);
|
||||
setInitialOnPremNuanceTtsCheck(true);
|
||||
setOnPremNuanceTtsCheck(true);
|
||||
} else {
|
||||
setInitialOnPremNuanceTtsCheck(false);
|
||||
setOnPremNuanceTtsCheck(false);
|
||||
}
|
||||
|
||||
if (credential.data.nuance_stt_uri) {
|
||||
setOnPremNuanceSttUrl(credential.data.nuance_stt_uri);
|
||||
setInitialOnPremNuanceSttCheck(true);
|
||||
setOnPremNuanceSttCheck(true);
|
||||
} else {
|
||||
setInitialOnPremNuanceSttCheck(false);
|
||||
setOnPremNuanceSttCheck(false);
|
||||
}
|
||||
|
||||
if (credential.data.tts_api_key) {
|
||||
setTtsApiKey(credential.data.tts_api_key);
|
||||
}
|
||||
@@ -277,6 +337,16 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
setCustomSttEndpoint(credential.data.custom_stt_endpoint || "");
|
||||
setTmpCustomTtsEndpoint(credential.data.custom_tts_endpoint || "");
|
||||
setTmpCustomSttEndpoint(credential.data.custom_stt_endpoint || "");
|
||||
setCustomVendorName(
|
||||
credential.data.vendor.startsWith(VENDOR_CUSTOM)
|
||||
? credential.data.vendor.substring(VENDOR_CUSTOM.length + 1)
|
||||
: credential.data.vendor
|
||||
);
|
||||
setCustomVendorAuthToken(credential.data.auth_token || "");
|
||||
setCustomVendorSttUrl(credential.data.custom_stt_url || "");
|
||||
setTmpCustomVendorSttUrl(credential.data.custom_stt_url || "");
|
||||
setCustomVendorTtsUrl(credential.data.custom_tts_url || "");
|
||||
setTmpCustomVendorTtsUrl(credential.data.custom_tts_url || "");
|
||||
}
|
||||
}, [credential]);
|
||||
|
||||
@@ -305,7 +375,11 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
name: "Select a vendor",
|
||||
value: "",
|
||||
},
|
||||
].concat(vendors)}
|
||||
]
|
||||
.concat(vendors)
|
||||
.filter(
|
||||
(v) => !DISABLE_CUSTOM_SPEECH || v.value !== VENDOR_CUSTOM
|
||||
)}
|
||||
onChange={(e) => {
|
||||
setVendor(e.target.value as Lowercase<Vendor>);
|
||||
setRegion("");
|
||||
@@ -315,6 +389,23 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
disabled={credential ? true : false}
|
||||
required
|
||||
/>
|
||||
{vendor === VENDOR_CUSTOM && (
|
||||
<>
|
||||
<label htmlFor="custom_vendor_name">
|
||||
Name<span>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="custom_vendor_name"
|
||||
required
|
||||
type="text"
|
||||
name="custom_vendor_name"
|
||||
placeholder="Vendor Name"
|
||||
value={customVendorName}
|
||||
onChange={(e) => setCustomVendorName(e.target.value)}
|
||||
disabled={credential ? true : false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<AccountSelect
|
||||
@@ -327,19 +418,21 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
</fieldset>
|
||||
{vendor && (
|
||||
<fieldset>
|
||||
{vendor !== VENDOR_DEEPGRAM && (
|
||||
<label htmlFor="use_for_tts" className="chk">
|
||||
<input
|
||||
id="use_for_tts"
|
||||
name="use_for_tts"
|
||||
type="checkbox"
|
||||
onChange={(e) => setTtsCheck(e.target.checked)}
|
||||
defaultChecked={ttsCheck}
|
||||
/>
|
||||
<div>Use for text-to-speech</div>
|
||||
</label>
|
||||
)}
|
||||
{vendor !== VENDOR_WELLSAID && (
|
||||
{vendor !== VENDOR_DEEPGRAM &&
|
||||
vendor !== VENDOR_SONIOX &&
|
||||
vendor != VENDOR_CUSTOM && (
|
||||
<label htmlFor="use_for_tts" className="chk">
|
||||
<input
|
||||
id="use_for_tts"
|
||||
name="use_for_tts"
|
||||
type="checkbox"
|
||||
onChange={(e) => setTtsCheck(e.target.checked)}
|
||||
defaultChecked={ttsCheck}
|
||||
/>
|
||||
<div>Use for text-to-speech</div>
|
||||
</label>
|
||||
)}
|
||||
{vendor !== VENDOR_WELLSAID && vendor !== VENDOR_CUSTOM && (
|
||||
<label htmlFor="use_for_stt" className="chk">
|
||||
<input
|
||||
id="use_for_stt"
|
||||
@@ -351,6 +444,87 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
<div>Use for speech-to-text</div>
|
||||
</label>
|
||||
)}
|
||||
{vendor === VENDOR_CUSTOM && (
|
||||
<Fragment>
|
||||
<Checkzone
|
||||
hidden
|
||||
name="custom_vendor_use_for_tts"
|
||||
label="Use for text-to-speech"
|
||||
initialCheck={initialTtsCheck}
|
||||
handleChecked={(e) => {
|
||||
setTtsCheck(e.target.checked);
|
||||
if (!e.target.checked) {
|
||||
setTmpCustomVendorTtsUrl(customVendorTtsUrl);
|
||||
setCustomVendorTtsUrl("");
|
||||
} else {
|
||||
setCustomVendorTtsUrl(tmpCustomVendorTtsUrl);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label htmlFor="custom_vendor_use_for_tts">
|
||||
TTS HTTP URL<span>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="custom_vendor_use_for_tts"
|
||||
type="text"
|
||||
name="custom_vendor_use_for_tts"
|
||||
placeholder="Required"
|
||||
required={ttsCheck}
|
||||
value={customVendorTtsUrl}
|
||||
onChange={(e) => {
|
||||
setCustomVendorTtsUrl(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Checkzone>
|
||||
|
||||
<Checkzone
|
||||
hidden
|
||||
name="custom_vendor_use_for_stt"
|
||||
label="Use for speech-to-text"
|
||||
initialCheck={initialSttCheck}
|
||||
handleChecked={(e) => {
|
||||
setSttCheck(e.target.checked);
|
||||
if (!e.target.checked) {
|
||||
setTmpCustomVendorSttUrl(customVendorSttUrl);
|
||||
setCustomVendorSttUrl("");
|
||||
} else {
|
||||
setCustomVendorSttUrl(tmpCustomVendorSttUrl);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label htmlFor="custom_vendor_use_for_stt">
|
||||
STT websocket URL<span>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="custom_vendor_use_for_stt"
|
||||
type="text"
|
||||
name="custom_vendor_use_for_stt"
|
||||
placeholder="Required"
|
||||
required={sttCheck}
|
||||
value={customVendorSttUrl}
|
||||
onChange={(e) => {
|
||||
setCustomVendorSttUrl(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Checkzone>
|
||||
</Fragment>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
{vendor === VENDOR_CUSTOM && (
|
||||
<fieldset>
|
||||
<label htmlFor="custom_vendor_auth_token">
|
||||
Authentication Token
|
||||
</label>
|
||||
<input
|
||||
id="custom_vendor_auth_token"
|
||||
type="text"
|
||||
name="custom_vendor_auth_token"
|
||||
placeholder="Authentication Token"
|
||||
value={customVendorAuthToken}
|
||||
onChange={(e) => setCustomVendorAuthToken(e.target.value)}
|
||||
disabled={credential ? true : false}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
{vendor === VENDOR_GOOGLE && (
|
||||
@@ -385,33 +559,108 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
</>
|
||||
)}
|
||||
{vendor === VENDOR_NUANCE && (
|
||||
<fieldset>
|
||||
<label htmlFor="nuance_client_id">
|
||||
Client ID<span>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="nuance_client_id"
|
||||
required
|
||||
type="text"
|
||||
name="nuance_client_id"
|
||||
placeholder="Client ID"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
disabled={credential ? true : false}
|
||||
/>
|
||||
<label htmlFor="nuance_secret">
|
||||
Secret<span>*</span>
|
||||
</label>
|
||||
<Passwd
|
||||
id="nuance_secret"
|
||||
required
|
||||
name="nuance_secret"
|
||||
placeholder="Secret Key"
|
||||
value={secretKey ? getObscuredSecret(secretKey) : secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
disabled={credential ? true : false}
|
||||
/>
|
||||
</fieldset>
|
||||
<>
|
||||
<fieldset>
|
||||
<label htmlFor="nuance_client_id">
|
||||
Client ID
|
||||
{!onPremNuanceSttCheck && !onPremNuanceTtsCheck && (
|
||||
<span>*</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id="nuance_client_id"
|
||||
required={!onPremNuanceSttCheck && !onPremNuanceTtsCheck}
|
||||
type="text"
|
||||
name="nuance_client_id"
|
||||
placeholder="Client ID"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
disabled={credential ? true : false}
|
||||
/>
|
||||
<label htmlFor="nuance_secret">
|
||||
Secret
|
||||
{!onPremNuanceSttCheck && !onPremNuanceTtsCheck && (
|
||||
<span>*</span>
|
||||
)}
|
||||
</label>
|
||||
<Passwd
|
||||
id="nuance_secret"
|
||||
required={!onPremNuanceSttCheck && !onPremNuanceTtsCheck}
|
||||
name="nuance_secret"
|
||||
placeholder="Secret Key"
|
||||
value={secretKey ? getObscuredSecret(secretKey) : secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
disabled={credential ? true : false}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<>
|
||||
<Checkzone
|
||||
hidden
|
||||
name="on_prem_nuance_use_tts"
|
||||
label="Use on-prem TTS"
|
||||
initialCheck={initialOnPremNuanceTtsCheck}
|
||||
handleChecked={(e) => {
|
||||
setOnPremNuanceTtsCheck(e.target.checked);
|
||||
if (!e.target.checked) {
|
||||
setTmpOnPremNuanceTtsUrl(onPremNuanceTtsUrl);
|
||||
setOnPremNuanceTtsUrl("");
|
||||
} else {
|
||||
setOnPremNuanceTtsUrl(tmpOnPremNuanceTtsUrl);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label htmlFor="on_prem_nuance_use_tts">
|
||||
TTS URI<span>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="on_prem_nuance_use_tts"
|
||||
type="text"
|
||||
name="on_prem_nuance_use_tts"
|
||||
placeholder="ip:port"
|
||||
pattern="(.*):([0-9]{0,6}$)"
|
||||
required={onPremNuanceTtsCheck}
|
||||
value={onPremNuanceTtsUrl}
|
||||
onChange={(e) => {
|
||||
setOnPremNuanceTtsUrl(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Checkzone>
|
||||
|
||||
<Checkzone
|
||||
hidden
|
||||
name="on_prem_nuance_use_stt"
|
||||
label="Use on-prem STT"
|
||||
initialCheck={initialOnPremNuanceSttCheck}
|
||||
handleChecked={(e) => {
|
||||
setOnPremNuanceSttCheck(e.target.checked);
|
||||
if (!e.target.checked) {
|
||||
setTmpOnPremNuanceSttUrl(onPremNuanceSttUrl);
|
||||
setOnPremNuanceSttUrl("");
|
||||
} else {
|
||||
setOnPremNuanceSttUrl(tmpOnPremNuanceSttUrl);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label htmlFor="on_prem_nuance_use_stt_lb">
|
||||
STT URI<span>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="on_prem_nuance_use_stt"
|
||||
type="text"
|
||||
name="on_prem_nuance_use_stt"
|
||||
placeholder="ip:port"
|
||||
pattern="(.*):([0-9]{0,6}$)"
|
||||
required={onPremNuanceSttCheck}
|
||||
value={onPremNuanceSttUrl}
|
||||
onChange={(e) => {
|
||||
setOnPremNuanceSttUrl(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Checkzone>
|
||||
</>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
{vendor === VENDOR_AWS && (
|
||||
<fieldset>
|
||||
@@ -448,7 +697,8 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
|
||||
)}
|
||||
{(vendor === VENDOR_MICROSOFT ||
|
||||
vendor === VENDOR_WELLSAID ||
|
||||
vendor === VENDOR_DEEPGRAM) && (
|
||||
vendor === VENDOR_DEEPGRAM ||
|
||||
vendor === VENDOR_SONIOX) && (
|
||||
<fieldset>
|
||||
<label htmlFor={`${vendor}_apikey`}>
|
||||
API key<span>*</span>
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { SpeechCredential, Account } from "src/api/types";
|
||||
import { ScopedAccess } from "src/components/scoped-access";
|
||||
import { Scope } from "src/store/types";
|
||||
import { getAccountFilter, setLocation } from "src/store/localStore";
|
||||
import { VENDOR_CUSTOM } from "src/vendor";
|
||||
|
||||
export const SpeechServices = () => {
|
||||
const user = useSelectState("user");
|
||||
@@ -140,7 +141,14 @@ export const SpeechServices = () => {
|
||||
title="Edit application"
|
||||
className="i"
|
||||
>
|
||||
<strong>Vendor: {credential.vendor}</strong>
|
||||
<strong>
|
||||
Vendor:{" "}
|
||||
{credential.vendor.startsWith(VENDOR_CUSTOM)
|
||||
? credential.vendor.substring(
|
||||
VENDOR_CUSTOM.length + 1
|
||||
)
|
||||
: credential.vendor}
|
||||
</strong>
|
||||
<Icons.ArrowRight />
|
||||
</Link>
|
||||
</ScopedAccess>
|
||||
|
||||
@@ -122,7 +122,7 @@ export const UserForm = ({ user }: UserFormProps) => {
|
||||
account_sid:
|
||||
scope !== USER_ACCOUNT && currentUser?.scope !== USER_ACCOUNT
|
||||
? null
|
||||
: accountSid,
|
||||
: accountSid || currentUser?.account_sid,
|
||||
})
|
||||
.then(() => {
|
||||
toastSuccess("User created successfully");
|
||||
@@ -152,7 +152,7 @@ export const UserForm = ({ user }: UserFormProps) => {
|
||||
account_sid:
|
||||
scope !== USER_ACCOUNT && currentUser?.scope !== USER_ACCOUNT
|
||||
? null
|
||||
: accountSid,
|
||||
: accountSid || currentUser?.account_sid,
|
||||
})
|
||||
.then(() => {
|
||||
user.refetch();
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
MSG_SOMETHING_WRONG,
|
||||
MSG_CAPSLOCK,
|
||||
MSG_PASSWD_MATCH,
|
||||
MSG_PASSWD_CRITERIA,
|
||||
} from "src/constants";
|
||||
|
||||
import type { IMessage } from "src/store/types";
|
||||
@@ -50,7 +49,22 @@ export const CreatePassword = () => {
|
||||
}
|
||||
|
||||
if (passwdSettings && !isValidPasswd(password, passwdSettings)) {
|
||||
setMessage(MSG_PASSWD_CRITERIA);
|
||||
setMessage(
|
||||
<>
|
||||
Password must:
|
||||
<ul>
|
||||
<li>
|
||||
Be at least {passwdSettings.min_password_length} characters long
|
||||
</li>
|
||||
{passwdSettings.require_digit && (
|
||||
<li>Contain at least one number</li>
|
||||
)}
|
||||
{passwdSettings.require_special_character && (
|
||||
<li>Contain at least one special character</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
55
src/containers/login/forgot-password.tsx
Normal file
55
src/containers/login/forgot-password.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, H1 } from "@jambonz/ui-kit";
|
||||
import { Message } from "src/components/forms";
|
||||
import { postForgotPassword } from "src/api";
|
||||
import { StatusCodes } from "src/api/types";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { MSG_SOMETHING_WRONG } from "src/constants";
|
||||
|
||||
import { ROUTE_LOGIN } from "src/router/routes";
|
||||
|
||||
export const ForgotPassword = () => {
|
||||
const [message, setMessage] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
setMessage("");
|
||||
|
||||
postForgotPassword({ email })
|
||||
.then((response) => {
|
||||
if (response.status === StatusCodes.NO_CONTENT) {
|
||||
navigate(ROUTE_LOGIN);
|
||||
} else {
|
||||
setMessage(MSG_SOMETHING_WRONG);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setMessage(error.error);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Forgot Password</H1>
|
||||
<form className="form form--login" onSubmit={handleSubmit}>
|
||||
<span>Enter your email and we will send you a password reset link</span>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
|
||||
{message && <Message message={message} />}
|
||||
<Button type="submit">Send me a reset password link</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPassword;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button, H1 } from "@jambonz/ui-kit";
|
||||
import { useLocation, Navigate } from "react-router-dom";
|
||||
import { useLocation, Navigate, Link } from "react-router-dom";
|
||||
|
||||
import { toastError, toastSuccess, useSelectState } from "src/store";
|
||||
import { useAuth } from "src/router/auth";
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
ROUTE_INTERNAL_ACCOUNTS,
|
||||
ROUTE_CREATE_PASSWORD,
|
||||
ROUTE_INTERNAL_APPLICATIONS,
|
||||
ROUTE_FORGOT_PASSWORD,
|
||||
} from "src/router/routes";
|
||||
import { USER_ACCOUNT } from "src/api/constants";
|
||||
import { USER_ACCOUNT, ENABLE_FORGOT_PASSWORD } from "src/api/constants";
|
||||
|
||||
export const Login = () => {
|
||||
const { signin, authorized } = useAuth();
|
||||
@@ -90,6 +91,13 @@ export const Login = () => {
|
||||
/>
|
||||
{message && <Message message={message} />}
|
||||
<Button type="submit">Log in</Button>
|
||||
{ENABLE_FORGOT_PASSWORD && (
|
||||
<div>
|
||||
<Link to={ROUTE_FORGOT_PASSWORD} title="Forgot Password">
|
||||
<p>Forgot Password</p>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
import React, { useContext } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { postLogin } from "src/api";
|
||||
import { postLogin, postLogout } from "src/api";
|
||||
import { StatusCodes } from "src/api/types";
|
||||
import {
|
||||
ROUTE_LOGIN,
|
||||
ROUTE_CREATE_PASSWORD,
|
||||
ROUTE_INTERNAL_ACCOUNTS,
|
||||
ROUTE_INTERNAL_APPLICATIONS,
|
||||
ROUTE_LOGIN,
|
||||
} from "./routes";
|
||||
import {
|
||||
SESS_OLD_PASSWORD,
|
||||
@@ -136,10 +136,27 @@ export const useProvideAuth = (): AuthStateContext => {
|
||||
};
|
||||
|
||||
const signout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
sessionStorage.setItem(SESS_FLASH_MSG, MSG_LOGGED_OUT);
|
||||
window.location.href = ROUTE_LOGIN;
|
||||
return new Promise((resolve, reject) => {
|
||||
postLogout()
|
||||
.then((response) => {
|
||||
if (response.status === StatusCodes.OK) {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
sessionStorage.setItem(SESS_FLASH_MSG, MSG_LOGGED_OUT);
|
||||
resolve(response.json);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
sessionStorage.setItem(SESS_FLASH_MSG, MSG_LOGGED_OUT);
|
||||
if (error) {
|
||||
reject(error);
|
||||
}
|
||||
reject(MSG_SOMETHING_WRONG);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,9 +6,11 @@ import { useSelectState } from "src/store";
|
||||
import { Login, Layout as LoginLayout } from "src/containers/login";
|
||||
import { Layout as InternalLayout } from "src/containers/internal";
|
||||
import { NotFound } from "src/containers/notfound";
|
||||
import { ENABLE_FORGOT_PASSWORD } from "src/api/constants";
|
||||
|
||||
/** Login */
|
||||
import CreatePassword from "src/containers/login/create-password";
|
||||
import ForgotPassword from "src/containers/login/forgot-password";
|
||||
|
||||
/** Top navi */
|
||||
import Users from "src/containers/internal/views/users";
|
||||
@@ -37,6 +39,12 @@ import PhoneNumbersEdit from "src/containers/internal/views/phone-numbers/edit";
|
||||
import MSTeamsTenants from "src/containers/internal/views/ms-teams-tenants";
|
||||
import MSTeamsTenantsAdd from "src/containers/internal/views/ms-teams-tenants/add";
|
||||
import MSTeamsTenantsEdit from "src/containers/internal/views/ms-teams-tenants/edit";
|
||||
import Lcrs from "src/containers/internal/views/least-cost-routing";
|
||||
import LcrsAdd from "src/containers/internal/views/least-cost-routing/add";
|
||||
import LcrsEdit from "src/containers/internal/views/least-cost-routing/edit";
|
||||
import Clients from "src/containers/internal/views/clients";
|
||||
import ClientsAdd from "src/containers/internal/views/clients/add";
|
||||
import ClientsEdit from "src/containers/internal/views/clients/edit";
|
||||
|
||||
export const Router = () => {
|
||||
const toast = useSelectState("toast");
|
||||
@@ -56,6 +64,9 @@ export const Router = () => {
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
{ENABLE_FORGOT_PASSWORD && (
|
||||
<Route path="forgot-password" element={<ForgotPassword />} />
|
||||
)}
|
||||
|
||||
{/* 404 page not found */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
@@ -123,6 +134,19 @@ export const Router = () => {
|
||||
path="ms-teams-tenants/:ms_teams_tenant_sid/edit"
|
||||
element={<MSTeamsTenantsEdit />}
|
||||
/>
|
||||
<Route path="least-cost-routing" element={<Lcrs />} />
|
||||
<Route path="least-cost-routing/add" element={<LcrsAdd />} />
|
||||
<Route
|
||||
path="least-cost-routing/:lcr_sid/edit"
|
||||
element={<LcrsEdit />}
|
||||
/>
|
||||
|
||||
<Route path="clients" element={<Clients />} />
|
||||
<Route path="clients/add" element={<ClientsAdd />} />
|
||||
<Route
|
||||
path="clients/:client_sid/edit"
|
||||
element={<ClientsEdit />}
|
||||
/>
|
||||
|
||||
{/* 404 page not found */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export const ROUTE_LOGIN = "/";
|
||||
export const ROUTE_CREATE_PASSWORD = "/create-password";
|
||||
export const ROUTE_FORGOT_PASSWORD = "/forgot-password";
|
||||
export const ROUTE_INTERNAL_USERS = "/internal/users";
|
||||
export const ROUTE_INTERNAL_SETTINGS = "/internal/settings";
|
||||
export const ROUTE_INTERNAL_ACCOUNTS = "/internal/accounts";
|
||||
export const ROUTE_INTERNAL_CLIENTS = "/internal/clients";
|
||||
export const ROUTE_INTERNAL_APPLICATIONS = "/internal/applications";
|
||||
export const ROUTE_INTERNAL_RECENT_CALLS = "/internal/recent-calls";
|
||||
export const ROUTE_INTERNAL_ALERTS = "/internal/alerts";
|
||||
@@ -10,3 +12,4 @@ export const ROUTE_INTERNAL_CARRIERS = "/internal/carriers";
|
||||
export const ROUTE_INTERNAL_SPEECH = "/internal/speech-services";
|
||||
export const ROUTE_INTERNAL_PHONE_NUMBERS = "/internal/phone-numbers";
|
||||
export const ROUTE_INTERNAL_MS_TEAMS_TENANTS = "/internal/ms-teams-tenants";
|
||||
export const ROUTE_INTERNAL_LEST_COST_ROUTING = "/internal/least-cost-routing";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { getServiceProviders } from "src/api";
|
||||
import { getLcrs, getServiceProviders } from "src/api";
|
||||
import { sortLocaleName } from "src/utils";
|
||||
import { getToken, parseJwt } from "src/router/auth";
|
||||
|
||||
import type { State, Action, UserData } from "./types";
|
||||
import { Scope } from "./types";
|
||||
import { ServiceProvider } from "src/api/types";
|
||||
import { Lcr, ServiceProvider } from "src/api/types";
|
||||
|
||||
/** A generic action assumes action.type is ALWAYS our state key */
|
||||
/** Since this is how we're designing our state interface we cool */
|
||||
@@ -76,3 +76,11 @@ export const serviceProvidersAsyncAction = async (): Promise<
|
||||
const response = await getServiceProviders();
|
||||
return response.json;
|
||||
};
|
||||
|
||||
export const lcrAsyncAction = async (): Promise<Lcr> => {
|
||||
const { json } = await getLcrs();
|
||||
if (json && json.length > 0) {
|
||||
return json[0];
|
||||
}
|
||||
return {} as Lcr;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
serviceProvidersAction,
|
||||
serviceProvidersAsyncAction,
|
||||
currentServiceProviderAction,
|
||||
lcrAsyncAction,
|
||||
} from "./actions";
|
||||
|
||||
import type {
|
||||
@@ -36,12 +37,14 @@ export const initialState: State = {
|
||||
const reducer: React.Reducer<State, Action<keyof State>> = (state, action) => {
|
||||
switch (action.type) {
|
||||
case "user":
|
||||
case "lcr":
|
||||
case "toast":
|
||||
return genericAction(state, action);
|
||||
case "serviceProviders":
|
||||
return serviceProvidersAction(state, action);
|
||||
case "currentServiceProvider":
|
||||
return currentServiceProviderAction(state, action);
|
||||
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
@@ -67,6 +70,10 @@ const middleware: MiddleWare = (dispatch) => {
|
||||
return userAsyncAction().then((payload) => {
|
||||
dispatch({ ...action, payload });
|
||||
});
|
||||
case "lcr":
|
||||
return lcrAsyncAction().then((payload) => {
|
||||
dispatch({ ...action, payload });
|
||||
});
|
||||
case "serviceProviders":
|
||||
return serviceProvidersAsyncAction().then((payload) => {
|
||||
dispatch({ ...action, payload });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
import type { UserJWT, ServiceProvider } from "src/api/types";
|
||||
import type { UserJWT, ServiceProvider, Lcr } from "src/api/types";
|
||||
|
||||
export type IMessage = string | JSX.Element;
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface State {
|
||||
accessControl: ACL;
|
||||
/** available service providers */
|
||||
serviceProviders: ServiceProvider[];
|
||||
/** Least route routing */
|
||||
lcr?: Lcr;
|
||||
/** current selected service provider */
|
||||
currentServiceProvider?: ServiceProvider;
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ fieldset {
|
||||
padding: ui-vars.$px02;
|
||||
border-radius: ui-vars.$px01;
|
||||
border: 2px solid ui-vars.$grey;
|
||||
max-width: vars.$widthinput;
|
||||
max-width: ui-vars.$width-mobile;
|
||||
position: relative;
|
||||
|
||||
> div {
|
||||
@@ -216,7 +216,7 @@ fieldset {
|
||||
align-items: center;
|
||||
|
||||
&:nth-child(1) {
|
||||
grid-template-columns: [col] calc(50% - #{ui-vars.$px02 * 2}) [col] 25% [col] 25%;
|
||||
grid-template-columns: [col] calc(50% - #{ui-vars.$px02 * 2}) [col] 20% [col] 30%;
|
||||
|
||||
@include mixins.small() {
|
||||
grid-template-columns: [col] 100%;
|
||||
@@ -268,3 +268,26 @@ fieldset {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lcr {
|
||||
@extend .gateway;
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
grid-gap: ui-vars.$px02;
|
||||
align-items: center;
|
||||
margin-left: 3%;
|
||||
|
||||
&:nth-child(1) {
|
||||
grid-template-columns: [col] calc(50% - #{ui-vars.$px02 * 2}) [col] 50%;
|
||||
|
||||
@include mixins.small() {
|
||||
grid-template-columns: [col] 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bucket_tag {
|
||||
@extend .lcr;
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ details {
|
||||
color: ui-vars.$pink;
|
||||
background-color: ui-vars.$dark;
|
||||
border-radius: ui-vars.$px01;
|
||||
margin-top: ui-vars.$px02;
|
||||
}
|
||||
|
||||
.pcap {
|
||||
|
||||
@@ -55,7 +55,7 @@ export const isValidPasswd = (
|
||||
password.length >= passwordSettings?.min_password_length &&
|
||||
(passwordSettings?.require_digit ? /\d/.test(password) : true) &&
|
||||
(passwordSettings?.require_special_character
|
||||
? /[!@#$%^&*(),.?"';:{}|<>+~]/.test(password)
|
||||
? /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)
|
||||
: true)
|
||||
);
|
||||
}
|
||||
@@ -225,6 +225,17 @@ export const createFilterString = (filterValue: string, label: string) => {
|
||||
return filterString.join("/");
|
||||
};
|
||||
|
||||
export const disableDefaultTrunkRouting = (userScope: UserData["scope"]) => {
|
||||
if (import.meta.env.VITE_APP_DISABLE_DEFAULT_TRUNK_ROUTING) {
|
||||
if (userScope === USER_ADMIN) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export {
|
||||
withSuspense,
|
||||
useMobileMedia,
|
||||
|
||||
13
src/vendor/index.tsx
vendored
13
src/vendor/index.tsx
vendored
@@ -17,6 +17,8 @@ export const VENDOR_NUANCE = "nuance";
|
||||
export const VENDOR_DEEPGRAM = "deepgram";
|
||||
export const VENDOR_IBM = "ibm";
|
||||
export const VENDOR_NVIDIA = "nvidia";
|
||||
export const VENDOR_SONIOX = "soniox";
|
||||
export const VENDOR_CUSTOM = "custom";
|
||||
|
||||
export const vendors: VendorOptions[] = [
|
||||
{
|
||||
@@ -51,6 +53,14 @@ export const vendors: VendorOptions[] = [
|
||||
name: "WellSaid",
|
||||
value: VENDOR_WELLSAID,
|
||||
},
|
||||
{
|
||||
name: "Soniox",
|
||||
value: VENDOR_SONIOX,
|
||||
},
|
||||
{
|
||||
name: "Custom",
|
||||
value: VENDOR_CUSTOM,
|
||||
},
|
||||
];
|
||||
|
||||
export const useRegionVendors = () => {
|
||||
@@ -104,6 +114,7 @@ export const useSpeechVendors = () => {
|
||||
import("./speech-recognizer/deepgram-speech-recognizer-lang"),
|
||||
import("./speech-recognizer/ibm-speech-recognizer-lang"),
|
||||
import("./speech-recognizer/nvidia-speech-recognizer-lang"),
|
||||
import("./speech-recognizer/soniox-speech-recognizer-lang"),
|
||||
import("./speech-synthesis/aws-speech-synthesis-lang"),
|
||||
import("./speech-synthesis/google-speech-synthesis-lang"),
|
||||
import("./speech-synthesis/ms-speech-synthesis-lang"),
|
||||
@@ -120,6 +131,7 @@ export const useSpeechVendors = () => {
|
||||
{ default: deepgramRecognizer },
|
||||
{ default: ibmRecognizer },
|
||||
{ default: nvidiaRecognizer },
|
||||
{ default: sonioxRecognizer },
|
||||
{ default: awsSynthesis },
|
||||
{ default: googleSynthesis },
|
||||
{ default: msSynthesis },
|
||||
@@ -147,6 +159,7 @@ export const useSpeechVendors = () => {
|
||||
deepgram: deepgramRecognizer,
|
||||
ibm: ibmRecognizer,
|
||||
nvidia: nvidiaRecognizer,
|
||||
soniox: sonioxRecognizer,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
10
src/vendor/speech-recognizer/soniox-speech-recognizer-lang.ts
vendored
Normal file
10
src/vendor/speech-recognizer/soniox-speech-recognizer-lang.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Language } from "../types";
|
||||
|
||||
export const languages: Language[] = [
|
||||
{
|
||||
name: "English (United States)",
|
||||
code: "en-US",
|
||||
},
|
||||
];
|
||||
|
||||
export default languages;
|
||||
@@ -28,6 +28,8 @@ export const languages: VoiceLanguage[] = [
|
||||
voices: [
|
||||
{ value: "da-DK-Standard-A", name: "Standard-A (Female)" },
|
||||
{ value: "da-DK-Wavenet-A", name: "Wavenet-A (Female)" },
|
||||
{ value: "da-DK-Neural2-D", name: "Neural2-D (Female)" },
|
||||
{ value: "da-DK-Neural2-F", name: "Neural2-F (Male)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -58,6 +60,10 @@ export const languages: VoiceLanguage[] = [
|
||||
{ value: "en-AU-Wavenet-B", name: "Wavenet-B (Male)" },
|
||||
{ value: "en-AU-Wavenet-C", name: "Wavenet-C (Female)" },
|
||||
{ value: "en-AU-Wavenet-D", name: "Wavenet-D (Male)" },
|
||||
{ value: "en-AU-Neural2-A", name: "Neural2-A (Female)" },
|
||||
{ value: "en-AU-Neural2-B", name: "Neural2-B (Male)" },
|
||||
{ value: "en-AU-Neural2-C", name: "Neural2-C (Female)" },
|
||||
{ value: "en-AU-Neural2-D", name: "Neural2-D (Male)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -86,6 +92,11 @@ export const languages: VoiceLanguage[] = [
|
||||
{ value: "en-GB-Wavenet-B", name: "Wavenet-B (Male)" },
|
||||
{ value: "en-GB-Wavenet-C", name: "Wavenet-C (Female)" },
|
||||
{ value: "en-GB-Wavenet-D", name: "Wavenet-D (Male)" },
|
||||
{ value: "en-GB-Neural2-A", name: "Neural2-A (Female)" },
|
||||
{ value: "en-GB-Neural2-B", name: "Neural2-B (Male)" },
|
||||
{ value: "en-GB-Neural2-C", name: "Neural2-C (Female)" },
|
||||
{ value: "en-GB-Neural2-D", name: "Neural2-D (Male)" },
|
||||
{ value: "en-GB-Neural2-F", name: "Neural2-F (Female)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -102,6 +113,17 @@ export const languages: VoiceLanguage[] = [
|
||||
{ value: "en-US-Wavenet-D", name: "Wavenet-D (Male)" },
|
||||
{ value: "en-US-Wavenet-E", name: "Wavenet-E (Female)" },
|
||||
{ value: "en-US-Wavenet-F", name: "Wavenet-F (Female)" },
|
||||
{ value: "en-US-Neural2-A", name: "Neural2-A (Male)" },
|
||||
{ value: "en-US-Neural2-C", name: "Neural2-C (Female)" },
|
||||
{ value: "en-US-Neural2-D", name: "Neural2-D (Male)" },
|
||||
{ value: "en-US-Neural2-E", name: "Neural2-E (Female)" },
|
||||
{ value: "en-US-Neural2-F", name: "Neural2-F (Female)" },
|
||||
{ value: "en-US-Neural2-G", name: "Neural2-G (Female)" },
|
||||
{ value: "en-US-Neural2-H", name: "Neural2-H (Female)" },
|
||||
{ value: "en-US-Neural2-I", name: "Neural2-I (Male)" },
|
||||
{ value: "en-US-Neural2-J", name: "Neural2-J (Male)" },
|
||||
{ value: "en-US-Studio-M", name: "Studio-M (Male)" },
|
||||
{ value: "en-US-Studio-O", name: "Studio-M (Female)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -110,6 +132,8 @@ export const languages: VoiceLanguage[] = [
|
||||
voices: [
|
||||
{ value: "fil-PH-Standard-A", name: "Standard-A (Female)" },
|
||||
{ value: "fil-PH-Wavenet-A", name: "Wavenet-A (Female)" },
|
||||
{ value: "fil-ph-Neural2-A", name: "Neural2-A (Female)" },
|
||||
{ value: "fil-ph-Neural2-D", name: "Neural2-A (Male)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -132,6 +156,10 @@ export const languages: VoiceLanguage[] = [
|
||||
{ value: "fr-CA-Wavenet-B", name: "Wavenet-B (Male)" },
|
||||
{ value: "fr-CA-Wavenet-C", name: "Wavenet-C (Female)" },
|
||||
{ value: "fr-CA-Wavenet-D", name: "Wavenet-D (Male)" },
|
||||
{ value: "fr-CA-Neural2-A", name: "Neural2-A (Female)" },
|
||||
{ value: "fr-CA-Neural2-B", name: "Neural2-B (Male)" },
|
||||
{ value: "fr-CA-Neural2-C", name: "Neural2-C (Female)" },
|
||||
{ value: "fr-CA-Neural2-D", name: "Neural2-D (Male)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -148,6 +176,11 @@ export const languages: VoiceLanguage[] = [
|
||||
{ value: "fr-FR-Wavenet-C", name: "Wavenet-C (Female)" },
|
||||
{ value: "fr-FR-Wavenet-D", name: "Wavenet-D (Male)" },
|
||||
{ value: "fr-FR-Wavenet-E", name: "Wavenet-E (Female)" },
|
||||
{ value: "fr-FR-Neural2-A", name: "Neural2-A (Female)" },
|
||||
{ value: "fr-FR-Neural2-B", name: "Neural2-B (Male)" },
|
||||
{ value: "fr-FR-Neural2-C", name: "Neural2-C (Female)" },
|
||||
{ value: "fr-FR-Neural2-D", name: "Neural2-D (Male)" },
|
||||
{ value: "fr-FR-Neural2-E", name: "Neural2-E (Female)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -164,6 +197,10 @@ export const languages: VoiceLanguage[] = [
|
||||
{ value: "de-DE-Wavenet-D", name: "Wavenet-D (Male)" },
|
||||
{ value: "de-DE-Wavenet-E", name: "Wavenet-E (Male)" },
|
||||
{ value: "de-DE-Wavenet-F", name: "Wavenet-F (Female)" },
|
||||
{ value: "de-DE-Neural2-B", name: "Neural2-B (Male)" },
|
||||
{ value: "de-DE-Neural2-C", name: "Neural2-C (Female)" },
|
||||
{ value: "de-DE-Neural2-D", name: "Neural2-D (Male)" },
|
||||
{ value: "de-DE-Neural2-F", name: "Neural2-F (Female)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -186,6 +223,10 @@ export const languages: VoiceLanguage[] = [
|
||||
{ value: "hi-IN-Wavenet-B", name: "Wavenet-B (Male)" },
|
||||
{ value: "hi-IN-Wavenet-C", name: "Wavenet-C (Male)" },
|
||||
{ value: "hi-IN-Wavenet-D", name: "Wavenet-D (Female)" },
|
||||
{ value: "hi-IN-Neural2-A", name: "Neural2-A (Female)" },
|
||||
{ value: "hi-IN-Neural2-B", name: "Neural2-B (Male)" },
|
||||
{ value: "hi-IN-Neural2-C", name: "Neural2-C (Male)" },
|
||||
{ value: "hi-IN-Neural2-D", name: "Neural2-D (Female)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -222,6 +263,8 @@ export const languages: VoiceLanguage[] = [
|
||||
{ value: "it-IT-Wavenet-B", name: "Wavenet-B (Female)" },
|
||||
{ value: "it-IT-Wavenet-C", name: "Wavenet-C (Male)" },
|
||||
{ value: "it-IT-Wavenet-D", name: "Wavenet-D (Male)" },
|
||||
{ value: "it-IT-Neural2-A", name: "Neural2-A (Female)" },
|
||||
{ value: "it-IT-Neural2-C", name: "Neural2-C (Male)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -236,6 +279,9 @@ export const languages: VoiceLanguage[] = [
|
||||
{ value: "ja-JP-Wavenet-B", name: "Wavenet-B (Female)" },
|
||||
{ value: "ja-JP-Wavenet-C", name: "Wavenet-C (Male)" },
|
||||
{ value: "ja-JP-Wavenet-D", name: "Wavenet-D (Male)" },
|
||||
{ value: "ja-JP-Neural2-B", name: "Neural2-B (Female)" },
|
||||
{ value: "ja-JP-Neural2-C", name: "Neural2-C (Male)" },
|
||||
{ value: "ja-JP-Neural2-D", name: "Neural2-D (Male)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -250,6 +296,9 @@ export const languages: VoiceLanguage[] = [
|
||||
{ value: "ko-KR-Wavenet-B", name: "Wavenet-B (Female)" },
|
||||
{ value: "ko-KR-Wavenet-C", name: "Wavenet-C (Male)" },
|
||||
{ value: "ko-KR-Wavenet-D", name: "Wavenet-D (Male)" },
|
||||
{ value: "ko-KR-Neural2-A", name: "Neural2-A (Female)" },
|
||||
{ value: "ko-KR-Neural2-B", name: "Neural2-B (Female)" },
|
||||
{ value: "ko-KR-Neural2-C", name: "Neural2-C (Male)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -316,6 +365,9 @@ export const languages: VoiceLanguage[] = [
|
||||
voices: [
|
||||
{ value: "pt-BR-Standard-A", name: "Standard-A (Female)" },
|
||||
{ value: "pt-BR-Wavenet-A", name: "Wavenet-A (Female)" },
|
||||
{ value: "pt-BR-Neural2-A", name: "Neural2-A (Female)" },
|
||||
{ value: "pt-BR-Neural2-B", name: "Neural2-B (Male)" },
|
||||
{ value: "pt-BR-Neural2-C", name: "Neural2-C (Female)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -359,7 +411,25 @@ export const languages: VoiceLanguage[] = [
|
||||
{
|
||||
code: "es-ES",
|
||||
name: "Spanish (Spain)",
|
||||
voices: [{ value: "es-ES-Standard-A", name: "Standard-A (Female)" }],
|
||||
voices: [
|
||||
{ value: "es-ES-Standard-A", name: "Standard-A (Female)" },
|
||||
{ value: "es-ES-Neural2-A", name: "Neural2-A (Female)" },
|
||||
{ value: "es-ES-Neural2-B", name: "Neural2-B (Male)" },
|
||||
{ value: "es-ES-Neural2-C", name: "Neural2-C (Female)" },
|
||||
{ value: "es-ES-Neural2-D", name: "Neural2-D (Female)" },
|
||||
{ value: "es-ES-Neural2-E", name: "Neural2-E (Female)" },
|
||||
{ value: "es-ES-Neural2-F", name: "Neural2-F (Male)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "es-US",
|
||||
name: "Spanish (US)",
|
||||
voices: [
|
||||
{ value: "es-US-Neural2-A", name: "Neural2-A (Female)" },
|
||||
{ value: "es-US-Neural2-B", name: "Neural2-B (Male)" },
|
||||
{ value: "es-US-Neural2-C", name: "Neural2-C (Male)" },
|
||||
{ value: "es-US-Studio-B", name: "Studio-B (Male)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
@@ -393,6 +463,11 @@ export const languages: VoiceLanguage[] = [
|
||||
{ value: "uk-UA-Wavenet-A", name: "Wavenet-A (Female)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: "th-TH",
|
||||
name: "Thai (Thailand)",
|
||||
voices: [{ value: "th-TH-Neural2-C", name: "Neural2-C (Female)" }],
|
||||
},
|
||||
{
|
||||
code: "vi-VN",
|
||||
name: "Vietnamese (Vietnam)",
|
||||
@@ -405,6 +480,8 @@ export const languages: VoiceLanguage[] = [
|
||||
{ value: "vi-VN-Wavenet-B", name: "Wavenet-B (Male)" },
|
||||
{ value: "vi-VN-Wavenet-C", name: "Wavenet-C (Female)" },
|
||||
{ value: "vi-VN-Wavenet-D", name: "Wavenet-D (Male)" },
|
||||
{ value: "vi-VN-Neural2-A", name: "Neural2-A (Female)" },
|
||||
{ value: "vi-VN-Neural2-D", name: "Neural2-D (Male)" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
5
src/vendor/types.ts
vendored
5
src/vendor/types.ts
vendored
@@ -6,7 +6,9 @@ export type Vendor =
|
||||
| "Nuance"
|
||||
| "Deepgram"
|
||||
| "IBM"
|
||||
| "Nvidia";
|
||||
| "Nvidia"
|
||||
| "Soniox"
|
||||
| "Custom";
|
||||
|
||||
export interface VendorOptions {
|
||||
name: Vendor;
|
||||
@@ -62,6 +64,7 @@ export interface RecognizerVendors {
|
||||
deepgram: Language[];
|
||||
ibm: Language[];
|
||||
nvidia: Language[];
|
||||
soniox: Language[];
|
||||
}
|
||||
|
||||
export interface SynthesisVendors {
|
||||
|
||||
Reference in New Issue
Block a user