406 lines
15 KiB
TypeScript
406 lines
15 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';
|
|
|
|
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');
|
|
}
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|