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:
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>
|
||||
Reference in New Issue
Block a user