1040 lines
40 KiB
TypeScript
1040 lines
40 KiB
TypeScript
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
|
import { deesCatalog } from '../plugins.js';
|
|
import { appState, type IAppState } from '../state/appstate.js';
|
|
import { viewHostCss } from './shared/index.js';
|
|
import type { IIncomingNumberConfig, ISipRoute } from '../../ts/config.ts';
|
|
|
|
const { DeesModal, DeesToast } = deesCatalog;
|
|
|
|
const CUSTOM_REGEX_KEY = '__custom_regex__';
|
|
const CUSTOM_COUNTRY_CODE_KEY = '__custom_country_code__';
|
|
|
|
function clone<T>(value: T): T {
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
|
|
function createRoute(): ISipRoute {
|
|
return {
|
|
id: `route-${Date.now()}`,
|
|
name: '',
|
|
priority: 0,
|
|
enabled: true,
|
|
match: { direction: 'outbound' },
|
|
action: {},
|
|
};
|
|
}
|
|
|
|
function createIncomingNumber(): IIncomingNumberConfig {
|
|
return {
|
|
id: `incoming-${Date.now()}`,
|
|
label: '',
|
|
mode: 'single',
|
|
countryCode: '+49',
|
|
areaCode: '',
|
|
localNumber: '',
|
|
};
|
|
}
|
|
|
|
function normalizeCountryCode(value?: string): string {
|
|
const trimmed = (value || '').trim();
|
|
if (!trimmed) return '';
|
|
const digits = trimmed.replace(/\D/g, '');
|
|
return digits ? `+${digits}` : '';
|
|
}
|
|
|
|
function normalizeNumberPart(value?: string): string {
|
|
return (value || '').replace(/\D/g, '');
|
|
}
|
|
|
|
function escapeRegex(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function anyDigits(length: number): string {
|
|
if (length <= 0) return '';
|
|
if (length === 1) return '\\d';
|
|
return `\\d{${length}}`;
|
|
}
|
|
|
|
function digitClass(start: number, end: number): string {
|
|
if (start === end) return String(start);
|
|
if (end === start + 1) return `[${start}${end}]`;
|
|
return `[${start}-${end}]`;
|
|
}
|
|
|
|
function unique(values: string[]): string[] {
|
|
return Array.from(new Set(values));
|
|
}
|
|
|
|
function buildRangeAlternatives(min: string, max: string): string[] {
|
|
if (min.length !== max.length || !/^\d*$/.test(min) || !/^\d*$/.test(max)) {
|
|
return [];
|
|
}
|
|
if (min.length === 0) {
|
|
return [''];
|
|
}
|
|
if (min === max) {
|
|
return [min];
|
|
}
|
|
if (/^0+$/.test(min) && /^9+$/.test(max)) {
|
|
return [anyDigits(min.length)];
|
|
}
|
|
|
|
let index = 0;
|
|
while (index < min.length && min[index] === max[index]) {
|
|
index += 1;
|
|
}
|
|
|
|
const prefix = min.slice(0, index);
|
|
const lowDigit = Number(min[index]);
|
|
const highDigit = Number(max[index]);
|
|
const restLength = min.length - index - 1;
|
|
const values: string[] = [];
|
|
|
|
values.push(
|
|
...buildRangeAlternatives(min.slice(index + 1), '9'.repeat(restLength)).map(
|
|
(suffix) => `${prefix}${lowDigit}${suffix}`,
|
|
),
|
|
);
|
|
|
|
if (highDigit - lowDigit > 1) {
|
|
values.push(`${prefix}${digitClass(lowDigit + 1, highDigit - 1)}${anyDigits(restLength)}`);
|
|
}
|
|
|
|
values.push(
|
|
...buildRangeAlternatives('0'.repeat(restLength), max.slice(index + 1)).map(
|
|
(suffix) => `${prefix}${highDigit}${suffix}`,
|
|
),
|
|
);
|
|
|
|
return unique(values);
|
|
}
|
|
|
|
function buildRangePattern(min: string, max: string): string {
|
|
const alternatives = buildRangeAlternatives(min, max).filter(Boolean);
|
|
if (!alternatives.length) return '';
|
|
if (alternatives.length === 1) return alternatives[0];
|
|
return `(?:${alternatives.join('|')})`;
|
|
}
|
|
|
|
function validateLocalRange(start?: string, end?: string): string | null {
|
|
const normalizedStart = normalizeNumberPart(start);
|
|
const normalizedEnd = normalizeNumberPart(end);
|
|
if (!normalizedStart || !normalizedEnd) {
|
|
return 'Range start and end are required';
|
|
}
|
|
if (normalizedStart.length !== normalizedEnd.length) {
|
|
return 'Range start and end must have the same length';
|
|
}
|
|
if (normalizedStart > normalizedEnd) {
|
|
return 'Range start must be less than or equal to range end';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getIncomingNumberPattern(entry: IIncomingNumberConfig): string {
|
|
if (entry.mode === 'regex') {
|
|
return (entry.pattern || '').trim();
|
|
}
|
|
|
|
const countryCode = normalizeCountryCode(entry.countryCode);
|
|
const areaCode = normalizeNumberPart(entry.areaCode);
|
|
const localNumber = normalizeNumberPart(entry.localNumber);
|
|
if (!countryCode || !areaCode || !localNumber) {
|
|
return '';
|
|
}
|
|
|
|
let localPattern = escapeRegex(localNumber);
|
|
if (entry.mode === 'range') {
|
|
const rangeEnd = normalizeNumberPart(entry.rangeEnd);
|
|
if (!rangeEnd || validateLocalRange(localNumber, rangeEnd)) {
|
|
return '';
|
|
}
|
|
localPattern = buildRangePattern(localNumber, rangeEnd);
|
|
}
|
|
|
|
const countryDigits = countryCode.slice(1);
|
|
const nationalArea = areaCode.startsWith('0') ? areaCode : `0${areaCode}`;
|
|
const internationalArea = areaCode.replace(/^0+/, '') || areaCode;
|
|
return `/^(?:\\+${countryDigits}${internationalArea}|${nationalArea})${localPattern}$/`;
|
|
}
|
|
|
|
function describeIncomingNumber(entry: IIncomingNumberConfig): string {
|
|
if (entry.mode === 'regex') {
|
|
return (entry.pattern || '').trim() || '(regex missing)';
|
|
}
|
|
|
|
const countryCode = normalizeCountryCode(entry.countryCode) || '+?';
|
|
const areaCode = normalizeNumberPart(entry.areaCode) || '?';
|
|
const localNumber = normalizeNumberPart(entry.localNumber) || '?';
|
|
if (entry.mode === 'range') {
|
|
const rangeEnd = normalizeNumberPart(entry.rangeEnd) || '?';
|
|
return `${countryCode} / ${areaCode} / ${localNumber}-${rangeEnd}`;
|
|
}
|
|
return `${countryCode} / ${areaCode} / ${localNumber}`;
|
|
}
|
|
|
|
function describeRouteAction(route: ISipRoute): string {
|
|
const action = route.action;
|
|
if (route.match.direction === 'outbound') {
|
|
const parts: string[] = [];
|
|
if (action.provider) parts.push(`-> ${action.provider}`);
|
|
if (action.failoverProviders?.length) parts.push(`(failover: ${action.failoverProviders.join(', ')})`);
|
|
if (action.stripPrefix) parts.push(`strip: ${action.stripPrefix}`);
|
|
if (action.prependPrefix) parts.push(`prepend: ${action.prependPrefix}`);
|
|
return parts.join(' ');
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
if (action.ivrMenuId) {
|
|
parts.push(`ivr: ${action.ivrMenuId}`);
|
|
} else if (action.targets?.length) {
|
|
parts.push(`ring: ${action.targets.join(', ')}`);
|
|
} else {
|
|
parts.push('ring: all devices');
|
|
}
|
|
if (action.ringBrowsers) parts.push('+ browsers');
|
|
if (action.voicemailBox) parts.push(`vm: ${action.voicemailBox}`);
|
|
if (action.noAnswerTimeout) parts.push(`timeout: ${action.noAnswerTimeout}s`);
|
|
return parts.join(' ');
|
|
}
|
|
|
|
@customElement('sipproxy-view-routes')
|
|
export class SipproxyViewRoutes extends DeesElement {
|
|
@state() accessor appData: IAppState = appState.getState();
|
|
@state() accessor config: any = null;
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
viewHostCss,
|
|
css`
|
|
.view-section { margin-bottom: 24px; }
|
|
`,
|
|
];
|
|
|
|
async connectedCallback(): Promise<void> {
|
|
await super.connectedCallback();
|
|
appState.subscribe((state) => { this.appData = state; });
|
|
await this.loadConfig();
|
|
}
|
|
|
|
private async loadConfig(): Promise<void> {
|
|
try {
|
|
this.config = await appState.apiGetConfig();
|
|
} catch {
|
|
// Show empty state.
|
|
}
|
|
}
|
|
|
|
private getRoutes(): ISipRoute[] {
|
|
return this.config?.routing?.routes || [];
|
|
}
|
|
|
|
private getIncomingNumbers(): IIncomingNumberConfig[] {
|
|
return this.config?.incomingNumbers || [];
|
|
}
|
|
|
|
private getCountryCodeOptions(extraCode?: string): Array<{ option: string; key: string }> {
|
|
const codes = new Set<string>(['+49']);
|
|
for (const entry of this.getIncomingNumbers()) {
|
|
const countryCode = normalizeCountryCode(entry.countryCode);
|
|
if (countryCode) codes.add(countryCode);
|
|
}
|
|
const normalizedExtra = normalizeCountryCode(extraCode);
|
|
if (normalizedExtra) codes.add(normalizedExtra);
|
|
|
|
return [
|
|
...Array.from(codes).sort((a, b) => a.localeCompare(b)).map((code) => ({ option: code, key: code })),
|
|
{ option: 'Custom', key: CUSTOM_COUNTRY_CODE_KEY },
|
|
];
|
|
}
|
|
|
|
private getProviderLabel(providerId?: string): string {
|
|
if (!providerId) return '(any)';
|
|
const provider = (this.config?.providers || []).find((item: any) => item.id === providerId);
|
|
return provider?.displayName || providerId;
|
|
}
|
|
|
|
private findIncomingNumberForRoute(route: ISipRoute): IIncomingNumberConfig | undefined {
|
|
if (route.match.direction !== 'inbound' || !route.match.numberPattern) {
|
|
return undefined;
|
|
}
|
|
|
|
return this.getIncomingNumbers().find((entry) => {
|
|
if (getIncomingNumberPattern(entry) !== route.match.numberPattern) {
|
|
return false;
|
|
}
|
|
if (entry.providerId && route.match.sourceProvider && entry.providerId !== route.match.sourceProvider) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
private countRoutesUsingIncomingNumber(entry: IIncomingNumberConfig): number {
|
|
const pattern = getIncomingNumberPattern(entry);
|
|
return this.getRoutes().filter((route) => {
|
|
if (route.match.direction !== 'inbound' || route.match.numberPattern !== pattern) {
|
|
return false;
|
|
}
|
|
if (entry.providerId && route.match.sourceProvider && entry.providerId !== route.match.sourceProvider) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}).length;
|
|
}
|
|
|
|
private async saveRoutes(routes: ISipRoute[]): Promise<boolean> {
|
|
const result = await appState.apiSaveConfig({ routing: { routes } });
|
|
if (!result.ok) return false;
|
|
await this.loadConfig();
|
|
return true;
|
|
}
|
|
|
|
private async saveIncomingNumbers(incomingNumbers: IIncomingNumberConfig[]): Promise<boolean> {
|
|
const result = await appState.apiSaveConfig({ incomingNumbers });
|
|
if (!result.ok) return false;
|
|
await this.loadConfig();
|
|
return true;
|
|
}
|
|
|
|
public render(): TemplateResult {
|
|
const routes = this.getRoutes();
|
|
const incomingNumbers = [...this.getIncomingNumbers()].sort((a, b) => a.label.localeCompare(b.label));
|
|
const sortedRoutes = [...routes].sort((a, b) => b.priority - a.priority);
|
|
|
|
const tiles: any[] = [
|
|
{
|
|
id: 'incoming-numbers',
|
|
title: 'Incoming Numbers',
|
|
value: incomingNumbers.length,
|
|
type: 'number',
|
|
icon: 'lucide:phoneIncoming',
|
|
description: 'Managed DIDs and regexes',
|
|
},
|
|
{
|
|
id: 'total-routes',
|
|
title: 'Total Routes',
|
|
value: routes.length,
|
|
type: 'number',
|
|
icon: 'lucide:route',
|
|
description: `${routes.filter((route) => route.enabled).length} active`,
|
|
},
|
|
{
|
|
id: 'inbound-routes',
|
|
title: 'Inbound Routes',
|
|
value: routes.filter((route) => route.match.direction === 'inbound').length,
|
|
type: 'number',
|
|
icon: 'lucide:phoneCall',
|
|
description: 'Incoming call routing rules',
|
|
},
|
|
{
|
|
id: 'outbound-routes',
|
|
title: 'Outbound Routes',
|
|
value: routes.filter((route) => route.match.direction === 'outbound').length,
|
|
type: 'number',
|
|
icon: 'lucide:phoneOutgoing',
|
|
description: 'Outgoing call routing rules',
|
|
},
|
|
];
|
|
|
|
return html`
|
|
<div class="view-section">
|
|
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
|
</div>
|
|
|
|
<div class="view-section">
|
|
<dees-table
|
|
heading1="Incoming Numbers"
|
|
heading2="${incomingNumbers.length} managed"
|
|
dataName="incomingNumbers"
|
|
.data=${incomingNumbers}
|
|
.rowKey=${'id'}
|
|
.columns=${this.getIncomingNumberColumns()}
|
|
.dataActions=${this.getIncomingNumberActions()}
|
|
></dees-table>
|
|
</div>
|
|
|
|
<div class="view-section">
|
|
<dees-table
|
|
heading1="Call Routes"
|
|
heading2="${routes.length} configured"
|
|
dataName="routes"
|
|
.data=${sortedRoutes}
|
|
.rowKey=${'id'}
|
|
.columns=${this.getRouteColumns()}
|
|
.dataActions=${this.getRouteActions()}
|
|
></dees-table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private getIncomingNumberColumns() {
|
|
return [
|
|
{
|
|
key: 'label',
|
|
header: 'Label',
|
|
sortable: true,
|
|
},
|
|
{
|
|
key: 'providerId',
|
|
header: 'Provider',
|
|
renderer: (_value: string | undefined, row: IIncomingNumberConfig) =>
|
|
html`<span>${this.getProviderLabel(row.providerId)}</span>`,
|
|
},
|
|
{
|
|
key: 'mode',
|
|
header: 'Type',
|
|
renderer: (value: string) => {
|
|
const label = value === 'regex' ? 'regex' : value === 'range' ? 'range' : 'number';
|
|
const color = value === 'regex' ? '#f59e0b' : value === 'range' ? '#60a5fa' : '#4ade80';
|
|
const bg = value === 'regex' ? '#422006' : value === 'range' ? '#1e3a5f' : '#1a3c2a';
|
|
return html`<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:${bg};color:${color}">${label}</span>`;
|
|
},
|
|
},
|
|
{
|
|
key: 'match',
|
|
header: 'Definition',
|
|
renderer: (_value: unknown, row: IIncomingNumberConfig) =>
|
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.82rem">${describeIncomingNumber(row)}</span>`,
|
|
},
|
|
{
|
|
key: 'pattern',
|
|
header: 'Generated Pattern',
|
|
renderer: (_value: unknown, row: IIncomingNumberConfig) =>
|
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.78rem;color:#94a3b8">${getIncomingNumberPattern(row) || '(incomplete)'}</span>`,
|
|
},
|
|
{
|
|
key: 'usage',
|
|
header: 'Used By',
|
|
renderer: (_value: unknown, row: IIncomingNumberConfig) => {
|
|
const count = this.countRoutesUsingIncomingNumber(row);
|
|
return html`<span style="font-weight:600;color:${count > 0 ? '#e2e8f0' : '#64748b'}">${count} route${count === 1 ? '' : 's'}</span>`;
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
private getIncomingNumberActions() {
|
|
return [
|
|
{
|
|
name: 'Add Number',
|
|
iconName: 'lucide:plus' as any,
|
|
type: ['header'] as any,
|
|
actionFunc: async () => {
|
|
await this.openIncomingNumberEditor(null);
|
|
},
|
|
},
|
|
{
|
|
name: 'Edit',
|
|
iconName: 'lucide:pencil' as any,
|
|
type: ['inRow'] as any,
|
|
actionFunc: async ({ item }: { item: IIncomingNumberConfig }) => {
|
|
await this.openIncomingNumberEditor(item);
|
|
},
|
|
},
|
|
{
|
|
name: 'Delete',
|
|
iconName: 'lucide:trash2' as any,
|
|
type: ['inRow'] as any,
|
|
actionFunc: async ({ item }: { item: IIncomingNumberConfig }) => {
|
|
const incomingNumbers = this.getIncomingNumbers().filter((entry) => entry.id !== item.id);
|
|
if (await this.saveIncomingNumbers(incomingNumbers)) {
|
|
DeesToast.success('Incoming number deleted');
|
|
} else {
|
|
DeesToast.error('Failed to delete incoming number');
|
|
}
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
private getRouteColumns() {
|
|
return [
|
|
{
|
|
key: 'priority',
|
|
header: 'Priority',
|
|
sortable: true,
|
|
renderer: (value: number) => html`<span style="font-weight:600;color:#94a3b8">${value}</span>`,
|
|
},
|
|
{
|
|
key: 'name',
|
|
header: 'Name',
|
|
sortable: true,
|
|
},
|
|
{
|
|
key: 'match',
|
|
header: 'Direction',
|
|
renderer: (_value: unknown, row: ISipRoute) => {
|
|
const direction = row.match.direction;
|
|
const color = direction === 'inbound' ? '#60a5fa' : '#4ade80';
|
|
const bg = direction === 'inbound' ? '#1e3a5f' : '#1a3c2a';
|
|
return html`<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:${bg};color:${color}">${direction}</span>`;
|
|
},
|
|
},
|
|
{
|
|
key: 'match',
|
|
header: 'Match',
|
|
renderer: (_value: unknown, row: ISipRoute) => {
|
|
const parts: string[] = [];
|
|
if (row.match.sourceProvider) parts.push(`provider: ${row.match.sourceProvider}`);
|
|
const incomingNumber = this.findIncomingNumberForRoute(row);
|
|
if (incomingNumber) {
|
|
parts.push(`did: ${incomingNumber.label}`);
|
|
} else if (row.match.numberPattern) {
|
|
parts.push(`number: ${row.match.numberPattern}`);
|
|
}
|
|
if (row.match.callerPattern) parts.push(`caller: ${row.match.callerPattern}`);
|
|
if (!parts.length) return html`<span style="color:#64748b;font-style:italic">catch-all</span>`;
|
|
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.82rem">${parts.join(', ')}</span>`;
|
|
},
|
|
},
|
|
{
|
|
key: 'action',
|
|
header: 'Action',
|
|
renderer: (_value: unknown, row: ISipRoute) =>
|
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.82rem">${describeRouteAction(row)}</span>`,
|
|
},
|
|
{
|
|
key: 'enabled',
|
|
header: 'Status',
|
|
renderer: (value: boolean) => {
|
|
const color = value ? '#4ade80' : '#71717a';
|
|
const bg = value ? '#1a3c2a' : '#3f3f46';
|
|
return html`<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase;background:${bg};color:${color}">${value ? 'Active' : 'Disabled'}</span>`;
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
private getRouteActions() {
|
|
return [
|
|
{
|
|
name: 'Add Route',
|
|
iconName: 'lucide:plus' as any,
|
|
type: ['header'] as any,
|
|
actionFunc: async () => {
|
|
await this.openRouteEditor(null);
|
|
},
|
|
},
|
|
{
|
|
name: 'Edit',
|
|
iconName: 'lucide:pencil' as any,
|
|
type: ['inRow'] as any,
|
|
actionFunc: async ({ item }: { item: ISipRoute }) => {
|
|
await this.openRouteEditor(item);
|
|
},
|
|
},
|
|
{
|
|
name: 'Toggle',
|
|
iconName: 'lucide:toggleLeft' as any,
|
|
type: ['inRow'] as any,
|
|
actionFunc: async ({ item }: { item: ISipRoute }) => {
|
|
const routes = this.getRoutes().map((route) =>
|
|
route.id === item.id ? { ...route, enabled: !route.enabled } : route,
|
|
);
|
|
if (await this.saveRoutes(routes)) {
|
|
DeesToast.success(item.enabled ? 'Route disabled' : 'Route enabled');
|
|
} else {
|
|
DeesToast.error('Failed to update route');
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'Delete',
|
|
iconName: 'lucide:trash2' as any,
|
|
type: ['inRow'] as any,
|
|
actionFunc: async ({ item }: { item: ISipRoute }) => {
|
|
const routes = this.getRoutes().filter((route) => route.id !== item.id);
|
|
if (await this.saveRoutes(routes)) {
|
|
DeesToast.success('Route deleted');
|
|
} else {
|
|
DeesToast.error('Failed to delete route');
|
|
}
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
private async openIncomingNumberEditor(existing: IIncomingNumberConfig | null): Promise<void> {
|
|
const providers = this.config?.providers || [];
|
|
const formData = existing ? clone(existing) : createIncomingNumber();
|
|
const countryCodeOptions = this.getCountryCodeOptions(formData.countryCode);
|
|
let definitionType: 'number' | 'regex' = formData.mode === 'regex' ? 'regex' : 'number';
|
|
let selectedCountryCode = countryCodeOptions.some((option) => option.key === normalizeCountryCode(formData.countryCode))
|
|
? normalizeCountryCode(formData.countryCode)
|
|
: CUSTOM_COUNTRY_CODE_KEY;
|
|
let customCountryCode = selectedCountryCode === CUSTOM_COUNTRY_CODE_KEY
|
|
? normalizeCountryCode(formData.countryCode)
|
|
: '';
|
|
let modalRef: any;
|
|
|
|
const applySelectedCountryCode = () => {
|
|
formData.countryCode = selectedCountryCode === CUSTOM_COUNTRY_CODE_KEY
|
|
? normalizeCountryCode(customCountryCode)
|
|
: selectedCountryCode;
|
|
};
|
|
|
|
const rerender = () => {
|
|
if (!modalRef) return;
|
|
modalRef.content = renderContent();
|
|
modalRef.requestUpdate();
|
|
};
|
|
|
|
const renderContent = () => {
|
|
applySelectedCountryCode();
|
|
const generatedPattern = getIncomingNumberPattern(formData);
|
|
|
|
return html`
|
|
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
|
<dees-input-text
|
|
.key=${'label'} .label=${'Label'} .value=${formData.label}
|
|
@input=${(e: Event) => { formData.label = (e.target as any).value; }}
|
|
></dees-input-text>
|
|
|
|
<dees-input-dropdown
|
|
.key=${'providerId'} .label=${'Provider'}
|
|
.selectedOption=${formData.providerId
|
|
? { option: this.getProviderLabel(formData.providerId), key: formData.providerId }
|
|
: { option: '(any)', key: '' }}
|
|
.options=${[
|
|
{ option: '(any)', key: '' },
|
|
...providers.map((provider: any) => ({ option: provider.displayName || provider.id, key: provider.id })),
|
|
]}
|
|
@selectedOption=${(e: CustomEvent) => { formData.providerId = e.detail.key || undefined; }}
|
|
></dees-input-dropdown>
|
|
|
|
<dees-input-dropdown
|
|
.key=${'definitionType'} .label=${'Definition Type'}
|
|
.selectedOption=${definitionType === 'regex'
|
|
? { option: 'Custom regex', key: 'regex' }
|
|
: { option: 'Phone number', key: 'number' }}
|
|
.options=${[
|
|
{ option: 'Custom regex', key: 'regex' },
|
|
{ option: 'Phone number', key: 'number' },
|
|
]}
|
|
@selectedOption=${(e: CustomEvent) => {
|
|
definitionType = e.detail.key;
|
|
formData.mode = definitionType === 'regex' ? 'regex' : formData.mode === 'range' ? 'range' : 'single';
|
|
if (definitionType === 'number' && !formData.countryCode) {
|
|
formData.countryCode = '+49';
|
|
selectedCountryCode = '+49';
|
|
}
|
|
rerender();
|
|
}}
|
|
></dees-input-dropdown>
|
|
|
|
${definitionType === 'regex' ? html`
|
|
<dees-input-text
|
|
.key=${'pattern'}
|
|
.label=${'Number Pattern'}
|
|
.description=${'Free-form pattern. Use this only when the structured number fields are not enough.'}
|
|
.value=${formData.pattern || ''}
|
|
@input=${(e: Event) => { formData.pattern = (e.target as any).value || undefined; }}
|
|
></dees-input-text>
|
|
` : html`
|
|
<dees-input-dropdown
|
|
.key=${'countryCode'} .label=${'Country Code'}
|
|
.selectedOption=${countryCodeOptions.find((option) => option.key === selectedCountryCode) || countryCodeOptions[0]}
|
|
.options=${countryCodeOptions}
|
|
@selectedOption=${(e: CustomEvent) => {
|
|
selectedCountryCode = e.detail.key || '+49';
|
|
rerender();
|
|
}}
|
|
></dees-input-dropdown>
|
|
|
|
${selectedCountryCode === CUSTOM_COUNTRY_CODE_KEY ? html`
|
|
<dees-input-text
|
|
.key=${'customCountryCode'} .label=${'Custom Country Code'}
|
|
.description=${'Example: +49'}
|
|
.value=${customCountryCode}
|
|
@input=${(e: Event) => {
|
|
customCountryCode = (e.target as any).value || '';
|
|
formData.countryCode = normalizeCountryCode(customCountryCode);
|
|
}}
|
|
></dees-input-text>
|
|
` : ''}
|
|
|
|
<dees-input-text
|
|
.key=${'areaCode'} .label=${'Area Code'}
|
|
.description=${'Example: 421 or 0421'}
|
|
.value=${formData.areaCode || ''}
|
|
@input=${(e: Event) => { formData.areaCode = (e.target as any).value || undefined; }}
|
|
></dees-input-text>
|
|
|
|
<dees-input-text
|
|
.key=${'localNumber'} .label=${formData.mode === 'range' ? 'Number Start' : 'Number'}
|
|
.description=${'Example: 219694'}
|
|
.value=${formData.localNumber || ''}
|
|
@input=${(e: Event) => { formData.localNumber = (e.target as any).value || undefined; }}
|
|
></dees-input-text>
|
|
|
|
<dees-input-checkbox
|
|
.key=${'rangeEnabled'} .label=${'Use range'} .value=${formData.mode === 'range'}
|
|
@newValue=${(e: CustomEvent) => {
|
|
formData.mode = e.detail ? 'range' : 'single';
|
|
rerender();
|
|
}}
|
|
></dees-input-checkbox>
|
|
|
|
${formData.mode === 'range' ? html`
|
|
<dees-input-text
|
|
.key=${'rangeEnd'} .label=${'Number End'}
|
|
.description=${'Range applies to the local number part only'}
|
|
.value=${formData.rangeEnd || ''}
|
|
@input=${(e: Event) => { formData.rangeEnd = (e.target as any).value || undefined; }}
|
|
></dees-input-text>
|
|
` : ''}
|
|
|
|
<div style="padding:10px 12px;border:1px solid #334155;border-radius:8px;background:#0f172a;">
|
|
<div style="font-size:.72rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px;font-weight:600;">Generated Regex</div>
|
|
<div style="font-family:'JetBrains Mono',monospace;font-size:.8rem;color:#e2e8f0;word-break:break-all;">${generatedPattern || '(complete the fields to generate a pattern)'}</div>
|
|
</div>
|
|
`}
|
|
</div>
|
|
`;
|
|
};
|
|
|
|
modalRef = await DeesModal.createAndShow({
|
|
heading: existing ? `Edit Incoming Number: ${existing.label}` : 'New Incoming Number',
|
|
width: 'small',
|
|
showCloseButton: true,
|
|
content: renderContent(),
|
|
menuOptions: [
|
|
{
|
|
name: 'Cancel',
|
|
iconName: 'lucide:x',
|
|
action: async (modal: any) => { modal.destroy(); },
|
|
},
|
|
{
|
|
name: 'Save',
|
|
iconName: 'lucide:check',
|
|
action: async (modal: any) => {
|
|
const next = clone(formData);
|
|
next.label = next.label.trim();
|
|
if (!next.label) {
|
|
DeesToast.error('Label is required');
|
|
return;
|
|
}
|
|
|
|
if (definitionType === 'regex') {
|
|
next.mode = 'regex';
|
|
next.pattern = (next.pattern || '').trim();
|
|
if (!next.pattern) {
|
|
DeesToast.error('Number pattern is required for custom regex');
|
|
return;
|
|
}
|
|
delete next.countryCode;
|
|
delete next.areaCode;
|
|
delete next.localNumber;
|
|
delete next.rangeEnd;
|
|
delete next.number;
|
|
delete next.rangeStart;
|
|
} else {
|
|
next.countryCode = selectedCountryCode === CUSTOM_COUNTRY_CODE_KEY
|
|
? normalizeCountryCode(customCountryCode)
|
|
: selectedCountryCode;
|
|
next.areaCode = normalizeNumberPart(next.areaCode);
|
|
next.localNumber = normalizeNumberPart(next.localNumber);
|
|
next.rangeEnd = normalizeNumberPart(next.rangeEnd);
|
|
next.mode = next.mode === 'range' ? 'range' : 'single';
|
|
|
|
if (!next.countryCode || !next.areaCode || !next.localNumber) {
|
|
DeesToast.error('Country code, area code, and number are required');
|
|
return;
|
|
}
|
|
if (next.mode === 'range') {
|
|
const validationError = validateLocalRange(next.localNumber, next.rangeEnd);
|
|
if (validationError) {
|
|
DeesToast.error(validationError);
|
|
return;
|
|
}
|
|
} else {
|
|
delete next.rangeEnd;
|
|
}
|
|
delete next.pattern;
|
|
delete next.number;
|
|
delete next.rangeStart;
|
|
}
|
|
|
|
if (!next.providerId) delete next.providerId;
|
|
const incomingNumbers = [...this.getIncomingNumbers()];
|
|
const index = incomingNumbers.findIndex((entry) => entry.id === next.id);
|
|
if (index >= 0) incomingNumbers[index] = next;
|
|
else incomingNumbers.push(next);
|
|
|
|
if (await this.saveIncomingNumbers(incomingNumbers)) {
|
|
modal.destroy();
|
|
DeesToast.success(existing ? 'Incoming number updated' : 'Incoming number created');
|
|
} else {
|
|
DeesToast.error('Failed to save incoming number');
|
|
}
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
private async openRouteEditor(existing: ISipRoute | null): Promise<void> {
|
|
const providers = this.config?.providers || [];
|
|
const devices = this.config?.devices || [];
|
|
const voiceboxes = this.config?.voiceboxes || [];
|
|
const ivrMenus = this.config?.ivr?.menus || [];
|
|
const incomingNumbers = this.getIncomingNumbers();
|
|
const formData = existing ? clone(existing) : createRoute();
|
|
let selectedIncomingNumberId = this.findIncomingNumberForRoute(formData)?.id || CUSTOM_REGEX_KEY;
|
|
let modalRef: any;
|
|
|
|
const rerender = () => {
|
|
if (!modalRef) return;
|
|
modalRef.content = renderContent();
|
|
modalRef.requestUpdate();
|
|
};
|
|
|
|
const renderContent = () => {
|
|
const incomingNumberOptions = [
|
|
{ option: 'Custom regex', key: CUSTOM_REGEX_KEY },
|
|
...incomingNumbers.map((entry) => ({
|
|
option: `${entry.label} | ${describeIncomingNumber(entry)}`,
|
|
key: entry.id,
|
|
})),
|
|
];
|
|
|
|
return html`
|
|
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
|
<dees-input-text
|
|
.key=${'name'} .label=${'Route Name'} .value=${formData.name}
|
|
@input=${(e: Event) => { formData.name = (e.target as any).value; }}
|
|
></dees-input-text>
|
|
|
|
<dees-input-dropdown
|
|
.key=${'direction'} .label=${'Direction'}
|
|
.selectedOption=${formData.match.direction === 'inbound'
|
|
? { option: 'inbound', key: 'inbound' }
|
|
: { option: 'outbound', key: 'outbound' }}
|
|
.options=${[
|
|
{ option: 'inbound', key: 'inbound' },
|
|
{ option: 'outbound', key: 'outbound' },
|
|
]}
|
|
@selectedOption=${(e: CustomEvent) => {
|
|
formData.match.direction = e.detail.key;
|
|
rerender();
|
|
}}
|
|
></dees-input-dropdown>
|
|
|
|
<dees-input-text
|
|
.key=${'priority'} .label=${'Priority (higher = matched first)'}
|
|
.value=${String(formData.priority)}
|
|
@input=${(e: Event) => { formData.priority = parseInt((e.target as any).value, 10) || 0; }}
|
|
></dees-input-text>
|
|
|
|
<dees-input-checkbox
|
|
.key=${'enabled'} .label=${'Enabled'} .value=${formData.enabled}
|
|
@newValue=${(e: CustomEvent) => { formData.enabled = e.detail; }}
|
|
></dees-input-checkbox>
|
|
|
|
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
|
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;font-weight:600;">Match Criteria</div>
|
|
</div>
|
|
|
|
${formData.match.direction === 'inbound' ? html`
|
|
<dees-input-dropdown
|
|
.key=${'sourceProvider'} .label=${'Source Provider'}
|
|
.selectedOption=${formData.match.sourceProvider
|
|
? { option: this.getProviderLabel(formData.match.sourceProvider), key: formData.match.sourceProvider }
|
|
: { option: '(any)', key: '' }}
|
|
.options=${[
|
|
{ option: '(any)', key: '' },
|
|
...providers.map((provider: any) => ({ option: provider.displayName || provider.id, key: provider.id })),
|
|
]}
|
|
@selectedOption=${(e: CustomEvent) => { formData.match.sourceProvider = e.detail.key || undefined; }}
|
|
></dees-input-dropdown>
|
|
|
|
<dees-input-dropdown
|
|
.key=${'incomingNumberId'} .label=${'DID'}
|
|
.selectedOption=${incomingNumberOptions.find((option) => option.key === selectedIncomingNumberId) || incomingNumberOptions[0]}
|
|
.options=${incomingNumberOptions}
|
|
@selectedOption=${(e: CustomEvent) => {
|
|
selectedIncomingNumberId = e.detail.key || CUSTOM_REGEX_KEY;
|
|
const selectedIncomingNumber = incomingNumbers.find((entry) => entry.id === selectedIncomingNumberId);
|
|
if (selectedIncomingNumber) {
|
|
formData.match.numberPattern = getIncomingNumberPattern(selectedIncomingNumber) || undefined;
|
|
if (selectedIncomingNumber.providerId) {
|
|
formData.match.sourceProvider = selectedIncomingNumber.providerId;
|
|
}
|
|
}
|
|
rerender();
|
|
}}
|
|
></dees-input-dropdown>
|
|
|
|
${selectedIncomingNumberId === CUSTOM_REGEX_KEY ? html`
|
|
<dees-input-text
|
|
.key=${'numberPattern'}
|
|
.label=${'Number Pattern'}
|
|
.description=${'Free-form inbound number pattern used only for Custom regex routes'}
|
|
.value=${formData.match.numberPattern || ''}
|
|
@input=${(e: Event) => { formData.match.numberPattern = (e.target as any).value || undefined; }}
|
|
></dees-input-text>
|
|
` : html`
|
|
<div style="padding:10px 12px;border:1px solid #334155;border-radius:8px;background:#0f172a;">
|
|
<div style="font-size:.72rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px;font-weight:600;">Selected Pattern</div>
|
|
<div style="font-family:'JetBrains Mono',monospace;font-size:.8rem;color:#e2e8f0;word-break:break-all;">${formData.match.numberPattern || '(not set)'}</div>
|
|
</div>
|
|
`}
|
|
|
|
<dees-input-text
|
|
.key=${'callerPattern'}
|
|
.label=${'Caller Pattern'}
|
|
.description=${'Optional caller-ID filter'}
|
|
.value=${formData.match.callerPattern || ''}
|
|
@input=${(e: Event) => { formData.match.callerPattern = (e.target as any).value || undefined; }}
|
|
></dees-input-text>
|
|
` : html`
|
|
<dees-input-text
|
|
.key=${'numberPattern'}
|
|
.label=${'Number Pattern'}
|
|
.description=${'Outbound dialed-number match. Exact, prefix*, range, or /regex/'}
|
|
.value=${formData.match.numberPattern || ''}
|
|
@input=${(e: Event) => { formData.match.numberPattern = (e.target as any).value || undefined; }}
|
|
></dees-input-text>
|
|
`}
|
|
|
|
<div style="margin-top:8px;padding-top:12px;border-top:1px solid #334155;">
|
|
<div style="font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;font-weight:600;">Action</div>
|
|
</div>
|
|
|
|
${formData.match.direction === 'inbound' ? html`
|
|
<dees-input-text
|
|
.key=${'targets'}
|
|
.label=${'Ring Devices (comma-separated IDs)'}
|
|
.description=${'Leave empty to ring all devices'}
|
|
.value=${(formData.action.targets || []).join(', ')}
|
|
@input=${(e: Event) => {
|
|
const value = (e.target as any).value.trim();
|
|
formData.action.targets = value ? value.split(',').map((item: string) => item.trim()).filter(Boolean) : undefined;
|
|
}}
|
|
></dees-input-text>
|
|
|
|
<dees-input-checkbox
|
|
.key=${'ringBrowsers'} .label=${'Ring browser clients'}
|
|
.value=${formData.action.ringBrowsers ?? false}
|
|
@newValue=${(e: CustomEvent) => { formData.action.ringBrowsers = e.detail; }}
|
|
></dees-input-checkbox>
|
|
|
|
<dees-input-dropdown
|
|
.key=${'voicemailBox'} .label=${'Voicemail Box (fallback)'}
|
|
.selectedOption=${formData.action.voicemailBox
|
|
? { option: formData.action.voicemailBox, key: formData.action.voicemailBox }
|
|
: { option: '(none)', key: '' }}
|
|
.options=${[
|
|
{ option: '(none)', key: '' },
|
|
...voiceboxes.map((voicebox: any) => ({ option: voicebox.id, key: voicebox.id })),
|
|
]}
|
|
@selectedOption=${(e: CustomEvent) => { formData.action.voicemailBox = e.detail.key || undefined; }}
|
|
></dees-input-dropdown>
|
|
|
|
<dees-input-dropdown
|
|
.key=${'ivrMenuId'} .label=${'IVR Menu'}
|
|
.selectedOption=${formData.action.ivrMenuId
|
|
? { option: formData.action.ivrMenuId, key: formData.action.ivrMenuId }
|
|
: { option: '(none)', key: '' }}
|
|
.options=${[
|
|
{ option: '(none)', key: '' },
|
|
...ivrMenus.map((menu: any) => ({ option: menu.name || menu.id, key: menu.id })),
|
|
]}
|
|
@selectedOption=${(e: CustomEvent) => { formData.action.ivrMenuId = e.detail.key || undefined; }}
|
|
></dees-input-dropdown>
|
|
` : html`
|
|
<dees-input-dropdown
|
|
.key=${'provider'} .label=${'Outbound Provider'}
|
|
.selectedOption=${formData.action.provider
|
|
? { option: this.getProviderLabel(formData.action.provider), key: formData.action.provider }
|
|
: { option: '(none)', key: '' }}
|
|
.options=${[
|
|
{ option: '(none)', key: '' },
|
|
...providers.map((provider: any) => ({ option: provider.displayName || provider.id, key: provider.id })),
|
|
]}
|
|
@selectedOption=${(e: CustomEvent) => { formData.action.provider = e.detail.key || undefined; }}
|
|
></dees-input-dropdown>
|
|
|
|
<dees-input-text
|
|
.key=${'stripPrefix'} .label=${'Strip Prefix'}
|
|
.value=${formData.action.stripPrefix || ''}
|
|
@input=${(e: Event) => { formData.action.stripPrefix = (e.target as any).value || undefined; }}
|
|
></dees-input-text>
|
|
|
|
<dees-input-text
|
|
.key=${'prependPrefix'} .label=${'Prepend Prefix'}
|
|
.value=${formData.action.prependPrefix || ''}
|
|
@input=${(e: Event) => { formData.action.prependPrefix = (e.target as any).value || undefined; }}
|
|
></dees-input-text>
|
|
`}
|
|
|
|
${formData.match.direction === 'inbound' && devices.length ? html`
|
|
<div style="font-size:.78rem;color:#94a3b8;">Known devices: ${devices.map((device: any) => device.id).join(', ')}</div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
};
|
|
|
|
modalRef = await DeesModal.createAndShow({
|
|
heading: existing ? `Edit Route: ${existing.name}` : 'New Route',
|
|
width: 'small',
|
|
showCloseButton: true,
|
|
content: renderContent(),
|
|
menuOptions: [
|
|
{
|
|
name: 'Cancel',
|
|
iconName: 'lucide:x',
|
|
action: async (modal: any) => { modal.destroy(); },
|
|
},
|
|
{
|
|
name: 'Save',
|
|
iconName: 'lucide:check',
|
|
action: async (modal: any) => {
|
|
const next = clone(formData);
|
|
next.name = next.name.trim();
|
|
if (!next.name) {
|
|
DeesToast.error('Route name is required');
|
|
return;
|
|
}
|
|
|
|
const selectedIncomingNumber = incomingNumbers.find((entry) => entry.id === selectedIncomingNumberId);
|
|
if (next.match.direction === 'inbound' && selectedIncomingNumber) {
|
|
next.match.numberPattern = getIncomingNumberPattern(selectedIncomingNumber) || undefined;
|
|
if (selectedIncomingNumber.providerId) {
|
|
next.match.sourceProvider = selectedIncomingNumber.providerId;
|
|
}
|
|
}
|
|
|
|
if (!next.match.numberPattern) delete next.match.numberPattern;
|
|
if (!next.match.callerPattern) delete next.match.callerPattern;
|
|
if (!next.match.sourceProvider) delete next.match.sourceProvider;
|
|
if (!next.match.sourceDevice) delete next.match.sourceDevice;
|
|
if (!next.action.provider) delete next.action.provider;
|
|
if (!next.action.stripPrefix) delete next.action.stripPrefix;
|
|
if (!next.action.prependPrefix) delete next.action.prependPrefix;
|
|
if (!next.action.targets?.length) delete next.action.targets;
|
|
if (!next.action.ringBrowsers) delete next.action.ringBrowsers;
|
|
if (!next.action.voicemailBox) delete next.action.voicemailBox;
|
|
if (!next.action.ivrMenuId) delete next.action.ivrMenuId;
|
|
if (!next.action.noAnswerTimeout) delete next.action.noAnswerTimeout;
|
|
|
|
const routes = [...this.getRoutes()];
|
|
const index = routes.findIndex((route) => route.id === next.id);
|
|
if (index >= 0) routes[index] = next;
|
|
else routes.push(next);
|
|
|
|
if (await this.saveRoutes(routes)) {
|
|
modal.destroy();
|
|
DeesToast.success(existing ? 'Route updated' : 'Route created');
|
|
} else {
|
|
DeesToast.error('Failed to save route');
|
|
}
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|