Compare commits

...

19 Commits

Author SHA1 Message Date
Hoan Luu Huu
63f8a82443 only download jaeger trace when open detail for recent call (#287) 2023-07-17 19:14:55 -04:00
Hoan Luu Huu
9ce1d83c8f fix: update name for carrier register authentication fields (#283) 2023-07-03 15:25:22 +01:00
Hoan Luu Huu
961b7ecccb recent call filter (#282)
* recent call filter

* fix review comment

* fix eslint
2023-07-03 15:06:09 +01:00
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
47 changed files with 6333 additions and 5544 deletions

4
.env
View File

@@ -9,4 +9,6 @@ VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
## disables Least cost routing feature
#VITE_APP_LCR_DISABLED=true
## 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

View File

@@ -17,7 +17,7 @@ jobs:
- name: prepare tag
id: prepare_tag
run: |
IMAGE_ID=$GITHUB_REPOSITORY
IMAGE_ID=jambonz/webapp
# Strip git ref prefix from version
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')

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

1671
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": "0.8.3",
"version": "0.8.4",
"license": "MIT",
"type": "module",
"engines": {
@@ -41,15 +41,16 @@
"deploy": "npm i && npm run build && npm run pm2"
},
"dependencies": {
"dayjs": "^1.11.5",
"@jambonz/ui-kit": "^0.0.21",
"dayjs": "^1.11.5",
"immutability-helper": "^3.1.1",
"react": "^18.0.0",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "^18.0.0",
"react-feather": "^2.0.10",
"react-router-dom": "^6.3.0",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"immutability-helper": "^3.1.1"
"wavesurfer.js": "^6.6.3"
},
"devDependencies": {
"@types/cors": "^2.8.12",
@@ -58,6 +59,7 @@
"@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,
@@ -49,6 +50,7 @@ app.get(
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);
}
@@ -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(
"/api/Accounts/:account_sid/RecentCalls/trace/:trace_id",
(req: Request, res: Response) => {

BIN
server/example.mp3 Normal file

Binary file not shown.

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 {
@@ -29,24 +34,28 @@ export const API_BASE_URL =
export const DEV_BASE_URL = import.meta.env.VITE_DEV_BASE_URL;
/** Disable custom speech vendor*/
export const DISABLE_CUSTOM_SPEECH: boolean = JSON.parse(
import.meta.env.VITE_DISABLE_CUSTOM_SPEECH || "false"
);
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 = JSON.parse(
import.meta.env.VITE_ENABLE_FORGOT_PASSWORD || "false"
);
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 = JSON.parse(
import.meta.env.VITE_APP_LCR_DISABLED || "false"
);
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 = JSON.parse(
import.meta.env.VITE_APP_JAEGER_TRACING_DISABLED || "false"
);
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;
@@ -119,7 +128,30 @@ export const SIP_GATEWAY_PROTOCOL_OPTIONS = [
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;
@@ -245,3 +277,5 @@ 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

@@ -24,6 +24,8 @@ import {
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 {
@@ -69,6 +71,9 @@ import type {
Lcr,
LcrRoute,
LcrCarrierSetEntry,
BucketCredential,
BucketCredentialTestResult,
Client,
} from "./types";
import { StatusCodes } from "./types";
import { JaegerRoot } from "./jaeger-types";
@@ -82,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>{},
};
@@ -266,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,
@@ -393,6 +409,10 @@ export const postLcrCarrierSetEntry = (
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>) => {
@@ -506,6 +526,12 @@ export const putLcrCarrierSetEntries = (
);
};
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) => {
@@ -577,6 +603,17 @@ 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) => {
@@ -615,6 +652,14 @@ export const getLcrCarrierSetEtries = (sid: string) => {
);
};
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>) => {
@@ -635,11 +680,11 @@ 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`
);
};
@@ -662,11 +707,15 @@ export const getServiceProviderRecentCall = (
);
};
export const getServiceProviderPcap = (sid: string, sipCallId: string) => {
export const getServiceProviderPcap = (
sid: string,
sipCallId: string,
method: string
) => {
return getBlob(
import.meta.env.DEV
? `${DEV_BASE_URL}/ServiceProviders/${sid}/RecentCalls/${sipCallId}/pcap`
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}/pcap`
? `${DEV_BASE_URL}/ServiceProviders/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
);
};

View File

@@ -37,6 +37,18 @@ export interface JaegerAttribute {
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;

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;
@@ -123,6 +129,10 @@ export interface SystemInformation {
monitoring_domain_name: string;
}
export interface TtsCache {
size: number;
}
/** API responses/payloads */
export interface User {
@@ -242,7 +252,23 @@ export interface Account {
registration_hook: null | WebHook;
service_provider_sid: 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 {
@@ -258,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 {
@@ -294,6 +321,7 @@ export interface RecentCall {
direction: string;
trunk: string;
trace_id: string;
recording_url?: string;
}
export interface SpeechCredential {
@@ -425,6 +453,14 @@ export interface LcrCarrierSetEntry {
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

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

@@ -42,6 +42,12 @@ import {
Share2,
ArrowUp,
ArrowDown,
Play,
Pause,
ChevronsLeft,
ChevronsRight,
Download,
Smartphone,
} from "react-feather";
import type { Icon } from "react-feather";
@@ -94,4 +100,10 @@ export const Icons: IconMap = {
Share2,
ArrowUp,
ArrowDown,
Play,
Pause,
ChevronsLeft,
ChevronsRight,
Download,
Smartphone,
};

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useRef } from "react";
import { classNames } from "@jambonz/ui-kit";
import { Icons } from "src/components/icons";
@@ -7,14 +7,18 @@ import "./styles.scss";
type SearchFilterProps = JSX.IntrinsicElements["input"] & {
filter: [string, React.Dispatch<React.SetStateAction<string>>];
delay?: number | null;
};
export const SearchFilter = ({
placeholder,
filter: [filterValue, setFilterValue],
delay,
}: SearchFilterProps) => {
const [focus, setFocus] = useState(false);
const [tmpFilterValue, setTmpFilterValue] = useState(filterValue);
const [appearance, setAppearance] = useState(false);
const typingTimeoutRef = useRef<number | null>(null);
const classes = {
"search-filter": true,
focused: focus,
@@ -23,7 +27,18 @@ export const SearchFilter = ({
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setFilterValue(e.target.value.toLowerCase());
setTmpFilterValue(e.target.value.toLowerCase());
if (delay) {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
setFilterValue(e.target.value.toLowerCase());
}, delay);
} else {
setFilterValue(e.target.value.toLowerCase());
}
if (e.target.value) {
setAppearance(true);
@@ -51,7 +66,7 @@ export const SearchFilter = ({
type="search"
name="search_filter"
placeholder={placeholder}
value={filterValue}
value={tmpFilterValue}
onChange={handleChange}
onFocus={() => {
setFocus(true);

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

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

View File

@@ -10,6 +10,7 @@ import {
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";
@@ -53,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,

View File

@@ -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-kit";
import { P, Button, ButtonGroup, MS, Icon } from "@jambonz/ui-kit";
import { Link, useNavigate } from "react-router-dom";
import { toastError, toastSuccess, useSelectState } from "src/store";
@@ -10,6 +10,8 @@ import {
useApiData,
postAccountLimit,
deleteAccountLimit,
deleteAccountTtsCache,
postAccountBucketCredentialTest,
} from "src/api";
import { ClipBoard, Icons, Modal, Section, Tooltip } from "src/components";
import {
@@ -22,7 +24,11 @@ import {
} from "src/components/forms";
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import {
AUDIO_FORMAT_OPTIONS,
BUCKET_VENDOR_OPTIONS,
CRED_OK,
DEFAULT_WEBHOOK,
DISABLE_CALL_RECORDING,
USER_ACCOUNT,
WEBHOOK_METHODS,
} from "src/api/constants";
@@ -35,16 +41,26 @@ import type {
WebhookMethod,
UseApiDataMap,
Limit,
TtsCache,
BucketCredential,
AwsTag,
} from "src/api/types";
import { hasLength } from "src/utils";
import { hasLength, hasValue } from "src/utils";
import { useRegionVendors } from "src/vendor";
type AccountFormProps = {
apps?: Application[];
limits?: UseApiDataMap<Limit[]>;
account?: UseApiDataMap<Account>;
ttsCache?: UseApiDataMap<TtsCache>;
};
export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
export const AccountForm = ({
apps,
limits,
account,
ttsCache,
}: AccountFormProps) => {
const navigate = useNavigate();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
@@ -60,6 +76,19 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
const [initialRegHook, setInitialRegHook] = useState(false);
const [initialQueueHook, setInitialQueueHook] = useState(false);
const [localLimits, setLocalLimits] = useState<Limit[]>([]);
const [clearTtsCacheFlag, setClearTtsCacheFlag] = useState(false);
const [recordAllCalls, setRecordAllCalls] = useState(false);
const [initialCheckRecordAllCall, setInitialCheckRecordAllCall] =
useState(false);
const [bucketVendor, setBucketVendor] = useState("");
const [recordFormat, setRecordFormat] = useState("mp3");
const [bucketRegion, setBucketRegion] = useState("us-east-1");
const [bucketName, setBucketName] = useState("");
const [bucketAccessKeyId, setBucketAccessKeyId] = useState("");
const [bucketSecretAccessKey, setBucketSecretAccessKey] = useState("");
const [bucketCredentialChecked, setBucketCredentialChecked] = useState(false);
const [bucketTags, setBucketTags] = useState<AwsTag[]>([]);
const regions = useRegionVendors();
/** This lets us map and render the same UI for each... */
const webhooks = [
@@ -105,6 +134,28 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
setModal(false);
};
const handleTestBucketCredential = (e: React.FormEvent) => {
e.preventDefault();
if (!account || !account.data) return;
const cred: BucketCredential = {
vendor: bucketVendor,
region: bucketRegion,
name: bucketName,
access_key_id: bucketAccessKeyId,
secret_access_key: bucketSecretAccessKey,
};
postAccountBucketCredentialTest(account?.data?.account_sid, cred).then(
({ json }) => {
if (json.status === CRED_OK) {
toastSuccess("Bucket Credential is valid.");
} else {
toastError(json.reason);
}
}
);
};
const handleRefresh = () => {
if (account && account.data) {
getAccountWebhook(account.data.account_sid)
@@ -139,6 +190,20 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
}
};
const handleClearCache = () => {
deleteAccountTtsCache(account?.data?.account_sid || "")
.then(() => {
if (ttsCache) {
ttsCache.refetch();
}
setClearTtsCacheFlag(false);
toastSuccess("Tts Cache successfully cleaned");
})
.catch((error) => {
toastError(error.msg);
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -189,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();
@@ -263,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>
@@ -446,6 +584,228 @@ export const AccountForm = ({ apps, limits, account }: AccountFormProps) => {
</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} />
@@ -476,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

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

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

@@ -33,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,
@@ -96,6 +100,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
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 = [
@@ -178,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) {
@@ -243,6 +249,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
setLocation();
if (application && application.data) {
setApplicationName(application.data.name);
setRecordAllCalls(application.data.record_all_calls ? true : false);
if (!applicationJson) {
setApplicationJson(application.data.app_json || "");
}
@@ -683,6 +690,23 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
</Checkzone>
</fieldset>
)}
{!DISABLE_CALL_RECORDING &&
accounts?.filter((a) => a.account_sid === accountSid).length &&
!accounts?.filter((a) => a.account_sid === accountSid)[0]
.record_all_calls && (
<fieldset>
<label htmlFor="record_all_call" className="chk">
<input
id="record_all_call"
name="record_all_call"
type="checkbox"
onChange={(e) => setRecordAllCalls(e.target.checked)}
checked={recordAllCalls}
/>
<div>Record all calls</div>
</label>
</fieldset>
)}
{message && <fieldset>{<Message message={message} />}</fieldset>}
<fieldset>
<ButtonGroup left>

View File

@@ -776,7 +776,7 @@ export const CarrierForm = ({
Does your carrier require authentication on outbound calls?
</MS>
<label htmlFor="sip_username">
Username {sipPass || sipRegister ? <span>*</span> : ""}
Auth username {sipPass || sipRegister ? <span>*</span> : ""}
</label>
<input
id="sip_username"
@@ -831,7 +831,7 @@ export const CarrierForm = ({
required={sipRegister}
onChange={(e) => setSipRealm(e.target.value)}
/>
<label htmlFor="from_user">SIP from user</label>
<label htmlFor="from_user">Username</label>
<input
id="from_user"
name="from_user"

View File

@@ -8,7 +8,7 @@ import {
} from "src/api";
import { toastError } from "src/store";
import type { Pcap } from "src/api/types";
import type { DownloadedBlob } from "src/api/types";
type PcapButtonProps = {
accountSid: string;
@@ -21,17 +21,18 @@ export const PcapButton = ({
serviceProviderSid,
sipCallId,
}: PcapButtonProps) => {
const [pcap, setPcap] = useState<Pcap>();
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)
: getServiceProviderPcap(serviceProviderSid, sipCallId);
? getPcap(accountSid, sipCallId, "register")
: getServiceProviderPcap(serviceProviderSid, sipCallId, "register");
p1.then(({ blob }) => {
if (blob) {
setPcap({

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

@@ -122,6 +122,7 @@ export const Card = ({
className={`lcr lcr--route lcr-card lcr-card-${
isDragging ? "disappear" : "appear"
}`}
// eslint-disable-next-line react/no-unknown-property
handler-id={handlerId}
>
<div>

View File

@@ -183,7 +183,7 @@ export const Lcrs = () => {
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr.lcr_sid}/edit`}
title="Edit carrier"
title="Edit Client"
>
<Icons.Edit3 />
</Link>

View File

@@ -15,7 +15,7 @@ export const CallDetail = ({ call }: CallDetailProps) => {
<div>{key}:</div>
<div>
{call[key as keyof typeof call]
? call[key as keyof typeof call].toString()
? String(call[key as keyof typeof call])
: "null"}
</div>
</React.Fragment>

View File

@@ -2,8 +2,8 @@ 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 { toastError } from "src/store";
import { RecentCall } from "src/api/types";
import { getSpansFromJaegerRoot } from "./utils";
function useWindowSize() {
const [windowSize, setWindowSize] = useState({
@@ -34,30 +34,6 @@ export const CallTracing = ({ call }: CallTracingProps) => {
const [jaegerGroup, setJaegerGroup] = useState<JaegerGroup>();
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[]) => {
groups.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
return groups.filter((value) => value.parentSpanId === spanId);
@@ -87,6 +63,7 @@ export const CallTracing = ({ call }: CallTracingProps) => {
};
const buildSpans = (root: JaegerRoot) => {
setJaegerRoot(root);
const spans = getSpansFromJaegerRoot(root);
const rootSpan = getRootSpan(spans);
if (rootSpan) {
@@ -142,15 +119,11 @@ export const CallTracing = ({ call }: CallTracingProps) => {
useEffect(() => {
if (call.trace_id && call.trace_id != "00000000000000000000000000000000") {
getJaegerTrace(call.account_sid, call.trace_id)
.then(({ json }) => {
if (json) {
buildSpans(json);
}
})
.catch((error) => {
toastError(error.msg);
});
getJaegerTrace(call.account_sid, call.trace_id).then(({ json }) => {
if (json) {
buildSpans(json);
}
});
}
}, []);

View File

@@ -2,13 +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;
@@ -18,6 +20,12 @@ 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">
<details
@@ -32,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" ? (
@@ -61,27 +69,26 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
</summary>
{call.trace_id === "00000000000000000000000000000000" ||
DISABLE_JAEGER_TRACING ? (
<CallDetail call={call} />
<CallDetail call={transformRecentCall(call)} />
) : (
<Tabs active={[activeTab, setActiveTab]}>
<Tab id="details" label="Details">
<CallDetail call={call} />
<CallDetail call={transformRecentCall(call)} />
</Tab>
<Tab id="tracing" label="Tracing">
<CallTracing call={call} />
{open && <CallTracing call={call} />}
</Tab>
</Tabs>
)}
{open && (
<div
style={{
display: "flex",
justifyContent: "space-between",
width: "300px",
}}
>
<PcapButton call={call} />
</div>
<>
<div className="footer">
{hasValue(call.recording_url) && <Player call={call} />}
<div className="footer__buttons">
<PcapButton call={call} />
</div>
</div>
</>
)}
</details>
</div>

View File

@@ -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,7 @@ export const RecentCalls = () => {
const [dateFilter, setDateFilter] = useState("today");
const [directionFilter, setDirectionFilter] = useState("io");
const [statusFilter, setStatusFilter] = useState("all");
const [filter, setFilter] = useState("");
const [pageNumber, setPageNumber] = useState(1);
const [perPageFilter, setPerPageFilter] = useState("25");
@@ -64,6 +66,7 @@ export const RecentCalls = () => {
: { days: Number(dateFilter) }),
...(statusFilter !== "all" && { answered: statusFilter }),
...(directionFilter !== "io" && { direction: directionFilter }),
...(filter && { filter }),
};
getRecentCalls(accountSid, payload)
@@ -94,7 +97,14 @@ export const RecentCalls = () => {
if (accountSid) {
handleFilterChange();
}
}, [accountSid, pageNumber, dateFilter, directionFilter, statusFilter]);
}, [
accountSid,
pageNumber,
dateFilter,
directionFilter,
statusFilter,
filter,
]);
/** Reset page number when filters change */
useEffect(() => {
@@ -136,6 +146,11 @@ export const RecentCalls = () => {
filter={[statusFilter, setStatusFilter]}
options={statusSelection}
/>
<SearchFilter
placeholder="Filter"
filter={[filter, setFilter]}
delay={1000}
/>
</section>
<Section {...(hasLength(calls) && { slim: true })}>
<div className="list">

View File

@@ -44,14 +44,17 @@ export const JaegerDetail = ({ group }: JaegerDetailProps) => {
.format("DD/MM/YY HH:mm:ss.SSS")}
</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Duration:</strong>
{!(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>
<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">

View File

@@ -3,28 +3,30 @@ import React, { useEffect, useState } from "react";
import { getPcap } from "src/api";
import { toastError } from "src/store";
import type { Pcap, RecentCall } from "src/api/types";
import type { DownloadedBlob, RecentCall } from "src/api/types";
type PcapButtonProps = {
call: RecentCall;
};
export const PcapButton = ({ call }: PcapButtonProps) => {
const [pcap, setPcap] = useState<Pcap>();
const [pcap, setPcap] = useState<DownloadedBlob | null>(null);
useEffect(() => {
getPcap(call.account_sid, call.sip_callid)
.then(({ blob }) => {
if (blob) {
setPcap({
data_url: URL.createObjectURL(blob),
file_name: `callid-${call.sip_callid}.pcap`,
});
}
})
.catch((error) => {
toastError(error.msg);
});
if (!pcap) {
getPcap(call.account_sid, call.sip_callid, "invite")
.then(({ blob }) => {
if (blob) {
setPcap({
data_url: URL.createObjectURL(blob),
file_name: `callid-${call.sip_callid}.pcap`,
});
}
})
.catch((error) => {
toastError(error.msg);
});
}
}, []);
if (pcap) {

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

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

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

View File

@@ -1,22 +1,25 @@
import React, { useEffect, useState } from "react";
import { ButtonGroup, Button } from "@jambonz/ui-kit";
import { ButtonGroup, Button, MS, P } from "@jambonz/ui-kit";
import {
useApiData,
postPasswordSettings,
postSystemInformation,
deleteTtsCache,
} from "src/api";
import { PasswordSettings, SystemInformation } from "src/api/types";
import { PasswordSettings, SystemInformation, TtsCache } from "src/api/types";
import { toastError, toastSuccess } from "src/store";
import { Selector } from "src/components/forms";
import { hasValue } from "src/utils";
import { PASSWORD_LENGTHS_OPTIONS, PASSWORD_MIN } from "src/api/constants";
import { Modal } from "src/components";
export const AdminSettings = () => {
const [passwordSettings, passwordSettingsFetcher] =
useApiData<PasswordSettings>("PasswordSettings");
const [systemInformatin, systemInformationFetcher] =
const [systemInformation, systemInformationFetcher] =
useApiData<SystemInformation>("SystemInformation");
const [ttsCache, ttsCacheFetcher] = useApiData<TtsCache>("TtsCache");
// Min value is 8
const [minPasswordLength, setMinPasswordLength] = useState(PASSWORD_MIN);
const [requireDigit, setRequireDigit] = useState(false);
@@ -24,6 +27,19 @@ export const AdminSettings = () => {
const [domainName, setDomainName] = useState("");
const [sipDomainName, setSipDomainName] = useState("");
const [monitoringDomainName, setMonitoringDomainName] = useState("");
const [clearTtsCacheFlag, setClearTtsCacheFlag] = useState(false);
const handleClearCache = () => {
deleteTtsCache()
.then(() => {
ttsCacheFetcher();
setClearTtsCacheFlag(false);
toastSuccess("Tts Cache successfully cleaned");
})
.catch((error) => {
toastError(error.msg);
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -62,12 +78,12 @@ export const AdminSettings = () => {
setMinPasswordLength(passwordSettings.min_password_length);
}
}
if (hasValue(systemInformatin)) {
setDomainName(systemInformatin.domain_name);
setSipDomainName(systemInformatin.sip_domain_name);
setMonitoringDomainName(systemInformatin.monitoring_domain_name);
if (hasValue(systemInformation)) {
setDomainName(systemInformation.domain_name);
setSipDomainName(systemInformation.sip_domain_name);
setMonitoringDomainName(systemInformation.monitoring_domain_name);
}
}, [passwordSettings, systemInformatin]);
}, [passwordSettings, systemInformation]);
return (
<>
@@ -132,6 +148,23 @@ export const AdminSettings = () => {
<div>Password require special character</div>
</label>
</fieldset>
<fieldset>
<ButtonGroup left>
<Button
onClick={(e: React.FormEvent) => {
e.preventDefault();
setClearTtsCacheFlag(true);
}}
small
disabled={!ttsCache || ttsCache.size === 0}
>
Clear TTS Cache
</Button>
</ButtonGroup>
<MS>{`There are ${
ttsCache ? ttsCache.size : 0
} cached TTS prompts`}</MS>
</fieldset>
<fieldset>
<ButtonGroup left>
<Button onClick={handleSubmit} small>
@@ -139,6 +172,14 @@ export const AdminSettings = () => {
</Button>
</ButtonGroup>
</fieldset>
{clearTtsCacheFlag && (
<Modal
handleSubmit={handleClearCache}
handleCancel={() => setClearTtsCacheFlag(false)}
>
<P>Are you sure you want to clean TTS cache?</P>
</Modal>
)}
</>
);
};

View File

@@ -17,7 +17,6 @@ import {
MSG_SOMETHING_WRONG,
MSG_CAPSLOCK,
MSG_PASSWD_MATCH,
MSG_PASSWD_CRITERIA,
} from "src/constants";
import type { IMessage } from "src/store/types";
@@ -50,7 +49,22 @@ export const CreatePassword = () => {
}
if (passwdSettings && !isValidPasswd(password, passwdSettings)) {
setMessage(MSG_PASSWD_CRITERIA);
setMessage(
<>
Password must:
<ul>
<li>
Be at least {passwdSettings.min_password_length} characters long
</li>
{passwdSettings.require_digit && (
<li>Contain at least one number</li>
)}
{passwdSettings.require_special_character && (
<li>Contain at least one special character</li>
)}
</ul>
</>
);
return;
}

View File

@@ -42,6 +42,9 @@ import MSTeamsTenantsEdit from "src/containers/internal/views/ms-teams-tenants/e
import Lcrs from "src/containers/internal/views/least-cost-routing";
import LcrsAdd from "src/containers/internal/views/least-cost-routing/add";
import LcrsEdit from "src/containers/internal/views/least-cost-routing/edit";
import Clients from "src/containers/internal/views/clients";
import ClientsAdd from "src/containers/internal/views/clients/add";
import ClientsEdit from "src/containers/internal/views/clients/edit";
export const Router = () => {
const toast = useSelectState("toast");
@@ -138,6 +141,13 @@ export const Router = () => {
element={<LcrsEdit />}
/>
<Route path="clients" element={<Clients />} />
<Route path="clients/add" element={<ClientsAdd />} />
<Route
path="clients/:client_sid/edit"
element={<ClientsEdit />}
/>
{/* 404 page not found */}
<Route path="*" element={<NotFound />} />
</Route>

View File

@@ -4,6 +4,7 @@ export const ROUTE_FORGOT_PASSWORD = "/forgot-password";
export const ROUTE_INTERNAL_USERS = "/internal/users";
export const ROUTE_INTERNAL_SETTINGS = "/internal/settings";
export const ROUTE_INTERNAL_ACCOUNTS = "/internal/accounts";
export const ROUTE_INTERNAL_CLIENTS = "/internal/clients";
export const ROUTE_INTERNAL_APPLICATIONS = "/internal/applications";
export const ROUTE_INTERNAL_RECENT_CALLS = "/internal/recent-calls";
export const ROUTE_INTERNAL_ALERTS = "/internal/alerts";

View File

@@ -287,3 +287,7 @@ fieldset {
}
}
}
.bucket_tag {
@extend .lcr;
}

View File

@@ -28,6 +28,8 @@ export const languages: VoiceLanguage[] = [
voices: [
{ value: "da-DK-Standard-A", name: "Standard-A (Female)" },
{ value: "da-DK-Wavenet-A", name: "Wavenet-A (Female)" },
{ value: "da-DK-Neural2-D", name: "Neural2-D (Female)" },
{ value: "da-DK-Neural2-F", name: "Neural2-F (Male)" },
],
},
{
@@ -58,6 +60,10 @@ export const languages: VoiceLanguage[] = [
{ value: "en-AU-Wavenet-B", name: "Wavenet-B (Male)" },
{ value: "en-AU-Wavenet-C", name: "Wavenet-C (Female)" },
{ value: "en-AU-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "en-AU-Neural2-A", name: "Neural2-A (Female)" },
{ value: "en-AU-Neural2-B", name: "Neural2-B (Male)" },
{ value: "en-AU-Neural2-C", name: "Neural2-C (Female)" },
{ value: "en-AU-Neural2-D", name: "Neural2-D (Male)" },
],
},
{
@@ -86,6 +92,11 @@ export const languages: VoiceLanguage[] = [
{ value: "en-GB-Wavenet-B", name: "Wavenet-B (Male)" },
{ value: "en-GB-Wavenet-C", name: "Wavenet-C (Female)" },
{ value: "en-GB-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "en-GB-Neural2-A", name: "Neural2-A (Female)" },
{ value: "en-GB-Neural2-B", name: "Neural2-B (Male)" },
{ value: "en-GB-Neural2-C", name: "Neural2-C (Female)" },
{ value: "en-GB-Neural2-D", name: "Neural2-D (Male)" },
{ value: "en-GB-Neural2-F", name: "Neural2-F (Female)" },
],
},
{
@@ -102,6 +113,17 @@ export const languages: VoiceLanguage[] = [
{ value: "en-US-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "en-US-Wavenet-E", name: "Wavenet-E (Female)" },
{ value: "en-US-Wavenet-F", name: "Wavenet-F (Female)" },
{ value: "en-US-Neural2-A", name: "Neural2-A (Male)" },
{ value: "en-US-Neural2-C", name: "Neural2-C (Female)" },
{ value: "en-US-Neural2-D", name: "Neural2-D (Male)" },
{ value: "en-US-Neural2-E", name: "Neural2-E (Female)" },
{ value: "en-US-Neural2-F", name: "Neural2-F (Female)" },
{ value: "en-US-Neural2-G", name: "Neural2-G (Female)" },
{ value: "en-US-Neural2-H", name: "Neural2-H (Female)" },
{ value: "en-US-Neural2-I", name: "Neural2-I (Male)" },
{ value: "en-US-Neural2-J", name: "Neural2-J (Male)" },
{ value: "en-US-Studio-M", name: "Studio-M (Male)" },
{ value: "en-US-Studio-O", name: "Studio-M (Female)" },
],
},
{
@@ -110,6 +132,8 @@ export const languages: VoiceLanguage[] = [
voices: [
{ value: "fil-PH-Standard-A", name: "Standard-A (Female)" },
{ value: "fil-PH-Wavenet-A", name: "Wavenet-A (Female)" },
{ value: "fil-ph-Neural2-A", name: "Neural2-A (Female)" },
{ value: "fil-ph-Neural2-D", name: "Neural2-A (Male)" },
],
},
{
@@ -132,6 +156,10 @@ export const languages: VoiceLanguage[] = [
{ value: "fr-CA-Wavenet-B", name: "Wavenet-B (Male)" },
{ value: "fr-CA-Wavenet-C", name: "Wavenet-C (Female)" },
{ value: "fr-CA-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "fr-CA-Neural2-A", name: "Neural2-A (Female)" },
{ value: "fr-CA-Neural2-B", name: "Neural2-B (Male)" },
{ value: "fr-CA-Neural2-C", name: "Neural2-C (Female)" },
{ value: "fr-CA-Neural2-D", name: "Neural2-D (Male)" },
],
},
{
@@ -148,6 +176,11 @@ export const languages: VoiceLanguage[] = [
{ value: "fr-FR-Wavenet-C", name: "Wavenet-C (Female)" },
{ value: "fr-FR-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "fr-FR-Wavenet-E", name: "Wavenet-E (Female)" },
{ value: "fr-FR-Neural2-A", name: "Neural2-A (Female)" },
{ value: "fr-FR-Neural2-B", name: "Neural2-B (Male)" },
{ value: "fr-FR-Neural2-C", name: "Neural2-C (Female)" },
{ value: "fr-FR-Neural2-D", name: "Neural2-D (Male)" },
{ value: "fr-FR-Neural2-E", name: "Neural2-E (Female)" },
],
},
{
@@ -164,6 +197,10 @@ export const languages: VoiceLanguage[] = [
{ value: "de-DE-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "de-DE-Wavenet-E", name: "Wavenet-E (Male)" },
{ value: "de-DE-Wavenet-F", name: "Wavenet-F (Female)" },
{ value: "de-DE-Neural2-B", name: "Neural2-B (Male)" },
{ value: "de-DE-Neural2-C", name: "Neural2-C (Female)" },
{ value: "de-DE-Neural2-D", name: "Neural2-D (Male)" },
{ value: "de-DE-Neural2-F", name: "Neural2-F (Female)" },
],
},
{
@@ -186,6 +223,10 @@ export const languages: VoiceLanguage[] = [
{ value: "hi-IN-Wavenet-B", name: "Wavenet-B (Male)" },
{ value: "hi-IN-Wavenet-C", name: "Wavenet-C (Male)" },
{ value: "hi-IN-Wavenet-D", name: "Wavenet-D (Female)" },
{ value: "hi-IN-Neural2-A", name: "Neural2-A (Female)" },
{ value: "hi-IN-Neural2-B", name: "Neural2-B (Male)" },
{ value: "hi-IN-Neural2-C", name: "Neural2-C (Male)" },
{ value: "hi-IN-Neural2-D", name: "Neural2-D (Female)" },
],
},
{
@@ -222,6 +263,8 @@ export const languages: VoiceLanguage[] = [
{ value: "it-IT-Wavenet-B", name: "Wavenet-B (Female)" },
{ value: "it-IT-Wavenet-C", name: "Wavenet-C (Male)" },
{ value: "it-IT-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "it-IT-Neural2-A", name: "Neural2-A (Female)" },
{ value: "it-IT-Neural2-C", name: "Neural2-C (Male)" },
],
},
{
@@ -236,6 +279,9 @@ export const languages: VoiceLanguage[] = [
{ value: "ja-JP-Wavenet-B", name: "Wavenet-B (Female)" },
{ value: "ja-JP-Wavenet-C", name: "Wavenet-C (Male)" },
{ value: "ja-JP-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "ja-JP-Neural2-B", name: "Neural2-B (Female)" },
{ value: "ja-JP-Neural2-C", name: "Neural2-C (Male)" },
{ value: "ja-JP-Neural2-D", name: "Neural2-D (Male)" },
],
},
{
@@ -250,6 +296,9 @@ export const languages: VoiceLanguage[] = [
{ value: "ko-KR-Wavenet-B", name: "Wavenet-B (Female)" },
{ value: "ko-KR-Wavenet-C", name: "Wavenet-C (Male)" },
{ value: "ko-KR-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "ko-KR-Neural2-A", name: "Neural2-A (Female)" },
{ value: "ko-KR-Neural2-B", name: "Neural2-B (Female)" },
{ value: "ko-KR-Neural2-C", name: "Neural2-C (Male)" },
],
},
{
@@ -316,6 +365,9 @@ export const languages: VoiceLanguage[] = [
voices: [
{ value: "pt-BR-Standard-A", name: "Standard-A (Female)" },
{ value: "pt-BR-Wavenet-A", name: "Wavenet-A (Female)" },
{ value: "pt-BR-Neural2-A", name: "Neural2-A (Female)" },
{ value: "pt-BR-Neural2-B", name: "Neural2-B (Male)" },
{ value: "pt-BR-Neural2-C", name: "Neural2-C (Female)" },
],
},
{
@@ -359,7 +411,25 @@ export const languages: VoiceLanguage[] = [
{
code: "es-ES",
name: "Spanish (Spain)",
voices: [{ value: "es-ES-Standard-A", name: "Standard-A (Female)" }],
voices: [
{ value: "es-ES-Standard-A", name: "Standard-A (Female)" },
{ value: "es-ES-Neural2-A", name: "Neural2-A (Female)" },
{ value: "es-ES-Neural2-B", name: "Neural2-B (Male)" },
{ value: "es-ES-Neural2-C", name: "Neural2-C (Female)" },
{ value: "es-ES-Neural2-D", name: "Neural2-D (Female)" },
{ value: "es-ES-Neural2-E", name: "Neural2-E (Female)" },
{ value: "es-ES-Neural2-F", name: "Neural2-F (Male)" },
],
},
{
code: "es-US",
name: "Spanish (US)",
voices: [
{ value: "es-US-Neural2-A", name: "Neural2-A (Female)" },
{ value: "es-US-Neural2-B", name: "Neural2-B (Male)" },
{ value: "es-US-Neural2-C", name: "Neural2-C (Male)" },
{ value: "es-US-Studio-B", name: "Studio-B (Male)" },
],
},
{
code: "sv-SE",
@@ -393,6 +463,11 @@ export const languages: VoiceLanguage[] = [
{ value: "uk-UA-Wavenet-A", name: "Wavenet-A (Female)" },
],
},
{
code: "th-TH",
name: "Thai (Thailand)",
voices: [{ value: "th-TH-Neural2-C", name: "Neural2-C (Female)" }],
},
{
code: "vi-VN",
name: "Vietnamese (Vietnam)",
@@ -405,6 +480,8 @@ export const languages: VoiceLanguage[] = [
{ value: "vi-VN-Wavenet-B", name: "Wavenet-B (Male)" },
{ value: "vi-VN-Wavenet-C", name: "Wavenet-C (Female)" },
{ value: "vi-VN-Wavenet-D", name: "Wavenet-D (Male)" },
{ value: "vi-VN-Neural2-A", name: "Neural2-A (Female)" },
{ value: "vi-VN-Neural2-D", name: "Neural2-D (Male)" },
],
},
];