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 = { '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 = { '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`
Select Device
${devices.length === 0 ? html`
No connected devices available.
` : ''}
`, 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`
Phone Number
{ number = (e.target as HTMLInputElement).value; }} >
Via Provider
${providers.length === 0 ? html`
No registered providers available.
` : ''}
`, 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`
Target Call ID
Leg ID to transfer
${otherCalls.length === 0 ? html`
No other active calls to transfer to.
` : ''}
`, 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`${directionIcon(val)}${val}`; }, }, { key: 'callerNumber', header: 'From', renderer: (val: string | null) => html`${val || '-'}`, }, { key: 'calleeNumber', header: 'To', renderer: (val: string | null) => html`${val || '-'}`, }, { key: 'providerUsed', header: 'Provider', renderer: (val: string | null) => val || '-', }, { key: 'startedAt', header: 'Time', renderer: (val: number) => html`${fmtTime(val)}`, }, { key: 'duration', header: 'Duration', renderer: (val: number) => html`${fmtDuration(val)}`, }, ]; } private renderCallCard(call: ICallStatus): TemplateResult { return html`
${directionIcon(call.direction)}
${call.callerNumber ? html`From${call.callerNumber}` : ''} ${call.callerNumber && call.calleeNumber ? html` → ` : ''} ${call.calleeNumber ? html`To${call.calleeNumber}` : ''}
${STATE_LABELS[call.state] || call.state} ${call.providerUsed ? html`${call.providerUsed}` : ''} ${fmtDuration(call.duration)}
${call.id}
${call.legs.length ? html` ${call.legs.map( (leg) => html` `, )}
Type State Remote Port Codec Pkts In Pkts Out
${LEG_TYPE_LABELS[leg.type] || leg.type} ${leg.state} ${leg.remoteMedia ? `${leg.remoteMedia.address}:${leg.remoteMedia.port}` : '--'} ${leg.rtpPort ?? '--'} ${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''} ${leg.pktReceived} ${leg.pktSent}
` : html`
No legs
`}
`; } 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`
${activeCalls.length > 0 ? activeCalls.map((call) => this.renderCallCard(call)) : html`
📞
No active calls
Calls will appear here when they are in progress
`}
`; } }