feat(routing): add rule-based SIP routing for inbound and outbound calls with dashboard route management

This commit is contained in:
2026-04-10 08:22:12 +00:00
parent f3e1c96872
commit fd3a408cc2
13 changed files with 893 additions and 114 deletions

View File

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

View File

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

View File

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

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