{% extends "console/layout.html" %}
{% block title %}{{ "routing.title" | t }} · {{site_name|default('RustPBX')}}{% endblock %}
{% block content %}
<div class="p-6">
<div class="mx-auto max-w-7xl space-y-6" x-data='routingConsole({
basePath: {{ base_path | tojson }},
filters: {{ filters | tojson }},
createUrl: {{ create_url | tojson }},
amiEndpoint: {{ ami_endpoint | default("/ami/v1") | tojson }},
forwardingCatalog: {{ forwarding_catalog | default({}) | tojson }}
})' x-init="init()">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">{{ "routing.title" | t }}</h1>
<p class="mt-2 text-sm text-slate-500">{{ "routing.prioritise_trunks" | t }}</p>
</div>
<div class="flex flex-wrap gap-3">
<button type="button"
class="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:border-sky-300 hover:text-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-200 focus:ring-offset-2"
:class="[reloading ? 'cursor-wait border-slate-200 text-slate-400 hover:border-slate-200 hover:text-slate-400' : '', pendingReload && !reloading ? 'reload-glow reload-glow-accent' : '']"
:disabled="reloading" @click="confirmReload()">
<template x-if="!reloading">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4.5 10a5.5 5.5 0 0 1 9.35-3.89l.65.65M15.5 10a5.5 5.5 0 0 1-9.35 3.89l-.65-.65M10 4.5V2m0 18v-2" />
</svg>
</template>
<template x-if="reloading">
<svg class="h-4 w-4 animate-spin text-slate-400" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 3v3m6.364 1.636-2.121 2.121M21 12h-3m-1.636 6.364-2.121-2.121M12 21v-3m-6.364-1.636 2.121-2.121M3 12h3m1.636-6.364 2.121 2.121" />
</svg>
</template>
{{ "routing.reload_routes" | t }}
</button>
<button type="button"
class="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:border-indigo-300 hover:text-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-200 focus:ring-offset-2"
@click="openValidation()">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 9a6 6 0 0 1 12 0c0 1.657-1.567 3.286-4.7 4.878l-.32.161a1 1 0 0 1-.96 0l-.32-.161C5.567 12.286 4 10.657 4 9Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M10 13v3m-2 0h4" />
</svg>
{{ "routing.routing_validation" | t }}
</button>
<a :href="createUrl"
class="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 4v12m6-6H4" />
</svg>
{{ "routing.new_route" | t }}
</a>
</div>
</div>
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-xl bg-white p-4 shadow-sm ring-1 ring-black/5">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "routing.total_routes" | t }}</div>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-2xl font-semibold text-slate-900" x-text="summary.total_routes"></span>
<span class="text-xs text-slate-500">{{ "routing.rules" | t }}</span>
</div>
</div>
<div class="rounded-xl bg-white p-4 shadow-sm ring-1 ring-black/5">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "routing.active_routes" | t }}</div>
<div class="mt-2 text-2xl font-semibold text-emerald-600" x-text="summary.active_routes"></div>
<div class="text-xs text-slate-500">{{ "routing.enabled_for_realtime" | t }}</div>
</div>
<div class="rounded-xl bg-white p-4 shadow-sm ring-1 ring-black/5">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "routing.paused_routes" | t }}</div>
<div class="mt-2 text-2xl font-semibold text-amber-600" x-text="pausedRoutes"></div>
<div class="text-xs text-slate-500">{{ "routing.temporarily_disabled" | t }}</div>
</div>
<div class="rounded-xl bg-white p-4 shadow-sm ring-1 ring-black/5">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "routing.last_deploy" | t }}</div>
<div class="mt-2 text-base font-semibold text-slate-900" x-text="formatDate(summary.last_deploy)"></div>
<div class="text-xs text-slate-500">{{ "routing.across_cluster" | t }}</div>
</div>
</section>
<template x-if="flash">
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700"
x-text="flash"></div>
</template>
<template x-if="error">
<div class="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" x-text="error">
</div>
</template>
<template x-if="lastReload">
<div class="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<span class="font-semibold text-slate-800">{{ "routing.latest_reload" | t }}</span>
<span class="ml-2" x-text="buildReloadSummary(lastReload)"></span>
</div>
<div class="text-xs text-slate-500">
<span>{{ "routing.finished" | t }} </span>
<span x-text="formatDate(lastReload.finished_at)"></span>
</div>
</div>
<div class="mt-2 text-xs text-slate-500">
<span>{{ "routing.sources" | t }}:</span>
<span> {{ "routing.embedded" | t }}
<span class="font-semibold text-slate-700" x-text="Number(lastReload.config_count) || 0"></span>
</span>,
<span> {{ "routing.include_files" | t }}
<span class="font-semibold text-slate-700" x-text="Number(lastReload.file_count) || 0"></span>
</span>
<template x-if="lastReload.generated">
<span>, {{ "routing.generated_file" | t }}
<span class="font-semibold text-slate-700"
x-text="Number(lastReload.generated.entries) || 0"></span>
</span>
</template>
</div>
<template x-if="lastReload.generated && (lastReload.generated.path || lastReload.generated.backup)">
<div class="mt-2 text-xs text-slate-500">
<template x-if="lastReload.generated.path">
<div>
<span class="font-semibold text-slate-600">{{ "routing.generated_file" | t }}</span>
<span class="ml-1 break-all" x-text="lastReload.generated.path"></span>
</div>
</template>
<template x-if="lastReload.generated.backup">
<div class="mt-1">
<span class="font-semibold text-slate-600">{{ "routing.previous_backup" | t }}</span>
<span class="ml-1 break-all" x-text="lastReload.generated.backup"></span>
</div>
</template>
</div>
</template>
<template x-if="lastReload.patterns && lastReload.patterns.length">
<div class="mt-2 text-xs text-slate-500">
<div class="font-semibold text-slate-600">{{ "routing.include_patterns" | t }}</div>
<ul class="mt-1 list-disc space-y-0.5 pl-5">
<template x-for="pattern in lastReload.patterns" :key="pattern">
<li class="break-all" x-text="pattern"></li>
</template>
</ul>
</div>
</template>
<template x-if="lastReload.files && lastReload.files.length">
<div class="mt-2 text-xs text-slate-500">
<div class="font-semibold text-slate-600">{{ "routing.include_files_label" | t }}</div>
<ul class="mt-1 list-disc space-y-0.5 pl-5">
<template x-for="file in lastReload.files" :key="file">
<li class="break-all" x-text="file"></li>
</template>
</ul>
</div>
</template>
</div>
</template>
<section class="rounded-xl bg-white p-5 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-wrap items-center gap-3">
<div class="relative">
<input type="search" x-model.debounce.300ms="search" @input.debounce.400ms="applyFilters()"
class="w-72 rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
placeholder="{{ "routing.search_routes_placeholder" | t }}">
<svg class="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12.5 12.5l4 4m-2.5-6a5.5 5.5 0 11-11 0 5.5 5.5 0 0111 0z" />
</svg>
</div>
<div class="flex gap-1 rounded-lg border border-slate-200 p-1 text-xs font-medium text-slate-600">
<button type="button" class="rounded-md px-3 py-1 transition"
:class="sourceFilter === 'all' ? 'bg-sky-100 text-sky-700' : 'hover:bg-slate-100'"
@click="setSource('all')">{{ "routing.filter_all" | t }}</button>
<button type="button" class="rounded-md px-3 py-1 transition"
:class="sourceFilter === 'from_trunk' ? 'bg-sky-100 text-sky-700' : 'hover:bg-slate-100'"
@click="setSource('from_trunk')">{{ "routing.filter_from_trunk" | t }}</button>
<button type="button" class="rounded-md px-3 py-1 transition"
:class="sourceFilter === 'any' ? 'bg-sky-100 text-sky-700' : 'hover:bg-slate-100'"
@click="setSource('any')">{{ "routing.source_any" | t }}</button>
</div>
<div class="flex gap-1 rounded-lg border border-slate-200 p-1 text-xs font-medium text-slate-600">
<button type="button" class="rounded-md px-3 py-1 transition"
:class="statusFilter === 'all' ? 'bg-emerald-100 text-emerald-700' : 'hover:bg-slate-100'"
@click="setStatus('all')">{{ "routing.filter_any_status" | t }}</button>
<button type="button" class="rounded-md px-3 py-1 transition"
:class="statusFilter === 'active' ? 'bg-emerald-100 text-emerald-700' : 'hover:bg-slate-100'"
@click="setStatus('active')">{{ "routing.filter_active" | t }}</button>
<button type="button" class="rounded-md px-3 py-1 transition"
:class="statusFilter === 'disabled' ? 'bg-rose-100 text-rose-700' : 'hover:bg-slate-100'"
@click="setStatus('disabled')">{{ "routing.filter_paused" | t }}</button>
</div>
<div class="flex gap-1 text-xs">
<template x-for="option in algorithmOptions" :key="option.value">
<button type="button"
class="rounded-lg border border-slate-200 px-3 py-1 font-medium transition"
:class="algorithmFilter === option.value ? 'border-sky-300 bg-sky-50 text-sky-700' : 'hover:bg-slate-100'"
@click="setAlgorithm(option.value)">
<span x-text="option.label"></span>
</button>
</template>
</div>
</div>
</div>
<div class="mt-5 overflow-hidden rounded-xl border border-slate-200">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th scope="col" class="px-4 py-3 text-left">{{ "routing.col_route" | t }}</th>
<th scope="col" class="px-4 py-3 text-left">{{ "routing.col_match" | t }}</th>
<th scope="col" class="px-4 py-3 text-left">{{ "routing.col_rewrite" | t }}</th>
<th scope="col" class="px-4 py-3 text-left">{{ "routing.col_source_target" | t }}</th>
<th scope="col" class="px-4 py-3 text-left">{{ "routing.col_actions" | t }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 text-sm text-slate-700">
<template x-if="loading">
<tr>
<td colspan="5" class="px-4 py-12 text-center text-sm text-slate-400">
<div class="flex flex-col items-center gap-2">
<svg class="h-8 w-8 animate-spin text-slate-300" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 3v3m6.364 1.636-2.121 2.121M21 12h-3m-1.636 6.364-2.121-2.121M12 21v-3m-6.364-1.636 2.121-2.121M3 12h3m1.636-6.364 2.121 2.121" />
</svg>
<div>{{ "routing.loading_routes" | t }}</div>
</div>
</td>
</tr>
</template>
<template x-if="!loading && !filteredRoutes.length">
<tr>
<td colspan="5" class="px-4 py-12 text-center text-sm text-slate-400">
{{ "routing.no_routes_found" | t }}
</td>
</tr>
</template>
<template x-for="route in filteredRoutes" :key="route.id">
<tr class="align-top transition hover:bg-slate-50"
:class="route.disabled ? 'opacity-70 bg-slate-50' : ''">
<td class="w-72 px-4 py-3">
<div class="flex flex-col gap-2">
<a :href="detailUrl(route)"
class="text-sm font-semibold text-slate-900 transition hover:text-sky-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
x-text="routeLabel(route)"></a>
<div class="flex flex-wrap items-center gap-2">
<span x-show="route.source_context === 'from_trunk'"
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-semibold bg-emerald-50 text-emerald-600 ring-1 ring-emerald-200">
<span x-text="route.source_trunk || '外线'"></span>
</span>
<span x-show="route.source_context !== 'from_trunk'"
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-semibold bg-slate-50 text-slate-500 ring-1 ring-slate-200">
{{ "routing.source_any" | t }}
</span>
<span
class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-0.5 text-[11px] font-medium text-slate-600 ring-1 ring-slate-200">
{{ "routing.priority_badge" | t }}
<span class="font-semibold" x-text="route.priority"></span>
</span>
<template x-if="route.disabled">
<span
class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-0.5 text-[11px] font-semibold text-amber-600 ring-1 ring-amber-200">
{{ "routing.paused_badge" | t }}
</span>
</template>
</div>
<div class="text-xs text-slate-500">
<span class="font-semibold" x-text="selectionLabel(route)"></span>
<template x-if="route.action.hash_key">
<span class="ml-2 font-mono text-slate-400"
x-text="`hash: ${route.action.hash_key}`"></span>
</template>
</div>
<template x-if="route.notes && route.notes.length">
<div class="text-[11px] text-slate-400"
x-text="route.notes.join(' · ')"></div>
</template>
</div>
</td>
<td class="px-4 py-3">
<div class="flex flex-col gap-1 text-xs text-slate-600">
<template x-for="item in matchPreview(route).items" :key="item.key">
<div>
<span class="font-semibold" x-text="item.label"></span>:
<span x-text="item.value"></span>
</div>
</template>
<template x-if="matchPreview(route).remaining">
<button type="button"
class="w-fit text-sky-600 transition hover:text-sky-700 focus:outline-none"
@click="toggleMatch(route)"
x-text="'+' + matchPreview(route).remaining + ' {{ "routing.show_more" | t }}'">
</button>
</template>
<template
x-if="matchPreview(route).expanded && matchPreview(route).total > 3">
<button type="button"
class="w-fit text-sky-600 transition hover:text-sky-700 focus:outline-none"
@click="toggleMatch(route)">
{{ "routing.show_less" | t }}
</button>
</template>
<template x-if="!matchPreview(route).items.length">
<div class="text-slate-400">—</div>
</template>
</div>
</td>
<td class="px-4 py-3">
<div class="flex flex-col gap-1 text-xs text-slate-600">
<template x-for="item in rewritePreview(route).items" :key="item.key">
<div>
<span class="font-semibold" x-text="item.label"></span>:
<span x-text="item.value"></span>
</div>
</template>
<template x-if="rewritePreview(route).remaining">
<button type="button"
class="w-fit text-sky-600 transition hover:text-sky-700 focus:outline-none"
@click="toggleRewrite(route)"
x-text="'+' + rewritePreview(route).remaining + ' {{ "routing.show_more" | t }}'">
</button>
</template>
<template
x-if="rewritePreview(route).expanded && rewritePreview(route).total > 3">
<button type="button"
class="w-fit text-sky-600 transition hover:text-sky-700 focus:outline-none"
@click="toggleRewrite(route)">
{{ "routing.show_less" | t }}
</button>
</template>
<template x-if="!rewritePreview(route).items.length">
<div class="text-slate-400">—</div>
</template>
</div>
</td>
<td class="px-4 py-3 text-xs text-slate-600">
<div>
<span class="font-semibold">{{ "routing.col_source" | t }}:</span>
<span x-text="sourceTrunkLabel(route)"></span>
</div>
<div class="mt-1">
<span class="font-semibold">{{ "routing.col_target" | t }}:</span>
<span x-text="targetTrunksLabel(route)"></span>
</div>
<template x-if="trunkPreview(route).items.length">
<div class="mt-2 flex flex-wrap gap-1 text-[11px] text-slate-500">
<template x-for="trunk in trunkPreview(route).items" :key="trunk.name">
<span
class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 font-medium">
<span x-text="trunk.name"></span>
<span class="font-semibold" x-text="trunk.weight ?? '—'"></span>
</span>
</template>
<template x-if="trunkPreview(route).remaining">
<span class="text-slate-400"
x-text="'+' + trunkPreview(route).remaining + ' {{ "routing.show_more" | t }}'">
</span>
</template>
</div>
</template>
</td>
<td class="px-4 py-3">
<div class="flex flex-col items-end gap-3">
<div class="text-xs text-slate-500">
{{ "routing.last_updated" | t }}
<span class="font-semibold"
x-text="formatDate(route.last_modified)"></span>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<button type="button"
class="inline-flex items-center gap-1 rounded-lg border px-2.5 py-1 text-xs font-semibold transition"
:class="processingToggle === route.id ? 'border-slate-200 text-slate-400 cursor-not-allowed' : route.disabled ? 'border-emerald-200 text-emerald-600 hover:bg-emerald-50' : 'border-amber-200 text-amber-600 hover:bg-amber-50'"
:disabled="processingToggle === route.id"
@click="toggleRoute(route)">
<template x-if="processingToggle === route.id">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 3v3m6.364 1.636-2.121 2.121M21 12h-3m-1.636 6.364-2.121-2.121M12 21v-3m-6.364-1.636 2.121-2.121M3 12h3m1.636-6.364 2.121 2.121" />
</svg>
</template>
<template x-if="processingToggle !== route.id">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none"
stroke="currentColor" stroke-width="1.6">
<path x-show="route.disabled" stroke-linecap="round"
stroke-linejoin="round" d="M4 5h12l-6 8z" />
<path x-show="!route.disabled" stroke-linecap="round"
stroke-linejoin="round"
d="M5 4h2v12H5zM13 4h2v12h-2z" />
</svg>
</template>
<span x-text="route.disabled ? '{{ "routing.btn_resume" | t }}' : '{{ "routing.btn_pause" | t }}'"></span>
</button>
<button type="button"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-2.5 py-1 text-xs font-semibold text-slate-600 transition hover:border-sky-300 hover:text-sky-700"
:class="processingClone === route.id ? 'border-slate-200 text-slate-300 cursor-not-allowed' : ''"
:disabled="processingClone === route.id"
@click="confirmClone(route)">
<template x-if="processingClone === route.id">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 3v3m6.364 1.636-2.121 2.121M21 12h-3m-1.636 6.364-2.121-2.121M12 21v-3m-6.364-1.636 2.121-2.121M3 12h3m1.636-6.364 2.121 2.121" />
</svg>
</template>
<template x-if="processingClone !== route.id">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none"
stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 6a2 2 0 0 1 2-2h6l4 4v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M8 4v4h4" />
</svg>
</template>
{{ "routing.btn_clone" | t }}
</button>
<button type="button"
class="inline-flex items-center gap-1 rounded-lg border border-rose-200 px-2.5 py-1 text-xs font-semibold text-rose-600 transition hover:bg-rose-50"
:class="processingDelete === route.id ? 'border-rose-100 text-rose-300 cursor-not-allowed' : ''"
:disabled="processingDelete === route.id"
@click="confirmDelete(route)">
<template x-if="processingDelete === route.id">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</template>
<template x-if="processingDelete !== route.id">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</template>
{{ "routing.btn_delete" | t }}
</button>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<template x-if="pagination && filteredRoutes.length">
<div
class="flex flex-col gap-3 border-t border-slate-100 px-4 py-3 text-xs text-slate-500 sm:flex-row sm:items-center sm:justify-between">
<div
x-text="`{{ "routing.pagination_showing" | t }}`.replace('{from}', pagination.showing_from).replace('{to}', pagination.showing_to).replace('{total}', pagination.total_items)">
</div>
<div class="flex items-center gap-2">
<button type="button"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 font-semibold transition"
:class="pagination.has_prev ? 'text-slate-600 hover:border-sky-300 hover:text-sky-700' : 'cursor-not-allowed text-slate-300'"
:disabled="!pagination.has_prev" @click="prevPage()">
{{ "routing.pagination_prev" | t }}
</button>
<div class="text-sm font-semibold text-slate-600">
{{ "routing.pagination_page" | t }} <span x-text="pagination.current_page"></span> / <span
x-text="pagination.total_pages"></span>
</div>
<button type="button"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 font-semibold transition"
:class="pagination.has_next ? 'text-slate-600 hover:border-sky-300 hover:text-sky-700' : 'cursor-not-allowed text-slate-300'"
:disabled="!pagination.has_next" @click="nextPage()">
{{ "routing.pagination_next" | t }}
</button>
</div>
</div>
</template>
</div>
</section>
<template x-if="fileRoutes.length > 0">
<section class="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/5">
<div class="border-b border-slate-200 bg-slate-50 px-4 py-3 flex items-center gap-2">
<svg class="h-4 w-4 text-slate-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd" />
</svg>
<span class="text-sm font-semibold text-slate-700">{{ "routing.file_routes_title" | t }}</span>
<span class="ml-auto inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500" x-text="fileRoutes.length"></span>
</div>
<p class="px-4 py-2 text-xs text-slate-500">{{ "routing.file_routes_notice" | t }}</p>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-left text-sm">
<thead class="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th scope="col" class="px-4 py-2">{{ "routing.col_route" | t }}</th>
<th scope="col" class="px-4 py-2">{{ "routing.col_match" | t }}</th>
<th scope="col" class="px-4 py-2">{{ "routing.col_source" | t }}</th>
<th scope="col" class="px-4 py-2">{{ "routing.col_priority" | t }}</th>
<th scope="col" class="px-4 py-2">{{ "routing.source_file" | t }}</th>
<th scope="col" class="px-4 py-2">{{ "routing.col_state" | t }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 bg-white text-slate-600">
<template x-for="(route, idx) in fileRoutes" :key="idx">
<tr class="bg-slate-50/50">
<td class="whitespace-nowrap px-4 py-2">
<span class="font-semibold text-slate-700" x-text="routeLabel(route)"></span>
<span class="ml-2 inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-500">{{ "routing.read_only" | t }}</span>
</td>
<td class="px-4 py-2 text-xs text-slate-500">
<template x-if="matchPreview(route).items.length">
<div class="flex flex-col gap-0.5">
<template x-for="item in matchPreview(route).items" :key="item.key">
<div><span class="font-semibold" x-text="item.label"></span>: <span x-text="item.value"></span></div>
</template>
</div>
</template>
<template x-if="!matchPreview(route).items.length">
<span>—</span>
</template>
</td>
<td class="whitespace-nowrap px-4 py-2">
<span x-text="route.source_context === 'from_trunk' ? (route.source_trunk || 'From Trunk') : 'Any'"></span>
</td>
<td class="whitespace-nowrap px-4 py-2">
<span class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-0.5 text-[11px] font-medium text-slate-600 ring-1 ring-slate-200">
{{ "routing.priority_badge" | t }}
<span class="font-semibold" x-text="route.priority"></span>
</span>
</td>
<td class="px-4 py-2 text-xs text-slate-400 font-mono break-all" x-text="route.source_file || '—'"></td>
<td class="whitespace-nowrap px-4 py-2">
<template x-if="!route.disabled">
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-600 ring-1 ring-emerald-100">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400"></span>
{{ "routing.filter_active" | t }}
</span>
</template>
<template x-if="route.disabled">
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-0.5 text-xs font-semibold text-amber-600 ring-1 ring-amber-200">
<span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>
{{ "routing.filter_paused" | t }}
</span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
</template>
<div x-show="validationOpen" x-cloak x-transition.opacity.duration.150ms
class="fixed inset-0 z-40 flex items-center justify-center px-4 py-12">
<div class="absolute inset-0 bg-slate-900/50 backdrop-blur-sm" @click="closeValidation()"></div>
<div class="relative z-10 w-full max-w-2xl origin-top rounded-2xl bg-white shadow-2xl ring-1 ring-black/10"
x-transition.scale.duration.150ms @keydown.escape.window="closeValidation()">
<div class="flex items-start justify-between gap-4 border-b border-slate-100 px-6 py-4">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "routing.validation_modal_title" | t }}</h2>
<p class="text-xs text-slate-500">{{ "routing.validation_modal_subtitle" | t }}</p>
</div>
<button type="button"
class="rounded-lg border border-slate-200 p-2 text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
@click="closeValidation()">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6l8 8M14 6l-8 8" />
</svg>
</button>
</div>
<form class="space-y-5 px-6 py-5" @submit.prevent="runValidation()">
<div class="grid gap-4 md:grid-cols-2">
<label class="flex flex-col gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "routing.validation_dialled_number" | t }}</span>
<input type="text" x-model.trim="validationInput" x-ref="validationInput"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
placeholder="{{ "routing.validation_dialled_placeholder" | t }}" required>
</label>
<label class="flex flex-col gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "routing.validation_source_trunk" | t }}</span>
<select x-model="validationSourceTrunk"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="">{{ "routing.validation_auto_detect" | t }}</option>
<template x-for="option in trunkOptions" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
</label>
<label class="flex flex-col gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "routing.validation_caller_uri" | t }}</span>
<input type="text" x-model.trim="validationCaller"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
placeholder="{{ "routing.validation_caller_placeholder" | t }}">
</label>
<label class="flex flex-col gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "routing.validation_source_address" | t }}</span>
<input type="text" x-model.trim="validationSourceIp"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
placeholder="{{ "routing.validation_source_address_placeholder" | t }}">
</label>
</div>
<div class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "routing.validation_data_source" | t }}</span>
<div class="flex flex-wrap gap-4 text-sm text-slate-600">
<label class="inline-flex items-center gap-2">
<input type="radio" name="validation-dataset" value="runtime"
x-model="validationDataset"
class="h-4 w-4 border-slate-300 text-sky-600 focus:ring-sky-500">
<span>{{ "routing.validation_runtime" | t }}</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="radio" name="validation-dataset" value="database"
x-model="validationDataset"
class="h-4 w-4 border-slate-300 text-sky-600 focus:ring-sky-500">
<span>{{ "routing.validation_database" | t }}</span>
</label>
</div>
<p class="text-xs text-slate-400">{{ "routing.validation_data_source_hint" | t }}</p>
</div>
<button type="button"
class="inline-flex items-center gap-2 text-xs font-semibold text-slate-500 transition hover:text-slate-700"
@click="validationAdvanced = !validationAdvanced">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 4v12m6-6H4" />
</svg>
<span x-text="validationAdvanced ? '{{ "routing.validation_hide_advanced" | t }}' : '{{ "routing.validation_advanced_options" | t }}'"></span>
</button>
<template x-if="validationAdvanced">
<div class="grid gap-4 md:grid-cols-2" x-transition>
<label class="flex flex-col gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "routing.validation_request_uri" | t }}</span>
<input type="text" x-model.trim="validationRequestUri"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
placeholder="{{ "routing.validation_request_uri_placeholder" | t }}">
</label>
<label class="flex flex-col gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "routing.validation_source_trunk" | t }}</span>
<select x-model="validationSourceTrunk"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="">{{ "routing.validation_auto_detect" | t }}</option>
<template x-for="option in trunkOptions" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
</label>
</div>
</template>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-xs text-slate-400" x-show="validationLatencyMs && !validationLoading">
{{ "routing.validation_evaluated_in" | t }}
<span class="font-semibold text-slate-600"
x-text="formatDuration(validationLatencyMs)"></span>
</div>
<div class="flex flex-wrap items-center gap-3">
<button type="button"
class="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100"
@click="resetValidation()" :disabled="validationLoading">
{{ "routing.btn_reset" | t }}
</button>
<button type="submit" :disabled="validationLoading"
class="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60">
<template x-if="!validationLoading">
<span class="inline-flex items-center gap-2">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 4v12" />
<path stroke-linecap="round" stroke-linejoin="round" d="M4 10h12" />
</svg>
{{ "routing.btn_evaluate" | t }}
</span>
</template>
<template x-if="validationLoading">
<span class="inline-flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 20 20" fill="none"
stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 10a6 6 0 0 1 6-6m0-2a8 8 0 1 0 8 8" />
</svg>
{{ "routing.btn_evaluating" | t }}
</span>
</template>
</button>
</div>
</div>
<template x-if="validationLoading">
<div class="rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-xs text-sky-700">
{{ "routing.validation_evaluating" | t }}
</div>
</template>
<template x-if="validationError && !validationLoading">
<div class="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-xs text-rose-700"
x-text="validationError"></div>
</template>
<template x-if="validationResult && !validationLoading">
<div
class="space-y-3 rounded-lg border border-slate-200 bg-white px-4 py-3 text-xs text-slate-600">
<div class="flex flex-wrap items-center gap-2">
<span class="font-semibold text-slate-700">{{ "routing.validation_result_rule" | t }}:</span>
<span x-text="validationResult.rule || '{{ "routing.validation_result_none" | t }}'"></span>
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold"
:class="validationResult.badgeClass">
<span class="h-1.5 w-1.5 rounded-full"
:class="validationStatusDot(validationResult.status)"></span>
<span x-text="validationResult.statusLabel"></span>
</span>
</div>
<div class="grid gap-1 sm:grid-cols-2">
<div><span class="font-semibold text-slate-700">{{ "routing.validation_result_direction" | t }}:</span>
<span class="uppercase" x-text="validationResult.direction || '—'"></span>
</div>
<div><span class="font-semibold text-slate-700">{{ "routing.validation_result_dialled" | t }}:</span>
<span x-text="validationResult.input || '—'"></span>
</div>
<div><span class="font-semibold text-slate-700">{{ "routing.validation_result_trunk" | t }}:</span>
<span x-text="validationResult.trunk || '—'"></span>
</div>
<div><span class="font-semibold text-slate-700">{{ "routing.validation_result_destination" | t }}:</span>
<span x-text="displayValue(validationResult.destination)"></span>
</div>
<div><span class="font-semibold text-slate-700">{{ "routing.validation_result_caller" | t }}:</span>
<span x-text="displayValue(validationResult.caller)"></span>
</div>
<div><span class="font-semibold text-slate-700">{{ "routing.validation_result_request_uri" | t }}:</span>
<span x-text="displayValue(validationResult.requestUri)"></span>
</div>
<div><span class="font-semibold text-slate-700">{{ "routing.validation_result_source_ip" | t }}:</span>
<span x-text="displayValue(validationResult.sourceIp)"></span>
</div>
<template x-if="validationResult.sourceTrunk">
<div><span class="font-semibold text-slate-700">{{ "routing.validation_result_source_trunk" | t }}:</span>
<span x-text="validationResult.sourceTrunk"></span>
</div>
</template>
<template x-if="validationResult.detectedTrunk">
<div><span class="font-semibold text-slate-700">{{ "routing.validation_result_detected_trunk" | t }}:</span>
<span x-text="validationResult.detectedTrunk"></span>
</div>
</template>
</div>
<div class="text-[11px] text-slate-500">
<span class="font-semibold text-slate-700">{{ "routing.validation_result_outcome" | t }}:</span>
<span x-text="validationResult.outcomeLabel"></span>
<template x-if="validationResult.defaultRoute">
<span>{{ "routing.validation_used_default_route" | t }}</span>
</template>
</div>
<div class="text-[11px] text-slate-500">
<span class="font-semibold text-slate-700">{{ "routing.validation_result_rewrites" | t }}:</span>
<span x-text="validationResult.rewritesText"></span>
</div>
<div class="text-[11px] text-slate-400">
{{ "routing.validation_result_evaluated" | t }} <span x-text="formatDate(validationResult.createdAt)"></span>
</div>
<template x-if="validationResult.rewritesDiff?.length">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
{{ "routing.validation_field_changes" | t }}
</div>
<div class="mt-2 grid gap-2">
<template x-for="item in validationResult.rewritesDiff"
:key="item.field + (item.after || '') + (item.before || '')">
<div class="rounded border border-slate-200 bg-white px-3 py-2">
<div class="text-[11px] font-semibold text-slate-700"
x-text="item.field"></div>
<div class="text-[11px] text-slate-500"><span
class="font-semibold">{{ "routing.validation_before" | t }}:</span>
<span x-text="displayValue(item.before)"></span>
</div>
<div class="text-[11px] text-slate-500"><span
class="font-semibold">{{ "routing.validation_after" | t }}:</span>
<span x-text="displayValue(item.after)"></span>
</div>
</div>
</template>
</div>
</div>
</template>
<template x-if="validationResult.abort">
<div
class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-[11px] text-rose-700">
<span class="font-semibold">{{ "routing.validation_rejected" | t }}:</span>
<span x-text="displayValue(validationResult.abort)"></span>
</div>
</template>
<template x-if="validationResult.headers && validationResult.headers.length">
<div class="text-[11px] text-slate-500">
<span class="font-semibold text-slate-700">{{ "routing.validation_result_headers" | t }}:</span>
<span x-text="validationResult.headers.join(', ')"></span>
</div>
</template>
</div>
</template>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('routingConsole', (options = {}) => ({
basePath: '',
apiBase: '{{ api_prefix | safe }}',
listEndpoint: '',
createUrl: '',
amiEndpoint: options.amiEndpoint || options.ami_path || '/ami/v1',
filtersRaw: {},
forwardingCatalog: { queues: [], ivr_projects: [] },
routes: [],
summary: { total_routes: 0, active_routes: 0, last_deploy: null },
pagination: null,
loading: false,
error: null,
flash: null,
search: '',
sourceFilter: 'all',
statusFilter: 'all',
algorithmFilter: 'all',
algorithmOptions: [],
sourceOptions: [],
statusOptions: [],
perPage: 20,
page: 1,
reloading: false,
pendingReload: false,
lastReload: null,
processingDelete: null,
processingClone: null,
processingToggle: null,
trunkOptions: [],
fileRoutes: [],
validationOpen: false,
validationAdvanced: false,
validationInput: '',
validationSourceTrunk: '',
validationCaller: '',
validationRequestUri: '',
validationSourceIp: '',
validationDataset: 'database',
validationLoading: false,
validationError: null,
validationResult: null,
validationLatencyMs: null,
expandedMatch: {},
expandedRewrite: {},
translations: {
failed_load: '{{ "routing.failed_load" | t }}',
unable_load: '{{ "routing.unable_load" | t }}',
failed_delete: '{{ "routing.failed_delete" | t }}',
failed_clone: '{{ "routing.failed_clone" | t }}',
failed_toggle: '{{ "routing.failed_toggle" | t }}',
failed_reload: '{{ "routing.failed_reload" | t }}',
reload_complete: '{{ "routing.reload_complete" | t }}',
reload_summary: '{{ "routing.reload_summary" | t }}',
reload_summary_count: '{{ "routing.reload_summary_count" | t }}',
reload_summary_duration: '{{ "routing.reload_summary_duration" | t }}',
confirm_delete_title: '{{ "routing.confirm_delete_title" | t }}',
confirm_delete_message: '{{ "routing.confirm_delete_message" | t }}',
confirm_clone_title: '{{ "routing.confirm_clone_title" | t }}',
confirm_clone_message: '{{ "routing.confirm_clone_message" | t }}',
confirm_reload_title: '{{ "routing.confirm_reload_title" | t }}',
confirm_reload_message: '{{ "routing.confirm_reload_message" | t }}',
confirm_label_delete: '{{ "routing.confirm_label_delete" | t }}',
confirm_label_clone: '{{ "routing.confirm_label_clone" | t }}',
confirm_label_reload: '{{ "routing.confirm_label_reload" | t }}',
confirm_label_cancel: '{{ "routing.confirm_label_cancel" | t }}',
flash_deleted: '{{ "routing.flash_deleted" | t }}',
flash_cloned: '{{ "routing.flash_cloned" | t }}',
flash_paused: '{{ "routing.flash_paused" | t }}',
flash_resumed: '{{ "routing.flash_resumed" | t }}',
source_any: '{{ "routing.source_any" | t }}',
target_queue: '{{ "routing.target_queue" | t }}',
target_ivr: '{{ "routing.target_ivr" | t }}',
target_voicemail: '{{ "routing.target_voicemail" | t }}',
target_default: '{{ "routing.target_default" | t }}',
route_unnamed: '{{ "routing.route_unnamed" | t }}',
status_not_handled: '{{ "routing.status_not_handled" | t }}',
status_forwarded: '{{ "routing.status_forwarded" | t }}',
status_rejected: '{{ "routing.status_rejected" | t }}',
outcome_no_match: '{{ "routing.outcome_no_match" | t }}',
outcome_forwarded: '{{ "routing.outcome_forwarded" | t }}',
outcome_rejected: '{{ "routing.outcome_rejected" | t }}',
no_rewrite_changes: '{{ "routing.no_rewrite_changes" | t }}',
default_route_label: '{{ "routing.default_route_label" | t }}',
validation_provide_number: '{{ "routing.validation_provide_number" | t }}',
validation_eval_failed: '{{ "routing.validation_eval_failed" | t }}',
sel_queue_handoff: '{{ "routing.sel_queue_handoff" | t }}',
sel_round_robin_balancing: '{{ "routing.sel_round_robin_balancing" | t }}',
sel_weighted_distribution: '{{ "routing.sel_weighted_distribution" | t }}',
sel_hash_based: '{{ "routing.sel_hash_based" | t }}',
},
init() {
this.basePath = this.normalizeBasePath(options.basePath);
this.listEndpoint = this.resolvePath(`${this.apiBase}/routing`);
this.createUrl = options.createUrl || this.resolvePath(`${this.basePath}/routing/new`);
this.filtersRaw = options.filters || {};
this.forwardingCatalog = options.forwardingCatalog || {};
this.setupFilterOptions();
this.fetchRoutes();
this.fetchPendingReload();
},
async fetchPendingReload() {
try {
const r = await fetch('{{ api_prefix | safe }}/pending-reloads');
if (r.ok) {
const d = await r.json();
this.pendingReload = !!d.pending?.routes;
}
} catch (_) {}
},
normalizeBasePath(path) {
if (!path) return '';
const trimmed = String(path).trim();
if (!trimmed.length || trimmed === '/') {
return '';
}
return trimmed.replace(/\/+$/, '');
},
resolvePath(path) {
if (!path) return '/';
const raw = String(path).trim();
if (!raw.length) return '/';
if (raw.startsWith('http://') || raw.startsWith('https://')) {
return raw;
}
const collapsed = raw.replace(/\/{2,}/g, '/');
return collapsed.startsWith('/') ? collapsed : `/${collapsed}`;
},
buildUrl(path) {
const clean = path.startsWith('/') ? path : `/${path}`;
return `${this.apiBase}${clean}`;
},
setupFilterOptions() {
const raw = this.filtersRaw || {};
this.algorithmOptions = this.normalizeAlgorithmOptions(raw.selection_algorithms);
if (!this.algorithmOptions.length) {
this.algorithmOptions = [
{ value: 'rr', label: 'Round robin' },
{ value: 'weight', label: 'Weighted' },
{ value: 'hash', label: 'Deterministic hash' },
];
}
this.directionOptions = this.normalizeDirectionOptions(raw.direction_options);
this.statusOptions = this.normalizeStatusOptions(raw.status_options);
this.trunkOptions = this.normalizeTrunkOptions(raw.trunks);
},
normalizeTrunkOptions(input) {
if (!Array.isArray(input)) {
return [];
}
return input
.map((item) => {
if (!item || typeof item !== 'object') {
return null;
}
const value = item.name || item.label || item.id;
if (!value) {
return null;
}
const label = item.display_name || item.label || item.name || String(value);
return {
value: String(value),
label: String(label),
};
})
.filter(Boolean);
},
normalizeAlgorithmOptions(input) {
if (!Array.isArray(input)) {
return [];
}
return input
.map((item) => {
if (item && typeof item === 'object') {
const value = item.value ?? item.name ?? item.id ?? '';
const label = item.label ?? item.title ?? value;
if (!value || !label) return null;
return { value: String(value), label: String(label) };
}
if (typeof item === 'string') {
const pretty = item.replace(/_/g, ' ');
return {
value: item,
label: pretty.charAt(0).toUpperCase() + pretty.slice(1),
};
}
return null;
})
.filter(Boolean);
},
normalizeDirectionOptions(input) {
if (!Array.isArray(input)) {
return [];
}
return input
.map((value) => (typeof value === 'string' && value.trim().length ? value.trim() : null))
.filter(Boolean);
},
normalizeStatusOptions(input) {
if (!Array.isArray(input)) {
return [];
}
return input
.map((item) => {
if (item && typeof item === 'object') {
return {
value: item.value,
label: item.label,
};
}
return null;
})
.filter((entry) => entry && entry.value !== undefined && entry.label);
},
buildPayload() {
const filters = {};
const term = this.search.trim();
if (term.length) {
filters.q = term;
}
if (this.sourceFilter !== 'all') {
filters.source_context = this.sourceFilter;
}
if (this.statusFilter !== 'all') {
filters.status = this.statusFilter === 'disabled' ? 'paused' : this.statusFilter;
}
if (this.algorithmFilter !== 'all') {
filters.selection = this.algorithmFilter;
}
return {
page: this.page,
per_page: this.perPage,
filters,
};
},
async fetchRoutes() {
this.loading = true;
this.error = null;
try {
const response = await fetch(this.listEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(this.buildPayload()),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || this.translations.failed_load);
}
const items = Array.isArray(data?.items) ? data.items : [];
this.routes = items.map((item) => ({
...item,
disabled: Boolean(item?.disabled),
direction: item?.direction || 'outbound',
action: item?.action || {},
notes: Array.isArray(item?.notes) ? item.notes : [],
toggle_url: item?.toggle_url,
clone_url: item?.clone_url,
delete_url: item?.delete_url,
}));
this.fileRoutes = Array.isArray(data?.file_routes) ? data.file_routes : [];
this.expandedMatch = {};
this.expandedRewrite = {};
this.summary = Object.assign(
{ total_routes: 0, active_routes: 0, last_deploy: null },
data?.summary || {}
);
this.updateFiltersFromResponse(data?.filters);
this.updatePagination(data);
} catch (err) {
console.error(err);
this.error = err?.message || this.translations.unable_load;
} finally {
this.loading = false;
}
},
updateFiltersFromResponse(filters) {
if (!filters || typeof filters !== 'object') {
return;
}
this.filtersRaw = filters;
this.setupFilterOptions();
},
updatePagination(meta) {
const perPageRaw = Number(meta?.per_page);
const currentPageRaw = Number(meta?.page);
const totalItemsRaw = Number(meta?.total_items);
const totalPagesRaw = Number(meta?.total_pages);
const perPage = Number.isFinite(perPageRaw) && perPageRaw > 0 ? perPageRaw : this.perPage;
const totalItems = Number.isFinite(totalItemsRaw) && totalItemsRaw >= 0
? totalItemsRaw
: this.routes.length;
const inferredPages = Math.max(Math.ceil(totalItems / perPage), 1);
const totalPages = Number.isFinite(totalPagesRaw) && totalPagesRaw >= 1
? Math.max(Math.min(totalPagesRaw, inferredPages || 1), 1)
: inferredPages;
const currentPage = Number.isFinite(currentPageRaw) && currentPageRaw > 0
? Math.min(currentPageRaw, totalPages)
: Math.min(this.page || 1, totalPages);
const resultsCount = this.routes.length;
const showingFrom = resultsCount ? ((currentPage - 1) * perPage) + 1 : 0;
const showingTo = resultsCount ? Math.min(showingFrom + resultsCount - 1, totalItems) : 0;
this.pagination = {
current_page: currentPage,
per_page: perPage,
total_items: totalItems,
total_pages: totalPages,
has_prev: currentPage > 1,
has_next: currentPage < totalPages,
prev_page: currentPage > 1 ? currentPage - 1 : null,
next_page: currentPage < totalPages ? currentPage + 1 : null,
showing_from: showingFrom,
showing_to: showingTo,
};
this.perPage = perPage;
this.page = currentPage;
},
applyFilters() {
this.page = 1;
this.fetchRoutes();
},
setSource(value) {
if (this.sourceFilter === value) {
this.sourceFilter = 'all';
return;
}
this.sourceFilter = value;
this.applyFilters();
},
setStatus(value) {
if (this.statusFilter === value) {
if (value !== 'all') {
this.statusFilter = 'all';
this.applyFilters();
}
return;
}
this.statusFilter = value;
this.applyFilters();
},
setAlgorithm(value) {
this.algorithmFilter = this.algorithmFilter === value ? 'all' : value;
this.applyFilters();
},
get filteredRoutes() {
return Array.isArray(this.routes) ? this.routes : [];
},
get pausedRoutes() {
const total = Number(this.summary.total_routes) || 0;
const active = Number(this.summary.active_routes) || 0;
return Math.max(total - active, 0);
},
goToPage(target) {
if (!this.pagination) return;
const totalPages = this.pagination.total_pages || 1;
const page = Math.min(Math.max(target, 1), totalPages);
if (page === this.page) return;
this.page = page;
this.fetchRoutes();
},
prevPage() {
if (this.pagination?.has_prev) {
this.goToPage(this.pagination.prev_page || (this.page - 1));
}
},
nextPage() {
if (this.pagination?.has_next) {
this.goToPage(this.pagination.next_page || (this.page + 1));
}
},
formatDate(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
},
formatDuration(milliseconds) {
const value = Number(milliseconds);
if (!Number.isFinite(value) || value < 0) {
return null;
}
if (value >= 1000) {
const seconds = value / 1000;
if (seconds >= 10) {
return `${Math.round(seconds)}s`;
}
return `${seconds.toFixed(1)}s`;
}
return `${Math.round(value)}ms`;
},
normalizeReloadMetrics(raw) {
if (!raw || typeof raw !== 'object') {
return null;
}
const toCount = (input) => {
const value = Number(input);
return Number.isFinite(value) && value >= 0 ? value : 0;
};
const durationRaw = Number(raw.duration_ms);
const generatedRaw = raw.generated;
const generated = generatedRaw && typeof generatedRaw === 'object'
? {
entries: toCount(generatedRaw.entries),
path: generatedRaw.path ? String(generatedRaw.path) : null,
backup: generatedRaw.backup ? String(generatedRaw.backup) : null,
}
: null;
return {
total: toCount(raw.total),
config_count: toCount(raw.config_count),
file_count: toCount(raw.file_count),
generated,
duration_ms: Number.isFinite(durationRaw) ? durationRaw : null,
started_at: raw.started_at || null,
finished_at: raw.finished_at || null,
files: Array.isArray(raw.files) ? raw.files.map((item) => String(item)) : [],
patterns: Array.isArray(raw.patterns) ? raw.patterns.map((item) => String(item)) : [],
};
},
buildReloadSummary(details) {
if (!details || typeof details !== 'object') {
return this.translations.reload_complete;
}
const total = Number(details.total);
const duration = this.formatDuration(details.duration_ms);
const totalText = Number.isFinite(total) && total >= 0
? String(total)
: null;
if (totalText && duration) {
return this.translations.reload_summary
.replace('{n}', totalText)
.replace('{duration}', duration);
}
if (totalText) {
return this.translations.reload_summary_count.replace('{n}', totalText);
}
if (duration) {
return this.translations.reload_summary_duration.replace('{duration}', duration);
}
return this.translations.reload_complete;
},
routeKey(route) {
if (!route || typeof route !== 'object') {
return '';
}
if (route.id !== undefined && route.id !== null) {
return `id:${route.id}`;
}
if (route.uuid) {
return `uuid:${route.uuid}`;
}
if (route.name) {
return `name:${route.name}`;
}
const index = this.routes.indexOf(route);
return index >= 0 ? `idx:${index}` : '';
},
isMatchExpanded(route) {
const key = this.routeKey(route);
return Boolean(key && this.expandedMatch[key]);
},
toggleMatch(route) {
const key = this.routeKey(route);
if (!key) {
return;
}
const next = !this.expandedMatch[key];
this.expandedMatch = { ...this.expandedMatch, [key]: next };
},
isRewriteExpanded(route) {
const key = this.routeKey(route);
return Boolean(key && this.expandedRewrite[key]);
},
toggleRewrite(route) {
const key = this.routeKey(route);
if (!key) {
return;
}
const next = !this.expandedRewrite[key];
this.expandedRewrite = { ...this.expandedRewrite, [key]: next };
},
matchEntries(route) {
const labels = {
'from_user': 'from.user',
'from_host': 'from.host',
'to_user': 'to.user',
'to_host': 'to.host',
'to_port': 'to.port',
'request_uri_user': 'request.uri.user',
'request_uri_host': 'request.uri.host',
'request_uri_port': 'request.uri.port',
'from': 'from',
'to': 'to',
'caller': 'caller',
'callee': 'callee',
};
const match = route?.match || {};
const entries = [];
Object.entries(match).forEach(([key, value]) => {
if (value === null || value === undefined || value === '') {
return;
}
if (key === 'headers' && typeof value === 'object') {
Object.entries(value).forEach(([headerKey, headerValue]) => {
entries.push({
key: headerKey,
label: `Header ${headerKey.replace(/^header\./, '')}`,
value: headerValue,
});
});
} else {
entries.push({
key,
label: labels[key] || key,
value,
});
}
});
return entries;
},
rewriteEntries(route) {
const labels = {
'from_user': 'from.user',
'from_host': 'from.host',
'to_user': 'to.user',
'to_host': 'to.host',
'to_port': 'to.port',
'request_uri_user': 'request.uri.user',
'request_uri_host': 'request.uri.host',
'request_uri_port': 'request.uri.port',
'from': 'from',
'to': 'to',
'caller': 'caller',
'callee': 'callee',
};
const rewrite = route?.rewrite || {};
const entries = [];
Object.entries(rewrite).forEach(([key, value]) => {
if (value === null || value === undefined || value === '') {
return;
}
if (key === 'headers' && typeof value === 'object') {
Object.entries(value).forEach(([headerKey, headerValue]) => {
entries.push({
key: headerKey,
label: `Set header ${headerKey.replace(/^header\./, '')}`,
value: headerValue,
});
});
} else {
entries.push({
key,
label: labels[key] || key,
value,
});
}
});
return entries;
},
matchPreview(route) {
const entries = this.matchEntries(route);
const expanded = this.isMatchExpanded(route);
return {
items: expanded ? entries : entries.slice(0, 3),
remaining: expanded ? 0 : Math.max(entries.length - 3, 0),
total: entries.length,
expanded,
};
},
rewritePreview(route) {
const entries = this.rewriteEntries(route);
const expanded = this.isRewriteExpanded(route);
return {
items: expanded ? entries : entries.slice(0, 3),
remaining: expanded ? 0 : Math.max(entries.length - 3, 0),
total: entries.length,
expanded,
};
},
trunkPreview(route) {
const targetType = (route?.action?.target_type || 'sip_trunk').toLowerCase();
if (targetType !== 'sip_trunk') {
return { items: [], remaining: 0 };
}
const trunks = Array.isArray(route?.action?.trunks) ? route.action.trunks : [];
return {
items: trunks.slice(0, 3),
remaining: Math.max(trunks.length - 3, 0),
};
},
selectionLabel(route) {
const targetType = (route?.action?.target_type || 'sip_trunk').toLowerCase();
if (targetType === 'queue') {
return this.translations.sel_queue_handoff;
}
const select = (route?.action?.select || 'rr').toLowerCase();
const map = {
rr: this.translations.sel_round_robin_balancing,
weight: this.translations.sel_weighted_distribution,
hash: this.translations.sel_hash_based,
};
return map[select] || select;
},
detailUrl(route) {
if (!route) {
return this.resolvePath(`${this.basePath}/routing`);
}
if (typeof route.detail_url === 'string' && route.detail_url.trim().length) {
return this.resolvePath(route.detail_url);
}
if (typeof route.edit_url === 'string' && route.edit_url.trim().length) {
return this.resolvePath(route.edit_url);
}
if (route.id !== undefined && route.id !== null) {
return this.resolvePath(`${this.basePath}/routing/${route.id}`);
}
return this.resolvePath(`${this.basePath}/routing`);
},
sourceTrunkLabel(route) {
const value = route?.source_trunk;
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length) {
return trimmed;
}
}
return this.translations.source_any;
},
targetTrunksLabel(route) {
const targetType = (route?.action?.target_type || 'sip_trunk').toLowerCase();
if (targetType === 'queue') {
const raw = (route?.action?.queue_file || '').trim();
const queues = this.forwardingCatalog.queues || [];
const match = queues.find(q => q.reference === raw) || queues.find(q => q.name === raw);
const display = match ? match.name : raw;
return display.length ? `${this.translations.target_queue} · ${display}` : this.translations.target_queue;
}
if (targetType === 'ivr') {
const raw = (route?.action?.ivr_file || '').trim();
const ivrs = this.forwardingCatalog.ivr_projects || [];
const match = ivrs.find(i => i.name === raw)
|| ivrs.find(i => i.file_path === raw)
|| ivrs.find(i => raw.endsWith('/' + i.name + '.toml') || raw.endsWith('/' + i.name + '.generated.toml'));
const display = match ? match.name : raw.split('/').pop().replace(/\.generated\.toml$/, '').replace(/\.toml$/, '');
const mode = match ? match.ivr_mode : '';
const suffix = mode ? ` (${mode})` : '';
return display.length ? `${this.translations.target_ivr} · ${display}${suffix}` : this.translations.target_ivr;
}
if (targetType === 'voicemail') {
const ext = (route?.action?.voicemail_extension || '').trim();
return ext.length ? `${this.translations.target_voicemail} · ${ext}` : this.translations.target_voicemail;
}
const targets = Array.isArray(route?.target_trunks)
? route.target_trunks.filter((item) => typeof item === 'string' && item.trim().length)
: [];
if (!targets.length) {
return this.translations.target_default;
}
return targets.join(', ');
},
routeLabel(route) {
if (!route) {
return this.translations.route_unnamed;
}
const name = (route.name || '').trim();
if (name.length) {
return name;
}
if (route.id !== undefined && route.id !== null) {
return `${this.translations.route_unnamed} #${route.id}`;
}
return this.translations.route_unnamed;
},
toggleEndpoint(route) {
if (!route || !route.id) {
return null;
}
if (typeof route.toggle_url === 'string' && route.toggle_url.trim().length) {
return this.resolvePath(route.toggle_url);
}
return this.resolvePath(`${this.basePath}/routing/${route.id}/toggle`);
},
cloneEndpoint(route) {
if (!route || !route.id) {
return null;
}
if (typeof route.clone_url === 'string' && route.clone_url.trim().length) {
return this.resolvePath(route.clone_url);
}
return this.resolvePath(`${this.basePath}/routing/${route.id}/clone`);
},
deleteEndpoint(route) {
if (!route || !route.id) {
return null;
}
if (typeof route.delete_url === 'string' && route.delete_url.trim().length) {
return this.resolvePath(route.delete_url);
}
return this.resolvePath(`${this.basePath}/routing/${route.id}`);
},
confirmDelete(route) {
if (!route || !route.id) {
return;
}
window.dispatchEvent(new CustomEvent('console:confirm', {
detail: {
title: this.translations.confirm_delete_title,
message: this.translations.confirm_delete_message.replace('{name}', this.routeLabel(route)),
confirmLabel: this.translations.confirm_label_delete,
cancelLabel: this.translations.confirm_label_cancel,
destructive: true,
onConfirm: () => this.deleteRoute(route),
},
}));
},
async deleteRoute(route) {
const endpoint = this.deleteEndpoint(route);
if (!endpoint) {
return;
}
this.processingDelete = route.id;
try {
const response = await fetch(endpoint, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || this.translations.failed_delete);
}
const label = this.routeLabel(route);
await this.fetchRoutes();
this.error = null;
this.flash = this.translations.flash_deleted.replace('{name}', label);
} catch (err) {
console.error(err);
this.error = err?.message || this.translations.failed_delete;
} finally {
this.processingDelete = null;
}
},
confirmClone(route) {
if (!route || !route.id) {
return;
}
window.dispatchEvent(new CustomEvent('console:confirm', {
detail: {
title: this.translations.confirm_clone_title,
message: this.translations.confirm_clone_message.replace('{name}', this.routeLabel(route)),
confirmLabel: this.translations.confirm_label_clone,
cancelLabel: this.translations.confirm_label_cancel,
destructive: false,
onConfirm: () => this.cloneRoute(route),
},
}));
},
async cloneRoute(route) {
const endpoint = this.cloneEndpoint(route);
if (!endpoint) {
return;
}
this.processingClone = route.id;
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Accept': 'application/json',
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || this.translations.failed_clone);
}
const label = this.routeLabel(route);
await this.fetchRoutes();
this.error = null;
this.flash = this.translations.flash_cloned.replace('{name}', label);
} catch (err) {
console.error(err);
this.error = err?.message || this.translations.failed_clone;
} finally {
this.processingClone = null;
}
},
async toggleRoute(route) {
const endpoint = this.toggleEndpoint(route);
if (!endpoint) {
return;
}
if (this.processingToggle === route.id) {
return;
}
this.processingToggle = route.id;
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Accept': 'application/json',
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || this.translations.failed_toggle);
}
const label = this.routeLabel(route);
await this.fetchRoutes();
this.error = null;
const disabled = Boolean(data?.disabled);
this.flash = disabled
? this.translations.flash_paused.replace('{name}', label)
: this.translations.flash_resumed.replace('{name}', label);
} catch (err) {
console.error(err);
this.error = err?.message || this.translations.failed_toggle;
} finally {
this.processingToggle = null;
}
},
confirmReload() {
if (this.reloading) {
return;
}
window.dispatchEvent(new CustomEvent('console:confirm', {
detail: {
title: this.translations.confirm_reload_title,
message: this.translations.confirm_reload_message,
confirmLabel: this.translations.confirm_label_reload,
cancelLabel: this.translations.confirm_label_cancel,
destructive: false,
onConfirm: () => this.reloadRoutes(),
},
}));
},
async reloadRoutes() {
if (this.reloading) {
return;
}
this.pendingReload = false;
this.reloading = true;
this.error = null;
const endpoint = `${this.amiEndpoint.replace(/\/$/, '')}/reload/routes`;
let successMessage = null;
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Accept': 'application/json',
},
credentials: 'include',
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || data?.error || this.translations.failed_reload);
}
const count = Number.isFinite(Number(data?.routes_reloaded)) ? Number(data.routes_reloaded) : null;
const metrics = this.normalizeReloadMetrics(data?.metrics);
this.lastReload = metrics;
successMessage = this.buildReloadSummary(metrics || (count !== null ? { total: count } : null));
await this.fetchRoutes();
this.flash = successMessage;
} catch (err) {
console.error(err);
this.error = err?.message || this.translations.failed_reload;
} finally {
this.reloading = false;
}
},
resetValidation() {
this.validationInput = '';
this.validationSourceTrunk = '';
this.validationCaller = '';
this.validationRequestUri = '';
this.validationSourceIp = '';
this.validationSourceTrunk = '';
this.validationDataset = 'database';
this.validationAdvanced = false;
this.validationLoading = false;
this.validationError = null;
this.validationResult = null;
this.validationLatencyMs = null;
},
openValidation() {
if (this.validationLoading) {
return;
}
this.resetValidation();
this.validationOpen = true;
this.$nextTick(() => {
this.$refs.validationInput?.focus();
});
},
closeValidation() {
this.validationOpen = false;
},
async runValidation() {
const callee = (this.validationInput || '').trim();
if (!callee) {
this.validationError = this.translations.validation_provide_number;
return;
}
const payload = {
callee,
dataset: this.validationDataset || 'database',
};
const caller = (this.validationCaller || '').trim();
if (caller) {
payload.caller = caller;
}
const sourceIp = (this.validationSourceIp || '').trim();
if (sourceIp) {
payload.source_ip = sourceIp;
}
const requestUri = (this.validationRequestUri || '').trim();
if (requestUri) {
payload.request_uri = requestUri;
}
const sourceTrunk = (this.validationSourceTrunk || '').trim();
if (sourceTrunk) {
payload.source_trunk = sourceTrunk;
}
this.validationLoading = true;
this.validationError = null;
this.validationResult = null;
this.validationLatencyMs = null;
const started = typeof performance !== 'undefined' ? performance.now() : Date.now();
try {
const response = await fetch(this.buildUrl('/diagnostics/routes/evaluate'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const message = data?.message || `Routing evaluation failed (${response.status})`;
throw new Error(message);
}
const summary = this.transformValidationResult(data, callee);
this.validationResult = summary;
const ended = typeof performance !== 'undefined' ? performance.now() : Date.now();
const latencyMs = Math.max(1, Math.round(ended - started));
this.validationLatencyMs = latencyMs;
} catch (err) {
this.validationError = err?.message || this.translations.validation_eval_failed;
} finally {
this.validationLoading = false;
}
},
transformValidationResult(data, input) {
const outcome = data?.outcome || {};
const type = (outcome.type || 'not_handled').toLowerCase();
let status = 'warning';
let statusLabel = this.translations.status_not_handled;
let badgeClass = 'bg-amber-50 text-amber-600 ring-1 ring-amber-200';
let outcomeLabel = this.translations.outcome_no_match;
let historyDetails = this.translations.outcome_no_match;
let defaultRoute = Boolean(data?.default_route);
if (type === 'forward') {
status = 'success';
statusLabel = this.translations.status_forwarded;
badgeClass = 'bg-emerald-50 text-emerald-600 ring-1 ring-emerald-200';
const destination = outcome.destination || 'destination not set';
outcomeLabel = this.translations.outcome_forwarded.replace('{destination}', destination);
historyDetails = outcomeLabel;
} else if (type === 'abort') {
status = 'error';
statusLabel = this.translations.status_rejected;
badgeClass = 'bg-rose-50 text-rose-600 ring-1 ring-rose-200';
const code = outcome.code ? ` ${outcome.code}` : '';
const reason = outcome.reason ? ` ${outcome.reason}` : '';
const detail = `${code}${reason}`.trim();
outcomeLabel = this.translations.outcome_rejected.replace('{detail}', detail);
historyDetails = outcomeLabel;
}
const rewriteOperations = Array.isArray(data?.rewrite_operations)
? data.rewrite_operations
: [];
const rewritesDiff = Array.isArray(data?.rewrites) ? data.rewrites : [];
return {
rule: data?.matched_rule || (defaultRoute ? this.translations.default_route_label : null),
status,
statusLabel,
badgeClass,
direction: data?.direction || '',
input,
trunk: data?.selected_trunk || null,
destination: outcome.destination || null,
caller: data?.caller || null,
requestUri: data?.request_uri || null,
sourceIp: data?.source_ip || null,
sourceTrunk: data?.source_trunk || null,
detectedTrunk: data?.detected_trunk || null,
defaultRoute,
outcomeLabel,
historyDetails,
createdAt: data?.evaluated_at || null,
rewriteOperations,
rewritesDiff,
rewritesText: rewriteOperations.length
? rewriteOperations.join(', ')
: this.translations.no_rewrite_changes,
headers: Array.isArray(outcome.headers) ? outcome.headers : [],
credential: outcome.credential || null,
abort: type === 'abort'
? { code: outcome.code, reason: outcome.reason || null }
: null,
};
},
validationStatusDot(status) {
switch ((status || '').toLowerCase()) {
case 'success':
return 'bg-emerald-500';
case 'error':
return 'bg-rose-500';
case 'warning':
return 'bg-amber-500';
default:
return 'bg-slate-400';
}
},
formatList(value) {
if (!value || (Array.isArray(value) && !value.length)) {
return '—';
}
if (Array.isArray(value)) {
return value
.filter((item) => item !== null && item !== undefined && item !== '')
.map((item) => String(item))
.join(', ');
}
return String(value);
},
displayValue(value) {
if (!value && value !== 0) {
return '—';
}
if (Array.isArray(value)) {
return this.formatList(value);
}
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch (_) {
return '—';
}
}
return String(value);
},
}));
});
</script>
{% endblock %}