Compare commits

..

50 Commits

Author SHA1 Message Date
Dave Horton
3fb63c82ac update version 2023-06-28 09:28:46 +01:00
Hoan Luu Huu
cb2d5926b2 Fix/alerts (#276)
* improve alert view

* improve alert view
2023-06-18 11:05:26 +01:00
Hoan Luu Huu
595e900468 fix showing client password (#274)
* fix showing client password

* fix client password validation
2023-06-15 20:53:13 -04:00
Hoan Luu Huu
0dd9548600 feat: clients (#272)
* feat: clients

* fix typo

* fix reivew comments

* add error message if account miss realm or device calling app

* fix: remove Showed By
2023-06-15 07:37:10 -04:00
Hoan Luu Huu
96ffce8cd1 feat: provision record all call (#254) 2023-06-13 09:03:44 -04:00
Hoan Luu Huu
b1fe033c12 feat/ update google tts voice for neutral and studio (#271) 2023-06-13 07:42:19 -04:00
Hoan Luu Huu
a8d12546d9 filter by from and to (#269) 2023-06-09 13:43:13 -04:00
EgleH
724d86821d hint passwordSettings to user when creating password (#267)
Co-authored-by: eglehelms <e.helms@cognigy.com>
2023-06-08 07:24:29 -04:00
Hoan Luu Huu
f91bbe9245 clean tts cache for account (#264) 2023-06-02 07:39:48 -04:00
Hoan Luu Huu
91625612d5 feat: add seconds to recent call sumary (#261) 2023-06-01 08:23:00 -04:00
Hoan Luu Huu
fbe71925b4 feat: admin clear cache (#263)
* feat: admin clear cache

* feat: admin clear cache
2023-06-01 07:49:26 -04:00
Dave Horton
377fd40e2c fix docker publish 2023-05-27 10:52:04 -04:00
Hoan Luu Huu
af37066201 fix: download pcap (#258) 2023-05-24 07:56:15 -04:00
Dave Horton
fffd86619d disabling lcr, jaeger, and custom speech for k8s (#256)
* disabling lcr, jaeger, and custom speech for k8s

* fix syntax error in entrypoint.sh
2023-05-22 15:03:43 -04:00
Dave Horton
77c270e078 fix docker build 2023-05-15 14:24:20 -04:00
Dave Horton
54ff53817f fix error when register status call-id is empty (#253) 2023-05-12 10:55:00 -04:00
Dave Horton
986b9a5eeb 0.8.3 2023-05-11 09:24:18 -04:00
Hoan Luu Huu
683693ec0e fix: tts.cached boolValue does not showup on spain detail (#250)
* fix: tts.cached boolValue does not showup on spain detail

* wip

* wip

* wip

* add env to disable jaeger tracing view

* fix: tts.cached boolValue does not showup on spain detail

* wip

* wip

* wip

* add env to disable jaeger tracing view

* fix: review coments

* fix review comment

* fix review comment
2023-05-11 08:34:16 -04:00
Hoan Luu Huu
6cb1c50cf0 feat: forgot password (#218)
* feat: forgot password

* feat: forgot password

* fix: enable flag

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-05-10 21:32:14 -04:00
Hoan Luu Huu
3d9a39ac3b fix: add/edit lcr should have secondary text to explain the purpose of lcr (#247) 2023-05-10 16:05:57 -04:00
Hoan Luu Huu
4d7e84fa43 feat: add sip gateway protocol for outbound traffic (#249)
* feat: add sip gateway protocol for outbound traffic

* fix: add tls/srtp
2023-05-10 16:02:39 -04:00
Hoan Luu Huu
41423a443a add secondary text to lcr list (#245) 2023-05-08 09:23:32 -04:00
Hoan Luu Huu
bfbd66ef5c Feat/jaeger (#243)
* added dummy jaeger json file

* added jaeger types file

* added dev jaeger endpoint

* added jaeger modal with trace visual / information

* refactored jaeger logic
fixed offsets on short duration spans

* refactored into smaller components & added basic scroll bar

* removed buttons, added scroll-x, fixed details height and scroll-y

* shrunk bar graph to fit view port

* slight adjustments

* removed ref and now calculate width based on window innerwidth

* @media for phone layouts

* -fixed details width and padding.
-removed scroll.tsx as not needed now
-using SpanKind to find parent for now

* -reduced truncate size for smaller screens

* -root span is now determined from parentSpanId not being found

* removed un-needed calls to /getRecentCalls as this was causing a race condition when pcap & jaeger fetching at same time
- removed console.log's

* wip: add tabs for recent callt tracing

* wip: add tabs for recent callt tracing

* wip: add tabs for recent callt tracing

* fix: review comments

* fix: review comments

---------

Co-authored-by: ajukes <ajukes@vibecoms.co.uk>
2023-05-05 20:12:46 -04:00
Hoan Luu Huu
414bca4fdc fix: unchecked checkzone still render input with required (#242) 2023-05-05 07:34:37 -04:00
Hoan Luu Huu
a6c8257b60 feat: Lcr (#237)
* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* fix: final review version

* fix: review comments

* fix: review comments

* fix: review comments

* fix: review comments

* wip: implement drag and drop

* add box shadow for lcr route

* fix review comment
2023-05-04 19:54:46 -04:00
Hoan Luu Huu
dbb39db54e #239 (#240)
* system Information

* system Information
2023-05-04 19:41:25 -04:00
EgleH
6712d8944b add a fallback accountSid for account users (#238)
Co-authored-by: eglehelms <e.helms@cognigy.com>
2023-04-24 07:47:42 -04:00
Hoan Luu Huu
7051985aad fix: update active sip_gateways (#235) 2023-04-13 10:22:08 -04:00
EgleH
88dae20666 Update base image to node:18.15-alpine3.16 (#234) 2023-04-12 13:15:39 -04:00
Dave Horton
977a08eaf7 push to docker 2023-04-10 09:41:37 -04:00
EgleH
1bfc722960 fix the webapp blocking account name if exists in anohter SP (#229)
* fix the webapp blocking account name if exists in anohter SP

* hard reload when changing SP

* password settings enhance special chars

---------

Co-authored-by: eglehelms <e.helms@cognigy.com>
2023-04-06 07:43:10 -04:00
Hoan Luu Huu
49c18ebd26 feat: carrier register status (#226)
* feat: carrier register status

* fix: typo issue

* update PcapButton

* is active for voice gateway to true

* fix: download pcap

* fix: download pcap
2023-04-03 13:21:26 -04:00
EgleH
aba8b2be3a logout with one click (#223)
Co-authored-by: eglehelms <e.helms@cognigy.com>
2023-03-30 07:44:19 -04:00
EgleH
f4d7880ab7 Add Logout call to signout (#221)
* Add Logout call to signout

* clean local storage even on error

---------

Co-authored-by: eglehelms <e.helms@cognigy.com>
2023-03-29 08:54:17 -04:00
Dave Horton
7f93489580 bump version 2023-03-28 14:15:26 -04:00
Dave Horton
19fafdc908 minor label change 2023-03-25 12:13:13 -04:00
Hoan Luu Huu
a165bfc4d6 feat: onpremise nuance (#220)
Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-03-25 11:21:14 -04:00
Hoan Luu Huu
e26d9b95cb fix: edit application does not clear webhook user/pass when checkbox is uncheck (#217)
Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-03-21 20:14:16 -04:00
Hoan Luu Huu
e425d825bc feat: custom Vendor (#215)
* feat: custom Vendor

* feat: custom Vendor

* fix: application with custom tts/stt vendor

* fix custom speech name when editing

* fix: all comments

* fix: remove custom in the list and show extra (custom)

* fix: prettier and application sythesizer selector

* fix: addd VITE_DISABLE_CUSTOM_SPEECH

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
2023-03-14 08:45:06 -04:00
EgleH
a8d28da221 add soniox as speech provider (#211)
Co-authored-by: EgleHelms <e.helms@cognigy.com>
2023-03-03 18:53:46 -05:00
EgleH
e3855e83f7 conditional required causing issue with focusable fields (#210)
Co-authored-by: EgleHelms <e.helms@cognigy.com>
2023-02-23 12:18:01 -05:00
Dave Horton
446b6e76e2 update dockerfile 2023-02-23 08:21:09 -05:00
EgleH
0b55cdcf85 add env VITE_APP_DISABLE_DEFAULT_TRUNK_ROUTING (#209)
Co-authored-by: EgleHelms <e.helms@cognigy.com>
2023-02-22 14:05:56 -05:00
Snyk bot
f1743a9129 fix: Dockerfile to reduce vulnerabilities (#208)
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314624
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314641
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314641
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314643
- https://snyk.io/vuln/SNYK-ALPINE316-OPENSSL-3314643
2023-02-22 07:32:42 -05:00
EgleH
ec46121696 upgrade node image (#206)
Co-authored-by: EgleHelms <e.helms@cognigy.com>
2023-02-20 10:31:07 -05:00
Hoan Luu Huu
b0808187bc feat: add app_json to application (#188)
* feat: add app_json to application

* fix: review comment

* fix: review comment

* fix: review comment

* textarea for initial json in applications form

* check/uncheck overide app_json will not erase app_json, update faile still have app_json

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-02-15 10:35:45 -05:00
Snyk bot
4a320b3c8c fix: Dockerfile to reduce vulnerabilities (#204)
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-UPSTREAM-NODE-3035792
- https://snyk.io/vuln/SNYK-UPSTREAM-NODE-3035795
- https://snyk.io/vuln/SNYK-UPSTREAM-NODE-3092932
- https://snyk.io/vuln/SNYK-UPSTREAM-NODE-3092933
- https://snyk.io/vuln/SNYK-UPSTREAM-NODE-3105822
2023-02-15 08:11:36 -05:00
EgleH
c09ce5947e Bug/speech backwards compatibility (#203)
* add /Accounts api call back

* add /accounts call back

* include sp user into check

---------

Co-authored-by: EgleHelms <e.helms@cognigy.com>
2023-02-14 15:34:11 -05:00
Hoan Luu Huu
7890b7031f feat: update @jambonz/ui-kit:0.0.21 (#200)
* feat: update @jambonz/ui-kit:0.0.21

* fix security vulnerabilities

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-02-13 08:47:56 -05:00
Hoan Luu Huu
1be5dcc06b feat: nvidia tts/stt speech (#201)
* feat: nvidia tts/stt speech

* fix: rename riva server uri

* fix: review comments

* fix: review comments

* sort vendors in alphabetic order

---------

Co-authored-by: Quan HL <quanluuhoang8@gmail.com>
Co-authored-by: Dave Horton <daveh@beachdognet.com>
2023-02-13 08:37:32 -05:00
150 changed files with 11279 additions and 990 deletions

11
.env
View File

@@ -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

View File

@@ -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

View File

@@ -1,4 +1,4 @@
FROM node:18.8.0-alpine 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.6.0-alpine as webapp
FROM node:18.14.1-alpine as webapp
RUN apk add curl
WORKDIR /opt/app
COPY . /opt/app

View File

@@ -137,11 +137,11 @@ of each category of component to get an idea of how the patterns are put into pr
## :art: UI and styling
We have a UI design system called [jambonz-ui](https://github.com/jambonz/jambonz-ui).
We have a UI design system called [@jambonz/ui-kit](https://github.com/jambonz/jambonz-ui).
It's public on `npm` and is being used for this project. It's still small and simple
but provides the foundational package content for building jambonz UIs. You can view
the storybook for it [here](https://jambonz-ui.vercel.app/) as well as view the docs
for it [here](https://www.jambonz.org/docs/jambonz-ui/).
for it [here](https://www.jambonz.org/docs/@jambonz/ui-kit/).
### A note on styles

View File

@@ -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

2006
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "jambonz-webapp",
"description": "A simple provisioning web app for jambonz",
"version": "v1.0.0",
"version": "0.8.4",
"license": "MIT",
"type": "module",
"engines": {
@@ -25,7 +25,7 @@
],
"scripts": {
"prepare": "husky install",
"postinstall": "rm -rf public/fonts && cp -R node_modules/jambonz-ui/public/fonts public/fonts",
"postinstall": "rm -rf public/fonts && cp -R node_modules/@jambonz/ui-kit/public/fonts public/fonts",
"start": "npm run dev",
"dev": "vite --port 3001",
"dev:server": "ts-node --esm server/dev.server.ts",
@@ -41,12 +41,16 @@
"deploy": "npm i && npm run build && npm run pm2"
},
"dependencies": {
"@jambonz/ui-kit": "^0.0.21",
"dayjs": "^1.11.5",
"jambonz-ui": "^0.0.19",
"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",

View File

@@ -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

Binary file not shown.

4588
server/sample-jaeger.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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`;

View File

@@ -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
View 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[];
}

View File

@@ -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,10 +252,28 @@ 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 {
name: string;
app_json: null | string;
call_hook: null | WebHook;
account_sid: null | string;
messaging_hook: null | WebHook;
@@ -245,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 {
@@ -280,6 +320,8 @@ export interface RecentCall {
remote_host: string;
direction: string;
trunk: string;
trace_id: string;
recording_url?: string;
}
export interface SpeechCredential {
@@ -302,11 +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 {
@@ -317,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;
@@ -342,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 {
@@ -361,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 {
@@ -369,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;

View File

@@ -1,5 +1,5 @@
import React from "react";
import { H1 } from "jambonz-ui";
import { H1 } from "@jambonz/ui-kit";
import { AccessControl } from "./access-control";

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { classNames } from "jambonz-ui";
import { classNames } from "@jambonz/ui-kit";
import { Icons } from "src/components/icons";
@@ -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"

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { classNames } from "jambonz-ui";
import { classNames } from "@jambonz/ui-kit";
import { Icons } from "src/components/icons";
import { sortLocaleName } from "src/utils";

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, forwardRef } from "react";
import { classNames } from "jambonz-ui";
import { classNames } from "@jambonz/ui-kit";
import "./styles.scss";
@@ -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>
);
}

View File

@@ -1,6 +1,6 @@
@use "src/styles/vars";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
.checkzone {
padding: ui-vars.$px02;

View File

@@ -1,5 +1,5 @@
import React, { useState, forwardRef } from "react";
import { classNames } from "jambonz-ui";
import { classNames } from "@jambonz/ui-kit";
import { Icons } from "src/components/icons";

View File

@@ -1,7 +1,7 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
.file-upload {
&__wrap {

View File

@@ -1,7 +1,7 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
.msg {
@include ui-mixins.ms();

View File

@@ -1,6 +1,6 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
.passwd {
position: relative;

View File

@@ -1,5 +1,5 @@
import React, { useState, forwardRef } from "react";
import { classNames } from "jambonz-ui";
import { classNames } from "@jambonz/ui-kit";
import { Icons } from "src/components/icons";

View File

@@ -1,8 +1,8 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "jambonz-ui/src/styles/index";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
@use "@jambonz/ui-kit/src/styles/index";
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
.selector {
position: relative;

View File

@@ -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,
};

View File

@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom";
import { Button, ButtonGroup } from "jambonz-ui";
import { Button, ButtonGroup } from "@jambonz/ui-kit";
import "./styles.scss";
@@ -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

View File

@@ -1,6 +1,6 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
.modal {
position: fixed;

View File

@@ -1,6 +1,6 @@
@use "src/styles/vars";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
.obscure {
@include ui-mixins.ms();

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useMemo } from "react";
import { Icon } from "jambonz-ui";
import { Icon } from "@jambonz/ui-kit";
import { Icons } from "../icons";

View File

@@ -1,7 +1,7 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
.pagination {
display: flex;

View File

@@ -1,5 +1,5 @@
import React from "react";
import { H1 } from "jambonz-ui";
import { H1 } from "@jambonz/ui-kit";
import { RequireAuth } from "./require-auth";

View File

@@ -1,5 +1,5 @@
import React from "react";
import { H1 } from "jambonz-ui";
import { H1 } from "@jambonz/ui-kit";
import { ScopedAccess } from "./scoped-access";
import { USER_SP, USER_ADMIN, USER_ACCOUNT } from "src/api/constants";

View File

@@ -1,5 +1,5 @@
import React, { useState, useCallback } from "react";
import { classNames } from "jambonz-ui";
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);

View File

@@ -1,7 +1,7 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
.search-filter {
position: relative;

View File

@@ -1,5 +1,5 @@
import React from "react";
import { classNames } from "jambonz-ui";
import { classNames } from "@jambonz/ui-kit";
import "./styles.scss";

View File

@@ -1,7 +1,7 @@
@use "src/styles/vars";
@use "jambonz-ui/src/styles/index";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
@use "@jambonz/ui-kit/src/styles/index";
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
.sec {
margin-top: ui-vars.$px03;

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { classNames } from "jambonz-ui";
import { classNames } from "@jambonz/ui-kit";
import { Icons } from "src/components/icons";

View File

@@ -1,5 +1,5 @@
import React from "react";
import { classNames } from "jambonz-ui";
import { classNames } from "@jambonz/ui-kit";
import "./styles.scss";

View File

@@ -1,5 +1,5 @@
@use "src/styles/vars";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
/** https://loading.io/css/ */

View File

@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom";
import { classNames } from "jambonz-ui";
import { classNames } from "@jambonz/ui-kit";
import { Icons } from "src/components";

View File

@@ -1,6 +1,6 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
.toast {
padding-left: ui-vars.$px02;

View File

@@ -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>
);
};

View File

@@ -1,7 +1,7 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
.tooltip {
cursor: help;

View File

@@ -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";

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { P, Button } from "jambonz-ui";
import { P, Button } from "@jambonz/ui-kit";
import { toastSuccess, toastError } from "src/store";
import { useApiData, postApiKey, deleteApiKey } from "src/api";

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react";
import { Outlet } from "react-router-dom";
import { Button, Icon, classNames } from "jambonz-ui";
import { Button, Icon, classNames } from "@jambonz/ui-kit";
import { UserMe } from "./user-me";
import { Navi } from "./navi";

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState, useMemo } from "react";
import { classNames, M, Icon, Button } from "jambonz-ui";
import { Link, useLocation } from "react-router-dom";
import { classNames, M, Icon, Button } from "@jambonz/ui-kit";
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}
/>

View File

@@ -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,
]
: []),
];

View File

@@ -1,7 +1,7 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
.navi {
width: 100%;

View File

@@ -1,7 +1,7 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
/** Generic layout: internal */
.internal {

View File

@@ -1,7 +1,7 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "jambonz-ui/src/styles/vars" as ui-vars;
@use "jambonz-ui/src/styles/mixins" as ui-mixins;
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
/** User layout **/
.user {

View File

@@ -1,5 +1,5 @@
import React from "react";
import { H1 } from "jambonz-ui";
import { H1 } from "@jambonz/ui-kit";
import { AccountForm } from "./form";

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { P } from "jambonz-ui";
import { P } from "@jambonz/ui-kit";
import { ModalClose, Modal } from "src/components";
import { getFetch } from "src/api";

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from "react";
import { H1 } from "jambonz-ui";
import { H1 } from "@jambonz/ui-kit";
import { useParams } from "react-router-dom";
import { ApiKeys } from "src/containers/internal/api-keys";
@@ -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`}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { P, Button, ButtonGroup, MS } from "jambonz-ui";
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>
)}
</>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { H1, M, Button, Icon } from "jambonz-ui";
import { H1, M, Button, Icon } from "@jambonz/ui-kit";
import { Link } from "react-router-dom";
import { useServiceProviderData, deleteAccount } from "src/api";

View File

@@ -0,0 +1,59 @@
import dayjs from "dayjs";
import React, { useState } from "react";
import { Alert } from "src/api/types";
import { Icons } from "src/components";
type AlertDetailsItemProps = {
alert: Alert;
};
export const AlertDetailItem = ({ alert }: AlertDetailsItemProps) => {
const [open, setOpen] = useState(false);
return (
<div className="item">
<details
className="clean"
onToggle={(e: React.BaseSyntheticEvent) => {
if (e.target.open && !open) {
setOpen(e.target.open);
}
}}
>
<summary className="txt--jam">
<div className="item__info">
<div className="item__title">
<strong>
{dayjs(alert.time).format("YYYY MM.DD hh:mm:ss a")}
</strong>
</div>
<div className="item__meta">
<div>
<div className="i txt--teal">
<Icons.AlertCircle />
<span>{alert.message}</span>
</div>
</div>
</div>
</div>
</summary>
<div className="item__details">
<div className="pre-grid">
{Object.keys(alert).map((key) => (
<React.Fragment key={key}>
<div>{key}:</div>
<div>
{alert[key as keyof typeof alert]
? String(alert[key as keyof typeof alert])
: "null"}
</div>
</React.Fragment>
))}
</div>
</div>
</details>
</div>
);
};
export default AlertDetailItem;

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { ButtonGroup, H1, M, MS } from "jambonz-ui";
import { ButtonGroup, H1, M, MS } from "@jambonz/ui-kit";
import dayjs from "dayjs";
import { getAlerts, useServiceProviderData } from "src/api";
@@ -16,7 +16,6 @@ import {
Section,
SelectFilter,
Spinner,
Icons,
} from "src/components";
import type { Account, Alert, PageQuery } from "src/api/types";
@@ -27,6 +26,7 @@ import {
getQueryFilter,
setLocation,
} from "src/store/localStore";
import AlertDetailItem from "./alert-detail-item";
export const Alerts = () => {
const user = useSelectState("user");
@@ -112,21 +112,7 @@ export const Alerts = () => {
<Spinner />
) : hasLength(alerts) ? (
alerts.map((alert) => (
<div className="item" key={`${alert.alert_type}-${alert.time}`}>
<div className="item__info">
<div className="item__title txt--jam">
<strong>
{dayjs(alert.time).format("YYYY MM.DD hh:mm a")}
</strong>
</div>
<div className="item__meta">
<div className="i">
<Icons.AlertCircle />
<span>{alert.message}</span>
</div>
</div>
</div>
</div>
<AlertDetailItem key={alert.time} alert={alert} />
))
) : (
<M>No data.</M>

View File

@@ -1,5 +1,5 @@
import React from "react";
import { H1 } from "jambonz-ui";
import { H1 } from "@jambonz/ui-kit";
import { ApplicationForm } from "./form";

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { P } from "jambonz-ui";
import { P } from "@jambonz/ui-kit";
import { Modal, ModalClose } from "src/components";
import { getFetch } from "src/api";

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from "react";
import { H1 } from "jambonz-ui";
import { H1 } from "@jambonz/ui-kit";
import { useParams } from "react-router-dom";
import { useApiData } from "src/api";

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Button, ButtonGroup, MS } from "jambonz-ui";
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit";
import { Link, useNavigate } from "react-router-dom";
import { toastError, toastSuccess, useSelectState } from "src/store";
@@ -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 = {
@@ -63,13 +71,22 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [applications] = useApiData<Application[]>("Applications");
const [applicationName, setApplicationName] = useState("");
const [applicationJson, setApplicationJson] = useState("");
const [tmpApplicationJson, setTmpApplicationJson] = useState("");
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);
@@ -79,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 = [
@@ -86,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,
},
@@ -94,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,
},
@@ -102,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,
},
@@ -145,6 +173,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
const payload = {
name: applicationName,
app_json: applicationJson || null,
call_hook: callWebhook || null,
account_sid: accountSid || null,
messaging_hook: messageWebhook || null,
@@ -154,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) {
@@ -181,13 +211,57 @@ 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 || "");
}
setTmpApplicationJson(applicationJson);
setInitialApplicationJson(
application.data.app_json != undefined &&
application.data.app_json.length !== 0
);
if (application.data.call_hook) {
setCallWebhook(application.data.call_hook);
setTmpCallWebhook(application.data.call_hook);
if (
application.data.call_hook.username ||
@@ -199,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 ||
@@ -210,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 ||
@@ -329,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>
@@ -344,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
@@ -364,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>
@@ -383,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 &&
@@ -418,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);
}}
/>
</>
)}
@@ -479,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(
@@ -500,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);
}}
@@ -521,6 +660,53 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
)}
</fieldset>
)}
{(import.meta.env.INITIAL_APP_JSON_ENABLED === undefined ||
import.meta.env.INITIAL_APP_JSON_ENABLED) && (
<fieldset>
<Checkzone
hidden
name="cz_pplication_json"
label="Override webhook for initial application"
initialCheck={initialApplicationJson}
handleChecked={(e) => {
if (e.target.checked && tmpApplicationJson) {
setApplicationJson(tmpApplicationJson);
}
if (!e.target.checked) {
setTmpApplicationJson(applicationJson);
setApplicationJson("");
}
}}
>
<textarea
id="input_application_json"
name="application_json"
rows={6}
cols={55}
placeholder="an array of jambonz verbs to execute"
value={applicationJson}
onChange={(e) => setApplicationJson(e.target.value)}
/>
</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>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { H1, M, Button, Icon } from "jambonz-ui";
import { H1, M, Button, Icon } from "@jambonz/ui-kit";
import { Link } from "react-router-dom";
import { deleteApplication, useServiceProviderData, useApiData } from "src/api";

View File

@@ -1,5 +1,5 @@
import React from "react";
import { H1 } from "jambonz-ui";
import { H1 } from "@jambonz/ui-kit";
import { CarrierForm } from "./form";

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { P } from "jambonz-ui";
import { P } from "@jambonz/ui-kit";
import { Modal, ModalClose } from "src/components";
import { getFetch } from "src/api";

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from "react";
import { H1 } from "jambonz-ui";
import { H1 } from "@jambonz/ui-kit";
import { useParams } from "react-router-dom";
import { useApiData } from "src/api";

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Button, ButtonGroup, Icon, MS, MXS, Tab, Tabs } from "jambonz-ui";
import { Button, ButtonGroup, Icon, MS, MXS, Tab, Tabs } from "@jambonz/ui-kit";
import {
deleteSipGateway,
@@ -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

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo, useEffect } from "react";
import { Link } from "react-router-dom";
import { Button, H1, Icon, M } from "jambonz-ui";
import { Button, H1, Icon, M } from "@jambonz/ui-kit";
import {
deleteCarrier,
deleteSipGateway,
@@ -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>

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

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

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

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

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

View File

@@ -0,0 +1,215 @@
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,
useServiceProviderData,
} from "src/api";
import { USER_ACCOUNT } from "src/api/constants";
import { Account, Client, 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 } 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 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) {
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 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);
}
if (client.data.password) {
setPassword(client.data.password);
}
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;

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

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

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

View File

@@ -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;

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

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

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

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

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

View File

@@ -1,5 +1,5 @@
import React from "react";
import { H1 } from "jambonz-ui";
import { H1 } from "@jambonz/ui-kit";
import { MsTeamsTenantForm } from "./form";

View File

@@ -1,5 +1,5 @@
import React from "react";
import { P } from "jambonz-ui";
import { P } from "@jambonz/ui-kit";
import { Modal } from "src/components";

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from "react";
import { H1 } from "jambonz-ui";
import { H1 } from "@jambonz/ui-kit";
import { useParams } from "react-router-dom";
import { useApiData } from "src/api";

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Button, ButtonGroup, MS } from "jambonz-ui";
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit";
import { Link, useNavigate } from "react-router-dom";
import {

View File

@@ -1,5 +1,5 @@
import React, { useState, useMemo } from "react";
import { Button, H1, Icon, M } from "jambonz-ui";
import { Button, H1, Icon, M } from "@jambonz/ui-kit";
import { Link } from "react-router-dom";
import {

View File

@@ -1,5 +1,5 @@
import React from "react";
import { H1 } from "jambonz-ui";
import { H1 } from "@jambonz/ui-kit";
import { PhoneNumberForm } from "./form";

View File

@@ -1,5 +1,5 @@
import React from "react";
import { P } from "jambonz-ui";
import { P } from "@jambonz/ui-kit";
import { Modal } from "src/components";

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from "react";
import { H1 } from "jambonz-ui";
import { H1 } from "@jambonz/ui-kit";
import { useParams } from "react-router-dom";
import { useApiData } from "src/api";

View File

@@ -1,4 +1,4 @@
import { Button, ButtonGroup, MS } from "jambonz-ui";
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit";
import React, { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { Button, ButtonGroup, H1, Icon, MS } from "jambonz-ui";
import { Button, ButtonGroup, H1, Icon, MS } from "@jambonz/ui-kit";
import { Link } from "react-router-dom";
import {

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

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

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { ButtonGroup, H1, M, MS } from "jambonz-ui";
import { ButtonGroup, H1, M, MS } from "@jambonz/ui-kit";
import dayjs from "dayjs";
import { getRecentCalls, useServiceProviderData } from "src/api";
@@ -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">

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

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

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