import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js'; import { deesCatalog } from '../plugins.js'; import { appState, type IAppState, type IProviderStatus } from '../state/appstate.js'; import { viewHostCss } from './shared/index.js'; import type { IStatsTile } from '@design.estate/dees-catalog'; // --------------------------------------------------------------------------- // Default provider templates // --------------------------------------------------------------------------- const PROVIDER_TEMPLATES = { sipgate: { domain: 'sipgate.de', outboundProxy: { address: 'sipgate.de', port: 5060 }, registerIntervalSec: 300, codecs: [9, 0, 8, 101], quirks: { earlyMediaSilence: false }, }, o2: { domain: 'sip.alice-voip.de', outboundProxy: { address: 'sip.alice-voip.de', port: 5060 }, registerIntervalSec: 300, codecs: [9, 0, 8, 101], quirks: { earlyMediaSilence: false }, }, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function slugify(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') || `provider-${Date.now()}`; } // --------------------------------------------------------------------------- // View element // --------------------------------------------------------------------------- @customElement('sipproxy-view-providers') export class SipproxyViewProviders extends DeesElement { @state() accessor appData: IAppState = appState.getState(); public static styles = [ cssManager.defaultStyles, viewHostCss, css` :host { display: block; padding: 16px; } .view-section { margin-bottom: 24px; } `, ]; // ---- lifecycle ----------------------------------------------------------- connectedCallback() { super.connectedCallback(); this.rxSubscriptions.push({ unsubscribe: appState.subscribe((s) => { this.appData = s; }), } as any); } // ---- stats tiles --------------------------------------------------------- private getStatsTiles(): IStatsTile[] { const providers = this.appData.providers || []; const total = providers.length; const registered = providers.filter((p) => p.registered).length; const unregistered = total - registered; return [ { id: 'total', title: 'Total Providers', value: total, type: 'number', icon: 'lucide:server', description: 'Configured SIP trunks', }, { id: 'registered', title: 'Registered', value: registered, type: 'number', icon: 'lucide:CheckCircle', color: 'hsl(142.1 76.2% 36.3%)', description: 'Active registrations', }, { id: 'unregistered', title: 'Unregistered', value: unregistered, type: 'number', icon: 'lucide:AlertCircle', color: unregistered > 0 ? 'hsl(0 84.2% 60.2%)' : undefined, description: unregistered > 0 ? 'Needs attention' : 'All healthy', }, ]; } // ---- table columns ------------------------------------------------------- private getColumns() { return [ { key: 'displayName', header: 'Name', sortable: true, }, { key: 'domain', header: 'Domain', value: (row: IProviderStatus) => (row as any).domain || '--', renderer: (val: string) => html`${val}`, }, { key: 'registered', header: 'Status', value: (row: IProviderStatus) => (row.registered ? 'Registered' : 'Not Registered'), renderer: (val: string, row: IProviderStatus) => { const on = row.registered; return html` ${val} `; }, }, { key: 'publicIp', header: 'Public IP', renderer: (val: any) => html`${val || '--'}`, }, ]; } // ---- table actions ------------------------------------------------------- private getDataActions() { return [ { name: 'Edit', iconName: 'lucide:pencil', type: ['inRow'] as any, actionFunc: async (actionData: any) => { await this.openEditModal(actionData.item.id); }, }, { name: 'Delete', iconName: 'lucide:Trash2', type: ['inRow'] as any, actionFunc: async (actionData: any) => { await this.confirmDelete(actionData.item); }, }, { name: 'Add Provider', iconName: 'lucide:plus', type: ['header'] as any, actionFunc: async () => { await this.openAddStepper(); }, }, ]; } // ---- add provider stepper ------------------------------------------------ private async openAddStepper() { const { DeesStepper } = await import('@design.estate/dees-catalog'); type TDeesStepper = InstanceType; // IStep / menuOptions types: we keep content typing loose (`any[]`) to // avoid having to import tsclass IMenuItem just for one parameter annotation. type TProviderType = 'Custom' | 'Sipgate' | 'O2/Alice'; interface IAccumulator { providerType: TProviderType; displayName: string; domain: string; outboundProxyAddress: string; outboundProxyPort: string; username: string; password: string; // Advanced — exposed in step 4 registerIntervalSec: string; codecs: string; earlyMediaSilence: boolean; } const accumulator: IAccumulator = { providerType: 'Custom', displayName: '', domain: '', outboundProxyAddress: '', outboundProxyPort: '5060', username: '', password: '', registerIntervalSec: '300', codecs: '9, 0, 8, 101', earlyMediaSilence: false, }; // Snapshot the currently-selected step's form (if any) into accumulator. const snapshotActiveForm = async (stepper: TDeesStepper) => { const form = stepper.activeForm; if (!form) return; const data: Record = await form.collectFormData(); Object.assign(accumulator, data); }; // Overwrite template-owned fields. Keep user-owned fields (username, // password) untouched. displayName is replaced only when empty or still // holds a branded auto-fill. const applyTemplate = (type: TProviderType) => { const tpl = type === 'Sipgate' ? PROVIDER_TEMPLATES.sipgate : type === 'O2/Alice' ? PROVIDER_TEMPLATES.o2 : null; if (!tpl) return; accumulator.domain = tpl.domain; accumulator.outboundProxyAddress = tpl.outboundProxy.address; accumulator.outboundProxyPort = String(tpl.outboundProxy.port); accumulator.registerIntervalSec = String(tpl.registerIntervalSec); accumulator.codecs = tpl.codecs.join(', '); accumulator.earlyMediaSilence = tpl.quirks.earlyMediaSilence; if ( !accumulator.displayName || accumulator.displayName === 'Sipgate' || accumulator.displayName === 'O2/Alice' ) { accumulator.displayName = type; } }; // --- Step builders (called after step 1 so accumulator is populated) --- const buildConnectionStep = (): any => ({ title: 'Connection', content: html` `, menuOptions: [ { name: 'Continue', iconName: 'lucide:arrow-right', action: async (stepper: TDeesStepper) => { await snapshotActiveForm(stepper); stepper.goNext(); }, }, ], }); const buildCredentialsStep = (): any => ({ title: 'Credentials', content: html` `, menuOptions: [ { name: 'Continue', iconName: 'lucide:arrow-right', action: async (stepper: TDeesStepper) => { await snapshotActiveForm(stepper); stepper.goNext(); }, }, ], }); const buildAdvancedStep = (): any => ({ title: 'Advanced', content: html` `, menuOptions: [ { name: 'Continue', iconName: 'lucide:arrow-right', action: async (stepper: TDeesStepper) => { await snapshotActiveForm(stepper); // Rebuild the review step so its rendering reflects the latest // accumulator values (the review step captures values at build time). stepper.steps = [...stepper.steps.slice(0, 4), buildReviewStep()]; await (stepper as any).updateComplete; stepper.goNext(); }, }, ], }); const buildReviewStep = (): any => { const resolvedId = slugify(accumulator.displayName); return { title: 'Review & Create', content: html`
Type
${accumulator.providerType}
Display Name
${accumulator.displayName}
ID
${resolvedId}
Domain
${accumulator.domain}
Outbound Proxy
${accumulator.outboundProxyAddress || accumulator.domain}:${accumulator.outboundProxyPort}
Username
${accumulator.username}
Password
${'*'.repeat(Math.min(accumulator.password.length, 12))}
Register Interval
${accumulator.registerIntervalSec}s
Codecs
${accumulator.codecs}
Early-Media Silence
${accumulator.earlyMediaSilence ? 'yes' : 'no'}
`, menuOptions: [ { name: 'Create Provider', iconName: 'lucide:check', action: async (stepper: TDeesStepper) => { // Collision-resolve id against current state. const existing = (this.appData.providers || []).map((p) => p.id); let uniqueId = resolvedId; let suffix = 2; while (existing.includes(uniqueId)) { uniqueId = `${resolvedId}-${suffix++}`; } const parsedCodecs = accumulator.codecs .split(',') .map((s: string) => parseInt(s.trim(), 10)) .filter((n: number) => !isNaN(n)); const newProvider: any = { id: uniqueId, displayName: accumulator.displayName.trim(), domain: accumulator.domain.trim(), outboundProxy: { address: accumulator.outboundProxyAddress.trim() || accumulator.domain.trim(), port: parseInt(accumulator.outboundProxyPort, 10) || 5060, }, username: accumulator.username.trim(), password: accumulator.password, registerIntervalSec: parseInt(accumulator.registerIntervalSec, 10) || 300, codecs: parsedCodecs.length ? parsedCodecs : [9, 0, 8, 101], quirks: { earlyMediaSilence: accumulator.earlyMediaSilence, }, }; try { const result = await appState.apiSaveConfig({ addProvider: newProvider, }); if (result.ok) { await stepper.destroy(); deesCatalog.DeesToast.success( `Provider "${newProvider.displayName}" created`, ); } else { deesCatalog.DeesToast.error('Failed to save provider'); } } catch (err: any) { console.error('Failed to create provider:', err); deesCatalog.DeesToast.error('Failed to create provider'); } }, }, ], }; }; // --- Step 1: Provider Type ------------------------------------------------ // // Note: `DeesStepper.createAndShow()` dismisses on backdrop click; a user // mid-form could lose work. Acceptable for v1 — revisit if users complain. const typeOptions: { option: string; key: TProviderType }[] = [ { option: 'Custom', key: 'Custom' }, { option: 'Sipgate', key: 'Sipgate' }, { option: 'O2 / Alice', key: 'O2/Alice' }, ]; const currentTypeOption = typeOptions.find((o) => o.key === accumulator.providerType) || null; const typeStep: any = { title: 'Choose provider type', content: html` `, menuOptions: [ { name: 'Continue', iconName: 'lucide:arrow-right', action: async (stepper: TDeesStepper) => { // `dees-input-dropdown.value` is an object `{option, key, payload?}`, // not a plain string — extract the `key` directly instead of using // the generic `snapshotActiveForm` helper (which would clobber // `accumulator.providerType`'s string type via Object.assign). const form = stepper.activeForm; if (form) { const data: Record = await form.collectFormData(); const selected = data.providerType; if (selected && typeof selected === 'object' && selected.key) { accumulator.providerType = selected.key as TProviderType; } } if (!accumulator.providerType) { accumulator.providerType = 'Custom'; } applyTemplate(accumulator.providerType); // (Re)build steps 2-5 with current accumulator values. stepper.steps = [ typeStep, buildConnectionStep(), buildCredentialsStep(), buildAdvancedStep(), buildReviewStep(), ]; await (stepper as any).updateComplete; stepper.goNext(); }, }, ], }; await DeesStepper.createAndShow({ steps: [typeStep] }); } // ---- edit provider modal ------------------------------------------------- private async openEditModal(providerId: string) { const { DeesModal } = await import('@design.estate/dees-catalog'); let cfg: any; try { cfg = await appState.apiGetConfig(); } catch { deesCatalog.DeesToast.error('Failed to load configuration'); return; } const provider = cfg.providers?.find((p: any) => p.id === providerId); if (!provider) { deesCatalog.DeesToast.error('Provider not found in configuration'); return; } const allDevices: { id: string; displayName: string }[] = (cfg.devices || []).map((d: any) => ({ id: d.id, displayName: d.displayName, })); const formData = { displayName: provider.displayName || '', domain: provider.domain || '', outboundProxyAddress: provider.outboundProxy?.address || '', outboundProxyPort: String(provider.outboundProxy?.port ?? 5060), username: provider.username || '', password: provider.password || '', registerIntervalSec: String(provider.registerIntervalSec ?? 300), codecs: (provider.codecs || []).join(', '), earlyMediaSilence: provider.quirks?.earlyMediaSilence ?? 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({ heading: `Edit Provider: ${formData.displayName}`, width: 'small', showCloseButton: true, content: html`
{ formData.displayName = (e.target as any).value; }} > { formData.domain = (e.target as any).value; }} > { formData.outboundProxyAddress = (e.target as any).value; }} > { formData.outboundProxyPort = (e.target as any).value; }} > { formData.username = (e.target as any).value; }} > { formData.password = (e.target as any).value; }} > { formData.registerIntervalSec = (e.target as any).value; }} > { formData.codecs = (e.target as any).value; }} > { formData.earlyMediaSilence = e.detail; }} >
Inbound Routing
Select which devices should ring when this provider receives an incoming call.
${allDevices.map((d) => html` { if (e.detail) { if (!formData.inboundDevices.includes(d.id)) { formData.inboundDevices = [...formData.inboundDevices, d.id]; } } else { formData.inboundDevices = formData.inboundDevices.filter((id) => id !== d.id); } }} > `)} { formData.ringBrowsers = e.detail; }} >
`, menuOptions: [ { name: 'Cancel', iconName: 'lucide:x', action: async (modalRef: any) => { modalRef.destroy(); }, }, { name: 'Save', iconName: 'lucide:check', action: async (modalRef: any) => { try { const codecs = formData.codecs .split(',') .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, displayName: formData.displayName.trim(), domain: formData.domain.trim(), outboundProxy: { address: formData.outboundProxyAddress.trim() || formData.domain.trim(), port: parseInt(formData.outboundProxyPort, 10) || 5060, }, username: formData.username.trim(), registerIntervalSec: parseInt(formData.registerIntervalSec, 10) || 300, codecs, quirks: { earlyMediaSilence: formData.earlyMediaSilence, }, }] as any[], routing: { routes: currentRoutes }, }; // Only send password if it was changed (not the masked placeholder). if (formData.password && !formData.password.match(/^\*+$/)) { updates.providers[0].password = formData.password; } const result = await appState.apiSaveConfig(updates); if (result.ok) { modalRef.destroy(); deesCatalog.DeesToast.success('Provider updated'); } else { deesCatalog.DeesToast.error('Failed to save changes'); } } catch (err: any) { console.error('Save failed:', err); deesCatalog.DeesToast.error('Failed to save changes'); } }, }, ], }); } // ---- delete confirmation ------------------------------------------------- private async confirmDelete(provider: IProviderStatus) { const { DeesModal } = await import('@design.estate/dees-catalog'); await DeesModal.createAndShow({ heading: 'Delete Provider', width: 'small', showCloseButton: true, content: html`
Are you sure you want to delete ${provider.displayName}? This action cannot be undone.
`, menuOptions: [ { name: 'Cancel', iconName: 'lucide:x', action: async (modalRef: any) => { modalRef.destroy(); }, }, { name: 'Delete', iconName: 'lucide:Trash2', action: async (modalRef: any) => { try { const result = await appState.apiSaveConfig({ removeProvider: provider.id, }); if (result.ok) { modalRef.destroy(); deesCatalog.DeesToast.success(`Provider "${provider.displayName}" deleted`); } else { deesCatalog.DeesToast.error('Failed to delete provider'); } } catch (err: any) { console.error('Delete failed:', err); deesCatalog.DeesToast.error('Failed to delete provider'); } }, }, ], }); } // ---- render -------------------------------------------------------------- public render(): TemplateResult { const providers = this.appData.providers || []; return html`
`; } }