Files
siprouter/ts_web/elements/sipproxy-view-calls.ts
Juergen Kunz f3e1c96872 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.
2026-04-09 23:03:55 +00:00

671 lines
22 KiB
TypeScript

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` &rarr; ` : ''}
${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">&#128222;</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>
`;
}
}