Files
siprouter/ts_web/elements/sipproxy-view-providers.ts

806 lines
28 KiB
TypeScript
Raw Normal View History

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`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${val}</span>`,
},
{
key: 'registered',
header: 'Status',
value: (row: IProviderStatus) => (row.registered ? 'Registered' : 'Not Registered'),
renderer: (val: string, row: IProviderStatus) => {
const on = row.registered;
return html`
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;${on ? 'background:#4ade80;box-shadow:0 0 6px #4ade80' : 'background:#f87171;box-shadow:0 0 6px #f87171'}"></span>
<span style="color:${on ? '#4ade80' : '#f87171'}">${val}</span>
`;
},
},
{
key: 'publicIp',
header: 'Public IP',
renderer: (val: any) =>
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${val || '--'}</span>`,
},
];
}
// ---- 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<typeof DeesStepper>;
// 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<string, any> = 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`
<dees-form>
<dees-input-text
.key=${'displayName'}
.label=${'Display Name'}
.value=${accumulator.displayName}
.required=${true}
></dees-input-text>
<dees-input-text
.key=${'domain'}
.label=${'Domain'}
.value=${accumulator.domain}
.required=${true}
></dees-input-text>
<dees-input-text
.key=${'outboundProxyAddress'}
.label=${'Outbound Proxy Address'}
.value=${accumulator.outboundProxyAddress}
></dees-input-text>
<dees-input-text
.key=${'outboundProxyPort'}
.label=${'Outbound Proxy Port'}
.value=${accumulator.outboundProxyPort}
></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Continue',
iconName: 'lucide:arrow-right',
action: async (stepper: TDeesStepper) => {
await snapshotActiveForm(stepper);
stepper.goNext();
},
},
],
});
const buildCredentialsStep = (): any => ({
title: 'Credentials',
content: html`
<dees-form>
<dees-input-text
.key=${'username'}
.label=${'Username / Auth ID'}
.value=${accumulator.username}
.required=${true}
></dees-input-text>
<dees-input-text
.key=${'password'}
.label=${'Password'}
.isPasswordBool=${true}
.value=${accumulator.password}
.required=${true}
></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Continue',
iconName: 'lucide:arrow-right',
action: async (stepper: TDeesStepper) => {
await snapshotActiveForm(stepper);
stepper.goNext();
},
},
],
});
const buildAdvancedStep = (): any => ({
title: 'Advanced',
content: html`
<dees-form>
<dees-input-text
.key=${'registerIntervalSec'}
.label=${'Register Interval (sec)'}
.value=${accumulator.registerIntervalSec}
></dees-input-text>
<dees-input-text
.key=${'codecs'}
.label=${'Codecs (comma-separated payload types)'}
.value=${accumulator.codecs}
></dees-input-text>
<dees-input-checkbox
.key=${'earlyMediaSilence'}
.label=${'Early Media Silence (quirk)'}
.value=${accumulator.earlyMediaSilence}
></dees-input-checkbox>
</dees-form>
`,
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`
<dees-panel>
<div
style="display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:.85rem;padding:8px 4px;"
>
<div style="color:#94a3b8;">Type</div>
<div>${accumulator.providerType}</div>
<div style="color:#94a3b8;">Display Name</div>
<div>${accumulator.displayName}</div>
<div style="color:#94a3b8;">ID</div>
<div style="font-family:'JetBrains Mono',monospace;">${resolvedId}</div>
<div style="color:#94a3b8;">Domain</div>
<div>${accumulator.domain}</div>
<div style="color:#94a3b8;">Outbound Proxy</div>
<div>
${accumulator.outboundProxyAddress || accumulator.domain}:${accumulator.outboundProxyPort}
</div>
<div style="color:#94a3b8;">Username</div>
<div>${accumulator.username}</div>
<div style="color:#94a3b8;">Password</div>
<div>${'*'.repeat(Math.min(accumulator.password.length, 12))}</div>
<div style="color:#94a3b8;">Register Interval</div>
<div>${accumulator.registerIntervalSec}s</div>
<div style="color:#94a3b8;">Codecs</div>
<div>${accumulator.codecs}</div>
<div style="color:#94a3b8;">Early-Media Silence</div>
<div>${accumulator.earlyMediaSilence ? 'yes' : 'no'}</div>
</div>
</dees-panel>
`,
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`
<dees-form>
<dees-input-dropdown
.key=${'providerType'}
.label=${'Provider Type'}
.options=${typeOptions}
.selectedOption=${currentTypeOption}
.enableSearch=${false}
.required=${true}
></dees-input-dropdown>
</dees-form>
`,
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<string, any> = 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`
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
<dees-input-text
.key=${'displayName'}
.label=${'Display Name'}
.value=${formData.displayName}
@input=${(e: Event) => { formData.displayName = (e.target as any).value; }}
></dees-input-text>
<dees-input-text
.key=${'domain'}
.label=${'Domain'}
.value=${formData.domain}
@input=${(e: Event) => { formData.domain = (e.target as any).value; }}
></dees-input-text>
<dees-input-text
.key=${'outboundProxyAddress'}
.label=${'Outbound Proxy Address'}
.value=${formData.outboundProxyAddress}
@input=${(e: Event) => { formData.outboundProxyAddress = (e.target as any).value; }}
></dees-input-text>
<dees-input-text
.key=${'outboundProxyPort'}
.label=${'Outbound Proxy Port'}
.value=${formData.outboundProxyPort}
@input=${(e: Event) => { formData.outboundProxyPort = (e.target as any).value; }}
></dees-input-text>
<dees-input-text
.key=${'username'}
.label=${'Username / Auth ID'}
.value=${formData.username}
@input=${(e: Event) => { formData.username = (e.target as any).value; }}
></dees-input-text>
<dees-input-text
.key=${'password'}
.label=${'Password'}
.isPasswordBool=${true}
.value=${formData.password}
.description=${'Leave unchanged to keep existing password'}
@input=${(e: Event) => { formData.password = (e.target as any).value; }}
></dees-input-text>
<dees-input-text
.key=${'registerIntervalSec'}
.label=${'Register Interval (sec)'}
.value=${formData.registerIntervalSec}
@input=${(e: Event) => { formData.registerIntervalSec = (e.target as any).value; }}
></dees-input-text>
<dees-input-text
.key=${'codecs'}
.label=${'Codecs (comma-separated payload types)'}
.value=${formData.codecs}
@input=${(e: Event) => { formData.codecs = (e.target as any).value; }}
></dees-input-text>
<dees-input-checkbox
.key=${'earlyMediaSilence'}
.label=${'Early Media Silence (quirk)'}
.value=${formData.earlyMediaSilence}
@newValue=${(e: CustomEvent) => { formData.earlyMediaSilence = 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;">
Inbound Routing
</div>
<div style="font-size:.8rem;color:#64748b;margin-bottom:12px;">
Select which devices should ring when this provider receives an incoming call.
</div>
${allDevices.map((d) => html`
<dees-input-checkbox
.key=${`inbound-${d.id}`}
.label=${d.displayName}
.value=${formData.inboundDevices.includes(d.id)}
@newValue=${(e: CustomEvent) => {
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);
}
}}
></dees-input-checkbox>
`)}
<dees-input-checkbox
.key=${'ringBrowsers'}
.label=${'Also ring all connected browsers'}
.value=${formData.ringBrowsers}
@newValue=${(e: CustomEvent) => { formData.ringBrowsers = e.detail; }}
></dees-input-checkbox>
</div>
</div>
`,
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`
<div style="padding:8px 0;font-size:.9rem;color:#e2e8f0;">
Are you sure you want to delete
<strong style="color:#f87171;">${provider.displayName}</strong>?
This action cannot be undone.
</div>
`,
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`
<div class="view-section">
<dees-statsgrid
.tiles=${this.getStatsTiles()}
.minTileWidth=${220}
.gap=${16}
></dees-statsgrid>
</div>
<div class="view-section">
<dees-table
heading1="Providers"
heading2="${providers.length} configured"
dataName="providers"
.data=${providers}
.rowKey=${'id'}
.searchable=${true}
.columns=${this.getColumns()}
.dataActions=${this.getDataActions()}
></dees-table>
</div>
`;
}
}