support applicatin env vars (#509)

* support applicatin env vars

* wip

* wip

* wip

* wip

* wip
This commit is contained in:
Hoan Luu Huu
2025-05-08 19:41:05 +07:00
committed by GitHub
parent db3a0cc646
commit 70a0c2d7b2
5 changed files with 165 additions and 3 deletions

2
.env
View File

@@ -1,4 +1,4 @@
# VITE_API_BASE_URL=http://127.0.0.1:3000/v1 #VITE_API_BASE_URL=http://127.0.0.1:3000/v1
#VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1 #VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
## enables choosing units and lisenced account call limits ## enables choosing units and lisenced account call limits

View File

@@ -447,3 +447,4 @@ export const API_SUBSCRIPTIONS = `${API_BASE_URL}/Subscriptions`;
export const API_CHANGE_PASSWORD = `${API_BASE_URL}/change-password`; export const API_CHANGE_PASSWORD = `${API_BASE_URL}/change-password`;
export const API_SIGNIN = `${API_BASE_URL}/signin`; export const API_SIGNIN = `${API_BASE_URL}/signin`;
export const API_GOOGLE_CUSTOM_VOICES = `${API_BASE_URL}/GoogleCustomVoices`; export const API_GOOGLE_CUSTOM_VOICES = `${API_BASE_URL}/GoogleCustomVoices`;
export const API_APP_ENV = `${API_BASE_URL}/AppEnv`;

View File

@@ -34,6 +34,7 @@ import {
API_CHANGE_PASSWORD, API_CHANGE_PASSWORD,
API_SIGNIN, API_SIGNIN,
API_GOOGLE_CUSTOM_VOICES, API_GOOGLE_CUSTOM_VOICES,
API_APP_ENV,
} from "./constants"; } from "./constants";
import { ROUTE_LOGIN } from "src/router/routes"; import { ROUTE_LOGIN } from "src/router/routes";
import { import {
@@ -94,6 +95,7 @@ import type {
GoogleCustomVoice, GoogleCustomVoice,
GoogleCustomVoicesQuery, GoogleCustomVoicesQuery,
SpeechSupportedLanguagesAndVoices, SpeechSupportedLanguagesAndVoices,
AppEnv,
} from "./types"; } from "./types";
import { Availability, StatusCodes } from "./types"; import { Availability, StatusCodes } from "./types";
import { JaegerRoot } from "./jaeger-types"; import { JaegerRoot } from "./jaeger-types";
@@ -812,6 +814,11 @@ export const getGoogleCustomVoices = (
const qryStr = getQuery<Partial<GoogleCustomVoicesQuery>>(query); const qryStr = getQuery<Partial<GoogleCustomVoicesQuery>>(query);
return getFetch<GoogleCustomVoice[]>(`${API_GOOGLE_CUSTOM_VOICES}?${qryStr}`); return getFetch<GoogleCustomVoice[]>(`${API_GOOGLE_CUSTOM_VOICES}?${qryStr}`);
}; };
// ENV VARS
export const getAppEnvSchema = (url: string) => {
return getFetch<AppEnv>(`${API_APP_ENV}?url=${url}`);
};
/** Wrappers for APIs that can have a mock dev server response */ /** Wrappers for APIs that can have a mock dev server response */

View File

