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', 'tool': 'Tool', }; function renderHistoryLegs(legs: ICallHistoryEntry['legs']): TemplateResult { if (!legs.length) { return html`-`; } return html`
${legs.map( (leg) => html`
${LEG_TYPE_LABELS[leg.type] || leg.type} ${leg.codec || '--'} ${STATE_LABELS[leg.state] || leg.state} ${leg.remoteMedia ? html`${leg.remoteMedia}` : ''}
`, )}
`; } 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; display: grid; gap: 16px; } .call-overview { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(240px, 0.9fr); gap: 14px; } .call-route-card, .call-facts-card, .legs-section { border-radius: 14px; border: 1px solid rgba(51, 65, 85, 0.75); background: linear-gradient(180deg, rgba(15, 23, 42, 0.92) 0%, rgba(8, 15, 31, 0.88) 100%); box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.08); } .call-route-card, .call-facts-card { padding: 14px; } .section-kicker { font-size: 0.62rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; } .route-line { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 12px; margin-top: 12px; } .route-party { min-width: 0; display: flex; flex-direction: column; gap: 4px; padding: 12px; border-radius: 12px; background: rgba(15, 23, 42, 0.6); border: 1px solid rgba(71, 85, 105, 0.45); } .route-party.align-end { text-align: right; align-items: flex-end; } .route-party-label { font-size: 0.64rem; letter-spacing: 0.06em; text-transform: uppercase; color: #64748b; } .route-party-value { min-width: 0; font-size: 0.95rem; font-weight: 600; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .route-arrow { width: 34px; height: 34px; border-radius: 999px; display: flex; align-items: center; justify-content: center; font-size: 1rem; color: #93c5fd; background: rgba(30, 41, 59, 0.8); border: 1px solid rgba(59, 130, 246, 0.35); } .call-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } .subtle-badge { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; border-radius: 999px; font-size: 0.66rem; font-weight: 700; letter-spacing: 0.03em; color: #cbd5e1; background: rgba(30, 41, 59, 0.9); border: 1px solid rgba(71, 85, 105, 0.45); } .call-facts-card { display: grid; gap: 8px; } .fact-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 7px 0; border-bottom: 1px solid rgba(51, 65, 85, 0.55); } .fact-row:last-child { border-bottom: none; padding-bottom: 0; } .fact-label { font-size: 0.65rem; letter-spacing: 0.06em; text-transform: uppercase; color: #64748b; } .fact-value { font-family: 'JetBrains Mono', monospace; font-size: 0.76rem; text-align: right; color: #e2e8f0; word-break: break-word; } .legs-section { padding: 14px; display: grid; gap: 12px; } .legs-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; } .legs-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 12px; } .leg-card { display: grid; gap: 12px; padding: 12px; border-radius: 12px; background: rgba(15, 23, 42, 0.6); border: 1px solid rgba(71, 85, 105, 0.4); } .leg-card-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; } .leg-card-badges { display: flex; flex-wrap: wrap; gap: 8px; } .leg-card-id { font-family: 'JetBrains Mono', monospace; font-size: 0.64rem; color: #64748b; word-break: break-all; text-align: right; } .leg-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; } .leg-fact { display: flex; flex-direction: column; gap: 4px; min-width: 0; } .leg-fact-wide { grid-column: 1 / -1; } .leg-fact-label { font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase; color: #64748b; } .leg-fact-value { font-family: 'JetBrains Mono', monospace; font-size: 0.76rem; color: #e2e8f0; word-break: break-word; } .leg-actions { display: flex; justify-content: flex-end; } .no-legs { padding: 16px; border-radius: 12px; border: 1px dashed rgba(71, 85, 105, 0.55); color: #64748b; font-size: 0.75rem; text-align: center; } .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; } @media (max-width: 820px) { .call-overview { grid-template-columns: 1fr; } .route-line { grid-template-columns: 1fr; } .route-arrow { justify-self: center; transform: rotate(90deg); } .route-party.align-end { text-align: left; align-items: flex-start; } .leg-card-top { flex-direction: column; } .leg-card-id { text-align: left; } } `, ]; async connectedCallback(): Promise { await 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:ArrowRightLeft', 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)}`, }, { key: 'legs', header: 'Legs', renderer: (val: ICallHistoryEntry['legs']) => renderHistoryLegs(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 Route
From
${call.callerNumber || 'Unknown caller'}
${directionIcon(call.direction)}
To
${call.calleeNumber || 'System'}
${call.legs.length} ${call.legs.length === 1 ? 'leg' : 'legs'} ${call.providerUsed || 'system handled'} started ${fmtTime(call.startedAt)}
Session
State ${STATE_LABELS[call.state] || call.state}
Direction ${call.direction}
Duration ${fmtDuration(call.duration)}
Provider ${call.providerUsed || '--'}
Active Legs
${call.legs.length}
${call.legs.length ? html`
${call.legs.map( (leg) => html`
${LEG_TYPE_LABELS[leg.type] || leg.type} ${STATE_LABELS[leg.state] || leg.state}
${leg.id}
Codec ${leg.codec || '--'}${leg.transcoding ? ' (transcode)' : ''}
RTP Port ${leg.rtpPort ?? '--'}
Remote Media ${leg.remoteMedia || '--'}
Packets In ${leg.pktReceived}
Packets Out ${leg.pktSent}
`, )}
` : html`
No legs reported yet. SIP/system legs should appear here as soon as the call is wired.
`}
`; } 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:PhoneIncoming', description: 'Incoming calls', }, { id: 'outbound', title: 'Outbound', value: outboundCount, type: 'number', icon: 'lucide:PhoneOutgoing', description: 'Outgoing calls', }, ]; return html` Calls
${activeCalls.length > 0 ? activeCalls.map((call) => this.renderCallCard(call)) : html`
📞
No active calls
Calls will appear here when they are in progress
`}
`; } }