feat(routing): add rule-based SIP routing for inbound and outbound calls with dashboard route management
This commit is contained in:
@@ -6,6 +6,7 @@ export * from './sipproxy-view-phone.js';
|
||||
export * from './sipproxy-view-contacts.js';
|
||||
export * from './sipproxy-view-providers.js';
|
||||
export * from './sipproxy-view-log.js';
|
||||
export * from './sipproxy-view-routes.js';
|
||||
|
||||
// Sub-components (used within views)
|
||||
export * from './sipproxy-devices.js';
|
||||
|
||||
@@ -8,11 +8,13 @@ import { SipproxyViewPhone } from './sipproxy-view-phone.js';
|
||||
import { SipproxyViewContacts } from './sipproxy-view-contacts.js';
|
||||
import { SipproxyViewProviders } from './sipproxy-view-providers.js';
|
||||
import { SipproxyViewLog } from './sipproxy-view-log.js';
|
||||
import { SipproxyViewRoutes } from './sipproxy-view-routes.js';
|
||||
|
||||
const VIEW_TABS = [
|
||||
{ name: 'Overview', iconName: 'lucide:layoutDashboard', element: SipproxyViewOverview },
|
||||
{ name: 'Calls', iconName: 'lucide:phone', element: SipproxyViewCalls },
|
||||
{ name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
|
||||
{ name: 'Routes', iconName: 'lucide:route', element: SipproxyViewRoutes },
|
||||
{ name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
|
||||
{ name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
|
||||
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },
|
||||
|
||||
@@ -367,8 +367,14 @@ export class SipproxyViewProviders extends DeesElement {
|
||||
registerIntervalSec: String(provider.registerIntervalSec ?? 300),
|
||||
codecs: (provider.codecs || []).join(', '),
|
||||
earlyMediaSilence: provider.quirks?.earlyMediaSilence ?? false,
|
||||
inboundDevices: [...(cfg.routing?.inbound?.[providerId] || [])] as string[],
|
||||
ringBrowsers: cfg.routing?.ringBrowsers?.[providerId] ?? false,
|
||||
inboundDevices: (() => {
|
||||
const route = (cfg.routing?.routes || []).find((r: any) => r.match?.direction === 'inbound' && r.match?.sourceProvider === providerId);
|
||||
return route?.action?.targets ? [...route.action.targets] : [];
|
||||
})() as string[],
|
||||
ringBrowsers: (() => {
|
||||
const route = (cfg.routing?.routes || []).find((r: any) => r.match?.direction === 'inbound' && r.match?.sourceProvider === providerId);
|
||||
return route?.action?.ringBrowsers ?? false;
|
||||
})(),
|
||||
};
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
@@ -484,6 +490,28 @@ export class SipproxyViewProviders extends DeesElement {
|
||||
.map((s: string) => parseInt(s.trim(), 10))
|
||||
.filter((n: number) => !isNaN(n));
|
||||
|
||||
// Build updated routes: update/create the inbound route for this provider.
|
||||
const currentRoutes = [...(cfg.routing?.routes || [])];
|
||||
const existingIdx = currentRoutes.findIndex((r: any) =>
|
||||
r.match?.direction === 'inbound' && r.match?.sourceProvider === providerId
|
||||
);
|
||||
const inboundRoute = {
|
||||
id: `inbound-${providerId}`,
|
||||
name: `Inbound from ${formData.displayName.trim() || providerId}`,
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
match: { direction: 'inbound' as const, sourceProvider: providerId },
|
||||
action: {
|
||||
targets: formData.inboundDevices.length ? formData.inboundDevices : undefined,
|
||||
ringBrowsers: formData.ringBrowsers,
|
||||
},
|
||||
};
|
||||
if (existingIdx >= 0) {
|
||||
currentRoutes[existingIdx] = { ...currentRoutes[existingIdx], ...inboundRoute };
|
||||
} else {
|
||||
currentRoutes.push(inboundRoute);
|
||||
}
|
||||
|
||||
const updates: any = {
|
||||
providers: [{
|
||||
id: providerId,
|
||||
@@ -500,10 +528,7 @@ export class SipproxyViewProviders extends DeesElement {
|
||||
earlyMediaSilence: formData.earlyMediaSilence,
|
||||
},
|
||||
}] as any[],
|
||||
routing: {
|
||||
inbound: { [providerId]: formData.inboundDevices },
|
||||
ringBrowsers: { [providerId]: formData.ringBrowsers },
|
||||
},
|
||||
routing: { routes: currentRoutes },
|
||||
};
|
||||
|
||||
// Only send password if it was changed (not the masked placeholder).
|
||||
|
||||
405
ts_web/elements/sipproxy-view-routes.ts
Normal file
405
ts_web/elements/sipproxy-view-routes.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
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';
|
||||
|
||||
const { DeesModal, DeesToast } = deesCatalog;
|
||||
|
||||
interface ISipRoute {
|
||||
id: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
match: {
|
||||
direction: 'inbound' | 'outbound';
|
||||
numberPattern?: string;
|
||||
callerPattern?: string;
|
||||
sourceProvider?: string;
|
||||
sourceDevice?: string;
|
||||
};
|
||||
action: {
|
||||
targets?: string[];
|
||||
ringBrowsers?: boolean;
|
||||
provider?: string;
|
||||
failoverProviders?: string[];
|
||||
stripPrefix?: string;
|
||||
prependPrefix?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@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; }
|
||||
`,
|
||||
];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
appState.subscribe((_k, s) => { this.appData = s; });
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
private async loadConfig() {
|
||||
try {
|
||||
this.config = await appState.apiGetConfig();
|
||||
} catch {
|
||||
// Will show empty table.
|
||||
}
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const cfg = this.config;
|
||||
const routes: ISipRoute[] = cfg?.routing?.routes || [];
|
||||
const sorted = [...routes].sort((a, b) => b.priority - a.priority);
|
||||
|
||||
const tiles: any[] = [
|
||||
{
|
||||
id: 'total',
|
||||
title: 'Total Routes',
|
||||
value: routes.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:route',
|
||||
description: `${routes.filter((r) => r.enabled).length} active`,
|
||||
},
|
||||
{
|
||||
id: 'inbound',
|
||||
title: 'Inbound',
|
||||
value: routes.filter((r) => r.match.direction === 'inbound').length,
|
||||
type: 'number',
|
||||
icon: 'lucide:phoneIncoming',
|
||||
description: 'Incoming call routes',
|
||||
},
|
||||
{
|
||||
id: 'outbound',
|
||||
title: 'Outbound',
|
||||
value: routes.filter((r) => r.match.direction === 'outbound').length,
|
||||
type: 'number',
|
||||
icon: 'lucide:phoneOutgoing',
|
||||
description: 'Outgoing call routes',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="view-section">
|
||||
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
||||
</div>
|
||||
|
||||
<div class="view-section">
|
||||
<dees-table
|
||||
heading1="Call Routes"
|
||||
heading2="${routes.length} configured"
|
||||
dataName="routes"
|
||||
.data=${sorted}
|
||||
.rowKey=${'id'}
|
||||
.columns=${this.getColumns()}
|
||||
.dataActions=${this.getDataActions()}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getColumns() {
|
||||
return [
|
||||
{
|
||||
key: 'priority',
|
||||
header: 'Priority',
|
||||
sortable: true,
|
||||
renderer: (val: number) =>
|
||||
html`<span style="font-weight:600;color:#94a3b8">${val}</span>`,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'match',
|
||||
header: 'Direction',
|
||||
renderer: (_val: any, row: ISipRoute) => {
|
||||
const dir = row.match.direction;
|
||||
const color = dir === 'inbound' ? '#60a5fa' : '#4ade80';
|
||||
const bg = dir === '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}">${dir}</span>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'match',
|
||||
header: 'Match',
|
||||
renderer: (_val: any, row: ISipRoute) => {
|
||||
const m = row.match;
|
||||
const parts: string[] = [];
|
||||
if (m.sourceProvider) parts.push(`provider: ${m.sourceProvider}`);
|
||||
if (m.sourceDevice) parts.push(`device: ${m.sourceDevice}`);
|
||||
if (m.numberPattern) parts.push(`number: ${m.numberPattern}`);
|
||||
if (m.callerPattern) parts.push(`caller: ${m.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: (_val: any, row: ISipRoute) => {
|
||||
const a = row.action;
|
||||
if (row.match.direction === 'outbound') {
|
||||
const parts: string[] = [];
|
||||
if (a.provider) parts.push(`\u2192 ${a.provider}`);
|
||||
if (a.failoverProviders?.length) parts.push(`(failover: ${a.failoverProviders.join(', ')})`);
|
||||
if (a.stripPrefix) parts.push(`strip: ${a.stripPrefix}`);
|
||||
if (a.prependPrefix) parts.push(`prepend: ${a.prependPrefix}`);
|
||||
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.82rem">${parts.join(' ')}</span>`;
|
||||
} else {
|
||||
const parts: string[] = [];
|
||||
if (a.targets?.length) parts.push(`ring: ${a.targets.join(', ')}`);
|
||||
else parts.push('ring: all devices');
|
||||
if (a.ringBrowsers) parts.push('+ browsers');
|
||||
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.82rem">${parts.join(' ')}</span>`;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
header: 'Status',
|
||||
renderer: (val: boolean) => {
|
||||
const color = val ? '#4ade80' : '#71717a';
|
||||
const bg = val ? '#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}">${val ? 'Active' : 'Disabled'}</span>`;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private getDataActions() {
|
||||
return [
|
||||
{
|
||||
name: 'Add',
|
||||
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 cfg = this.config;
|
||||
const routes = (cfg?.routing?.routes || []).map((r: ISipRoute) =>
|
||||
r.id === item.id ? { ...r, enabled: !r.enabled } : r,
|
||||
);
|
||||
const result = await appState.apiSaveConfig({ routing: { routes } });
|
||||
if (result.ok) {
|
||||
DeesToast.success(item.enabled ? 'Route disabled' : 'Route enabled');
|
||||
await this.loadConfig();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: ISipRoute }) => {
|
||||
const cfg = this.config;
|
||||
const routes = (cfg?.routing?.routes || []).filter((r: ISipRoute) => r.id !== item.id);
|
||||
const result = await appState.apiSaveConfig({ routing: { routes } });
|
||||
if (result.ok) {
|
||||
DeesToast.success('Route deleted');
|
||||
await this.loadConfig();
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async openRouteEditor(existing: ISipRoute | null) {
|
||||
const cfg = this.config;
|
||||
const providers = cfg?.providers || [];
|
||||
const devices = cfg?.devices || [];
|
||||
|
||||
const formData: ISipRoute = existing
|
||||
? JSON.parse(JSON.stringify(existing))
|
||||
: {
|
||||
id: `route-${Date.now()}`,
|
||||
name: '',
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
match: { direction: 'outbound' as const },
|
||||
action: {},
|
||||
};
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: existing ? `Edit Route: ${existing.name}` : 'New Route',
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
content: 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; }}
|
||||
></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>
|
||||
|
||||
<dees-input-text
|
||||
.key=${'numberPattern'}
|
||||
.label=${'Number Pattern'}
|
||||
.description=${'Exact, prefix with * (e.g. +49*), or /regex/'}
|
||||
.value=${formData.match.numberPattern || ''}
|
||||
@input=${(e: Event) => { formData.match.numberPattern = (e.target as any).value || undefined; }}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.key=${'callerPattern'}
|
||||
.label=${'Caller Pattern'}
|
||||
.description=${'Match caller ID (same syntax)'}
|
||||
.value=${formData.match.callerPattern || ''}
|
||||
@input=${(e: Event) => { formData.match.callerPattern = (e.target as any).value || undefined; }}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-dropdown
|
||||
.key=${'sourceProvider'} .label=${'Source Provider (inbound)'}
|
||||
.selectedOption=${formData.match.sourceProvider
|
||||
? { option: formData.match.sourceProvider, key: formData.match.sourceProvider }
|
||||
: { option: '(any)', key: '' }}
|
||||
.options=${[
|
||||
{ option: '(any)', key: '' },
|
||||
...providers.map((p: any) => ({ option: p.displayName || p.id, key: p.id })),
|
||||
]}
|
||||
@selectedOption=${(e: CustomEvent) => { formData.match.sourceProvider = e.detail.key || undefined; }}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<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>
|
||||
|
||||
<dees-input-dropdown
|
||||
.key=${'provider'} .label=${'Outbound Provider'}
|
||||
.selectedOption=${formData.action.provider
|
||||
? { option: formData.action.provider, key: formData.action.provider }
|
||||
: { option: '(none)', key: '' }}
|
||||
.options=${[
|
||||
{ option: '(none)', key: '' },
|
||||
...providers.map((p: any) => ({ option: p.displayName || p.id, key: p.id })),
|
||||
]}
|
||||
@selectedOption=${(e: CustomEvent) => { formData.action.provider = e.detail.key || undefined; }}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<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 v = (e.target as any).value.trim();
|
||||
formData.action.targets = v ? v.split(',').map((s: string) => s.trim()) : 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-text
|
||||
.key=${'stripPrefix'} .label=${'Strip Prefix (outbound)'}
|
||||
.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 (outbound)'}
|
||||
.value=${formData.action.prependPrefix || ''}
|
||||
@input=${(e: Event) => { formData.action.prependPrefix = (e.target as any).value || undefined; }}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => { modalRef.destroy(); },
|
||||
},
|
||||
{
|
||||
name: 'Save',
|
||||
iconName: 'lucide:check',
|
||||
action: async (modalRef: any) => {
|
||||
if (!formData.name.trim()) {
|
||||
DeesToast.error('Route name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up empty optional fields.
|
||||
if (!formData.match.numberPattern) delete formData.match.numberPattern;
|
||||
if (!formData.match.callerPattern) delete formData.match.callerPattern;
|
||||
if (!formData.match.sourceProvider) delete formData.match.sourceProvider;
|
||||
if (!formData.match.sourceDevice) delete formData.match.sourceDevice;
|
||||
if (!formData.action.provider) delete formData.action.provider;
|
||||
if (!formData.action.stripPrefix) delete formData.action.stripPrefix;
|
||||
if (!formData.action.prependPrefix) delete formData.action.prependPrefix;
|
||||
if (!formData.action.targets?.length) delete formData.action.targets;
|
||||
if (!formData.action.ringBrowsers) delete formData.action.ringBrowsers;
|
||||
|
||||
const currentRoutes = [...(cfg?.routing?.routes || [])];
|
||||
const idx = currentRoutes.findIndex((r: any) => r.id === formData.id);
|
||||
if (idx >= 0) {
|
||||
currentRoutes[idx] = formData;
|
||||
} else {
|
||||
currentRoutes.push(formData);
|
||||
}
|
||||
|
||||
const result = await appState.apiSaveConfig({ routing: { routes: currentRoutes } });
|
||||
if (result.ok) {
|
||||
modalRef.destroy();
|
||||
DeesToast.success(existing ? 'Route updated' : 'Route created');
|
||||
await this.loadConfig();
|
||||
} else {
|
||||
DeesToast.error('Failed to save route');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user