Routing rules: - RouteRule model: 3 match fields (caller_id, dest, ingress_trunk regex), 3 action types (node/pool/auto), time-based (days of week, time range) - Engine router.go: CompiledRule with pre-compiled regex, PickNodeForCall with cascade (dead node → next rule), pickNodeFromPool - Separate sync.RWMutex for Router — zero contention admin vs traffic - Validate-before-save pattern: engine validates before JSON save - Graceful degradation: corrupted rules skipped, no rules → PickNode() Persistence: - data/routing.json with atomic save (tmp→rename), config.Manager - Auto-created empty on first start API (admin only): - GET/POST/PUT/DELETE /api/routing/rules - PUT /api/routing/rules/:id/toggle - PUT /api/routing/rules/reorder Integration: - /api/route now uses PickNodeForCall with caller_id/dest/ingress_trunk query params (already sent by route.lua) - RouteResponse includes matched_rule field for debugging - Main.go loads routing rules on startup with graceful fallback Web UI: - /routing page: table with priority arrows, inline edit, create form, regex test tool, toggle, delete, action badges, hits counter Tests: - 28 tests: matchRule, time matching, PickNodeForCall cascade, pool scoring, validation, hit counting, compilation
402 lines
18 KiB
Svelte
402 lines
18 KiB
Svelte
<script lang="ts">
|
||
import { browser } from '$app/environment';
|
||
import { goto } from '$app/navigation';
|
||
import { onMount } from 'svelte';
|
||
import {
|
||
isAuthenticated, getRoutingRules, createRoutingRule, updateRoutingRule,
|
||
deleteRoutingRule, toggleRoutingRule, reorderRoutingRules, getNodes,
|
||
type RouteRule, type NodeInfo
|
||
} from '$lib/api';
|
||
import { toast } from 'svelte-sonner';
|
||
import { Plus, Trash2, Save, X, ArrowLeftRight, ArrowUp, ArrowDown } from 'lucide-svelte';
|
||
|
||
let rules: RouteRule[] = [];
|
||
let nodes: NodeInfo[] = [];
|
||
let loading = true;
|
||
|
||
let editing: string | null = null;
|
||
let editData: Partial<RouteRule> & { pool_ids_str?: string } = {};
|
||
let creating = false;
|
||
let newData: Partial<RouteRule> & { pool_ids_str?: string } = {
|
||
action_type: 'auto',
|
||
priority: 10,
|
||
action_pool_ids: [] as string[],
|
||
};
|
||
|
||
// Regex test inputs
|
||
let testCallerId = '';
|
||
let testDest = '';
|
||
let testIngress = '';
|
||
|
||
if (browser && !isAuthenticated()) { goto('/login'); }
|
||
|
||
onMount(async () => {
|
||
await loadAll();
|
||
await loadNodes();
|
||
});
|
||
|
||
async function loadAll() {
|
||
loading = true;
|
||
try { rules = await getRoutingRules(); }
|
||
catch (e: any) { toast.error(e.message); }
|
||
finally { loading = false; }
|
||
}
|
||
|
||
async function loadNodes() {
|
||
try { nodes = await getNodes(); }
|
||
catch { /* nodes optional */ }
|
||
}
|
||
|
||
// --- Helpers ---
|
||
|
||
function matches(str: string, pattern: string): boolean {
|
||
if (!str || !pattern) return false;
|
||
try { return new RegExp(pattern).test(str); } catch { return false; }
|
||
}
|
||
|
||
function joinPoolIDs(ids: string[] | undefined): string {
|
||
return (ids || []).join(',');
|
||
}
|
||
|
||
function splitPoolIDs(s: string | undefined): string[] {
|
||
if (!s) return [];
|
||
return s.split(',').map(x => x.trim()).filter(x => x);
|
||
}
|
||
|
||
// --- CRUD ---
|
||
|
||
function startEdit(r: RouteRule) {
|
||
editing = r.id;
|
||
editData = {
|
||
...r,
|
||
pool_ids_str: joinPoolIDs(r.action_pool_ids),
|
||
};
|
||
}
|
||
|
||
function cancelEdit() {
|
||
editing = null;
|
||
creating = false;
|
||
editData = {};
|
||
newData = { action_type: 'auto', priority: 10, action_pool_ids: [] };
|
||
testCallerId = testDest = testIngress = '';
|
||
}
|
||
|
||
async function saveEdit() {
|
||
if (!editing) return;
|
||
try {
|
||
const payload = { ...editData };
|
||
(payload as any).action_pool_ids = splitPoolIDs((payload as any).pool_ids_str);
|
||
delete (payload as any).pool_ids_str;
|
||
|
||
const updated = await updateRoutingRule(editing, payload);
|
||
const idx = rules.findIndex(r => r.id === editing);
|
||
if (idx >= 0) rules[idx] = updated;
|
||
editing = null;
|
||
toast.success('Правило обновлено');
|
||
} catch (e: any) { toast.error(e.message); }
|
||
}
|
||
|
||
async function saveCreate() {
|
||
const payload: any = { ...newData };
|
||
if (!payload.name?.trim()) { toast.error('Название обязательно'); return; }
|
||
|
||
if (payload.action_type === 'pool') {
|
||
payload.action_pool_ids = splitPoolIDs(payload.pool_ids_str);
|
||
} else if (payload.action_type === 'node') {
|
||
payload.action_pool_ids = [];
|
||
}
|
||
delete payload.pool_ids_str;
|
||
|
||
try {
|
||
const created = await createRoutingRule(payload);
|
||
rules = [...rules, created];
|
||
cancelEdit();
|
||
toast.success('Правило создано');
|
||
} catch (e: any) { toast.error(e.message); }
|
||
}
|
||
|
||
async function removeRule(id: string) {
|
||
if (!confirm('Удалить правило?')) return;
|
||
try {
|
||
await deleteRoutingRule(id);
|
||
rules = rules.filter(r => r.id !== id);
|
||
toast.success('Правило удалено');
|
||
} catch (e: any) { toast.error(e.message); }
|
||
}
|
||
|
||
async function toggleRule(id: string, enabled: boolean) {
|
||
try {
|
||
const updated = await toggleRoutingRule(id, enabled);
|
||
rules = rules.map(r => r.id === id ? updated : r);
|
||
} catch (e: any) { toast.error(e.message); }
|
||
}
|
||
|
||
async function moveRule(idx: number, dir: -1 | 1) {
|
||
const ni = idx + dir;
|
||
if (ni < 0 || ni >= rules.length) return;
|
||
const reordered = [...rules];
|
||
[reordered[idx], reordered[ni]] = [reordered[ni], reordered[idx]];
|
||
try {
|
||
await reorderRoutingRules(reordered.map(r => r.id));
|
||
await loadAll();
|
||
} catch (e: any) { toast.error(e.message); }
|
||
}
|
||
|
||
// --- Display ---
|
||
|
||
function actionBadge(t: string): string {
|
||
switch (t) {
|
||
case 'node': return 'bg-violet-100 text-violet-700';
|
||
case 'pool': return 'bg-sky-100 text-sky-700';
|
||
default: return 'bg-gray-100 text-gray-700';
|
||
}
|
||
}
|
||
|
||
function actionLabel(t: string): string {
|
||
switch (t) { case 'node': return 'Нода'; case 'pool': return 'Пул'; default: return 'Авто'; }
|
||
}
|
||
|
||
function matchSummary(r: RouteRule): string {
|
||
const parts: string[] = [];
|
||
if (r.match_caller_id) parts.push('CID');
|
||
if (r.match_destination) parts.push('Dest');
|
||
if (r.match_ingress_trunk) parts.push('Trunk');
|
||
if (r.match_days) parts.push('Days=' + r.match_days);
|
||
if (r.match_time_start || r.match_time_end) parts.push((r.match_time_start || '--') + '-' + (r.match_time_end || '--'));
|
||
return parts.length > 0 ? parts.join(', ') : '—';
|
||
}
|
||
|
||
function timeDisplay(r: RouteRule): string {
|
||
if (!r.match_days && !r.match_time_start && !r.match_time_end) return '—';
|
||
const parts: string[] = [];
|
||
if (r.match_days) parts.push('дни ' + r.match_days);
|
||
if (r.match_time_start || r.match_time_end) parts.push((r.match_time_start || '--') + '–' + (r.match_time_end || '--'));
|
||
return parts.join(' ');
|
||
}
|
||
</script>
|
||
|
||
<div class="space-y-6">
|
||
<div class="flex items-center justify-between">
|
||
<h1 class="text-xl font-bold flex items-center gap-2"><ArrowLeftRight class="h-5 w-5" /> Маршрутизация</h1>
|
||
<button on:click={() => (creating = true)} class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90">
|
||
<Plus class="h-4 w-4" /> Добавить правило
|
||
</button>
|
||
</div>
|
||
|
||
{#if creating}
|
||
<div class="border rounded-lg p-4 bg-yellow-50 border-yellow-200 space-y-3">
|
||
<h3 class="text-sm font-semibold">Новое правило маршрутизации</h3>
|
||
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<input class="px-3 py-2 border rounded-md text-sm" placeholder="Название правила *" bind:value={newData.name} />
|
||
<input class="px-3 py-2 border rounded-md text-sm" placeholder="Приоритет (меньше = раньше)" type="number" bind:value={newData.priority} />
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="text-xs text-muted-foreground">Caller ID (regex)</label>
|
||
<input class="w-full px-3 py-2 border rounded-md text-sm font-mono" placeholder="^7\d{10}$" bind:value={newData.match_caller_id} />
|
||
</div>
|
||
<div>
|
||
<label class="text-xs text-muted-foreground">Destination (regex)</label>
|
||
<input class="w-full px-3 py-2 border rounded-md text-sm font-mono" placeholder="^383122.*$" bind:value={newData.match_destination} />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="text-xs text-muted-foreground">Ingress Trunk (regex)</label>
|
||
<input class="w-full px-3 py-2 border rounded-md text-sm font-mono" placeholder="^trk-.*$" bind:value={newData.match_ingress_trunk} />
|
||
</div>
|
||
<div>
|
||
<label class="text-xs text-muted-foreground">Дни недели ("1-5" = пн-пт)</label>
|
||
<input class="w-full px-3 py-2 border rounded-md text-sm" placeholder="1-5" bind:value={newData.match_days} />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="text-xs text-muted-foreground">Начало (HH:MM)</label>
|
||
<input class="w-full px-3 py-2 border rounded-md text-sm" placeholder="09:00" bind:value={newData.match_time_start} />
|
||
</div>
|
||
<div>
|
||
<label class="text-xs text-muted-foreground">Конец (HH:MM)</label>
|
||
<input class="w-full px-3 py-2 border rounded-md text-sm" placeholder="18:00" bind:value={newData.match_time_end} />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||
<div>
|
||
<label class="text-xs text-muted-foreground">Действие</label>
|
||
<select class="w-full px-3 py-2 border rounded-md text-sm bg-background" bind:value={newData.action_type}>
|
||
<option value="auto">Авто (scoring)</option>
|
||
<option value="node">Нода</option>
|
||
<option value="pool">Пул нод</option>
|
||
</select>
|
||
</div>
|
||
|
||
{#if newData.action_type === 'node'}
|
||
<div>
|
||
<label class="text-xs text-muted-foreground">Node ID</label>
|
||
<select class="w-full px-3 py-2 border rounded-md text-sm bg-background font-mono" bind:value={newData.action_node_id}>
|
||
<option value="">— выбрать —</option>
|
||
{#each nodes as n}
|
||
<option value={n.node_id}>{n.node_id}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
{:else if newData.action_type === 'pool'}
|
||
<div class="col-span-2">
|
||
<label class="text-xs text-muted-foreground">Node IDs (через запятую)</label>
|
||
<input class="w-full px-3 py-2 border rounded-md text-sm font-mono" placeholder="asterisk01,asterisk02" bind:value={newData.pool_ids_str} />
|
||
{#if nodes.length > 0}
|
||
<div class="mt-1 flex flex-wrap gap-1">
|
||
{#each nodes as n}
|
||
<label class="flex items-center gap-1 text-xs px-2 py-1 border rounded cursor-pointer hover:bg-secondary">
|
||
<input type="checkbox" checked={splitPoolIDs(newData.pool_ids_str).includes(n.node_id)}
|
||
on:change={(e) => {
|
||
const current = splitPoolIDs(newData.pool_ids_str);
|
||
const target = e.target as HTMLInputElement;
|
||
newData.pool_ids_str = target.checked
|
||
? [...current, n.node_id].join(',')
|
||
: current.filter(id => id !== n.node_id).join(',');
|
||
}} />
|
||
{n.node_id}
|
||
</label>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<div>
|
||
<label class="text-xs text-muted-foreground">Описание</label>
|
||
<input class="w-full px-3 py-2 border rounded-md text-sm" placeholder="Для чего это правило" bind:value={newData.description} />
|
||
</div>
|
||
|
||
<!-- Regex test -->
|
||
<div class="border-t pt-3">
|
||
<p class="text-xs font-medium text-muted-foreground mb-2">Проверка regex</p>
|
||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||
{#each [
|
||
{ label: 'Caller ID', val: testCallerId, bind: (v: string) => testCallerId = v, pattern: newData.match_caller_id },
|
||
{ label: 'Destination', val: testDest, bind: (v: string) => testDest = v, pattern: newData.match_destination },
|
||
{ label: 'Ingress Trunk', val: testIngress, bind: (v: string) => testIngress = v, pattern: newData.match_ingress_trunk },
|
||
] as item}
|
||
<div>
|
||
<input class="w-full px-3 py-2 border rounded-md text-sm" placeholder={`Тест ${item.label}`} value={item.val} on:input={(e) => item.bind(e.currentTarget.value)} />
|
||
{#if item.val && item.pattern}
|
||
<span class="text-xs {matches(item.val, item.pattern) ? 'text-green-600' : 'text-red-600'}">
|
||
{matches(item.val, item.pattern) ? '✓ match' : '✗ no match'}
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-2 pt-2">
|
||
<button on:click={saveCreate} class="flex items-center gap-1 px-3 py-2 bg-primary text-primary-foreground rounded-md text-sm"><Save class="h-3 w-3" /> Создать</button>
|
||
<button on:click={cancelEdit} class="flex items-center gap-1 px-3 py-2 border rounded-md text-sm"><X class="h-3 w-3" /> Отмена</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if loading}
|
||
<p class="text-muted-foreground text-sm">Загрузка...</p>
|
||
{:else if rules.length === 0}
|
||
<p class="text-muted-foreground text-sm py-8 text-center">
|
||
Нет правил маршрутизации. Звонки идут через стандартный скоринг.
|
||
</p>
|
||
{:else}
|
||
<div class="border rounded-lg overflow-hidden">
|
||
<table class="w-full text-sm">
|
||
<thead class="bg-secondary">
|
||
<tr>
|
||
<th class="px-3 py-3 text-left font-medium w-16">#</th>
|
||
<th class="px-3 py-3 text-left font-medium">Название</th>
|
||
<th class="px-3 py-3 text-left font-medium">Условия</th>
|
||
<th class="px-3 py-3 text-left font-medium">Действие</th>
|
||
<th class="px-3 py-3 text-left font-medium">Время</th>
|
||
<th class="px-3 py-3 text-center font-medium w-12">Hits</th>
|
||
<th class="px-3 py-3 text-center font-medium w-14">Вкл</th>
|
||
<th class="px-3 py-3 text-right font-medium w-32">Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each rules as r, idx (r.id)}
|
||
{#if editing === r.id}
|
||
<tr class="border-t bg-yellow-50">
|
||
<td class="px-3 py-2 text-center font-mono text-xs">{r.priority}</td>
|
||
<td class="px-3 py-2"><input class="w-full px-2 py-1 border rounded text-sm" bind:value={editData.name} /></td>
|
||
<td class="px-3 py-2"><div class="space-y-1">
|
||
<input class="w-full px-2 py-1 border rounded text-xs font-mono" placeholder="CID" bind:value={editData.match_caller_id} />
|
||
<input class="w-full px-2 py-1 border rounded text-xs font-mono" placeholder="Dest" bind:value={editData.match_destination} />
|
||
<input class="w-full px-2 py-1 border rounded text-xs font-mono" placeholder="Trunk" bind:value={editData.match_ingress_trunk} />
|
||
</div></td>
|
||
<td class="px-3 py-2">
|
||
<select class="w-full px-2 py-1 border rounded text-xs mb-1" bind:value={editData.action_type}>
|
||
<option value="auto">Авто</option>
|
||
<option value="node">Нода</option>
|
||
<option value="pool">Пул</option>
|
||
</select>
|
||
{#if editData.action_type === 'node'}
|
||
<input class="w-full px-2 py-1 border rounded text-xs font-mono" placeholder="node_id" bind:value={editData.action_node_id} />
|
||
{:else if editData.action_type === 'pool'}
|
||
<input class="w-full px-2 py-1 border rounded text-xs font-mono" placeholder="id1,id2" bind:value={editData.pool_ids_str} />
|
||
{/if}
|
||
</td>
|
||
<td class="px-3 py-2"><div class="space-y-1">
|
||
<input class="w-full px-2 py-1 border rounded text-xs" placeholder="дни" bind:value={editData.match_days} />
|
||
<input class="w-full px-2 py-1 border rounded text-xs" placeholder="с" bind:value={editData.match_time_start} />
|
||
<input class="w-full px-2 py-1 border rounded text-xs" placeholder="по" bind:value={editData.match_time_end} />
|
||
</div></td>
|
||
<td class="px-3 py-2 text-center text-xs">{r.hits ?? 0}</td>
|
||
<td class="px-3 py-2 text-center"><label class="flex justify-center"><input type="checkbox" checked={editData.enabled} /></label></td>
|
||
<td class="px-3 py-2 text-right">
|
||
<div class="flex gap-1 justify-end">
|
||
<button on:click={saveEdit} class="px-2 py-1 bg-primary text-primary-foreground rounded text-xs"><Save class="h-3 w-3 inline" /></button>
|
||
<button on:click={cancelEdit} class="px-2 py-1 border rounded text-xs"><X class="h-3 w-3 inline" /></button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{:else}
|
||
<tr class="border-t hover:bg-secondary/50">
|
||
<td class="px-3 py-3 text-center">
|
||
<div class="flex items-center justify-center gap-0.5">
|
||
<button on:click={() => moveRule(idx, -1)} disabled={idx === 0} class="text-muted-foreground hover:text-primary disabled:opacity-20"><ArrowUp class="h-3 w-3" /></button>
|
||
<span class="w-5 font-mono text-xs">{r.priority}</span>
|
||
<button on:click={() => moveRule(idx, 1)} disabled={idx === rules.length - 1} class="text-muted-foreground hover:text-primary disabled:opacity-20"><ArrowDown class="h-3 w-3" /></button>
|
||
</div>
|
||
</td>
|
||
<td class="px-3 py-3 font-medium">{r.name}</td>
|
||
<td class="px-3 py-3 font-mono text-xs text-muted-foreground max-w-56 break-all">{matchSummary(r)}</td>
|
||
<td class="px-3 py-3">
|
||
<span class="px-2 py-1 rounded text-xs font-medium {actionBadge(r.action_type)}">
|
||
{actionLabel(r.action_type)}
|
||
{#if r.action_type === 'node' && r.action_node_id}
|
||
<span class="ml-1 font-mono opacity-70">{r.action_node_id}</span>
|
||
{:else if r.action_type === 'pool' && r.action_pool_ids?.length}
|
||
<span class="ml-1 font-mono opacity-70">{r.action_pool_ids.length} нод</span>
|
||
{/if}
|
||
</span>
|
||
</td>
|
||
<td class="px-3 py-3 text-xs text-muted-foreground">{timeDisplay(r)}</td>
|
||
<td class="px-3 py-3 text-center font-mono text-xs">{r.hits ?? 0}</td>
|
||
<td class="px-3 py-3 text-center">
|
||
<button on:click={() => toggleRule(r.id, !r.enabled)} class="text-xs">{r.enabled ? '🟢' : '🔴'}</button>
|
||
</td>
|
||
<td class="px-3 py-3 text-right">
|
||
<button on:click={() => startEdit(r)} class="px-2 py-1 text-xs text-primary hover:underline mr-1">Изменить</button>
|
||
<button on:click={() => removeRule(r.id)} class="px-2 py-1 text-xs text-destructive hover:underline"><Trash2 class="h-3 w-3 inline" /></button>
|
||
</td>
|
||
</tr>
|
||
{/if}
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{/if}
|
||
</div>
|