mirror of
https://github.com/jambonz/jambonz-webapp.git
synced 2025-12-19 05:37:43 +00:00
feat: provision record all call (#254)
This commit is contained in:
4
.env
4
.env
@@ -9,4 +9,6 @@ VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
|
|||||||
## disables Least cost routing feature
|
## disables Least cost routing feature
|
||||||
#VITE_APP_LCR_DISABLED=true
|
#VITE_APP_LCR_DISABLED=true
|
||||||
## disables Jaeger Tracing feature
|
## disables Jaeger Tracing feature
|
||||||
#VITE_APP_JAEGER_TRACING_DISABLED=true
|
#VITE_APP_JAEGER_TRACING_DISABLED=true
|
||||||
|
## enable record All Calls feature
|
||||||
|
#VITE_APP_DISABLE_CALL_RECORDING=true
|
||||||
@@ -14,10 +14,11 @@ DISABLE_LCR=${DISABLE_LCR:-false}
|
|||||||
DISABLE_JAEGER_TRACING=${DISABLE_JAEGER_TRACING:-false}
|
DISABLE_JAEGER_TRACING=${DISABLE_JAEGER_TRACING:-false}
|
||||||
DISABLE_CUSTOM_SPEECH=${DISABLE_CUSTOM_SPEECH:-false}
|
DISABLE_CUSTOM_SPEECH=${DISABLE_CUSTOM_SPEECH:-false}
|
||||||
ENABLE_FORGOT_PASSWORD=${ENABLE_FORGOT_PASSWORD:-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
|
# 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
|
# This is declared and utilized in the web app: src/api/constants.ts
|
||||||
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}\"};</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
|
sed -i -e "\@</head>@i\ $SCRIPT_TAG" ./dist/index.html
|
||||||
|
|
||||||
# Start the frontend web app static server
|
# Start the frontend web app static server
|
||||||
|
|||||||
44
package-lock.json
generated
44
package-lock.json
generated
@@ -18,7 +18,8 @@
|
|||||||
"react-dnd-html5-backend": "16.0.1",
|
"react-dnd-html5-backend": "16.0.1",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
"react-feather": "^2.0.10",
|
"react-feather": "^2.0.10",
|
||||||
"react-router-dom": "^6.3.0"
|
"react-router-dom": "^6.3.0",
|
||||||
|
"wavesurfer.js": "^6.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.12",
|
"@types/cors": "^2.8.12",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"@types/react": "^18.0.0",
|
"@types/react": "^18.0.0",
|
||||||
"@types/react-dom": "^18.0.0",
|
"@types/react-dom": "^18.0.0",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
|
"@types/wavesurfer.js": "^6.0.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||||
"@typescript-eslint/parser": "^5.30.6",
|
"@typescript-eslint/parser": "^5.30.6",
|
||||||
"@vitejs/plugin-react": "^1.3.0",
|
"@vitejs/plugin-react": "^1.3.0",
|
||||||
@@ -823,6 +825,12 @@
|
|||||||
"integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==",
|
"integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/debounce": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/express": {
|
"node_modules/@types/express": {
|
||||||
"version": "4.17.13",
|
"version": "4.17.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz",
|
||||||
@@ -931,6 +939,15 @@
|
|||||||
"integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==",
|
"integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/wavesurfer.js": {
|
||||||
|
"version": "6.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/wavesurfer.js/-/wavesurfer.js-6.0.6.tgz",
|
||||||
|
"integrity": "sha512-fD54o0RXZXxkOb+69Rt6rGViaHpIc1Mmde2aOX9qPhlQhrCPepybGnsekiG407+7scPlaK+hmuPez5AnnmlzGg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/debounce": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/yauzl": {
|
"node_modules/@types/yauzl": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz",
|
||||||
@@ -6518,6 +6535,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wavesurfer.js": {
|
||||||
|
"version": "6.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-6.6.3.tgz",
|
||||||
|
"integrity": "sha512-XqUOXe8+SOTe8uKCHaqW0vJ5etCCQvq/NgaPycn9HAX/nUi+2zoWD+w9i7H5vBT9UCDNawOia+vS5Ct3kZGQzA=="
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7212,6 +7234,12 @@
|
|||||||
"integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==",
|
"integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@types/debounce": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@types/express": {
|
"@types/express": {
|
||||||
"version": "4.17.13",
|
"version": "4.17.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz",
|
||||||
@@ -7315,6 +7343,15 @@
|
|||||||
"integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==",
|
"integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@types/wavesurfer.js": {
|
||||||
|
"version": "6.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/wavesurfer.js/-/wavesurfer.js-6.0.6.tgz",
|
||||||
|
"integrity": "sha512-fD54o0RXZXxkOb+69Rt6rGViaHpIc1Mmde2aOX9qPhlQhrCPepybGnsekiG407+7scPlaK+hmuPez5AnnmlzGg==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/debounce": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@types/yauzl": {
|
"@types/yauzl": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz",
|
||||||
@@ -10921,6 +10958,11 @@
|
|||||||
"rollup": "^2.75.6"
|
"rollup": "^2.75.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"wavesurfer.js": {
|
||||||
|
"version": "6.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-6.6.3.tgz",
|
||||||
|
"integrity": "sha512-XqUOXe8+SOTe8uKCHaqW0vJ5etCCQvq/NgaPycn9HAX/nUi+2zoWD+w9i7H5vBT9UCDNawOia+vS5Ct3kZGQzA=="
|
||||||
|
},
|
||||||
"which": {
|
"which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -41,15 +41,16 @@
|
|||||||
"deploy": "npm i && npm run build && npm run pm2"
|
"deploy": "npm i && npm run build && npm run pm2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dayjs": "^1.11.5",
|
|
||||||
"@jambonz/ui-kit": "^0.0.21",
|
"@jambonz/ui-kit": "^0.0.21",
|
||||||
|
"dayjs": "^1.11.5",
|
||||||
|
"immutability-helper": "^3.1.1",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
|
"react-dnd": "16.0.1",
|
||||||
|
"react-dnd-html5-backend": "16.0.1",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
"react-feather": "^2.0.10",
|
"react-feather": "^2.0.10",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"react-dnd": "16.0.1",
|
"wavesurfer.js": "^6.6.3"
|
||||||
"react-dnd-html5-backend": "16.0.1",
|
|
||||||
"immutability-helper": "^3.1.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.12",
|
"@types/cors": "^2.8.12",
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
"@types/react": "^18.0.0",
|
"@types/react": "^18.0.0",
|
||||||
"@types/react-dom": "^18.0.0",
|
"@types/react-dom": "^18.0.0",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
|
"@types/wavesurfer.js": "^6.0.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||||
"@typescript-eslint/parser": "^5.30.6",
|
"@typescript-eslint/parser": "^5.30.6",
|
||||||
"@vitejs/plugin-react": "^1.3.0",
|
"@vitejs/plugin-react": "^1.3.0",
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ app.get(
|
|||||||
for (let i = 0; i < 500; i++) {
|
for (let i = 0; i < 500; i++) {
|
||||||
const attempted_at = new Date(start.getTime() + i * increment);
|
const attempted_at = new Date(start.getTime() + i * increment);
|
||||||
const failed = 0 === i % 5;
|
const failed = 0 === i % 5;
|
||||||
|
const call_sid = nanoid();
|
||||||
const call: RecentCall = {
|
const call: RecentCall = {
|
||||||
account_sid: req.params.account_sid,
|
account_sid: req.params.account_sid,
|
||||||
call_sid: nanoid(),
|
call_sid,
|
||||||
from: "15083084809",
|
from: "15083084809",
|
||||||
to: "18882349999",
|
to: "18882349999",
|
||||||
answered: !failed,
|
answered: !failed,
|
||||||
@@ -49,6 +50,7 @@ app.get(
|
|||||||
direction: 0 === i % 2 ? "inbound" : "outbound",
|
direction: 0 === i % 2 ? "inbound" : "outbound",
|
||||||
trunk: 0 === i % 2 ? "twilio" : "user",
|
trunk: 0 === i % 2 ? "twilio" : "user",
|
||||||
trace_id: nanoid(),
|
trace_id: nanoid(),
|
||||||
|
recording_url: `http://127.0.0.1:3002/api/Accounts/${req.params.account_sid}/RecentCalls/${call_sid}/record`,
|
||||||
};
|
};
|
||||||
data.push(call);
|
data.push(call);
|
||||||
}
|
}
|
||||||
@@ -137,6 +139,24 @@ 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(
|
app.get(
|
||||||
"/api/Accounts/:account_sid/RecentCalls/trace/:trace_id",
|
"/api/Accounts/:account_sid/RecentCalls/trace/:trace_id",
|
||||||
(req: Request, res: Response) => {
|
(req: Request, res: Response) => {
|
||||||
|
|||||||
BIN
server/example.mp3
Normal file
BIN
server/example.mp3
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ interface JambonzWindowObject {
|
|||||||
DISABLE_JAEGER_TRACING: string;
|
DISABLE_JAEGER_TRACING: string;
|
||||||
DISABLE_CUSTOM_SPEECH: string;
|
DISABLE_CUSTOM_SPEECH: string;
|
||||||
ENABLE_FORGOT_PASSWORD: string;
|
ENABLE_FORGOT_PASSWORD: string;
|
||||||
|
DISABLE_CALL_RECORDING: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -51,6 +52,11 @@ export const DISABLE_JAEGER_TRACING: boolean =
|
|||||||
window.JAMBONZ?.DISABLE_JAEGER_TRACING === "true" ||
|
window.JAMBONZ?.DISABLE_JAEGER_TRACING === "true" ||
|
||||||
JSON.parse(import.meta.env.VITE_APP_JAEGER_TRACING_DISABLED || "false");
|
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 */
|
/** TCP Max Port */
|
||||||
export const TCP_MAX_PORT = 65535;
|
export const TCP_MAX_PORT = 65535;
|
||||||
|
|
||||||
@@ -122,7 +128,30 @@ export const SIP_GATEWAY_PROTOCOL_OPTIONS = [
|
|||||||
value: "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 */
|
/** Password Length options */
|
||||||
|
|
||||||
export const PASSWORD_MIN = 8;
|
export const PASSWORD_MIN = 8;
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ import type {
|
|||||||
Lcr,
|
Lcr,
|
||||||
LcrRoute,
|
LcrRoute,
|
||||||
LcrCarrierSetEntry,
|
LcrCarrierSetEntry,
|
||||||
|
BucketCredential,
|
||||||
|
BucketCredentialTestResult,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { StatusCodes } from "./types";
|
import { StatusCodes } from "./types";
|
||||||
import { JaegerRoot } from "./jaeger-types";
|
import { JaegerRoot } from "./jaeger-types";
|
||||||
@@ -83,6 +85,7 @@ const fetchTransport = <Type>(
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(url, options);
|
const response = await fetch(url, options);
|
||||||
const transport: FetchTransport<Type> = {
|
const transport: FetchTransport<Type> = {
|
||||||
|
headers: response.headers,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
json: <Type>{},
|
json: <Type>{},
|
||||||
};
|
};
|
||||||
@@ -267,6 +270,16 @@ export const postAccount = (payload: Partial<Account>) => {
|
|||||||
return postFetch<SidResponse, Partial<Account>>(API_ACCOUNTS, payload);
|
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>) => {
|
export const postApplication = (payload: Partial<Application>) => {
|
||||||
return postFetch<SidResponse, Partial<Application>>(
|
return postFetch<SidResponse, Partial<Application>>(
|
||||||
API_APPLICATIONS,
|
API_APPLICATIONS,
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ export interface JaegerAttribute {
|
|||||||
value: JaegerValue;
|
value: JaegerValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WaveSufferSttResult {
|
||||||
|
vendor: string;
|
||||||
|
transcript: string;
|
||||||
|
confidence: number;
|
||||||
|
language_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaveSufferDtmfResult {
|
||||||
|
dtmf: string;
|
||||||
|
duration: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JaegerValue {
|
export interface JaegerValue {
|
||||||
stringValue: string;
|
stringValue: string;
|
||||||
doubleValue: string;
|
doubleValue: string;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export enum StatusCodes {
|
|||||||
/** Fetch transport interfaces */
|
/** Fetch transport interfaces */
|
||||||
|
|
||||||
export interface FetchTransport<Type> {
|
export interface FetchTransport<Type> {
|
||||||
|
headers: Headers;
|
||||||
status: StatusCodes;
|
status: StatusCodes;
|
||||||
json: Type;
|
json: Type;
|
||||||
blob?: Blob;
|
blob?: Blob;
|
||||||
@@ -87,7 +88,7 @@ export interface SelectorOptions {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pcap {
|
export interface DownloadedBlob {
|
||||||
data_url: string;
|
data_url: string;
|
||||||
file_name: string;
|
file_name: string;
|
||||||
}
|
}
|
||||||
@@ -102,6 +103,11 @@ export interface CredentialTestResult {
|
|||||||
tts: CredentialTest;
|
tts: CredentialTest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BucketCredentialTestResult {
|
||||||
|
status: CredentialStatus;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LimitField {
|
export interface LimitField {
|
||||||
label: string;
|
label: string;
|
||||||
category: LimitCategories;
|
category: LimitCategories;
|
||||||
@@ -246,7 +252,23 @@ export interface Account {
|
|||||||
registration_hook: null | WebHook;
|
registration_hook: null | WebHook;
|
||||||
service_provider_sid: string;
|
service_provider_sid: string;
|
||||||
device_calling_application_sid: null | string;
|
device_calling_application_sid: null | string;
|
||||||
lcr_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 {
|
export interface Application {
|
||||||
@@ -262,6 +284,7 @@ export interface Application {
|
|||||||
speech_synthesis_language: null | string;
|
speech_synthesis_language: null | string;
|
||||||
speech_recognizer_vendor: null | Lowercase<Vendor>;
|
speech_recognizer_vendor: null | Lowercase<Vendor>;
|
||||||
speech_recognizer_language: null | string;
|
speech_recognizer_language: null | string;
|
||||||
|
record_all_calls: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PhoneNumber {
|
export interface PhoneNumber {
|
||||||
@@ -298,6 +321,7 @@ export interface RecentCall {
|
|||||||
direction: string;
|
direction: string;
|
||||||
trunk: string;
|
trunk: string;
|
||||||
trace_id: string;
|
trace_id: string;
|
||||||
|
recording_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpeechCredential {
|
export interface SpeechCredential {
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ import {
|
|||||||
Share2,
|
Share2,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
Download,
|
||||||
} from "react-feather";
|
} from "react-feather";
|
||||||
|
|
||||||
import type { Icon } from "react-feather";
|
import type { Icon } from "react-feather";
|
||||||
@@ -94,4 +99,9 @@ export const Icons: IconMap = {
|
|||||||
Share2,
|
Share2,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
Download,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import "./styles.scss";
|
|||||||
type TooltipProps = {
|
type TooltipProps = {
|
||||||
text: IMessage;
|
text: IMessage;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
subStyle?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Tooltip = ({ text, children }: TooltipProps) => {
|
export const Tooltip = ({ text, children, subStyle }: TooltipProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="tooltip">
|
<div className="tooltip">
|
||||||
<div className="tooltip__reveal">{text}</div>
|
<div className="tooltip__reveal">{text}</div>
|
||||||
{children}
|
{children}
|
||||||
<Icons.HelpCircle />
|
{subStyle === "info" ? <Icons.Info /> : <Icons.HelpCircle />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { P, Button, ButtonGroup, MS } from "@jambonz/ui-kit";
|
import { P, Button, ButtonGroup, MS, Icon } from "@jambonz/ui-kit";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { toastError, toastSuccess, useSelectState } from "src/store";
|
import { toastError, toastSuccess, useSelectState } from "src/store";
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
postAccountLimit,
|
postAccountLimit,
|
||||||
deleteAccountLimit,
|
deleteAccountLimit,
|
||||||
deleteAccountTtsCache,
|
deleteAccountTtsCache,
|
||||||
|
postAccountBucketCredentialTest,
|
||||||
} from "src/api";
|
} from "src/api";
|
||||||
import { ClipBoard, Icons, Modal, Section, Tooltip } from "src/components";
|
import { ClipBoard, Icons, Modal, Section, Tooltip } from "src/components";
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +24,11 @@ import {
|
|||||||
} from "src/components/forms";
|
} from "src/components/forms";
|
||||||
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
|
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
|
||||||
import {
|
import {
|
||||||
|
AUDIO_FORMAT_OPTIONS,
|
||||||
|
BUCKET_VENDOR_OPTIONS,
|
||||||
|
CRED_OK,
|
||||||
DEFAULT_WEBHOOK,
|
DEFAULT_WEBHOOK,
|
||||||
|
DISABLE_CALL_RECORDING,
|
||||||
USER_ACCOUNT,
|
USER_ACCOUNT,
|
||||||
WEBHOOK_METHODS,
|
WEBHOOK_METHODS,
|
||||||
} from "src/api/constants";
|
} from "src/api/constants";
|
||||||
@@ -37,8 +42,11 @@ import type {
|
|||||||
UseApiDataMap,
|
UseApiDataMap,
|
||||||
Limit,
|
Limit,
|
||||||
TtsCache,
|
TtsCache,
|
||||||
|
BucketCredential,
|
||||||
|
AwsTag,
|
||||||
} from "src/api/types";
|
} from "src/api/types";
|
||||||
import { hasLength } from "src/utils";
|
import { hasLength, hasValue } from "src/utils";
|
||||||
|
import { useRegionVendors } from "src/vendor";
|
||||||
|
|
||||||
type AccountFormProps = {
|
type AccountFormProps = {
|
||||||
apps?: Application[];
|
apps?: Application[];
|
||||||
@@ -69,6 +77,18 @@ export const AccountForm = ({
|
|||||||
const [initialQueueHook, setInitialQueueHook] = useState(false);
|
const [initialQueueHook, setInitialQueueHook] = useState(false);
|
||||||
const [localLimits, setLocalLimits] = useState<Limit[]>([]);
|
const [localLimits, setLocalLimits] = useState<Limit[]>([]);
|
||||||
const [clearTtsCacheFlag, setClearTtsCacheFlag] = useState(false);
|
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... */
|
/** This lets us map and render the same UI for each... */
|
||||||
const webhooks = [
|
const webhooks = [
|
||||||
@@ -114,6 +134,28 @@ export const AccountForm = ({
|
|||||||
setModal(false);
|
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 = () => {
|
const handleRefresh = () => {
|
||||||
if (account && account.data) {
|
if (account && account.data) {
|
||||||
getAccountWebhook(account.data.account_sid)
|
getAccountWebhook(account.data.account_sid)
|
||||||
@@ -212,6 +254,24 @@ export const AccountForm = ({
|
|||||||
queue_event_hook: queueHook || account.data.queue_event_hook,
|
queue_event_hook: queueHook || account.data.queue_event_hook,
|
||||||
registration_hook: regHook || account.data.registration_hook,
|
registration_hook: regHook || account.data.registration_hook,
|
||||||
device_calling_application_sid: appId || null,
|
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(() => {
|
.then(() => {
|
||||||
account.refetch();
|
account.refetch();
|
||||||
@@ -286,9 +346,64 @@ export const AccountForm = ({
|
|||||||
setInitialQueueHook(false);
|
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]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section slim>
|
<Section slim>
|
||||||
@@ -488,6 +603,209 @@ export const AccountForm = ({
|
|||||||
} cached TTS prompts`}</MS>
|
} cached TTS prompts`}</MS>
|
||||||
</fieldset>
|
</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 && (
|
{message && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<Message message={message} />
|
<Message message={message} />
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ import {
|
|||||||
ROUTE_INTERNAL_ACCOUNTS,
|
ROUTE_INTERNAL_ACCOUNTS,
|
||||||
ROUTE_INTERNAL_APPLICATIONS,
|
ROUTE_INTERNAL_APPLICATIONS,
|
||||||
} from "src/router/routes";
|
} 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 {
|
import type {
|
||||||
RecognizerVendors,
|
RecognizerVendors,
|
||||||
@@ -96,6 +100,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
|||||||
const [credentials] = useApiData<SpeechCredential[]>(apiUrl);
|
const [credentials] = useApiData<SpeechCredential[]>(apiUrl);
|
||||||
const [softTtsVendor, setSoftTtsVendor] = useState<VendorOptions[]>(vendors);
|
const [softTtsVendor, setSoftTtsVendor] = useState<VendorOptions[]>(vendors);
|
||||||
const [softSttVendor, setSoftSttVendor] = 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... */
|
/** This lets us map and render the same UI for each... */
|
||||||
const webhooks = [
|
const webhooks = [
|
||||||
@@ -178,6 +183,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
|||||||
speech_synthesis_voice: synthVoice || null,
|
speech_synthesis_voice: synthVoice || null,
|
||||||
speech_recognizer_vendor: recogVendor || null,
|
speech_recognizer_vendor: recogVendor || null,
|
||||||
speech_recognizer_language: recogLang || null,
|
speech_recognizer_language: recogLang || null,
|
||||||
|
record_all_calls: recordAllCalls ? 1 : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (application && application.data) {
|
if (application && application.data) {
|
||||||
@@ -243,6 +249,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
|||||||
setLocation();
|
setLocation();
|
||||||
if (application && application.data) {
|
if (application && application.data) {
|
||||||
setApplicationName(application.data.name);
|
setApplicationName(application.data.name);
|
||||||
|
setRecordAllCalls(application.data.record_all_calls ? true : false);
|
||||||
if (!applicationJson) {
|
if (!applicationJson) {
|
||||||
setApplicationJson(application.data.app_json || "");
|
setApplicationJson(application.data.app_json || "");
|
||||||
}
|
}
|
||||||
@@ -683,6 +690,23 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
|
|||||||
</Checkzone>
|
</Checkzone>
|
||||||
</fieldset>
|
</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>}
|
{message && <fieldset>{<Message message={message} />}</fieldset>}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<ButtonGroup left>
|
<ButtonGroup left>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "src/api";
|
} from "src/api";
|
||||||
import { toastError } from "src/store";
|
import { toastError } from "src/store";
|
||||||
|
|
||||||
import type { Pcap } from "src/api/types";
|
import type { DownloadedBlob } from "src/api/types";
|
||||||
|
|
||||||
type PcapButtonProps = {
|
type PcapButtonProps = {
|
||||||
accountSid: string;
|
accountSid: string;
|
||||||
@@ -21,7 +21,7 @@ export const PcapButton = ({
|
|||||||
serviceProviderSid,
|
serviceProviderSid,
|
||||||
sipCallId,
|
sipCallId,
|
||||||
}: PcapButtonProps) => {
|
}: PcapButtonProps) => {
|
||||||
const [pcap, setPcap] = useState<Pcap>();
|
const [pcap, setPcap] = useState<DownloadedBlob>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sipCallId) return;
|
if (!sipCallId) return;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const CallDetail = ({ call }: CallDetailProps) => {
|
|||||||
<div>{key}:</div>
|
<div>{key}:</div>
|
||||||
<div>
|
<div>
|
||||||
{call[key as keyof typeof call]
|
{call[key as keyof typeof call]
|
||||||
? call[key as keyof typeof call].toString()
|
? String(call[key as keyof typeof call])
|
||||||
: "null"}
|
: "null"}
|
||||||
</div>
|
</div>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { Bar } from "./jaeger/bar";
|
import { Bar } from "./jaeger/bar";
|
||||||
import { JaegerGroup, JaegerRoot, JaegerSpan } from "src/api/jaeger-types";
|
import { JaegerGroup, JaegerRoot, JaegerSpan } from "src/api/jaeger-types";
|
||||||
import { getJaegerTrace } from "src/api";
|
import { getJaegerTrace } from "src/api";
|
||||||
import { toastError } from "src/store";
|
|
||||||
import { RecentCall } from "src/api/types";
|
import { RecentCall } from "src/api/types";
|
||||||
|
import { getSpansFromJaegerRoot } from "./utils";
|
||||||
|
|
||||||
function useWindowSize() {
|
function useWindowSize() {
|
||||||
const [windowSize, setWindowSize] = useState({
|
const [windowSize, setWindowSize] = useState({
|
||||||
@@ -34,30 +34,6 @@ export const CallTracing = ({ call }: CallTracingProps) => {
|
|||||||
const [jaegerGroup, setJaegerGroup] = useState<JaegerGroup>();
|
const [jaegerGroup, setJaegerGroup] = useState<JaegerGroup>();
|
||||||
const windowSize = useWindowSize();
|
const windowSize = useWindowSize();
|
||||||
|
|
||||||
const getSpansFromJaegerRoot = (trace: JaegerRoot) => {
|
|
||||||
setJaegerRoot(trace);
|
|
||||||
const spans: JaegerSpan[] = [];
|
|
||||||
trace.resourceSpans.forEach((resourceSpan) => {
|
|
||||||
resourceSpan.instrumentationLibrarySpans.forEach(
|
|
||||||
(instrumentationLibrarySpan) => {
|
|
||||||
instrumentationLibrarySpan.spans.forEach((value) => {
|
|
||||||
const attrs = value.attributes.filter(
|
|
||||||
(attr) =>
|
|
||||||
!(
|
|
||||||
attr.key.startsWith("telemetry") ||
|
|
||||||
attr.key.startsWith("internal")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
value.attributes = attrs;
|
|
||||||
spans.push(value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
spans.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
|
|
||||||
return spans;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getGroupsByParent = (spanId: string, groups: JaegerGroup[]) => {
|
const getGroupsByParent = (spanId: string, groups: JaegerGroup[]) => {
|
||||||
groups.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
|
groups.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
|
||||||
return groups.filter((value) => value.parentSpanId === spanId);
|
return groups.filter((value) => value.parentSpanId === spanId);
|
||||||
@@ -87,6 +63,7 @@ export const CallTracing = ({ call }: CallTracingProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildSpans = (root: JaegerRoot) => {
|
const buildSpans = (root: JaegerRoot) => {
|
||||||
|
setJaegerRoot(root);
|
||||||
const spans = getSpansFromJaegerRoot(root);
|
const spans = getSpansFromJaegerRoot(root);
|
||||||
const rootSpan = getRootSpan(spans);
|
const rootSpan = getRootSpan(spans);
|
||||||
if (rootSpan) {
|
if (rootSpan) {
|
||||||
@@ -142,15 +119,11 @@ export const CallTracing = ({ call }: CallTracingProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (call.trace_id && call.trace_id != "00000000000000000000000000000000") {
|
if (call.trace_id && call.trace_id != "00000000000000000000000000000000") {
|
||||||
getJaegerTrace(call.account_sid, call.trace_id)
|
getJaegerTrace(call.account_sid, call.trace_id).then(({ json }) => {
|
||||||
.then(({ json }) => {
|
if (json) {
|
||||||
if (json) {
|
buildSpans(json);
|
||||||
buildSpans(json);
|
}
|
||||||
}
|
});
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toastError(error.msg);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ import React, { useState } from "react";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { Icons } from "src/components";
|
import { Icons } from "src/components";
|
||||||
import { formatPhoneNumber } from "src/utils";
|
import { formatPhoneNumber, hasValue } from "src/utils";
|
||||||
import { PcapButton } from "./pcap";
|
import { PcapButton } from "./pcap";
|
||||||
import type { RecentCall } from "src/api/types";
|
import type { RecentCall } from "src/api/types";
|
||||||
import { Tabs, Tab } from "@jambonz/ui-kit";
|
import { Tabs, Tab } from "@jambonz/ui-kit";
|
||||||
import CallDetail from "./call-detail";
|
import CallDetail from "./call-detail";
|
||||||
import CallTracing from "./call-tracing";
|
import CallTracing from "./call-tracing";
|
||||||
import { DISABLE_JAEGER_TRACING } from "src/api/constants";
|
import { DISABLE_JAEGER_TRACING } from "src/api/constants";
|
||||||
|
import { Player } from "./player";
|
||||||
|
import "./styles.scss";
|
||||||
|
|
||||||
type DetailsItemProps = {
|
type DetailsItemProps = {
|
||||||
call: RecentCall;
|
call: RecentCall;
|
||||||
@@ -18,6 +20,12 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState("");
|
const [activeTab, setActiveTab] = useState("");
|
||||||
|
|
||||||
|
const transformRecentCall = (call: RecentCall): RecentCall => {
|
||||||
|
const newCall = { ...call };
|
||||||
|
delete newCall.recording_url;
|
||||||
|
return newCall;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="item">
|
<div className="item">
|
||||||
<details
|
<details
|
||||||
@@ -61,11 +69,11 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
|
|||||||
</summary>
|
</summary>
|
||||||
{call.trace_id === "00000000000000000000000000000000" ||
|
{call.trace_id === "00000000000000000000000000000000" ||
|
||||||
DISABLE_JAEGER_TRACING ? (
|
DISABLE_JAEGER_TRACING ? (
|
||||||
<CallDetail call={call} />
|
<CallDetail call={transformRecentCall(call)} />
|
||||||
) : (
|
) : (
|
||||||
<Tabs active={[activeTab, setActiveTab]}>
|
<Tabs active={[activeTab, setActiveTab]}>
|
||||||
<Tab id="details" label="Details">
|
<Tab id="details" label="Details">
|
||||||
<CallDetail call={call} />
|
<CallDetail call={transformRecentCall(call)} />
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab id="tracing" label="Tracing">
|
<Tab id="tracing" label="Tracing">
|
||||||
<CallTracing call={call} />
|
<CallTracing call={call} />
|
||||||
@@ -73,15 +81,14 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<>
|
||||||
style={{
|
<div className="footer">
|
||||||
display: "flex",
|
{hasValue(call.recording_url) && <Player call={call} />}
|
||||||
justifyContent: "space-between",
|
<div className="footer__buttons">
|
||||||
width: "300px",
|
<PcapButton call={call} />
|
||||||
}}
|
</div>
|
||||||
>
|
</div>
|
||||||
<PcapButton call={call} />
|
</>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,14 +44,17 @@ export const JaegerDetail = ({ group }: JaegerDetailProps) => {
|
|||||||
.format("DD/MM/YY HH:mm:ss.SSS")}
|
.format("DD/MM/YY HH:mm:ss.SSS")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="spanDetailsWrapper__details">
|
{!(group.name && group.name.startsWith("dtmf:")) && (
|
||||||
<div className="spanDetailsWrapper__details_header">
|
<div className="spanDetailsWrapper__details">
|
||||||
<strong>Duration:</strong>
|
<div className="spanDetailsWrapper__details_header">
|
||||||
|
<strong>Duration:</strong>
|
||||||
|
</div>
|
||||||
|
<div className="spanDetailsWrapper__details_body">
|
||||||
|
{formattedDuration(group.durationMs)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="spanDetailsWrapper__details_body">
|
)}
|
||||||
{formattedDuration(group.durationMs)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{group.attributes.map((attribute) => (
|
{group.attributes.map((attribute) => (
|
||||||
<div key={attribute.key} className="spanDetailsWrapper__details">
|
<div key={attribute.key} className="spanDetailsWrapper__details">
|
||||||
<div className="spanDetailsWrapper__details_header">
|
<div className="spanDetailsWrapper__details_header">
|
||||||
|
|||||||
@@ -3,28 +3,30 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { getPcap } from "src/api";
|
import { getPcap } from "src/api";
|
||||||
import { toastError } from "src/store";
|
import { toastError } from "src/store";
|
||||||
|
|
||||||
import type { Pcap, RecentCall } from "src/api/types";
|
import type { DownloadedBlob, RecentCall } from "src/api/types";
|
||||||
|
|
||||||
type PcapButtonProps = {
|
type PcapButtonProps = {
|
||||||
call: RecentCall;
|
call: RecentCall;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PcapButton = ({ call }: PcapButtonProps) => {
|
export const PcapButton = ({ call }: PcapButtonProps) => {
|
||||||
const [pcap, setPcap] = useState<Pcap>();
|
const [pcap, setPcap] = useState<DownloadedBlob | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getPcap(call.account_sid, call.sip_callid, "invite")
|
if (!pcap) {
|
||||||
.then(({ blob }) => {
|
getPcap(call.account_sid, call.sip_callid, "invite")
|
||||||
if (blob) {
|
.then(({ blob }) => {
|
||||||
setPcap({
|
if (blob) {
|
||||||
data_url: URL.createObjectURL(blob),
|
setPcap({
|
||||||
file_name: `callid-${call.sip_callid}.pcap`,
|
data_url: URL.createObjectURL(blob),
|
||||||
});
|
file_name: `callid-${call.sip_callid}.pcap`,
|
||||||
}
|
});
|
||||||
})
|
}
|
||||||
.catch((error) => {
|
})
|
||||||
toastError(error.msg);
|
.catch((error) => {
|
||||||
});
|
toastError(error.msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (pcap) {
|
if (pcap) {
|
||||||
|
|||||||
469
src/containers/internal/views/recent-calls/player.tsx
Normal file
469
src/containers/internal/views/recent-calls/player.tsx
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import WaveSurfer from "wavesurfer.js";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Icon, P } from "@jambonz/ui-kit";
|
||||||
|
import { Icons, ModalClose } from "src/components";
|
||||||
|
import { getBlob, getJaegerTrace } from "src/api";
|
||||||
|
import { DownloadedBlob, RecentCall } from "src/api/types";
|
||||||
|
import RegionsPlugin, { Region } from "wavesurfer.js/src/plugin/regions";
|
||||||
|
import TimelinePlugin from "wavesurfer.js/src/plugin/timeline";
|
||||||
|
import { API_BASE_URL } from "src/api/constants";
|
||||||
|
import {
|
||||||
|
JaegerRoot,
|
||||||
|
JaegerSpan,
|
||||||
|
WaveSufferDtmfResult,
|
||||||
|
WaveSufferSttResult,
|
||||||
|
} from "src/api/jaeger-types";
|
||||||
|
import {
|
||||||
|
getSpanAttributeByName,
|
||||||
|
getSpansByName,
|
||||||
|
getSpansByNameRegex,
|
||||||
|
getSpansFromJaegerRoot,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
type PlayerProps = {
|
||||||
|
call: RecentCall;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Player = ({ call }: PlayerProps) => {
|
||||||
|
const { recording_url, call_sid } = call;
|
||||||
|
const url =
|
||||||
|
recording_url && recording_url.startsWith("http://")
|
||||||
|
? recording_url
|
||||||
|
: `${API_BASE_URL}${recording_url}`;
|
||||||
|
const JUMP_DURATION = 15; //seconds
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [playBackTime, setPlayBackTime] = useState("");
|
||||||
|
const [jaegerRoot, setJeagerRoot] = useState<JaegerRoot>();
|
||||||
|
const [waveSufferRegionData, setWaveSufferRegionData] =
|
||||||
|
useState<WaveSufferSttResult | null>();
|
||||||
|
const [waveSufferDtmfData, setWaveSufferDtmfData] =
|
||||||
|
useState<WaveSufferDtmfResult | null>();
|
||||||
|
const [regionChecked, setRegionChecked] = useState(false);
|
||||||
|
|
||||||
|
const wavesurferId = `wavesurfer--${call_sid}`;
|
||||||
|
const wavesurferTimelineId = `timeline-${wavesurferId}`;
|
||||||
|
const waveSufferRef = useRef<WaveSurfer | null>(null);
|
||||||
|
|
||||||
|
const [record, setRecord] = useState<DownloadedBlob | null>(null);
|
||||||
|
|
||||||
|
const drawDtmfRegionForSpan = (s: JaegerSpan, startPoint: JaegerSpan) => {
|
||||||
|
if (waveSufferRef.current) {
|
||||||
|
const r = waveSufferRef.current.regions.list[s.spanId];
|
||||||
|
if (!r) {
|
||||||
|
const [dtmfValue] = getSpanAttributeByName(s.attributes, "dtmf");
|
||||||
|
const [durationValue] = getSpanAttributeByName(
|
||||||
|
s.attributes,
|
||||||
|
"duration"
|
||||||
|
);
|
||||||
|
if (dtmfValue && durationValue) {
|
||||||
|
const start =
|
||||||
|
(s.startTimeUnixNano - startPoint.startTimeUnixNano) /
|
||||||
|
1_000_000_000;
|
||||||
|
const duration =
|
||||||
|
Number(durationValue.value.stringValue.replace("ms", "")) / 1_000;
|
||||||
|
// as duration of DTMF is short, cannot be shown in wavesuffer,
|
||||||
|
// adjust region width here.
|
||||||
|
const delta = duration <= 0.1 ? 0.1 : duration;
|
||||||
|
const end = start + delta;
|
||||||
|
|
||||||
|
const region = waveSufferRef.current.addRegion({
|
||||||
|
id: s.spanId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
color: "rgba(138, 43, 226, 0.15)",
|
||||||
|
drag: false,
|
||||||
|
loop: false,
|
||||||
|
resize: false,
|
||||||
|
});
|
||||||
|
changeRegionMouseStyle(region);
|
||||||
|
|
||||||
|
const att: WaveSufferDtmfResult = {
|
||||||
|
dtmf: dtmfValue.value.stringValue,
|
||||||
|
duration: durationValue.value.stringValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
region.on("click", () => {
|
||||||
|
setWaveSufferDtmfData(att);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeRegionMouseStyle = (region: Region, channel = 0) => {
|
||||||
|
region.element.style.display = regionChecked ? "" : "none";
|
||||||
|
region.element.style.height = "49%";
|
||||||
|
region.element.style.top = channel === 0 ? "0" : "51%";
|
||||||
|
|
||||||
|
region.element.addEventListener("mouseenter", () => {
|
||||||
|
region.element.style.cursor = "pointer"; // Change to your desired cursor style
|
||||||
|
});
|
||||||
|
|
||||||
|
region.element.addEventListener("mouseleave", () => {
|
||||||
|
region.element.style.cursor = "default";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawSttRegionForSpan = (
|
||||||
|
s: JaegerSpan,
|
||||||
|
startPoint: JaegerSpan,
|
||||||
|
channel = 0
|
||||||
|
) => {
|
||||||
|
if (waveSufferRef.current) {
|
||||||
|
const r = waveSufferRef.current.regions.list[s.spanId];
|
||||||
|
if (!r) {
|
||||||
|
const start =
|
||||||
|
(s.startTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000 +
|
||||||
|
0.05; // add magic 0.01 second in each region start time to isolate 2 near regions
|
||||||
|
const end =
|
||||||
|
(s.endTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
|
||||||
|
|
||||||
|
const region = waveSufferRef.current.addRegion({
|
||||||
|
id: s.spanId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
color: "rgba(255, 0, 0, 0.15)",
|
||||||
|
drag: false,
|
||||||
|
loop: false,
|
||||||
|
resize: false,
|
||||||
|
});
|
||||||
|
changeRegionMouseStyle(region, channel);
|
||||||
|
const [sttResult] = getSpanAttributeByName(s.attributes, "stt.result");
|
||||||
|
let att: WaveSufferSttResult;
|
||||||
|
if (sttResult) {
|
||||||
|
const data = JSON.parse(sttResult.value.stringValue);
|
||||||
|
att = {
|
||||||
|
vendor: data.vendor.name,
|
||||||
|
transcript: data.alternatives[0].transcript,
|
||||||
|
confidence: data.alternatives[0].confidence,
|
||||||
|
language_code: data.language_code,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const [sttResolve] = getSpanAttributeByName(
|
||||||
|
s.attributes,
|
||||||
|
"stt.resolve"
|
||||||
|
);
|
||||||
|
if (sttResolve && sttResolve.value.stringValue === "timeout") {
|
||||||
|
att = {
|
||||||
|
vendor: "",
|
||||||
|
transcript: "None (speech session timeout)",
|
||||||
|
confidence: 0,
|
||||||
|
language_code: "",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
att = {
|
||||||
|
vendor: "",
|
||||||
|
transcript:
|
||||||
|
"None (call disconnected or speech session terminated)",
|
||||||
|
confidence: 0,
|
||||||
|
language_code: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
region.on("click", () => {
|
||||||
|
setWaveSufferRegionData(att);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWavesufferRegion = () => {
|
||||||
|
if (jaegerRoot) {
|
||||||
|
const spans = getSpansFromJaegerRoot(jaegerRoot);
|
||||||
|
const [startPoint] = getSpansByName(spans, "background-listen:listen");
|
||||||
|
// there should be only one startPoint for background listen
|
||||||
|
if (startPoint) {
|
||||||
|
const gatherSpans = getSpansByNameRegex(spans, /:gather{/);
|
||||||
|
gatherSpans.forEach((s) => {
|
||||||
|
drawSttRegionForSpan(s, startPoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcribeSpans = getSpansByNameRegex(spans, /stt-listen:/);
|
||||||
|
transcribeSpans.forEach((cs) => {
|
||||||
|
// Channel start from 0
|
||||||
|
const channel = Number(cs.name.split(":")[1]);
|
||||||
|
drawSttRegionForSpan(
|
||||||
|
cs,
|
||||||
|
startPoint,
|
||||||
|
channel > 0 ? channel - 1 : channel
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dtmfSpans = getSpansByNameRegex(spans, /dtmf:/);
|
||||||
|
dtmfSpans.forEach((ds) => {
|
||||||
|
drawDtmfRegionForSpan(ds, startPoint);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
buildWavesufferRegion();
|
||||||
|
}, [jaegerRoot, isReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBlob(url).then(({ blob, headers }) => {
|
||||||
|
if (blob) {
|
||||||
|
const ext = headers.get("Content-Type") === "audio/wav" ? "wav" : "mp3";
|
||||||
|
setRecord({
|
||||||
|
data_url: URL.createObjectURL(blob),
|
||||||
|
file_name: `callid-${call_sid}.${ext}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (call.trace_id && call.trace_id != "00000000000000000000000000000000") {
|
||||||
|
getJaegerTrace(call.account_sid, call.trace_id).then(({ json }) => {
|
||||||
|
if (json) {
|
||||||
|
setJeagerRoot(json);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function formatTime(seconds: number) {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
return `${minutes.toString().padStart(2, "0")}:${remainingSeconds
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (waveSufferRef.current !== null || !record) return;
|
||||||
|
waveSufferRef.current = WaveSurfer.create({
|
||||||
|
container: `#${wavesurferId}`,
|
||||||
|
waveColor: "#da1c5c",
|
||||||
|
progressColor: "grey",
|
||||||
|
height: 50,
|
||||||
|
cursorWidth: 1,
|
||||||
|
cursorColor: "lightgray",
|
||||||
|
normalize: true,
|
||||||
|
responsive: true,
|
||||||
|
fillParent: true,
|
||||||
|
splitChannels: true,
|
||||||
|
scrollParent: true,
|
||||||
|
plugins: [
|
||||||
|
RegionsPlugin.create({}),
|
||||||
|
TimelinePlugin.create({
|
||||||
|
container: `#${wavesurferTimelineId}`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
waveSufferRef.current.load(record?.data_url);
|
||||||
|
// All event should be after load
|
||||||
|
waveSufferRef.current.on("finish", () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
waveSufferRef.current.on("play", () => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
waveSufferRef.current.on("pause", () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
waveSufferRef.current.on("ready", () => {
|
||||||
|
setIsReady(true);
|
||||||
|
setPlayBackTime(formatTime(waveSufferRef.current?.getDuration() || 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
waveSufferRef.current.on("audioprocess", () => {
|
||||||
|
setPlayBackTime(formatTime(waveSufferRef.current?.getCurrentTime() || 0));
|
||||||
|
});
|
||||||
|
}, [record]);
|
||||||
|
|
||||||
|
const togglePlayback = () => {
|
||||||
|
if (waveSufferRef.current) {
|
||||||
|
if (!isPlaying) {
|
||||||
|
waveSufferRef.current.play();
|
||||||
|
} else {
|
||||||
|
waveSufferRef.current.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPlaybackJump = (delta: number) => {
|
||||||
|
if (waveSufferRef.current) {
|
||||||
|
const idx = waveSufferRef.current.getCurrentTime() + delta;
|
||||||
|
const value =
|
||||||
|
idx <= 0
|
||||||
|
? 0
|
||||||
|
: idx >= waveSufferRef.current.getDuration()
|
||||||
|
? waveSufferRef.current.getDuration() - 1
|
||||||
|
: idx;
|
||||||
|
waveSufferRef.current.setCurrentTime(value);
|
||||||
|
setPlayBackTime(formatTime(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="media-container">
|
||||||
|
<div id={wavesurferId} />
|
||||||
|
<div id={wavesurferTimelineId} />
|
||||||
|
<div className="media-container__center">
|
||||||
|
<strong>{playBackTime}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="media-container__center">
|
||||||
|
<button
|
||||||
|
className="btnty"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPlaybackJump(-JUMP_DURATION);
|
||||||
|
}}
|
||||||
|
title="Jump left"
|
||||||
|
disabled={!isReady}
|
||||||
|
>
|
||||||
|
<Icon>
|
||||||
|
<Icons.ChevronsLeft />
|
||||||
|
</Icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btnty"
|
||||||
|
type="button"
|
||||||
|
onClick={togglePlayback}
|
||||||
|
title="play/pause"
|
||||||
|
disabled={!isReady}
|
||||||
|
>
|
||||||
|
<Icon>{isPlaying ? <Icons.Pause /> : <Icons.Play />}</Icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btnty"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPlaybackJump(JUMP_DURATION);
|
||||||
|
}}
|
||||||
|
title="Jump right"
|
||||||
|
disabled={!isReady}
|
||||||
|
>
|
||||||
|
<Icon>
|
||||||
|
<Icons.ChevronsRight />
|
||||||
|
</Icon>
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={record.data_url}
|
||||||
|
download={record.file_name}
|
||||||
|
className="btnty"
|
||||||
|
title="Download record file"
|
||||||
|
>
|
||||||
|
<Icon>
|
||||||
|
<Icons.Download />
|
||||||
|
</Icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<label htmlFor="is_active" className="chk">
|
||||||
|
<input
|
||||||
|
id={`is_active${call.call_sid}`}
|
||||||
|
name="is_active"
|
||||||
|
type="checkbox"
|
||||||
|
checked={regionChecked}
|
||||||
|
onChange={(e) => {
|
||||||
|
setRegionChecked(e.target.checked);
|
||||||
|
if (waveSufferRef.current) {
|
||||||
|
const regionsList = waveSufferRef.current.regions.list;
|
||||||
|
for (const [, region] of Object.entries(regionsList)) {
|
||||||
|
region.element.style.display = e.target.checked ? "" : "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>Overlay STT and DTMF events</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{waveSufferRegionData && (
|
||||||
|
<ModalClose handleClose={() => setWaveSufferRegionData(null)}>
|
||||||
|
<div className="spanDetailsWrapper__header">
|
||||||
|
<P>
|
||||||
|
<strong>Speech to text result</strong>
|
||||||
|
</P>
|
||||||
|
</div>
|
||||||
|
<div className="spanDetailsWrapper">
|
||||||
|
<div className="spanDetailsWrapper__detailsWrapper">
|
||||||
|
{waveSufferRegionData.vendor && (
|
||||||
|
<div className="spanDetailsWrapper__details">
|
||||||
|
<div className="spanDetailsWrapper__details_header">
|
||||||
|
<strong>Vendor:</strong>
|
||||||
|
</div>
|
||||||
|
<div className="spanDetailsWrapper__details_body">
|
||||||
|
{waveSufferRegionData.vendor}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{waveSufferRegionData.confidence !== 0 && (
|
||||||
|
<div className="spanDetailsWrapper__details">
|
||||||
|
<div className="spanDetailsWrapper__details_header">
|
||||||
|
<strong>Confidence:</strong>
|
||||||
|
</div>
|
||||||
|
<div className="spanDetailsWrapper__details_body">
|
||||||
|
{waveSufferRegionData.confidence}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{waveSufferRegionData.language_code && (
|
||||||
|
<div className="spanDetailsWrapper__details">
|
||||||
|
<div className="spanDetailsWrapper__details_header">
|
||||||
|
<strong>Language code:</strong>
|
||||||
|
</div>
|
||||||
|
<div className="spanDetailsWrapper__details_body">
|
||||||
|
{waveSufferRegionData.language_code}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{waveSufferRegionData.transcript && (
|
||||||
|
<div className="spanDetailsWrapper__details">
|
||||||
|
<div className="spanDetailsWrapper__details_header">
|
||||||
|
<strong>Transcript:</strong>
|
||||||
|
</div>
|
||||||
|
<div className="spanDetailsWrapper__details_body">
|
||||||
|
{waveSufferRegionData.transcript}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalClose>
|
||||||
|
)}
|
||||||
|
{waveSufferDtmfData && (
|
||||||
|
<ModalClose handleClose={() => setWaveSufferDtmfData(null)}>
|
||||||
|
<div className="spanDetailsWrapper__header">
|
||||||
|
<P>
|
||||||
|
<strong>Dtmf result</strong>
|
||||||
|
</P>
|
||||||
|
</div>
|
||||||
|
<div className="spanDetailsWrapper">
|
||||||
|
<div className="spanDetailsWrapper__detailsWrapper">
|
||||||
|
<div className="spanDetailsWrapper__details">
|
||||||
|
<div className="spanDetailsWrapper__details_header">
|
||||||
|
<strong>Dtmf:</strong>
|
||||||
|
</div>
|
||||||
|
<div className="spanDetailsWrapper__details_body">
|
||||||
|
{waveSufferDtmfData.dtmf}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="spanDetailsWrapper__details">
|
||||||
|
<div className="spanDetailsWrapper__details_header">
|
||||||
|
<strong>Duration:</strong>
|
||||||
|
</div>
|
||||||
|
<div className="spanDetailsWrapper__details_body">
|
||||||
|
{waveSufferDtmfData.duration}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalClose>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/containers/internal/views/recent-calls/styles.scss
Normal file
48
src/containers/internal/views/recent-calls/styles.scss
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
@use "src/styles/vars";
|
||||||
|
@use "src/styles/mixins";
|
||||||
|
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
|
||||||
|
|
||||||
|
.wavesuffer {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: ui-vars.$px02;
|
||||||
|
|
||||||
|
&__buttons {
|
||||||
|
padding: ui-vars.$px02;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: ui-vars.$px02;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-container {
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: ui-vars.$px01;
|
||||||
|
padding: 13px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&__center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
grid-gap: ui-vars.$px01;
|
||||||
|
margin-top: ui-vars.$px01;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: transparent;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ico {
|
||||||
|
color: ui-vars.$white;
|
||||||
|
@include mixins.icosize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/containers/internal/views/recent-calls/utils.ts
Normal file
46
src/containers/internal/views/recent-calls/utils.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { JaegerAttribute, JaegerRoot, JaegerSpan } from "src/api/jaeger-types";
|
||||||
|
|
||||||
|
export const getSpansFromJaegerRoot = (trace: JaegerRoot) => {
|
||||||
|
const spans: JaegerSpan[] = [];
|
||||||
|
trace.resourceSpans.forEach((resourceSpan) => {
|
||||||
|
resourceSpan.instrumentationLibrarySpans.forEach(
|
||||||
|
(instrumentationLibrarySpan) => {
|
||||||
|
instrumentationLibrarySpan.spans.forEach((value) => {
|
||||||
|
const attrs = value.attributes.filter(
|
||||||
|
(attr) =>
|
||||||
|
!(
|
||||||
|
attr.key.startsWith("telemetry") ||
|
||||||
|
attr.key.startsWith("internal")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
value.attributes = attrs;
|
||||||
|
spans.push(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
spans.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
|
||||||
|
return spans;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSpansByName = (
|
||||||
|
spans: JaegerSpan[],
|
||||||
|
name: string
|
||||||
|
): JaegerSpan[] => {
|
||||||
|
return spans.filter((s) => s.name === name);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSpansByNameRegex = (
|
||||||
|
spans: JaegerSpan[],
|
||||||
|
pattern: RegExp
|
||||||
|
): JaegerSpan[] => {
|
||||||
|
const matcher = new RegExp(pattern);
|
||||||
|
return spans.filter((s) => matcher.test(s.name));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSpanAttributeByName = (
|
||||||
|
attr: JaegerAttribute[],
|
||||||
|
name: string
|
||||||
|
): JaegerAttribute[] => {
|
||||||
|
return attr.filter((a) => a.key === name);
|
||||||
|
};
|
||||||
@@ -287,3 +287,7 @@ fieldset {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bucket_tag {
|
||||||
|
@extend .lcr;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user