initial commit — SIP B2BUA + WebRTC bridge with Rust codec engine
Full-featured SIP router with multi-provider trunking, browser softphone via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML noise suppression, Kokoro neural TTS announcements, and a Lit-based web dashboard with live call monitoring and REST API.
This commit is contained in:
11
ts_web/elements/index.ts
Normal file
11
ts_web/elements/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Views
|
||||
export * from './sipproxy-app.js';
|
||||
export * from './sipproxy-view-overview.js';
|
||||
export * from './sipproxy-view-calls.js';
|
||||
export * from './sipproxy-view-phone.js';
|
||||
export * from './sipproxy-view-contacts.js';
|
||||
export * from './sipproxy-view-providers.js';
|
||||
export * from './sipproxy-view-log.js';
|
||||
|
||||
// Sub-components (used within views)
|
||||
export * from './sipproxy-devices.js';
|
||||
10
ts_web/elements/shared/css.ts
Normal file
10
ts_web/elements/shared/css.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { css } from '../../plugins.js';
|
||||
|
||||
export const viewHostCss = css`
|
||||
:host {
|
||||
display: block;
|
||||
margin: auto;
|
||||
max-width: 1280px;
|
||||
padding: 16px;
|
||||
}
|
||||
`;
|
||||
1
ts_web/elements/shared/index.ts
Normal file
1
ts_web/elements/shared/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { viewHostCss } from './css.js';
|
||||
90
ts_web/elements/sipproxy-app.ts
Normal file
90
ts_web/elements/sipproxy-app.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||
import { deesCatalog } from '../plugins.js';
|
||||
import { NotificationManager } from '../state/notification-manager.js';
|
||||
import { appRouter } from '../router.js';
|
||||
import { SipproxyViewOverview } from './sipproxy-view-overview.js';
|
||||
import { SipproxyViewCalls } from './sipproxy-view-calls.js';
|
||||
import { SipproxyViewPhone } from './sipproxy-view-phone.js';
|
||||
import { SipproxyViewContacts } from './sipproxy-view-contacts.js';
|
||||
import { SipproxyViewProviders } from './sipproxy-view-providers.js';
|
||||
import { SipproxyViewLog } from './sipproxy-view-log.js';
|
||||
|
||||
const VIEW_TABS = [
|
||||
{ name: 'Overview', iconName: 'lucide:layoutDashboard', element: SipproxyViewOverview },
|
||||
{ name: 'Calls', iconName: 'lucide:phone', element: SipproxyViewCalls },
|
||||
{ name: 'Phone', iconName: 'lucide:headset', element: SipproxyViewPhone },
|
||||
{ name: 'Contacts', iconName: 'lucide:contactRound', element: SipproxyViewContacts },
|
||||
{ name: 'Providers', iconName: 'lucide:server', element: SipproxyViewProviders },
|
||||
{ name: 'Log', iconName: 'lucide:scrollText', element: SipproxyViewLog },
|
||||
];
|
||||
|
||||
// Map slug -> tab for routing.
|
||||
const SLUG_TO_TAB = new Map(VIEW_TABS.map((t) => [t.name.toLowerCase(), t]));
|
||||
|
||||
@customElement('sipproxy-app')
|
||||
export class SipproxyApp extends DeesElement {
|
||||
private notificationManager = new NotificationManager();
|
||||
private appdash: InstanceType<typeof deesCatalog.DeesSimpleAppDash> | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host { display: block; height: 100%; }
|
||||
dees-simple-appdash { height: 100%; }
|
||||
`,
|
||||
];
|
||||
|
||||
private suppressViewSelectEvent = false;
|
||||
|
||||
async firstUpdated() {
|
||||
this.appdash = this.shadowRoot?.querySelector('dees-simple-appdash') as InstanceType<typeof deesCatalog.DeesSimpleAppDash>;
|
||||
if (this.appdash) {
|
||||
this.notificationManager.init(this.appdash);
|
||||
|
||||
// Listen for user tab selections — sync URL.
|
||||
this.appdash.addEventListener('view-select', ((e: CustomEvent) => {
|
||||
if (this.suppressViewSelectEvent) return;
|
||||
const viewName: string = e.detail?.view?.name || e.detail?.name || '';
|
||||
const slug = viewName.toLowerCase();
|
||||
if (slug && slug !== appRouter.getCurrentView()) {
|
||||
appRouter.navigateTo(slug as any, true);
|
||||
}
|
||||
}) as EventListener);
|
||||
|
||||
// Wire up router -> appdash (for browser back/forward).
|
||||
appRouter.setNavigateHandler((view) => {
|
||||
const tab = SLUG_TO_TAB.get(view);
|
||||
if (tab && this.appdash) {
|
||||
this.suppressViewSelectEvent = true;
|
||||
this.appdash.loadView(tab);
|
||||
this.suppressViewSelectEvent = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Deep link: if URL isn't "overview", navigate to the right tab.
|
||||
const initial = appRouter.getCurrentView();
|
||||
if (initial !== 'overview') {
|
||||
const tab = SLUG_TO_TAB.get(initial);
|
||||
if (tab) {
|
||||
this.suppressViewSelectEvent = true;
|
||||
this.appdash.loadView(tab);
|
||||
this.suppressViewSelectEvent = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.notificationManager.destroy();
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<dees-simple-appdash
|
||||
.name=${'SipRouter'}
|
||||
.viewTabs=${VIEW_TABS}
|
||||
></dees-simple-appdash>
|
||||
`;
|
||||
}
|
||||
}
|
||||
56
ts_web/elements/sipproxy-devices.ts
Normal file
56
ts_web/elements/sipproxy-devices.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { DeesElement, customElement, html, css, cssManager, property, type TemplateResult } from '../plugins.js';
|
||||
import type { IDeviceStatus } from '../state/appstate.js';
|
||||
|
||||
@customElement('sipproxy-devices')
|
||||
export class SipproxyDevices extends DeesElement {
|
||||
@property({ type: Array }) accessor devices: IDeviceStatus[] = [];
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host { display: block; margin-bottom: 1.5rem; }
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<dees-table
|
||||
heading1="Devices"
|
||||
heading2="${this.devices.length} registered"
|
||||
dataName="devices"
|
||||
.data=${this.devices}
|
||||
.rowKey=${'id'}
|
||||
highlight-updates="flash"
|
||||
.searchable=${false}
|
||||
.columns=${[
|
||||
{
|
||||
key: 'displayName',
|
||||
header: 'Device',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
value: (row: any) => (row.connected ? 'Connected' : 'Disconnected'),
|
||||
renderer: (val: string, row: any) => {
|
||||
const on = row.connected;
|
||||
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: 'contact',
|
||||
header: 'Contact',
|
||||
renderer: (_val: any, row: any) => {
|
||||
const c = row.contact;
|
||||
const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--';
|
||||
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`;
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
670
ts_web/elements/sipproxy-view-calls.ts
Normal file
670
ts_web/elements/sipproxy-view-calls.ts
Normal file
@@ -0,0 +1,670 @@
|
||||
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||
import { deesCatalog } from '../plugins.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
import { appState, type IAppState, type ICallStatus, type ICallHistoryEntry, type ILegStatus, type IDeviceStatus } from '../state/appstate.js';
|
||||
import { viewHostCss } from './shared/index.js';
|
||||
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
'setting-up': 'Setting Up',
|
||||
'ringing': 'Ringing',
|
||||
'connected': 'Connected',
|
||||
'on-hold': 'On Hold',
|
||||
'transferring': 'Transferring',
|
||||
'terminating': 'Hanging Up',
|
||||
'terminated': 'Ended',
|
||||
};
|
||||
|
||||
function stateBadgeStyle(s: string): string {
|
||||
if (s.includes('ringing') || s === 'setting-up') return 'background:#854d0e;color:#fbbf24';
|
||||
if (s === 'connected') return 'background:#166534;color:#4ade80';
|
||||
if (s === 'on-hold') return 'background:#1e3a5f;color:#38bdf8';
|
||||
return 'background:#7f1d1d;color:#f87171';
|
||||
}
|
||||
|
||||
function legTypeBadgeStyle(type: string): string {
|
||||
if (type === 'sip-device') return 'background:#1e3a5f;color:#38bdf8';
|
||||
if (type === 'sip-provider') return 'background:#4a1d7a;color:#c084fc';
|
||||
if (type === 'webrtc') return 'background:#065f46;color:#34d399';
|
||||
return 'background:#374151;color:#9ca3af';
|
||||
}
|
||||
|
||||
const LEG_TYPE_LABELS: Record<string, string> = {
|
||||
'sip-device': 'SIP Device',
|
||||
'sip-provider': 'SIP Provider',
|
||||
'webrtc': 'WebRTC',
|
||||
};
|
||||
|
||||
function directionIcon(dir: string): string {
|
||||
if (dir === 'inbound') return '\u2199';
|
||||
if (dir === 'outbound') return '\u2197';
|
||||
return '\u2194';
|
||||
}
|
||||
|
||||
function fmtDuration(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
if (m > 0) return `${m}m ${String(s).padStart(2, '0')}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
@customElement('sipproxy-view-calls')
|
||||
export class SipproxyViewCalls 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;
|
||||
}
|
||||
|
||||
.calls-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Active call tile content */
|
||||
.call-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.direction-icon {
|
||||
font-size: 1.1rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: var(--dees-color-bg-primary, #0f172a);
|
||||
border: 1px solid var(--dees-color-border-default, #334155);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.call-parties {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.call-parties .label {
|
||||
color: var(--dees-color-text-secondary, #64748b);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.call-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.6rem;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.call-duration {
|
||||
font-size: 0.8rem;
|
||||
color: var(--dees-color-text-secondary, #94a3b8);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.call-id {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
color: #475569;
|
||||
padding: 0 16px 8px;
|
||||
}
|
||||
|
||||
.call-body {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.legs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.legs-table th {
|
||||
text-align: left;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--dees-color-border-default, #334155);
|
||||
}
|
||||
|
||||
.legs-table td {
|
||||
padding: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
border-bottom: 1px solid var(--dees-color-border-subtle, rgba(51, 65, 85, 0.5));
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.legs-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover { opacity: 0.85; }
|
||||
.btn:active { opacity: 0.7; }
|
||||
.btn-danger { background: #dc2626; color: #fff; }
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-secondary { background: #334155; color: #e2e8f0; }
|
||||
|
||||
.btn-remove {
|
||||
background: transparent;
|
||||
color: #f87171;
|
||||
border: 1px solid #7f1d1d;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: #64748b;
|
||||
}
|
||||
.empty-state-icon { font-size: 2.5rem; margin-bottom: 12px; opacity: 0.4; }
|
||||
.empty-state-text { font-size: 0.9rem; font-weight: 500; }
|
||||
.empty-state-sub { font-size: 0.75rem; margin-top: 4px; }
|
||||
`,
|
||||
];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.rxSubscriptions.push({
|
||||
unsubscribe: appState.subscribe((s) => {
|
||||
this.appData = s;
|
||||
}),
|
||||
} as any);
|
||||
}
|
||||
|
||||
private async handleAddParticipant(call: ICallStatus) {
|
||||
const devices = this.appData.devices?.filter((d) => d.connected) || [];
|
||||
let selectedDeviceId = devices.length > 0 ? devices[0].id : '';
|
||||
|
||||
await deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Add Participant',
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
content: html`
|
||||
<div style="padding: 0.5rem 0;">
|
||||
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
||||
Select Device
|
||||
</div>
|
||||
<select
|
||||
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.85rem;outline:none;box-sizing:border-box;"
|
||||
@change=${(e: Event) => {
|
||||
selectedDeviceId = (e.target as HTMLSelectElement).value;
|
||||
}}
|
||||
>
|
||||
${devices.map(
|
||||
(d) => html`<option value=${d.id}>${d.displayName}${d.isBrowser ? ' (Browser)' : ''}</option>`,
|
||||
)}
|
||||
</select>
|
||||
${devices.length === 0
|
||||
? html`<div style="color:#f87171;font-size:.75rem;margin-top:.5rem;">No connected devices available.</div>`
|
||||
: ''}
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Add',
|
||||
iconName: 'lucide:plus',
|
||||
action: async (modalRef: any) => {
|
||||
if (!selectedDeviceId) return;
|
||||
const res = await appState.apiAddLeg(call.id, selectedDeviceId);
|
||||
if (res.ok) {
|
||||
deesCatalog.DeesToast.success('Participant added');
|
||||
} else {
|
||||
deesCatalog.DeesToast.error('Failed to add participant');
|
||||
}
|
||||
modalRef.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => {
|
||||
modalRef.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async handleAddExternal(call: ICallStatus) {
|
||||
const providers = this.appData.providers?.filter((p) => p.registered) || [];
|
||||
let number = '';
|
||||
let selectedProviderId = providers.length > 0 ? providers[0].id : '';
|
||||
|
||||
await deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Add External Participant',
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
content: html`
|
||||
<div style="padding: 0.5rem 0;">
|
||||
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
||||
Phone Number
|
||||
</div>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="Enter number to dial..."
|
||||
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.95rem;font-family:'JetBrains Mono',monospace;outline:none;box-sizing:border-box;margin-bottom:12px;"
|
||||
@input=${(e: InputEvent) => { number = (e.target as HTMLInputElement).value; }}
|
||||
>
|
||||
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
||||
Via Provider
|
||||
</div>
|
||||
<select
|
||||
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.85rem;outline:none;box-sizing:border-box;"
|
||||
@change=${(e: Event) => { selectedProviderId = (e.target as HTMLSelectElement).value; }}
|
||||
>
|
||||
${providers.map(
|
||||
(p) => html`<option value=${p.id}>${p.displayName}</option>`,
|
||||
)}
|
||||
</select>
|
||||
${providers.length === 0
|
||||
? html`<div style="color:#f87171;font-size:.75rem;margin-top:.5rem;">No registered providers available.</div>`
|
||||
: ''}
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Dial',
|
||||
iconName: 'lucide:phoneOutgoing',
|
||||
action: async (modalRef: any) => {
|
||||
if (!number.trim()) {
|
||||
deesCatalog.DeesToast.error('Enter a phone number');
|
||||
return;
|
||||
}
|
||||
const res = await appState.apiAddExternal(call.id, number.trim(), selectedProviderId || undefined);
|
||||
if (res.ok) {
|
||||
deesCatalog.DeesToast.success(`Dialing ${number}...`);
|
||||
} else {
|
||||
deesCatalog.DeesToast.error('Failed to dial external number');
|
||||
}
|
||||
modalRef.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => {
|
||||
modalRef.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async handleRemoveLeg(call: ICallStatus, leg: ILegStatus) {
|
||||
const res = await appState.apiRemoveLeg(call.id, leg.id);
|
||||
if (res.ok) {
|
||||
deesCatalog.DeesToast.success('Leg removed');
|
||||
} else {
|
||||
deesCatalog.DeesToast.error('Failed to remove leg');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTransfer(call: ICallStatus) {
|
||||
let targetCallId = '';
|
||||
let targetLegId = '';
|
||||
|
||||
const otherCalls =
|
||||
this.appData.calls?.filter((c) => c.id !== call.id && c.state !== 'terminated') || [];
|
||||
|
||||
await deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Transfer Call',
|
||||
width: 'small',
|
||||
showCloseButton: true,
|
||||
content: html`
|
||||
<div style="padding: 0.5rem 0;">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
||||
Target Call ID
|
||||
</div>
|
||||
<select
|
||||
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.85rem;outline:none;box-sizing:border-box;"
|
||||
@change=${(e: Event) => {
|
||||
targetCallId = (e.target as HTMLSelectElement).value;
|
||||
}}
|
||||
>
|
||||
<option value="">Select a call...</option>
|
||||
${otherCalls.map(
|
||||
(c) =>
|
||||
html`<option value=${c.id}>
|
||||
${c.direction} - ${c.callerNumber || '?'} -> ${c.calleeNumber || '?'}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:.7rem;color:#64748b;margin-bottom:.35rem;text-transform:uppercase;letter-spacing:.04em;">
|
||||
Leg ID to transfer
|
||||
</div>
|
||||
<select
|
||||
style="width:100%;padding:.5rem .6rem;border-radius:6px;border:1px solid #334155;background:#0c0f1a;color:#e2e8f0;font-size:.85rem;outline:none;box-sizing:border-box;"
|
||||
@change=${(e: Event) => {
|
||||
targetLegId = (e.target as HTMLSelectElement).value;
|
||||
}}
|
||||
>
|
||||
<option value="">Select a leg...</option>
|
||||
${call.legs.map(
|
||||
(l) =>
|
||||
html`<option value=${l.id}>${LEG_TYPE_LABELS[l.type] || l.type} - ${l.state}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
${otherCalls.length === 0
|
||||
? html`<div style="color:#f87171;font-size:.75rem;margin-top:.75rem;">
|
||||
No other active calls to transfer to.
|
||||
</div>`
|
||||
: ''}
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Transfer',
|
||||
iconName: 'lucide:arrow-right-left',
|
||||
action: async (modalRef: any) => {
|
||||
if (!targetCallId || !targetLegId) {
|
||||
deesCatalog.DeesToast.error('Please select both a target call and a leg');
|
||||
return;
|
||||
}
|
||||
const res = await appState.apiTransfer(call.id, targetLegId, targetCallId);
|
||||
if (res.ok) {
|
||||
deesCatalog.DeesToast.success('Transfer initiated');
|
||||
} else {
|
||||
deesCatalog.DeesToast.error('Transfer failed');
|
||||
}
|
||||
modalRef.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => {
|
||||
modalRef.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async handleHangup(call: ICallStatus) {
|
||||
await appState.apiHangup(call.id);
|
||||
}
|
||||
|
||||
private getHistoryColumns() {
|
||||
return [
|
||||
{
|
||||
key: 'direction',
|
||||
header: 'Direction',
|
||||
renderer: (val: string) => {
|
||||
return html`<span style="margin-right:6px">${directionIcon(val)}</span><span class="badge" style="background:#374151;color:#9ca3af">${val}</span>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'callerNumber',
|
||||
header: 'From',
|
||||
renderer: (val: string | null) =>
|
||||
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.8rem">${val || '-'}</span>`,
|
||||
},
|
||||
{
|
||||
key: 'calleeNumber',
|
||||
header: 'To',
|
||||
renderer: (val: string | null) =>
|
||||
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.8rem">${val || '-'}</span>`,
|
||||
},
|
||||
{
|
||||
key: 'providerUsed',
|
||||
header: 'Provider',
|
||||
renderer: (val: string | null) => val || '-',
|
||||
},
|
||||
{
|
||||
key: 'startedAt',
|
||||
header: 'Time',
|
||||
renderer: (val: number) =>
|
||||
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${fmtTime(val)}</span>`,
|
||||
},
|
||||
{
|
||||
key: 'duration',
|
||||
header: 'Duration',
|
||||
renderer: (val: number) =>
|
||||
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${fmtDuration(val)}</span>`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private renderCallCard(call: ICallStatus): TemplateResult {
|
||||
return html`
|
||||
<dees-tile>
|
||||
<div slot="header">
|
||||
<div class="call-header">
|
||||
<div class="direction-icon">${directionIcon(call.direction)}</div>
|
||||
<div class="call-parties">
|
||||
${call.callerNumber
|
||||
? html`<span class="label">From</span>${call.callerNumber}`
|
||||
: ''}
|
||||
${call.callerNumber && call.calleeNumber ? html` → ` : ''}
|
||||
${call.calleeNumber
|
||||
? html`<span class="label">To</span>${call.calleeNumber}`
|
||||
: ''}
|
||||
</div>
|
||||
<div class="call-meta">
|
||||
<span class="badge" style="${stateBadgeStyle(call.state)}">
|
||||
${STATE_LABELS[call.state] || call.state}
|
||||
</span>
|
||||
${call.providerUsed
|
||||
? html`<span class="badge" style="background:#374151;color:#9ca3af">${call.providerUsed}</span>`
|
||||
: ''}
|
||||
<span class="call-duration">${fmtDuration(call.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="call-id">${call.id}</div>
|
||||
<div class="call-body">
|
||||
${call.legs.length
|
||||
? html`
|
||||
<table class="legs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>State</th>
|
||||
<th>Remote</th>
|
||||
<th>Port</th>
|
||||
<th>Codec</th>
|
||||
<th>Pkts In</th>
|
||||
<th>Pkts Out</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${call.legs.map(
|
||||
(leg) => html`
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge" style="${legTypeBadgeStyle(leg.type)}">
|
||||
${LEG_TYPE_LABELS[leg.type] || leg.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" style="${stateBadgeStyle(leg.state)}">
|
||||
${leg.state}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
${leg.remoteMedia
|
||||
? `${leg.remoteMedia.address}:${leg.remoteMedia.port}`
|
||||
: '--'}
|
||||
</td>
|
||||
<td>${leg.rtpPort ?? '--'}</td>
|
||||
<td>
|
||||
${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''}
|
||||
</td>
|
||||
<td>${leg.pktReceived}</td>
|
||||
<td>${leg.pktSent}</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-remove"
|
||||
@click=${() => this.handleRemoveLeg(call, leg)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
`
|
||||
: html`<div style="color:#64748b;font-size:.75rem;font-style:italic;margin-bottom:8px;">
|
||||
No legs
|
||||
</div>`}
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-primary" @click=${() => this.handleAddParticipant(call)}>
|
||||
Add Device
|
||||
</button>
|
||||
<button class="btn btn-primary" @click=${() => this.handleAddExternal(call)}>
|
||||
Add External
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click=${() => this.handleTransfer(call)}>
|
||||
Transfer
|
||||
</button>
|
||||
<button class="btn btn-danger" @click=${() => this.handleHangup(call)}>
|
||||
Hang Up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
`;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const { appData } = this;
|
||||
const activeCalls = appData.calls?.filter((c) => c.state !== 'terminated') || [];
|
||||
const history = appData.callHistory || [];
|
||||
const inboundCount = activeCalls.filter((c) => c.direction === 'inbound').length;
|
||||
const outboundCount = activeCalls.filter((c) => c.direction === 'outbound').length;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'active',
|
||||
title: 'Active Calls',
|
||||
value: activeCalls.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone',
|
||||
color: 'hsl(142.1 76.2% 36.3%)',
|
||||
description: activeCalls.length === 1 ? '1 call' : `${activeCalls.length} calls`,
|
||||
},
|
||||
{
|
||||
id: 'inbound',
|
||||
title: 'Inbound',
|
||||
value: inboundCount,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone-incoming',
|
||||
description: 'Incoming calls',
|
||||
},
|
||||
{
|
||||
id: 'outbound',
|
||||
title: 'Outbound',
|
||||
value: outboundCount,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone-outgoing',
|
||||
description: 'Outgoing calls',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="view-section">
|
||||
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
||||
</div>
|
||||
|
||||
<div class="view-section">
|
||||
<div class="calls-list">
|
||||
${activeCalls.length > 0
|
||||
? activeCalls.map((call) => this.renderCallCard(call))
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📞</div>
|
||||
<div class="empty-state-text">No active calls</div>
|
||||
<div class="empty-state-sub">
|
||||
Calls will appear here when they are in progress
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view-section">
|
||||
<dees-table
|
||||
heading1="Call History"
|
||||
heading2="${history.length} calls"
|
||||
dataName="calls"
|
||||
.data=${history}
|
||||
.rowKey=${'id'}
|
||||
.searchable=${true}
|
||||
.columns=${this.getHistoryColumns()}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
372
ts_web/elements/sipproxy-view-contacts.ts
Normal file
372
ts_web/elements/sipproxy-view-contacts.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||
import { deesCatalog } from '../plugins.js';
|
||||
import { appState, type IAppState, type IContact } from '../state/appstate.js';
|
||||
import { appRouter } from '../router.js';
|
||||
import { viewHostCss } from './shared/index.js';
|
||||
import type { IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
@customElement('sipproxy-view-contacts')
|
||||
export class SipproxyViewContacts extends DeesElement {
|
||||
@state() accessor appData: IAppState = appState.getState();
|
||||
@state() accessor saving = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
}
|
||||
.view-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.rxSubscriptions.push({
|
||||
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
|
||||
} as any);
|
||||
}
|
||||
|
||||
// ---------- CRUD operations ----------
|
||||
|
||||
private async openAddModal() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
const formData = { name: '', number: '', company: '', notes: '' };
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Add Contact',
|
||||
content: html`
|
||||
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
||||
<dees-input-text
|
||||
.label=${'Name'}
|
||||
.required=${true}
|
||||
.value=${''}
|
||||
@input=${(e: Event) => { formData.name = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.label=${'Number'}
|
||||
.required=${true}
|
||||
.value=${''}
|
||||
@input=${(e: Event) => { formData.number = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.label=${'Company'}
|
||||
.value=${''}
|
||||
@input=${(e: Event) => { formData.company = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.label=${'Notes'}
|
||||
.value=${''}
|
||||
@input=${(e: Event) => { formData.notes = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
action: async (modal) => { modal.destroy(); },
|
||||
},
|
||||
{
|
||||
name: 'Add Contact',
|
||||
action: async (modal) => {
|
||||
const name = formData.name.trim();
|
||||
const number = formData.number.trim();
|
||||
|
||||
if (!name || !number) {
|
||||
deesCatalog.DeesToast.error('Name and number are required', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const newContact: IContact = {
|
||||
id: `contact-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name,
|
||||
number,
|
||||
company: formData.company.trim() || undefined,
|
||||
notes: formData.notes.trim() || undefined,
|
||||
};
|
||||
|
||||
await this.saveContacts([...this.appData.contacts, newContact]);
|
||||
modal.destroy();
|
||||
deesCatalog.DeesToast.info('Contact added');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async openEditModal(contact: IContact) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
const formData = {
|
||||
name: contact.name,
|
||||
number: contact.number,
|
||||
company: contact.company || '',
|
||||
notes: contact.notes || '',
|
||||
};
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Edit Contact',
|
||||
content: html`
|
||||
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
||||
<dees-input-text
|
||||
.label=${'Name'}
|
||||
.required=${true}
|
||||
.value=${formData.name}
|
||||
@input=${(e: Event) => { formData.name = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.label=${'Number'}
|
||||
.required=${true}
|
||||
.value=${formData.number}
|
||||
@input=${(e: Event) => { formData.number = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.label=${'Company'}
|
||||
.value=${formData.company}
|
||||
@input=${(e: Event) => { formData.company = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.label=${'Notes'}
|
||||
.value=${formData.notes}
|
||||
@input=${(e: Event) => { formData.notes = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
action: async (modal) => { modal.destroy(); },
|
||||
},
|
||||
{
|
||||
name: 'Save Changes',
|
||||
action: async (modal) => {
|
||||
const name = formData.name.trim();
|
||||
const number = formData.number.trim();
|
||||
|
||||
if (!name || !number) {
|
||||
deesCatalog.DeesToast.error('Name and number are required', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedContacts = this.appData.contacts.map((c) =>
|
||||
c.id === contact.id
|
||||
? { ...c, name, number, company: formData.company.trim() || undefined, notes: formData.notes.trim() || undefined }
|
||||
: c,
|
||||
);
|
||||
|
||||
await this.saveContacts(updatedContacts);
|
||||
modal.destroy();
|
||||
deesCatalog.DeesToast.info('Contact updated');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async deleteContact(contact: IContact) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Delete Contact',
|
||||
content: html`
|
||||
<p style="color: #e2e8f0; margin: 0 0 8px;">
|
||||
Are you sure you want to delete <strong>${contact.name}</strong>?
|
||||
</p>
|
||||
<p style="color: #94a3b8; font-size: 0.85rem; margin: 0;">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
action: async (modal) => { modal.destroy(); },
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
action: async (modal) => {
|
||||
const updatedContacts = this.appData.contacts.filter((c) => c.id !== contact.id);
|
||||
await this.saveContacts(updatedContacts);
|
||||
modal.destroy();
|
||||
deesCatalog.DeesToast.info('Contact deleted');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async toggleStar(contact: IContact) {
|
||||
const updatedContacts = this.appData.contacts.map((c) =>
|
||||
c.id === contact.id ? { ...c, starred: !c.starred } : c,
|
||||
);
|
||||
await this.saveContacts(updatedContacts);
|
||||
}
|
||||
|
||||
private async saveContacts(contacts: IContact[]) {
|
||||
this.saving = true;
|
||||
try {
|
||||
const config = await appState.apiGetConfig();
|
||||
config.contacts = contacts;
|
||||
await appState.apiSaveConfig(config);
|
||||
} catch (e: any) {
|
||||
deesCatalog.DeesToast.error(`Save failed: ${e.message}`, 4000);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private getColumns() {
|
||||
return [
|
||||
{
|
||||
key: 'starred',
|
||||
header: '',
|
||||
sortable: false,
|
||||
renderer: (val: boolean | undefined, row: IContact) => {
|
||||
const starred = val === true;
|
||||
return html`
|
||||
<span
|
||||
style="cursor:pointer;font-size:1.2rem;color:${starred ? '#fbbf24' : '#475569'};transition:color 0.15s;"
|
||||
@click=${(e: Event) => { e.stopPropagation(); this.toggleStar(row); }}
|
||||
>${starred ? '\u2605' : '\u2606'}</span>
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'number',
|
||||
header: 'Number',
|
||||
renderer: (val: string) =>
|
||||
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.85rem;letter-spacing:.02em">${val}</span>`,
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
header: 'Company',
|
||||
renderer: (val: string | undefined) => val || '-',
|
||||
},
|
||||
{
|
||||
key: 'notes',
|
||||
header: 'Notes',
|
||||
renderer: (val: string | undefined) =>
|
||||
html`<span style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;color:#94a3b8" title=${val || ''}>${val || '-'}</span>`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private getDataActions() {
|
||||
return [
|
||||
{
|
||||
name: 'Call',
|
||||
iconName: 'lucide:phone' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IContact }) => {
|
||||
appState.selectContact(item);
|
||||
appRouter.navigateTo('phone' as any);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Star',
|
||||
iconName: 'lucide:star' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IContact }) => {
|
||||
await this.toggleStar(item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:pencil' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IContact }) => {
|
||||
await this.openEditModal(item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2' as any,
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async ({ item }: { item: IContact }) => {
|
||||
await this.deleteContact(item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Add Contact',
|
||||
iconName: 'lucide:plus' as any,
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.openAddModal();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------- Render ----------
|
||||
|
||||
public render(): TemplateResult {
|
||||
const contacts = this.appData.contacts || [];
|
||||
const companies = new Set(
|
||||
contacts.map((c) => c.company?.trim()).filter((c) => c && c.length > 0),
|
||||
);
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'total',
|
||||
title: 'Total Contacts',
|
||||
value: contacts.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:contactRound',
|
||||
description: contacts.length === 1 ? '1 contact' : `${contacts.length} contacts`,
|
||||
},
|
||||
{
|
||||
id: 'starred',
|
||||
title: 'Starred',
|
||||
value: contacts.filter((c) => c.starred).length,
|
||||
type: 'number',
|
||||
icon: 'lucide:star',
|
||||
color: 'hsl(45 93% 47%)',
|
||||
description: 'Quick-dial contacts',
|
||||
},
|
||||
{
|
||||
id: 'companies',
|
||||
title: 'Companies',
|
||||
value: companies.size,
|
||||
type: 'number',
|
||||
icon: 'lucide:building2',
|
||||
description: `${companies.size} unique`,
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="view-section">
|
||||
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
||||
</div>
|
||||
|
||||
<div class="view-section">
|
||||
<dees-table
|
||||
heading1="Contacts"
|
||||
heading2="${contacts.length} total"
|
||||
dataName="contacts"
|
||||
.data=${this.sortedContacts(contacts)}
|
||||
.rowKey=${'id'}
|
||||
.searchable=${true}
|
||||
.columns=${this.getColumns()}
|
||||
.dataActions=${this.getDataActions()}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private sortedContacts(contacts: IContact[]): IContact[] {
|
||||
return [...contacts].sort((a, b) => {
|
||||
if (a.starred && !b.starred) return -1;
|
||||
if (!a.starred && b.starred) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
}
|
||||
71
ts_web/elements/sipproxy-view-log.ts
Normal file
71
ts_web/elements/sipproxy-view-log.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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';
|
||||
|
||||
@customElement('sipproxy-view-log')
|
||||
export class SipproxyViewLog extends DeesElement {
|
||||
@state() accessor appData: IAppState = appState.getState();
|
||||
|
||||
private chartLog: any = null;
|
||||
private lastLogCount = 0;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host { display: block; padding: 1rem; height: 100%; }
|
||||
dees-chart-log { height: calc(100vh - 120px); }
|
||||
`,
|
||||
];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.rxSubscriptions.push({
|
||||
unsubscribe: appState.subscribe((s) => {
|
||||
const prev = this.appData;
|
||||
this.appData = s;
|
||||
this.pushNewLogs(prev.logLines, s.logLines);
|
||||
}),
|
||||
} as any);
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.chartLog = this.shadowRoot?.querySelector('dees-chart-log');
|
||||
}
|
||||
|
||||
private pushNewLogs(oldLines: string[], newLines: string[]) {
|
||||
if (!this.chartLog || !newLines.length) return;
|
||||
|
||||
// Only push lines that are new since last update.
|
||||
const newCount = newLines.length;
|
||||
if (newCount <= this.lastLogCount) return;
|
||||
|
||||
const fresh = newLines.slice(this.lastLogCount);
|
||||
this.lastLogCount = newCount;
|
||||
|
||||
for (const line of fresh) {
|
||||
const level = this.detectLevel(line);
|
||||
this.chartLog.addLog(level, line);
|
||||
}
|
||||
}
|
||||
|
||||
private detectLevel(line: string): 'debug' | 'info' | 'warn' | 'error' | 'success' {
|
||||
if (line.includes('[err]') || line.includes('error') || line.includes('ERR')) return 'error';
|
||||
if (line.includes('WARN') || line.includes('warn')) return 'warn';
|
||||
if (line.includes('registered') || line.includes('CONNECTED')) return 'success';
|
||||
if (line.includes('[rtp') || line.includes('[detect]')) return 'debug';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<dees-chart-log
|
||||
label="SIP Trace Log"
|
||||
mode="structured"
|
||||
.autoScroll=${true}
|
||||
.maxEntries=${500}
|
||||
.showMetrics=${true}
|
||||
.highlightKeywords=${['REGISTER', 'INVITE', 'BYE', 'registered', 'error', 'CONNECTED']}
|
||||
></dees-chart-log>
|
||||
`;
|
||||
}
|
||||
}
|
||||
207
ts_web/elements/sipproxy-view-overview.ts
Normal file
207
ts_web/elements/sipproxy-view-overview.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
import { appState, type IAppState } from '../state/appstate.js';
|
||||
import { viewHostCss } from './shared/index.js';
|
||||
|
||||
@customElement('sipproxy-view-overview')
|
||||
export class SipproxyViewOverview extends DeesElement {
|
||||
@state() accessor appData: IAppState = appState.getState();
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #64748b;
|
||||
margin: 32px 0 12px;
|
||||
}
|
||||
|
||||
.section-heading:first-of-type {
|
||||
margin-top: 24px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.rxSubscriptions.push({
|
||||
unsubscribe: appState.subscribe((s) => {
|
||||
this.appData = s;
|
||||
}),
|
||||
} as any);
|
||||
}
|
||||
|
||||
private fmtUptime(sec: number): string {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = sec % 60;
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
return `${h}h ${String(m).padStart(2, '0')}m ${String(s).padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const { appData } = this;
|
||||
const activeCalls = appData.calls?.filter((c) => c.state !== 'terminated') || [];
|
||||
const activeCount = activeCalls.length;
|
||||
const registeredProviders = appData.providers?.filter((p) => p.registered).length || 0;
|
||||
const connectedDevices = appData.devices?.filter((d) => d.connected).length || 0;
|
||||
const inboundCalls = activeCalls.filter((c) => c.direction === 'inbound').length;
|
||||
const outboundCalls = activeCalls.filter((c) => c.direction === 'outbound').length;
|
||||
const webrtcSessions = activeCalls.reduce(
|
||||
(sum, c) => sum + c.legs.filter((l) => l.type === 'webrtc').length,
|
||||
0,
|
||||
);
|
||||
const totalRtpPackets = activeCalls.reduce(
|
||||
(sum, c) => sum + c.legs.reduce((lsum, l) => lsum + l.pktSent + l.pktReceived, 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'active-calls',
|
||||
title: 'Active Calls',
|
||||
value: activeCount,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone',
|
||||
color: 'hsl(142.1 76.2% 36.3%)',
|
||||
description: activeCount === 1 ? '1 call in progress' : `${activeCount} calls in progress`,
|
||||
},
|
||||
{
|
||||
id: 'providers',
|
||||
title: 'Registered Providers',
|
||||
value: registeredProviders,
|
||||
type: 'number',
|
||||
icon: 'lucide:server',
|
||||
color: 'hsl(217.2 91.2% 59.8%)',
|
||||
description: `${appData.providers?.length || 0} configured`,
|
||||
},
|
||||
{
|
||||
id: 'devices',
|
||||
title: 'Connected Devices',
|
||||
value: connectedDevices,
|
||||
type: 'number',
|
||||
icon: 'lucide:wifi',
|
||||
color: 'hsl(270 70% 60%)',
|
||||
description: `${appData.devices?.length || 0} total`,
|
||||
},
|
||||
{
|
||||
id: 'uptime',
|
||||
title: 'Uptime',
|
||||
value: this.fmtUptime(appData.uptime),
|
||||
type: 'text',
|
||||
icon: 'lucide:clock',
|
||||
description: 'Since last restart',
|
||||
},
|
||||
{
|
||||
id: 'inbound',
|
||||
title: 'Inbound Calls',
|
||||
value: inboundCalls,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone-incoming',
|
||||
description: 'Currently active',
|
||||
},
|
||||
{
|
||||
id: 'outbound',
|
||||
title: 'Outbound Calls',
|
||||
value: outboundCalls,
|
||||
type: 'number',
|
||||
icon: 'lucide:phone-outgoing',
|
||||
description: 'Currently active',
|
||||
},
|
||||
{
|
||||
id: 'webrtc',
|
||||
title: 'WebRTC Sessions',
|
||||
value: webrtcSessions,
|
||||
type: 'number',
|
||||
icon: 'lucide:globe',
|
||||
color: 'hsl(166 72% 40%)',
|
||||
description: 'Browser connections',
|
||||
},
|
||||
{
|
||||
id: 'rtp-packets',
|
||||
title: 'Total RTP Packets',
|
||||
value: totalRtpPackets,
|
||||
type: 'number',
|
||||
icon: 'lucide:radio',
|
||||
description: 'Sent + received across legs',
|
||||
},
|
||||
];
|
||||
|
||||
const allDevices = appData.devices || [];
|
||||
const onlineCount = allDevices.filter((d) => d.connected).length;
|
||||
|
||||
return html`
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${220}
|
||||
.gap=${16}
|
||||
></dees-statsgrid>
|
||||
|
||||
<div class="section-heading">Devices</div>
|
||||
<dees-table
|
||||
heading1="Devices"
|
||||
heading2="${onlineCount} of ${allDevices.length} online"
|
||||
dataName="devices"
|
||||
.data=${allDevices}
|
||||
.rowKey=${'id'}
|
||||
.searchable=${false}
|
||||
.columns=${[
|
||||
{
|
||||
key: 'connected',
|
||||
header: 'Status',
|
||||
renderer: (val: boolean) => {
|
||||
const on = val === true;
|
||||
return html`
|
||||
<span style="display:inline-flex;align-items:center;gap:6px">
|
||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;${on ? 'background:#4ade80;box-shadow:0 0 6px #4ade80' : 'background:#f87171;box-shadow:0 0 6px #f87171'}"></span>
|
||||
<span style="font-size:.7rem;font-weight:600;text-transform:uppercase;color:${on ? '#4ade80' : '#f87171'}">${on ? 'Online' : 'Offline'}</span>
|
||||
</span>
|
||||
`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'displayName',
|
||||
header: 'Device',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Type',
|
||||
value: (row: any) => (row.isBrowser ? 'Browser' : 'SIP Device'),
|
||||
renderer: (val: string, row: any) => {
|
||||
const isBrowser = row.isBrowser;
|
||||
const bg = isBrowser ? '#065f46' : '#1e3a5f';
|
||||
const fg = isBrowser ? '#34d399' : '#38bdf8';
|
||||
return html`<span style="display:inline-block;font-size:.6rem;padding:2px 6px;border-radius:3px;font-weight:600;text-transform:uppercase;background:${bg};color:${fg}">${val}</span>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'contact',
|
||||
header: 'Contact',
|
||||
renderer: (_val: any, row: any) => {
|
||||
const c = row.contact;
|
||||
const text = c ? (c.port ? `${c.address}:${c.port}` : c.address) : '--';
|
||||
return html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${text}</span>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'aor',
|
||||
header: 'AOR',
|
||||
renderer: (val: any) =>
|
||||
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.75rem">${val || '--'}</span>`,
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
815
ts_web/elements/sipproxy-view-phone.ts
Normal file
815
ts_web/elements/sipproxy-view-phone.ts
Normal file
@@ -0,0 +1,815 @@
|
||||
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
||||
import { deesCatalog } from '../plugins.js';
|
||||
import { appState, type IAppState, type IContact } from '../state/appstate.js';
|
||||
import { WebRtcClient, getAudioDevices, type IAudioDevices } from '../state/webrtc-client.js';
|
||||
import { viewHostCss } from './shared/index.js';
|
||||
|
||||
interface IIncomingCall {
|
||||
callId: string;
|
||||
from: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
// Module-level singleton — survives view mount/unmount cycles.
|
||||
let sharedRtcClient: WebRtcClient | null = null;
|
||||
let sharedKeepAliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let sharedRegistered = false;
|
||||
|
||||
@customElement('sipproxy-view-phone')
|
||||
export class SipproxyViewPhone extends DeesElement {
|
||||
@state() accessor appData: IAppState = appState.getState();
|
||||
|
||||
// WebRTC state
|
||||
@state() accessor rtcState: string = 'idle';
|
||||
@state() accessor registered = false;
|
||||
@state() accessor incomingCalls: IIncomingCall[] = [];
|
||||
@state() accessor activeCallId: string | null = null;
|
||||
@state() accessor audioDevices: IAudioDevices = { inputs: [], outputs: [] };
|
||||
@state() accessor selectedInput: string = '';
|
||||
@state() accessor selectedOutput: string = '';
|
||||
@state() accessor localLevel: number = 0;
|
||||
@state() accessor remoteLevel: number = 0;
|
||||
|
||||
// Dialer state
|
||||
@state() accessor dialNumber = '';
|
||||
@state() accessor dialStatus = '';
|
||||
@state() accessor calling = false;
|
||||
@state() accessor currentCallId: string | null = null;
|
||||
@state() accessor selectedDeviceId: string = '';
|
||||
@state() accessor selectedProviderId: string = '';
|
||||
@state() accessor callDuration: number = 0;
|
||||
|
||||
private rtcClient: WebRtcClient | null = null;
|
||||
private levelTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private durationTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ---------- Two-column layout ---------- */
|
||||
.phone-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ---------- Tile content padding ---------- */
|
||||
.tile-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ---------- Dialer inputs ---------- */
|
||||
.dialer-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.call-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.btn:active:not(:disabled) { transform: scale(0.97); }
|
||||
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.btn-call {
|
||||
background: linear-gradient(135deg, #16a34a, #15803d);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(22, 163, 74, 0.3);
|
||||
}
|
||||
.btn-hangup {
|
||||
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.3);
|
||||
}
|
||||
|
||||
.dial-status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--dees-color-text-secondary, #94a3b8);
|
||||
min-height: 1.2em;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Contact card (passport-style) */
|
||||
.contact-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.10), rgba(56, 189, 248, 0.06));
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.contact-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #2563eb, #0ea5e9, #38bdf8);
|
||||
}
|
||||
.contact-card-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #2563eb, #0ea5e9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
.contact-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.contact-card-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.contact-card-number {
|
||||
font-size: 0.9rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #38bdf8;
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.contact-card-company {
|
||||
font-size: 0.75rem;
|
||||
color: var(--dees-color-text-secondary, #64748b);
|
||||
}
|
||||
.contact-card-clear {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.contact-card-clear:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Starred contacts grid */
|
||||
.contacts-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--dees-color-border-subtle, #334155);
|
||||
}
|
||||
.contacts-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--dees-color-text-secondary, #64748b);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.contacts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.btn-contact {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--dees-color-border-default, #1e3a5f);
|
||||
border-radius: 8px;
|
||||
background: rgba(30, 58, 95, 0.4);
|
||||
color: #38bdf8;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-contact:hover:not(:disabled) {
|
||||
background: rgba(37, 99, 235, 0.25);
|
||||
border-color: #2563eb;
|
||||
}
|
||||
.btn-contact:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.btn-contact .contact-name { display: block; margin-bottom: 2px; }
|
||||
.btn-contact .contact-number {
|
||||
display: block; font-size: 0.7rem; color: #64748b;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.btn-contact .contact-company {
|
||||
display: block; font-size: 0.65rem; color: #475569; margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---------- Phone status ---------- */
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: var(--dees-color-bg-primary, #0f172a);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--dees-color-border-default, #334155);
|
||||
}
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.on { background: #4ade80; box-shadow: 0 0 8px #4ade80; }
|
||||
.dot.off { background: #f87171; box-shadow: 0 0 8px #f87171; }
|
||||
.dot.pending { background: #fbbf24; box-shadow: 0 0 8px #fbbf24; animation: dotPulse 1.5s ease-in-out infinite; }
|
||||
@keyframes dotPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
.status-label { font-size: 0.9rem; font-weight: 500; }
|
||||
.status-detail { font-size: 0.75rem; color: var(--dees-color-text-secondary, #64748b); margin-left: auto; }
|
||||
|
||||
/* Active call banner */
|
||||
.active-call-banner {
|
||||
padding: 14px 16px;
|
||||
background: linear-gradient(135deg, rgba(22, 163, 74, 0.15), rgba(16, 185, 129, 0.1));
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.active-call-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.active-call-pulse { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; animation: dotPulse 1s ease-in-out infinite; }
|
||||
.active-call-label { font-size: 0.85rem; font-weight: 600; color: #4ade80; }
|
||||
.active-call-duration { font-size: 0.8rem; color: #94a3b8; margin-left: auto; font-family: 'JetBrains Mono', monospace; }
|
||||
.active-call-number { font-size: 1rem; font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
/* Audio device dropdowns spacing */
|
||||
dees-input-dropdown {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Level meters */
|
||||
.levels {
|
||||
display: flex; gap: 16px; margin-bottom: 16px; padding: 12px 16px;
|
||||
background: var(--dees-color-bg-primary, #0f172a); border-radius: 10px;
|
||||
border: 1px solid var(--dees-color-border-default, #334155);
|
||||
}
|
||||
.level-group { flex: 1; }
|
||||
.level-label { font-size: 0.65rem; color: #64748b; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.level-bar-bg { height: 6px; border-radius: 3px; background: #1e293b; overflow: hidden; }
|
||||
.level-bar { height: 100%; border-radius: 3px; transition: width 60ms linear; }
|
||||
.level-bar.mic { background: linear-gradient(90deg, #4ade80, #22c55e); }
|
||||
.level-bar.spk { background: linear-gradient(90deg, #38bdf8, #0ea5e9); }
|
||||
|
||||
/* Incoming calls */
|
||||
.incoming-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--dees-color-border-subtle, #334155); }
|
||||
.incoming-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--dees-color-text-secondary, #94a3b8); margin-bottom: 10px; }
|
||||
.incoming-row {
|
||||
display: flex; align-items: center; gap: 10px; padding: 12px 14px;
|
||||
background: rgba(251, 191, 36, 0.06); border-radius: 10px; margin-bottom: 8px;
|
||||
border: 1px solid rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
.incoming-ring { font-size: 0.7rem; font-weight: 700; color: #fbbf24; animation: dotPulse 1s infinite; letter-spacing: 0.04em; }
|
||||
.incoming-from { flex: 1; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; }
|
||||
.btn-sm { padding: 8px 16px; border: none; border-radius: 8px; font-weight: 600; font-size: 0.8rem; cursor: pointer; touch-action: manipulation; }
|
||||
.btn-accept { background: #16a34a; color: #fff; }
|
||||
.btn-accept:active { background: #166534; }
|
||||
.btn-reject { background: #dc2626; color: #fff; }
|
||||
.btn-reject:active { background: #991b1b; }
|
||||
.no-incoming { font-size: 0.8rem; color: #475569; padding: 8px 0; }
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
@media (max-width: 768px) {
|
||||
.phone-layout { grid-template-columns: 1fr; }
|
||||
.call-actions { flex-direction: column; }
|
||||
.btn { padding: 14px 20px; font-size: 1rem; }
|
||||
.incoming-row { flex-wrap: wrap; }
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.rxSubscriptions.push({
|
||||
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
|
||||
} as any);
|
||||
this.tryAutoRegister();
|
||||
this.loadAudioDevices();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.durationTimer) clearInterval(this.durationTimer);
|
||||
this.stopLevelMeter();
|
||||
}
|
||||
|
||||
// ---------- Audio device loading ----------
|
||||
|
||||
private async loadAudioDevices() {
|
||||
this.audioDevices = await getAudioDevices();
|
||||
if (!this.selectedInput && this.audioDevices.inputs.length) {
|
||||
this.selectedInput = this.audioDevices.inputs[0].deviceId;
|
||||
}
|
||||
if (!this.selectedOutput && this.audioDevices.outputs.length) {
|
||||
this.selectedOutput = this.audioDevices.outputs[0].deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- WebRTC registration ----------
|
||||
|
||||
private tryAutoRegister() {
|
||||
if (sharedRtcClient && sharedRegistered) {
|
||||
this.rtcClient = sharedRtcClient;
|
||||
this.registered = true;
|
||||
this.rtcState = sharedRtcClient.state;
|
||||
this.setupSignalingHandler();
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = (window as any).__sipRouterWs as WebSocket | undefined;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
this.registerSoftphone(ws);
|
||||
} else {
|
||||
const timer = setInterval(() => {
|
||||
const ws2 = (window as any).__sipRouterWs as WebSocket | undefined;
|
||||
if (ws2?.readyState === WebSocket.OPEN) {
|
||||
clearInterval(timer);
|
||||
this.registerSoftphone(ws2);
|
||||
}
|
||||
}, 200);
|
||||
setTimeout(() => clearInterval(timer), 10000);
|
||||
}
|
||||
}
|
||||
|
||||
private registerSoftphone(ws: WebSocket) {
|
||||
if (!sharedRtcClient) {
|
||||
sharedRtcClient = new WebRtcClient(() => {});
|
||||
}
|
||||
this.rtcClient = sharedRtcClient;
|
||||
|
||||
(this.rtcClient as any).onStateChange = (s: string) => {
|
||||
this.rtcState = s;
|
||||
if (s === 'connected') this.startLevelMeter();
|
||||
else if (s === 'idle' || s === 'error') this.stopLevelMeter();
|
||||
};
|
||||
|
||||
if (this.selectedInput) this.rtcClient.setInputDevice(this.selectedInput);
|
||||
if (this.selectedOutput) this.rtcClient.setOutputDevice(this.selectedOutput);
|
||||
|
||||
this.rtcClient.setWebSocket(ws);
|
||||
this.setupSignalingHandler();
|
||||
|
||||
if (!sharedRegistered) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'webrtc-register',
|
||||
sessionId: this.rtcClient.id,
|
||||
userAgent: navigator.userAgent,
|
||||
}));
|
||||
|
||||
if (sharedKeepAliveTimer) clearInterval(sharedKeepAliveTimer);
|
||||
sharedKeepAliveTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN && sharedRtcClient) {
|
||||
ws.send(JSON.stringify({ type: 'webrtc-register', sessionId: sharedRtcClient.id }));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
sharedRegistered = true;
|
||||
}
|
||||
|
||||
this.registered = true;
|
||||
this.rtcState = this.rtcClient.state;
|
||||
}
|
||||
|
||||
private setupSignalingHandler() {
|
||||
(window as any).__sipRouterWebRtcHandler = (msg: any) => {
|
||||
this.rtcClient?.handleSignaling(msg);
|
||||
|
||||
if (msg.type === 'webrtc-registered') {
|
||||
const d = msg.data || msg;
|
||||
if (d.deviceId) appState.setBrowserDeviceId(d.deviceId);
|
||||
}
|
||||
|
||||
if (msg.type === 'webrtc-incoming') {
|
||||
const d = msg.data || msg;
|
||||
if (!this.incomingCalls.find((c) => c.callId === d.callId)) {
|
||||
this.incomingCalls = [...this.incomingCalls, {
|
||||
callId: d.callId,
|
||||
from: d.from || 'Unknown',
|
||||
time: Date.now(),
|
||||
}];
|
||||
deesCatalog.DeesToast.show({
|
||||
message: `Incoming call from ${d.from || 'Unknown'}`,
|
||||
type: 'info',
|
||||
duration: 5000,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
} else if (msg.type === 'webrtc-call-ended') {
|
||||
const d = msg.data || msg;
|
||||
this.incomingCalls = this.incomingCalls.filter((c) => c.callId !== d.callId);
|
||||
if (this.activeCallId === d.callId) {
|
||||
this.rtcClient?.hangup();
|
||||
this.activeCallId = null;
|
||||
this.stopDurationTimer();
|
||||
deesCatalog.DeesToast.info('Call ended');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Level meters ----------
|
||||
|
||||
private startLevelMeter() {
|
||||
this.stopLevelMeter();
|
||||
this.levelTimer = setInterval(() => {
|
||||
if (this.rtcClient) {
|
||||
this.localLevel = this.rtcClient.getLocalLevel();
|
||||
this.remoteLevel = this.rtcClient.getRemoteLevel();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private stopLevelMeter() {
|
||||
if (this.levelTimer) {
|
||||
clearInterval(this.levelTimer);
|
||||
this.levelTimer = null;
|
||||
}
|
||||
this.localLevel = 0;
|
||||
this.remoteLevel = 0;
|
||||
}
|
||||
|
||||
// ---------- Call duration ----------
|
||||
|
||||
private startDurationTimer() {
|
||||
this.stopDurationTimer();
|
||||
this.callDuration = 0;
|
||||
this.durationTimer = setInterval(() => {
|
||||
this.callDuration++;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private stopDurationTimer() {
|
||||
if (this.durationTimer) {
|
||||
clearInterval(this.durationTimer);
|
||||
this.durationTimer = null;
|
||||
}
|
||||
this.callDuration = 0;
|
||||
}
|
||||
|
||||
private fmtDuration(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ---------- Call actions ----------
|
||||
|
||||
private async acceptCall(callId: string) {
|
||||
this.incomingCalls = this.incomingCalls.filter((c) => c.callId !== callId);
|
||||
this.activeCallId = callId;
|
||||
|
||||
if (this.rtcClient) {
|
||||
if (this.selectedInput) this.rtcClient.setInputDevice(this.selectedInput);
|
||||
if (this.selectedOutput) this.rtcClient.setOutputDevice(this.selectedOutput);
|
||||
await this.rtcClient.startCall();
|
||||
}
|
||||
|
||||
const ws = (window as any).__sipRouterWs as WebSocket | undefined;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'webrtc-accept', callId, sessionId: this.rtcClient?.id }));
|
||||
}
|
||||
|
||||
this.startDurationTimer();
|
||||
}
|
||||
|
||||
private rejectCall(callId: string) {
|
||||
const ws = (window as any).__sipRouterWs as WebSocket | undefined;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'webrtc-reject', callId }));
|
||||
}
|
||||
this.incomingCalls = this.incomingCalls.filter((c) => c.callId !== callId);
|
||||
}
|
||||
|
||||
private selectContact(contact: IContact) {
|
||||
appState.selectContact(contact);
|
||||
this.dialNumber = contact.number;
|
||||
}
|
||||
|
||||
private clearContact() {
|
||||
appState.clearSelectedContact();
|
||||
this.dialNumber = '';
|
||||
}
|
||||
|
||||
private async makeCall(number?: string) {
|
||||
const num = number || this.dialNumber.trim();
|
||||
if (!num) {
|
||||
this.dialStatus = 'Please enter a phone number';
|
||||
return;
|
||||
}
|
||||
this.dialNumber = num;
|
||||
this.calling = true;
|
||||
this.dialStatus = 'Initiating call...';
|
||||
|
||||
try {
|
||||
const res = await appState.apiCall(
|
||||
num,
|
||||
this.selectedDeviceId || undefined,
|
||||
this.selectedProviderId || undefined,
|
||||
);
|
||||
if (res.ok && res.callId) {
|
||||
this.currentCallId = res.callId;
|
||||
this.dialStatus = 'Call initiated';
|
||||
this.startDurationTimer();
|
||||
appState.clearSelectedContact();
|
||||
} else {
|
||||
this.dialStatus = `Error: ${res.error || 'unknown'}`;
|
||||
deesCatalog.DeesToast.error(`Call failed: ${res.error || 'unknown'}`, 4000);
|
||||
this.calling = false;
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.dialStatus = `Failed: ${e.message}`;
|
||||
deesCatalog.DeesToast.error(`Call failed: ${e.message}`, 4000);
|
||||
this.calling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async hangup() {
|
||||
if (!this.currentCallId) return;
|
||||
this.dialStatus = 'Hanging up...';
|
||||
try {
|
||||
await appState.apiHangup(this.currentCallId);
|
||||
this.dialStatus = 'Call ended';
|
||||
this.currentCallId = null;
|
||||
this.calling = false;
|
||||
this.stopDurationTimer();
|
||||
} catch (e: any) {
|
||||
this.dialStatus = `Hangup failed: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private getConnectedDevices() {
|
||||
return this.appData.devices.filter((d) => d.connected);
|
||||
}
|
||||
|
||||
private getRegisteredProviders() {
|
||||
return this.appData.providers.filter((p) => p.registered);
|
||||
}
|
||||
|
||||
private getDotClass(): string {
|
||||
if (this.rtcState === 'connected') return 'on';
|
||||
if (this.rtcState === 'connecting' || this.rtcState === 'requesting-mic') return 'pending';
|
||||
if (this.registered) return 'on';
|
||||
return 'off';
|
||||
}
|
||||
|
||||
private getStateLabel(): string {
|
||||
const labels: Record<string, string> = {
|
||||
idle: 'Registered - Ready',
|
||||
'requesting-mic': 'Requesting Microphone...',
|
||||
connecting: 'Connecting Audio...',
|
||||
connected: 'On Call',
|
||||
error: 'Error',
|
||||
};
|
||||
return labels[this.rtcState] || this.rtcState;
|
||||
}
|
||||
|
||||
updated() {
|
||||
const active = this.appData.calls?.find((c) => c.state !== 'terminated' && c.direction === 'outbound');
|
||||
if (active) {
|
||||
this.currentCallId = active.id;
|
||||
this.calling = true;
|
||||
} else if (this.calling && !active) {
|
||||
this.calling = false;
|
||||
this.currentCallId = null;
|
||||
this.stopDurationTimer();
|
||||
}
|
||||
|
||||
const connected = this.getConnectedDevices();
|
||||
if (this.selectedDeviceId && !connected.find((d) => d.id === this.selectedDeviceId)) {
|
||||
this.selectedDeviceId = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Render ----------
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="phone-layout">
|
||||
${this.renderDialer()}
|
||||
${this.renderPhoneStatus()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDialer(): TemplateResult {
|
||||
const connected = this.getConnectedDevices();
|
||||
const registeredProviders = this.getRegisteredProviders();
|
||||
const selectedContact = this.appData.selectedContact;
|
||||
const starredContacts = this.appData.contacts.filter((c) => c.starred);
|
||||
|
||||
return html`
|
||||
<dees-tile heading="Dialer">
|
||||
<div class="tile-body">
|
||||
<div class="dialer-inputs">
|
||||
${selectedContact ? html`
|
||||
<div class="contact-card">
|
||||
<div class="contact-card-avatar">
|
||||
${selectedContact.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="contact-card-info">
|
||||
<div class="contact-card-name">${selectedContact.name}</div>
|
||||
<div class="contact-card-number">${selectedContact.number}</div>
|
||||
${selectedContact.company ? html`
|
||||
<div class="contact-card-company">${selectedContact.company}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<button
|
||||
class="contact-card-clear"
|
||||
@click=${() => this.clearContact()}
|
||||
title="Clear selection"
|
||||
>×</button>
|
||||
</div>
|
||||
` : html`
|
||||
<dees-input-text
|
||||
.label=${'Phone Number'}
|
||||
.value=${this.dialNumber}
|
||||
@input=${(e: Event) => { this.dialNumber = (e.target as any).value; }}
|
||||
></dees-input-text>
|
||||
`}
|
||||
|
||||
<dees-input-dropdown
|
||||
.label=${'Call from'}
|
||||
.options=${connected.map((d) => ({
|
||||
option: `${d.displayName}${d.id === this.appData.browserDeviceId ? ' (this browser)' : d.isBrowser ? ' (WebRTC)' : ''}`,
|
||||
key: d.id,
|
||||
}))}
|
||||
.selectedOption=${this.selectedDeviceId ? {
|
||||
option: connected.find((d) => d.id === this.selectedDeviceId)?.displayName || this.selectedDeviceId,
|
||||
key: this.selectedDeviceId,
|
||||
} : null}
|
||||
@selectedOption=${(e: CustomEvent) => { this.selectedDeviceId = e.detail.key; }}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<dees-input-dropdown
|
||||
.label=${'Via provider'}
|
||||
.options=${[
|
||||
{ option: 'Default', key: '' },
|
||||
...registeredProviders.map((p) => ({ option: p.displayName, key: p.id })),
|
||||
]}
|
||||
.selectedOption=${{ option: this.selectedProviderId ? (registeredProviders.find((p) => p.id === this.selectedProviderId)?.displayName || this.selectedProviderId) : 'Default', key: this.selectedProviderId || '' }}
|
||||
@selectedOption=${(e: CustomEvent) => { this.selectedProviderId = e.detail.key; }}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="call-actions">
|
||||
<button
|
||||
class="btn btn-call"
|
||||
?disabled=${this.calling || !this.selectedDeviceId}
|
||||
@click=${() => this.makeCall(selectedContact?.number)}
|
||||
>Call</button>
|
||||
<button
|
||||
class="btn btn-hangup"
|
||||
?disabled=${!this.currentCallId}
|
||||
@click=${() => this.hangup()}
|
||||
>Hang Up</button>
|
||||
</div>
|
||||
|
||||
${this.dialStatus ? html`<div class="dial-status">${this.dialStatus}</div>` : ''}
|
||||
|
||||
${starredContacts.length ? html`
|
||||
<div class="contacts-section">
|
||||
<div class="contacts-label">Quick Dial</div>
|
||||
<div class="contacts-grid">
|
||||
${starredContacts.map((c) => html`
|
||||
<button
|
||||
class="btn-contact"
|
||||
?disabled=${this.calling}
|
||||
@click=${() => this.selectContact(c)}
|
||||
>
|
||||
<span class="contact-name">${c.name}</span>
|
||||
<span class="contact-number">${c.number}</span>
|
||||
${c.company ? html`<span class="contact-company">${c.company}</span>` : ''}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</dees-tile>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPhoneStatus(): TemplateResult {
|
||||
const micPct = Math.min(100, Math.round(this.localLevel * 300));
|
||||
const spkPct = Math.min(100, Math.round(this.remoteLevel * 300));
|
||||
|
||||
return html`
|
||||
<dees-tile heading="Phone Status">
|
||||
<div class="tile-body">
|
||||
<!-- Registration status -->
|
||||
<div class="status-indicator">
|
||||
<span class="dot ${this.getDotClass()}"></span>
|
||||
<span class="status-label">${this.registered ? this.getStateLabel() : 'Connecting...'}</span>
|
||||
${this.appData.browserDeviceId ? html`
|
||||
<span class="status-detail">ID: ${this.appData.browserDeviceId.slice(0, 12)}...</span>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Active call banner -->
|
||||
${this.rtcState === 'connected' || this.calling ? html`
|
||||
<div class="active-call-banner">
|
||||
<div class="active-call-header">
|
||||
<span class="active-call-pulse"></span>
|
||||
<span class="active-call-label">Active Call</span>
|
||||
<span class="active-call-duration">${this.fmtDuration(this.callDuration)}</span>
|
||||
</div>
|
||||
${this.dialNumber ? html`
|
||||
<div class="active-call-number">${this.dialNumber}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Level meters -->
|
||||
${this.rtcState === 'connected' ? html`
|
||||
<div class="levels">
|
||||
<div class="level-group">
|
||||
<div class="level-label">Microphone</div>
|
||||
<div class="level-bar-bg"><div class="level-bar mic" style="width:${micPct}%"></div></div>
|
||||
</div>
|
||||
<div class="level-group">
|
||||
<div class="level-label">Speaker</div>
|
||||
<div class="level-bar-bg"><div class="level-bar spk" style="width:${spkPct}%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Audio device selectors -->
|
||||
<dees-input-dropdown
|
||||
.label=${'Microphone'}
|
||||
.enableSearch=${false}
|
||||
.options=${this.audioDevices.inputs.map((d) => ({
|
||||
option: d.label || 'Microphone',
|
||||
key: d.deviceId,
|
||||
}))}
|
||||
.selectedOption=${this.selectedInput ? {
|
||||
option: this.audioDevices.inputs.find((d) => d.deviceId === this.selectedInput)?.label || 'Microphone',
|
||||
key: this.selectedInput,
|
||||
} : null}
|
||||
@selectedOption=${(e: CustomEvent) => { this.selectedInput = e.detail.key; this.rtcClient?.setInputDevice(this.selectedInput); }}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-dropdown
|
||||
.label=${'Speaker'}
|
||||
.enableSearch=${false}
|
||||
.options=${this.audioDevices.outputs.map((d) => ({
|
||||
option: d.label || 'Speaker',
|
||||
key: d.deviceId,
|
||||
}))}
|
||||
.selectedOption=${this.selectedOutput ? {
|
||||
option: this.audioDevices.outputs.find((d) => d.deviceId === this.selectedOutput)?.label || 'Speaker',
|
||||
key: this.selectedOutput,
|
||||
} : null}
|
||||
@selectedOption=${(e: CustomEvent) => { this.selectedOutput = e.detail.key; this.rtcClient?.setOutputDevice(this.selectedOutput); }}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<!-- Incoming calls -->
|
||||
<div class="incoming-section">
|
||||
<div class="incoming-label">Incoming Calls</div>
|
||||
${this.incomingCalls.length ? this.incomingCalls.map((call) => html`
|
||||
<div class="incoming-row">
|
||||
<span class="incoming-ring">RINGING</span>
|
||||
<span class="incoming-from">${call.from}</span>
|
||||
<button class="btn-sm btn-accept" @click=${() => this.acceptCall(call.callId)}>Accept</button>
|
||||
<button class="btn-sm btn-reject" @click=${() => this.rejectCall(call.callId)}>Reject</button>
|
||||
</div>
|
||||
`) : html`
|
||||
<div class="no-incoming">No incoming calls</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</dees-tile>
|
||||
`;
|
||||
}
|
||||
}
|
||||
607
ts_web/elements/sipproxy-view-providers.ts
Normal file
607
ts_web/elements/sipproxy-view-providers.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
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:check-circle',
|
||||
color: 'hsl(142.1 76.2% 36.3%)',
|
||||
description: 'Active registrations',
|
||||
},
|
||||
{
|
||||
id: 'unregistered',
|
||||
title: 'Unregistered',
|
||||
value: unregistered,
|
||||
type: 'number',
|
||||
icon: 'lucide:alert-circle',
|
||||
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:trash-2',
|
||||
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.openAddModal();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Add Sipgate',
|
||||
iconName: 'lucide:phone',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.openAddModal(PROVIDER_TEMPLATES.sipgate, 'Sipgate');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Add O2/Alice',
|
||||
iconName: 'lucide:phone',
|
||||
type: ['header'] as any,
|
||||
actionFunc: async () => {
|
||||
await this.openAddModal(PROVIDER_TEMPLATES.o2, 'O2/Alice');
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- add provider modal --------------------------------------------------
|
||||
|
||||
private async openAddModal(
|
||||
template?: typeof PROVIDER_TEMPLATES.sipgate,
|
||||
templateName?: string,
|
||||
) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
const formData = {
|
||||
displayName: templateName || '',
|
||||
domain: template?.domain || '',
|
||||
outboundProxyAddress: template?.outboundProxy?.address || '',
|
||||
outboundProxyPort: String(template?.outboundProxy?.port ?? 5060),
|
||||
username: '',
|
||||
password: '',
|
||||
registerIntervalSec: String(template?.registerIntervalSec ?? 300),
|
||||
codecs: template?.codecs ? template.codecs.join(', ') : '9, 0, 8, 101',
|
||||
earlyMediaSilence: template?.quirks?.earlyMediaSilence ?? false,
|
||||
};
|
||||
|
||||
const heading = template
|
||||
? `Add ${templateName} Provider`
|
||||
: 'Add Provider';
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading,
|
||||
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}
|
||||
@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>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalRef: any) => {
|
||||
modalRef.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:check',
|
||||
action: async (modalRef: any) => {
|
||||
if (!formData.displayName.trim() || !formData.domain.trim()) {
|
||||
deesCatalog.DeesToast.error('Display name and domain are required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const providerId = slugify(formData.displayName);
|
||||
const codecs = formData.codecs
|
||||
.split(',')
|
||||
.map((s: string) => parseInt(s.trim(), 10))
|
||||
.filter((n: number) => !isNaN(n));
|
||||
|
||||
const newProvider: any = {
|
||||
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(),
|
||||
password: formData.password,
|
||||
registerIntervalSec: parseInt(formData.registerIntervalSec, 10) || 300,
|
||||
codecs,
|
||||
quirks: {
|
||||
earlyMediaSilence: formData.earlyMediaSilence,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await appState.apiSaveConfig({
|
||||
addProvider: newProvider,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
modalRef.destroy();
|
||||
deesCatalog.DeesToast.success(`Provider "${formData.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');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 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: [...(cfg.routing?.inbound?.[providerId] || [])] as string[],
|
||||
ringBrowsers: cfg.routing?.ringBrowsers?.[providerId] ?? 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));
|
||||
|
||||
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: {
|
||||
inbound: { [providerId]: formData.inboundDevices },
|
||||
ringBrowsers: { [providerId]: formData.ringBrowsers },
|
||||
},
|
||||
};
|
||||
|
||||
// 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:trash-2',
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user