feat: CID-based routing rules engine with flexible match conditions

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
This commit is contained in:
Maksim Totmin
2026-06-25 21:51:37 +07:00
parent 2d9d179b0a
commit a7722af29e
13 changed files with 1928 additions and 16 deletions
+52
View File
@@ -233,3 +233,55 @@ export async function setBalanceMode(mode: 'best' | 'weighted_random') {
body: JSON.stringify({ mode }),
});
}
// --- Routing Rules ---
export interface RouteRule {
id: string;
name: string;
enabled: boolean;
priority: number;
match_caller_id: string;
match_destination: string;
match_ingress_trunk: string;
match_days: string;
match_time_start: string;
match_time_end: string;
action_type: 'node' | 'pool' | 'auto';
action_node_id: string;
action_pool_ids: string[];
description: string;
hits: number;
created_at: string;
updated_at: string;
}
export async function getRoutingRules(): Promise<RouteRule[]> {
return request<RouteRule[]>('/routing/rules');
}
export async function createRoutingRule(data: Partial<RouteRule>) {
return request<RouteRule>('/routing/rules', { method: 'POST', body: JSON.stringify(data) });
}
export async function updateRoutingRule(id: string, data: Partial<RouteRule>) {
return request<RouteRule>(`/routing/rules/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(data) });
}
export async function deleteRoutingRule(id: string) {
return request<void>(`/routing/rules/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
export async function toggleRoutingRule(id: string, enabled: boolean) {
return request<RouteRule>(`/routing/rules/${encodeURIComponent(id)}/toggle`, {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
}
export async function reorderRoutingRules(order: string[]) {
return request<{ status: string }>('/routing/rules/reorder', {
method: 'PUT',
body: JSON.stringify(order),
});
}
+4 -1
View File
@@ -3,7 +3,7 @@
import { goto } from '$app/navigation';
import '../app.css';
import { Toaster } from 'svelte-sonner';
import { LayoutDashboard, Phone, Cable, Users } from 'lucide-svelte';
import { LayoutDashboard, Phone, Cable, Users, ArrowLeftRight } from 'lucide-svelte';
let token: string | null = null;
let user: { username: string; role: string } | null = null;
@@ -53,6 +53,9 @@
<a href="/trunks" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/trunks' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}">
<Cable class="h-4 w-4" /> Транки
</a>
<a href="/routing" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/routing' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}">
<ArrowLeftRight class="h-4 w-4" /> Маршрутизация
</a>
<a href="/users" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/users' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}">
<Users class="h-4 w-4" /> Пользователи
</a>
+401
View File
@@ -0,0 +1,401 @@
<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>