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`
`;
}
private getColumns() {
return [
{
key: 'priority',
header: 'Priority',
sortable: true,
renderer: (val: number) =>
html`${val}`,
},
{
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`${dir}`;
},
},
{
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`catch-all`;
return html`${parts.join(', ')}`;
},
},
{
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`${parts.join(' ')}`;
} 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`${parts.join(' ')}`;
}
},
},
{
key: 'enabled',
header: 'Status',
renderer: (val: boolean) => {
const color = val ? '#4ade80' : '#71717a';
const bg = val ? '#1a3c2a' : '#3f3f46';
return html`${val ? 'Active' : 'Disabled'}`;
},
},
];
}
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`
{ formData.name = (e.target as any).value; }}
>
{ formData.match.direction = e.detail.key; }}
>
{ formData.priority = parseInt((e.target as any).value, 10) || 0; }}
>
{ formData.enabled = e.detail; }}
>
{ formData.match.numberPattern = (e.target as any).value || undefined; }}
>
{ formData.match.callerPattern = (e.target as any).value || undefined; }}
>
({ option: p.displayName || p.id, key: p.id })),
]}
@selectedOption=${(e: CustomEvent) => { formData.match.sourceProvider = e.detail.key || undefined; }}
>
({ option: p.displayName || p.id, key: p.id })),
]}
@selectedOption=${(e: CustomEvent) => { formData.action.provider = e.detail.key || undefined; }}
>
{
const v = (e.target as any).value.trim();
formData.action.targets = v ? v.split(',').map((s: string) => s.trim()) : undefined;
}}
>
{ formData.action.ringBrowsers = e.detail; }}
>
{ formData.action.stripPrefix = (e.target as any).value || undefined; }}
>
{ formData.action.prependPrefix = (e.target as any).value || undefined; }}
>
`,
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');
}
},
},
],
});
}
}