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:
@@ -22,8 +22,7 @@ GATEWAY_SIP_PORT=5080
|
|||||||
GATEWAY_SIP_DOMAIN=gateway.helu.ca
|
GATEWAY_SIP_DOMAIN=gateway.helu.ca
|
||||||
|
|
||||||
# --- Speaches STT ---
|
# --- Speaches STT ---
|
||||||
SPEACHES_URL=http://perseus.helu.ca:22070
|
SPEACHES_URL=http://localhost:22070
|
||||||
SPEACHES_PROD_URL=http://pan.helu.ca:22070
|
|
||||||
SPEACHES_MODEL=whisper-large-v3
|
SPEACHES_MODEL=whisper-large-v3
|
||||||
|
|
||||||
# --- Audio Classifier ---
|
# --- Audio Classifier ---
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -36,3 +36,8 @@ Thumbs.db
|
|||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Dashboard (SvelteKit)
|
||||||
|
dashboard/node_modules/
|
||||||
|
dashboard/.svelte-kit/
|
||||||
|
dashboard/build/
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ class SpeachesSettings(BaseSettings):
|
|||||||
model_config = SettingsConfigDict(env_prefix="SPEACHES_")
|
model_config = SettingsConfigDict(env_prefix="SPEACHES_")
|
||||||
|
|
||||||
url: str = "http://localhost:22070"
|
url: str = "http://localhost:22070"
|
||||||
prod_url: str = "http://localhost:22070"
|
|
||||||
model: str = "whisper-large-v3"
|
model: str = "whisper-large-v3"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2111
dashboard/package-lock.json
generated
Normal file
2111
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
dashboard/package.json
Normal file
24
dashboard/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "hold-slayer-dashboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
|
"@sveltejs/kit": "^2.16.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
|
"@types/node": "^25.8.0",
|
||||||
|
"svelte": "^5.25.3",
|
||||||
|
"svelte-check": "^4.1.4",
|
||||||
|
"tailwindcss": "^4.1.3",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^6.2.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
dashboard/src/app.css
Normal file
1
dashboard/src/app.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
23
dashboard/src/app.html
Normal file
23
dashboard/src/app.html
Normal 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
50
dashboard/src/lib/api.ts
Normal 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();
|
||||||
|
}
|
||||||
74
dashboard/src/lib/types.ts
Normal file
74
dashboard/src/lib/types.ts
Normal 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;
|
||||||
|
}
|
||||||
84
dashboard/src/routes/+layout.svelte
Normal file
84
dashboard/src/routes/+layout.svelte
Normal 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>
|
||||||
2
dashboard/src/routes/+layout.ts
Normal file
2
dashboard/src/routes/+layout.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const ssr = false;
|
||||||
|
export const prerender = false;
|
||||||
516
dashboard/src/routes/+page.svelte
Normal file
516
dashboard/src/routes/+page.svelte
Normal 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>
|
||||||
12
dashboard/svelte.config.js
Normal file
12
dashboard/svelte.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
fallback: 'index.html',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
13
dashboard/tsconfig.json
Normal file
13
dashboard/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
13
dashboard/vite.config.ts
Normal file
13
dashboard/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8000',
|
||||||
|
'/ws': { target: 'ws://localhost:8000', ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user