@@ -338,6 +338,7 @@ export interface Application {
fallback_speech_recognizer_vendor: null | string; fallback_speech_recognizer_vendor: null | string;
fallback_speech_recognizer_language: null | string; fallback_speech_recognizer_language: null | string;
fallback_speech_recognizer_label: null | string; fallback_speech_recognizer_label: null | string;
env_vars: null | string;
} }
export interface PhoneNumber { export interface PhoneNumber {
@@ -781,3 +782,14 @@ export interface CartesiaOptions {
speed: number; speed: number;
emotion: CartesiaEmotions; emotion: CartesiaEmotions;
} }
export interface AppEnvProperty {
description: string;
type: string;
required?: boolean;
default?: string | number | boolean;
}
export interface AppEnv {
[key: string]: AppEnvProperty;
}

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit"; import { Button, ButtonGroup, MS } 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";
import { ClipBoard, Section } from "src/components"; import { ClipBoard, Section, Tooltip } from "src/components";
import { import {
Selector, Selector,
Checkzone, Checkzone,
@@ -23,6 +23,7 @@ import {
putApplication, putApplication,
useServiceProviderData, useServiceProviderData,
useApiData, useApiData,
getAppEnvSchema,
} from "src/api"; } from "src/api";
import { import {
ROUTE_INTERNAL_ACCOUNTS, ROUTE_INTERNAL_ACCOUNTS,
@@ -48,6 +49,7 @@ import type {
WebhookMethod, WebhookMethod,
UseApiDataMap, UseApiDataMap,
SpeechCredential, SpeechCredential,
AppEnv,
} from "src/api/types"; } from "src/api/types";
import { MSG_REQUIRED_FIELDS, MSG_WEBHOOK_FIELDS } from "src/constants"; import { MSG_REQUIRED_FIELDS, MSG_WEBHOOK_FIELDS } from "src/constants";
import { hasLength, isUserAccountScope, useRedirect } from "src/utils"; import { hasLength, isUserAccountScope, useRedirect } from "src/utils";
@@ -122,6 +124,12 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
useState(""); useState("");
const [initalCheckFallbackSpeech, setInitalCheckFallbackSpeech] = const [initalCheckFallbackSpeech, setInitalCheckFallbackSpeech] =
useState(false); useState(false);
const [appEnv, setAppEnv] = useState<AppEnv | null>(null);
const appEnvTimeoutRef = useRef<number | null>(null);
const [envVars, setEnvVars] = useState<Record<
string,
string | number | boolean
> | null>(null);
/** 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 = [
@@ -134,6 +142,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
tmpStateSet: setTmpCallWebhook, tmpStateSet: setTmpCallWebhook,
initialCheck: initialCallWebhook, initialCheck: initialCallWebhook,
required: true, required: true,
webhookEnv: appEnv,
}, },
{ {
label: "Call status", label: "Call status",
@@ -197,6 +206,15 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
speech_recognizer_label: recogLabel || null, speech_recognizer_label: recogLabel || null,
record_all_calls: recordAllCalls ? 1 : 0, record_all_calls: recordAllCalls ? 1 : 0,
use_for_fallback_speech: useForFallbackSpeech ? 1 : 0, use_for_fallback_speech: useForFallbackSpeech ? 1 : 0,
env_vars: envVars
? JSON.stringify(
Object.keys(envVars).reduce(
(acc, key) =>
appEnv && appEnv[key] ? { ...acc, [key]: envVars[key] } : acc,
{},
),
)
: null,
fallback_speech_synthesis_vendor: useForFallbackSpeech fallback_speech_synthesis_vendor: useForFallbackSpeech
? fallbackSpeechSynthsisVendor || null ? fallbackSpeechSynthsisVendor || null
: null, : null,
@@ -513,6 +531,9 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
application.data.fallback_speech_synthesis_voice, application.data.fallback_speech_synthesis_voice,
); );
} }
if (application.data.env_vars) {
setEnvVars(JSON.parse(application.data.env_vars));
}
} }
}, [application]); }, [application]);
@@ -548,6 +569,33 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
setFallbackSpeechRecognizerLabel(tmp); setFallbackSpeechRecognizerLabel(tmp);
}; };
useEffect(() => {
if (callWebhook && callWebhook.url) {
// Clear any existing timeout to prevent multiple requests
if (appEnvTimeoutRef.current) {
clearTimeout(appEnvTimeoutRef.current);
appEnvTimeoutRef.current = null;
}
appEnvTimeoutRef.current = setTimeout(() => {
getAppEnvSchema(callWebhook.url)
.then(({ json }) => {
setAppEnv(json);
})
.catch((error) => {
setMessage(error.msg);
});
}, 500);
}
return () => {
if (appEnvTimeoutRef.current) {
clearTimeout(appEnvTimeoutRef.current);
appEnvTimeoutRef.current = null;
}
};
}, [callWebhook]);
return ( return (
<Section slim> <Section slim>
<form <form
@@ -699,6 +747,100 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
}} }}
/> />
</Checkzone> </Checkzone>
{webhook.webhookEnv &&
Object.keys(webhook.webhookEnv).length > 0 && (
<>
{Object.keys(webhook.webhookEnv).map((key) => {
const envType = webhook.webhookEnv![key].type;
const isBoolean = envType === "boolean";
const isNumber = envType === "number";
const defaultValue = webhook.webhookEnv![key].default;
return (
<div className="inp" key={key}>
{isBoolean ? (
// Boolean input as checkbox
<label htmlFor={`env_${key}`} className="chk">
<input
id={`env_${key}`}
type="checkbox"
name={`env_${key}`}
required={webhook.webhookEnv![key].required}
checked={
envVars && envVars[key] !== undefined
? Boolean(envVars[key])
: Boolean(defaultValue)
}
onChange={(e) => {
setEnvVars({
...(envVars || {}),
[key]: e.target.checked,
});
}}
/>
<Tooltip
text={webhook.webhookEnv![key].description}
>
{key}
{webhook.webhookEnv![key].required && (
<span>*</span>
)}
</Tooltip>
</label>
) : (
// Text or number input
<>
<label htmlFor={`env_${key}`}>
<Tooltip
text={webhook.webhookEnv![key].description}
>
{key}
{webhook.webhookEnv![key].required && (
<span>*</span>
)}
</Tooltip>
</label>
<input
id={`env_${key}`}
type={isNumber ? "number" : "text"}
name={`env_${key}`}
placeholder={
webhook.webhookEnv![key].description
}
required={webhook.webhookEnv![key].required}
value={
envVars && envVars[key] !== undefined
? String(envVars[key])
: defaultValue !== undefined
? String(defaultValue)
: ""
}
onChange={(e) => {
// Convert to proper type based on schema
let newValue;
if (isNumber) {
newValue =
e.target.value === ""
? ""
: Number(e.target.value);
} else {
newValue = e.target.value;
}
setEnvVars({
...(envVars || {}),
[key]: newValue,
});
}}
/>
</>
)}
</div>
);
})}
</>
)}
</fieldset> </fieldset>
); );
})} })}