chore(config): update speaches config and ignore sveltekit dashboard

- Simplified .env.example to use localhost SPEACHES_URL
- Removed unused prod_url from SpeachesSettings config
- Added dashboard node_modules and build dirs to .gitignore
- Streamlines local development setup
This commit is contained in:
2026-05-16 18:21:07 -04:00
parent ecf37658ce
commit dbdb03beb9
15 changed files with 2929 additions and 3 deletions

1
dashboard/src/app.css Normal file
View File

@@ -0,0 +1 @@
@import 'tailwindcss';

23
dashboard/src/app.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hold Slayer — Gateway Dashboard</title>
<!-- Apply theme before first paint to prevent flash of wrong theme. -->
<script>
try {
const stored = localStorage.getItem('hold-slayer-theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (stored !== 'light' && systemDark)) {
document.documentElement.classList.add('dark');
}
} catch (_) {}
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div>%sveltekit.body%</div>
</body>
</html>

50
dashboard/src/lib/api.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { CallSummary, DeviceStatus, GatewayEvent, GatewayStatus, HealthStatus } from './types';
async function get<T>(path: string): Promise<T> {
const res = await fetch(path);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json() as Promise<T>;
}
export async function fetchGatewayStatus(): Promise<GatewayStatus> {
return get<GatewayStatus>('/');
}
export async function fetchHealth(): Promise<HealthStatus> {
return get<HealthStatus>('/health');
}
export async function fetchActiveCalls(): Promise<CallSummary[]> {
return get<CallSummary[]>('/api/calls/active');
}
export async function fetchDevices(): Promise<DeviceStatus[]> {
return get<DeviceStatus[]>('/api/devices');
}
export async function hangupCall(callId: string): Promise<void> {
const res = await fetch(`/api/calls/${callId}/hangup`, { method: 'POST' });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
}
export function connectEventStream(
onEvent: (e: GatewayEvent) => void,
onClose: () => void,
): () => void {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws/events`);
ws.onmessage = (msg) => {
try {
const event = JSON.parse(msg.data as string) as GatewayEvent;
onEvent(event);
} catch {
// malformed frame — ignore
}
};
ws.onclose = () => onClose();
ws.onerror = () => onClose();
return () => ws.close();
}

View File

@@ -0,0 +1,74 @@
export interface GatewayStatus {
name: string;
version: string;
status: string;
uptime: number;
active_calls: number;
trunk: {
registered: boolean;
host?: string;
mock?: boolean;
};
}
export interface HealthStatus {
status: 'healthy' | 'degraded';
gateway: string;
sip_engine: string;
sip_trunk: {
registered: boolean;
host?: string;
mock?: boolean;
reason?: string;
};
}
export interface DeviceStatus {
id: string;
name: string;
type: string;
is_online: boolean;
last_seen: string;
can_receive_call: boolean;
}
export type CallStatus =
| 'initiating'
| 'ringing'
| 'connected'
| 'navigating_ivr'
| 'on_hold'
| 'human_detected'
| 'transferring'
| 'bridged'
| 'completed'
| 'failed'
| 'cancelled';
export type AudioType =
| 'silence'
| 'music'
| 'ivr_prompt'
| 'live_human'
| 'ringing'
| 'dtmf'
| 'unknown';
export interface CallSummary {
call_id: string;
remote_number: string;
status: CallStatus;
mode: string;
duration: number;
hold_time: number;
audio_type: AudioType;
intent?: string;
}
export interface GatewayEvent {
type: string;
call_id?: string;
timestamp: string;
data: Record<string, unknown>;
message: string;
}

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/stores';
import { onMount } from 'svelte';
let { children } = $props();
type ThemeOverride = 'dark' | 'light' | null;
let override = $state<ThemeOverride>(null);
let systemDark = $state(true);
let isDark = $derived(override !== null ? override === 'dark' : systemDark);
$effect(() => {
document.documentElement.classList.toggle('dark', isDark);
});
function toggleTheme() {
const next = !isDark;
if (next === systemDark) {
override = null;
localStorage.removeItem('hold-slayer-theme');
} else {
override = next ? 'dark' : 'light';
localStorage.setItem('hold-slayer-theme', override);
}
}
onMount(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
systemDark = mq.matches;
const stored = localStorage.getItem('hold-slayer-theme');
if (stored === 'dark' || stored === 'light') {
override = stored as ThemeOverride;
}
const onSystemChange = (e: MediaQueryListEvent) => {
systemDark = e.matches;
};
mq.addEventListener('change', onSystemChange);
return () => mq.removeEventListener('change', onSystemChange);
});
const nav = [{ href: '/', label: 'Dashboard' }];
</script>
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
<header
class="border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur sticky top-0 z-10"
>
<div class="mx-auto max-w-7xl px-4 py-3 flex items-center gap-6">
<div class="flex items-center gap-2">
<span class="text-orange-500 text-lg leading-none">🔥</span>
<span class="font-semibold text-gray-900 dark:text-white tracking-tight">Hold Slayer</span>
<span class="text-gray-500 text-sm hidden sm:inline">Gateway</span>
</div>
<nav class="flex gap-1 ml-2">
{#each nav as item}
<a
href={item.href}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {$page.url.pathname ===
item.href
? 'bg-orange-600 text-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'}"
>
{item.label}
</a>
{/each}
</nav>
<button
onclick={toggleTheme}
class="ml-auto text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? 'Light' : 'Dark'}
</button>
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-6">
{@render children()}
</main>
</div>

View File

@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;

View File

@@ -0,0 +1,516 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import {
connectEventStream,
fetchActiveCalls,
fetchDevices,
fetchGatewayStatus,
fetchHealth,
hangupCall,
} from '$lib/api';
import type {
CallSummary,
DeviceStatus,
GatewayEvent,
GatewayStatus,
HealthStatus,
} from '$lib/types';
// ── State ─────────────────────────────────────────────────────────────────
let gateway = $state<GatewayStatus | null>(null);
let health = $state<HealthStatus | null>(null);
let devices = $state<DeviceStatus[]>([]);
let calls = $state<CallSummary[]>([]);
let events = $state<GatewayEvent[]>([]);
let wsConnected = $state(false);
let wsReconnecting = $state(false);
let loadError = $state<string | null>(null);
// ── Helpers ───────────────────────────────────────────────────────────────
function dot(ok: boolean) {
return ok
? 'bg-green-500 shadow-green-500/50 shadow-sm'
: 'bg-red-500 shadow-red-500/50 shadow-sm';
}
function fmtUptime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function relTime(iso: string): string {
const diff = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
}
function fmtDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function callStatusClass(status: string): string {
switch (status) {
case 'human_detected':
case 'bridged':
return 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-800';
case 'on_hold':
case 'navigating_ivr':
return 'bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800';
case 'failed':
case 'cancelled':
return 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800';
default:
return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700';
}
}
function eventClass(type: string): string {
if (type === 'holdslayer.human_detected') return 'text-green-600 dark:text-green-400';
if (type === 'holdslayer.hold_detected') return 'text-amber-600 dark:text-amber-400';
if (type.includes('failed') || type === 'call.failed') return 'text-red-500 dark:text-red-400';
if (type.startsWith('holdslayer.')) return 'text-orange-500 dark:text-orange-400';
if (type.startsWith('sip.')) return 'text-blue-500 dark:text-blue-400';
return 'text-gray-500 dark:text-gray-400';
}
// ── Data loading ──────────────────────────────────────────────────────────
async function loadStatus() {
try {
[gateway, health] = await Promise.all([fetchGatewayStatus(), fetchHealth()]);
loadError = null;
} catch (e) {
loadError = String(e);
}
}
async function loadDevices() {
try {
devices = await fetchDevices();
} catch {
// silently retry
}
}
async function loadCalls() {
try {
calls = await fetchActiveCalls();
} catch {
// silently retry
}
}
async function doHangup(callId: string) {
try {
await hangupCall(callId);
calls = calls.filter((c) => c.call_id !== callId);
} catch (e) {
console.error('Hangup failed:', e);
}
}
// ── WebSocket ─────────────────────────────────────────────────────────────
let disconnectWs: (() => void) | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = 1000;
function onWsEvent(event: GatewayEvent) {
// Prepend to feed, keep last 50
events = [event, ...events].slice(0, 50);
// Update trunk status from sip events
if (event.type === 'sip.trunk.registered' && health) {
health = {
...health,
sip_trunk: { ...health.sip_trunk, registered: true },
};
} else if (event.type === 'sip.trunk.registration_failed' && health) {
health = {
...health,
sip_trunk: { ...health.sip_trunk, registered: false },
};
}
// Refresh active calls on any call lifecycle event
if (event.type.startsWith('call.') || event.type.startsWith('holdslayer.')) {
loadCalls();
}
}
function connectWs() {
wsReconnecting = false;
disconnectWs = connectEventStream(
(e) => {
wsConnected = true;
reconnectDelay = 1000;
onWsEvent(e);
},
() => {
wsConnected = false;
wsReconnecting = true;
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
connectWs();
}, reconnectDelay);
},
);
// Optimistically mark connected; the first event confirms it
wsConnected = true;
}
// ── Timers ────────────────────────────────────────────────────────────────
let statusTimer: ReturnType<typeof setInterval>;
let devicesTimer: ReturnType<typeof setInterval>;
let callsTimer: ReturnType<typeof setInterval>;
onMount(() => {
loadStatus();
loadDevices();
loadCalls();
connectWs();
statusTimer = setInterval(loadStatus, 30_000);
devicesTimer = setInterval(loadDevices, 15_000);
callsTimer = setInterval(loadCalls, 5_000);
});
onDestroy(() => {
clearInterval(statusTimer);
clearInterval(devicesTimer);
clearInterval(callsTimer);
if (reconnectTimer) clearTimeout(reconnectTimer);
disconnectWs?.();
});
</script>
<div class="space-y-5">
<!-- Page title + WS indicator -->
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">Gateway Dashboard</h1>
<div class="flex items-center gap-2 text-xs text-gray-500">
<span class="inline-block w-2 h-2 rounded-full {dot(wsConnected)}"></span>
{#if wsReconnecting}
<span class="text-amber-500 dark:text-amber-400">Reconnecting…</span>
{:else if wsConnected}
<span>Live</span>
{:else}
<span class="text-red-500">Disconnected</span>
{/if}
</div>
</div>
{#if loadError}
<div
class="p-4 rounded-lg bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-300 text-sm"
>
{loadError}
</div>
{/if}
<!-- ── Status cards ──────────────────────────────────────────────────── -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Gateway -->
<div
class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-3"
>
<div class="flex items-center gap-2">
<span
class="inline-block w-2.5 h-2.5 rounded-full {dot(gateway?.status === 'running')}"
></span>
<span class="font-medium text-sm text-gray-900 dark:text-white">Gateway</span>
{#if gateway?.version}
<span
class="ml-auto text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700"
>
v{gateway.version}
</span>
{/if}
</div>
{#if gateway}
<dl class="text-sm space-y-1.5">
<div class="flex justify-between">
<dt class="text-gray-500">Status</dt>
<dd class="text-gray-700 dark:text-gray-200 capitalize">{gateway.status}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Uptime</dt>
<dd class="text-gray-700 dark:text-gray-200">{fmtUptime(gateway.uptime)}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Active calls</dt>
<dd class="text-gray-700 dark:text-gray-200">{gateway.active_calls}</dd>
</div>
</dl>
{:else if !loadError}
<p class="text-gray-400 text-xs animate-pulse">Loading…</p>
{/if}
</div>
<!-- SIP Trunk -->
<div
class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-3"
>
<div class="flex items-center gap-2">
<span
class="inline-block w-2.5 h-2.5 rounded-full {dot(
health?.sip_trunk.registered ?? false,
)}"
></span>
<span class="font-medium text-sm text-gray-900 dark:text-white">SIP Trunk</span>
{#if health?.sip_trunk.mock}
<span
class="ml-auto text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800"
>
Mock
</span>
{/if}
</div>
{#if health}
<dl class="text-sm space-y-1.5">
<div class="flex justify-between">
<dt class="text-gray-500">Registered</dt>
<dd
class={health.sip_trunk.registered
? 'text-green-600 dark:text-green-400'
: 'text-red-500 dark:text-red-400'}
>
{health.sip_trunk.registered ? 'Yes' : 'No'}
</dd>
</div>
{#if health.sip_trunk.host}
<div class="flex justify-between gap-2">
<dt class="text-gray-500 shrink-0">Host</dt>
<dd class="text-gray-500 dark:text-gray-400 text-xs font-mono truncate">
{health.sip_trunk.host}
</dd>
</div>
{/if}
{#if health.sip_trunk.reason && !health.sip_trunk.registered}
<div class="text-red-500 dark:text-red-400 text-xs pt-1">
{health.sip_trunk.reason}
</div>
{/if}
</dl>
{:else if !loadError}
<p class="text-gray-400 text-xs animate-pulse">Loading…</p>
{/if}
</div>
<!-- SIP Engine -->
<div
class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4 space-y-3"
>
<div class="flex items-center gap-2">
<span
class="inline-block w-2.5 h-2.5 rounded-full {dot(health?.sip_engine === 'ready')}"
></span>
<span class="font-medium text-sm text-gray-900 dark:text-white">SIP Engine</span>
</div>
{#if health}
<dl class="text-sm space-y-1.5">
<div class="flex justify-between">
<dt class="text-gray-500">Engine</dt>
<dd
class={health.sip_engine === 'ready'
? 'text-green-600 dark:text-green-400'
: 'text-amber-500 dark:text-amber-400'}
>
{health.sip_engine}
</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Gateway</dt>
<dd class="text-gray-700 dark:text-gray-200 capitalize">{health.gateway}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Overall</dt>
<dd
class={health.status === 'healthy'
? 'text-green-600 dark:text-green-400'
: 'text-amber-500 dark:text-amber-400'}
>
{health.status}
</dd>
</div>
</dl>
{:else if !loadError}
<p class="text-gray-400 text-xs animate-pulse">Loading…</p>
{/if}
</div>
</div>
<!-- ── Devices + Active calls ────────────────────────────────────────── -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Registered Devices -->
<div
class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4"
>
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
Registered Devices
</h2>
{#if devices.length === 0}
<p class="text-gray-500 text-sm">No devices registered.</p>
{:else}
<table class="w-full text-sm">
<thead>
<tr
class="text-left text-xs text-gray-500 dark:text-gray-600 border-b border-gray-200 dark:border-gray-800"
>
<th class="pb-2 font-medium">Name</th>
<th class="pb-2 font-medium">Type</th>
<th class="pb-2 font-medium text-center">Online</th>
<th class="pb-2 font-medium hidden sm:table-cell">Last seen</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800/60">
{#each devices as device}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors">
<td class="py-2 pr-3 text-gray-900 dark:text-white font-medium">{device.name}</td>
<td class="py-2 pr-3 text-gray-500 dark:text-gray-400 text-xs capitalize">
{device.type.replace('_', ' ')}
</td>
<td class="py-2 text-center">
<span class="inline-block w-2 h-2 rounded-full {dot(device.is_online)}"></span>
</td>
<td class="py-2 text-gray-500 text-xs hidden sm:table-cell">
{relTime(device.last_seen)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<!-- Active Calls -->
<div
class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-4"
>
<h2 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
Active Calls
</h2>
{#if calls.length === 0}
<p class="text-gray-500 text-sm">No active calls.</p>
{:else}
<table class="w-full text-sm">
<thead>
<tr
class="text-left text-xs text-gray-500 dark:text-gray-600 border-b border-gray-200 dark:border-gray-800"
>
<th class="pb-2 font-medium">Number</th>
<th class="pb-2 font-medium">Status</th>
<th class="pb-2 font-medium text-right">Hold</th>
<th class="pb-2 font-medium text-right">Dur.</th>
<th class="pb-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800/60">
{#each calls as call}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors">
<td class="py-2 pr-2">
<div class="font-mono text-xs text-gray-900 dark:text-white">
{call.remote_number}
</div>
{#if call.intent}
<div class="text-gray-400 text-xs truncate max-w-28" title={call.intent}>
{call.intent}
</div>
{/if}
</td>
<td class="py-2 pr-2">
<span
class="text-xs px-1.5 py-0.5 rounded {callStatusClass(call.status)}"
>
{call.status.replace('_', ' ')}
</span>
</td>
<td class="py-2 pr-2 text-right text-gray-500 text-xs whitespace-nowrap">
{call.hold_time > 0 ? fmtDuration(call.hold_time) : '—'}
</td>
<td class="py-2 pr-2 text-right text-gray-500 text-xs whitespace-nowrap">
{fmtDuration(call.duration)}
</td>
<td class="py-2 text-right">
<button
onclick={() => doHangup(call.call_id)}
class="text-xs px-2 py-0.5 rounded bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800 hover:bg-red-100 dark:hover:bg-red-900 transition-colors"
>
End
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
<!-- ── Event Feed ────────────────────────────────────────────────────── -->
<div class="rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
<div
class="px-4 py-3 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between"
>
<h2 class="text-sm font-medium text-gray-900 dark:text-white">Event Feed</h2>
<span class="text-xs text-gray-500">
{events.length} events · live via WebSocket
</span>
</div>
{#if events.length === 0}
<p class="px-4 py-5 text-gray-500 text-sm">
No events yet — waiting for gateway activity.
</p>
{:else}
<div class="overflow-x-auto max-h-96 overflow-y-auto">
<table class="w-full text-sm">
<thead class="sticky top-0 bg-white dark:bg-gray-900">
<tr
class="text-left text-xs text-gray-500 dark:text-gray-600 border-b border-gray-200 dark:border-gray-800"
>
<th class="px-4 py-2 font-medium whitespace-nowrap">Time</th>
<th class="px-4 py-2 font-medium">Event</th>
<th class="px-4 py-2 font-medium hidden sm:table-cell">Call</th>
<th class="px-4 py-2 font-medium">Message</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100/40 dark:divide-gray-800/40">
{#each events as event}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors">
<td class="px-4 py-2 text-gray-500 whitespace-nowrap text-xs">
{relTime(event.timestamp)}
</td>
<td class="px-4 py-2">
<code class="text-xs {eventClass(event.type)}">{event.type}</code>
</td>
<td class="px-4 py-2 hidden sm:table-cell">
{#if event.call_id}
<code class="text-xs text-gray-400 font-mono">
{event.call_id.slice(-8)}
</code>
{:else}
<span class="text-gray-300 dark:text-gray-700"></span>
{/if}
</td>
<td class="px-4 py-2 text-gray-600 dark:text-gray-300 text-xs max-w-xs truncate">
{event.message}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>