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:
253
ts_web/state/appstate.ts
Normal file
253
ts_web/state/appstate.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Application state — receives live updates from the proxy via WebSocket.
|
||||
*/
|
||||
|
||||
export interface IProviderStatus {
|
||||
id: string;
|
||||
displayName: string;
|
||||
registered: boolean;
|
||||
publicIp: string | null;
|
||||
}
|
||||
|
||||
export interface IDeviceStatus {
|
||||
id: string;
|
||||
displayName: string;
|
||||
contact: { address: string; port: number } | null;
|
||||
aor: string;
|
||||
connected: boolean;
|
||||
isBrowser: boolean;
|
||||
}
|
||||
|
||||
export interface ILegStatus {
|
||||
id: string;
|
||||
type: 'sip-device' | 'sip-provider' | 'webrtc';
|
||||
state: string;
|
||||
remoteMedia: { address: string; port: number } | null;
|
||||
rtpPort: number | null;
|
||||
pktSent: number;
|
||||
pktReceived: number;
|
||||
codec: string | null;
|
||||
transcoding: boolean;
|
||||
}
|
||||
|
||||
export interface ICallStatus {
|
||||
id: string;
|
||||
state: string;
|
||||
direction: 'inbound' | 'outbound' | 'internal';
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
providerUsed: string | null;
|
||||
createdAt: number;
|
||||
duration: number;
|
||||
legs: ILegStatus[];
|
||||
}
|
||||
|
||||
export interface ICallHistoryEntry {
|
||||
id: string;
|
||||
direction: 'inbound' | 'outbound' | 'internal';
|
||||
callerNumber: string | null;
|
||||
calleeNumber: string | null;
|
||||
providerUsed: string | null;
|
||||
startedAt: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface IContact {
|
||||
id: string;
|
||||
name: string;
|
||||
number: string;
|
||||
company?: string;
|
||||
notes?: string;
|
||||
starred?: boolean;
|
||||
}
|
||||
|
||||
export interface IAppState {
|
||||
connected: boolean;
|
||||
browserDeviceId: string | null;
|
||||
uptime: number;
|
||||
providers: IProviderStatus[];
|
||||
devices: IDeviceStatus[];
|
||||
calls: ICallStatus[];
|
||||
callHistory: ICallHistoryEntry[];
|
||||
contacts: IContact[];
|
||||
selectedContact: IContact | null;
|
||||
logLines: string[];
|
||||
}
|
||||
|
||||
const MAX_LOG = 200;
|
||||
let knownInstanceId: string | null = null;
|
||||
|
||||
class AppStateManager {
|
||||
private state: IAppState = {
|
||||
connected: false,
|
||||
browserDeviceId: null,
|
||||
uptime: 0,
|
||||
providers: [],
|
||||
devices: [],
|
||||
calls: [],
|
||||
callHistory: [],
|
||||
contacts: [],
|
||||
selectedContact: null,
|
||||
logLines: [],
|
||||
};
|
||||
|
||||
private listeners = new Set<(state: IAppState) => void>();
|
||||
private ws: WebSocket | null = null;
|
||||
|
||||
getState(): IAppState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
subscribe(listener: (state: IAppState) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private update(partial: Partial<IAppState>): void {
|
||||
this.state = { ...this.state, ...partial };
|
||||
for (const fn of this.listeners) fn(this.state);
|
||||
}
|
||||
|
||||
addLog(msg: string): void {
|
||||
const lines = [...this.state.logLines, msg];
|
||||
if (lines.length > MAX_LOG) lines.splice(0, lines.length - MAX_LOG);
|
||||
this.update({ logLines: lines });
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
// Guard against duplicate connections.
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||
|
||||
ws.onopen = () => {
|
||||
this.ws = ws;
|
||||
(window as any).__sipRouterWs = ws;
|
||||
this.update({ connected: true });
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this.ws = null;
|
||||
this.update({ connected: false });
|
||||
setTimeout(() => this.connect(), 2000);
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.type === 'status') {
|
||||
// Auto-reload if backend restarted (different instance).
|
||||
if (m.data.instanceId) {
|
||||
if (knownInstanceId && knownInstanceId !== m.data.instanceId) {
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
knownInstanceId = m.data.instanceId;
|
||||
}
|
||||
this.update({
|
||||
uptime: m.data.uptime,
|
||||
providers: m.data.providers || [],
|
||||
devices: m.data.devices || [],
|
||||
calls: m.data.calls || [],
|
||||
callHistory: m.data.callHistory || [],
|
||||
contacts: m.data.contacts || [],
|
||||
});
|
||||
} else if (m.type === 'log') {
|
||||
this.addLog(`${m.ts} ${m.data.message}`);
|
||||
} else if (m.type === 'call-update') {
|
||||
// Real-time call state update — will be picked up by next status snapshot.
|
||||
} else if (m.type?.startsWith('webrtc-')) {
|
||||
// Route to any registered WebRTC signaling handler.
|
||||
const handler = (window as any).__sipRouterWebRtcHandler;
|
||||
if (handler) handler(m);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
}
|
||||
|
||||
setBrowserDeviceId(deviceId: string): void {
|
||||
this.update({ browserDeviceId: deviceId });
|
||||
}
|
||||
|
||||
selectContact(contact: IContact | null): void {
|
||||
this.update({ selectedContact: contact });
|
||||
}
|
||||
|
||||
clearSelectedContact(): void {
|
||||
this.update({ selectedContact: null });
|
||||
}
|
||||
|
||||
async apiCall(number: string, deviceId?: string, providerId?: string): Promise<{ ok: boolean; callId?: string; error?: string }> {
|
||||
const res = await fetch('/api/call', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ number, deviceId, providerId }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async apiHangup(callId: string): Promise<{ ok: boolean }> {
|
||||
const res = await fetch('/api/hangup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ callId }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async apiAddLeg(callId: string, deviceId: string): Promise<{ ok: boolean }> {
|
||||
const res = await fetch(`/api/call/${callId}/addleg`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceId }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async apiAddExternal(callId: string, number: string, providerId?: string): Promise<{ ok: boolean }> {
|
||||
const res = await fetch(`/api/call/${callId}/addexternal`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ number, providerId }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async apiRemoveLeg(callId: string, legId: string): Promise<{ ok: boolean }> {
|
||||
const res = await fetch(`/api/call/${callId}/removeleg`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ legId }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async apiTransfer(sourceCallId: string, legId: string, targetCallId: string): Promise<{ ok: boolean }> {
|
||||
const res = await fetch('/api/transfer', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sourceCallId, legId, targetCallId }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async apiGetConfig(): Promise<any> {
|
||||
const res = await fetch('/api/config');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async apiSaveConfig(updates: any): Promise<{ ok: boolean }> {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
|
||||
export const appState = new AppStateManager();
|
||||
Reference in New Issue
Block a user