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; }} >
Match Criteria
{ 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; }} >
Action
({ 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'); } }, }, ], }); } }