{% extends "console/layout.html" %}
{% block title %}{{ "settings.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='settingsConsole({
basePath: {{ (base_path | default("/console")) | tojson }},
settings: {{ settings | default({}) | tojson }},
currentUser: {{ current_user | default({}) | tojson }},
t: window._settingsTranslations
})' x-init="init()">
<div x-show="appReloadModal.open" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4 py-6"
@keydown.escape.window="closeReloadModal()" @click.self="closeReloadModal()">
<div class="w-full max-w-3xl rounded-2xl bg-white shadow-2xl ring-1 ring-black/10">
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<div>
<h3 class="text-base font-semibold text-slate-900">{{ "settings.reload_application" | t }}</h3>
<p class="text-xs text-slate-500">{{ "settings.validate_config_and_restart" | t }}</p>
</div>
<button type="button" class="rounded-md p-2 text-slate-400 transition hover:text-slate-600"
@click="closeReloadModal()" :disabled="appReloading">
<span class="sr-only">{{ "common.close" | t }}</span>
<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>
<div class="px-6 py-4">
<div class="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
<p>{{ "settings.reload_application_desc" | t }}</p>
</div>
<ul class="mt-4 space-y-3">
<template x-for="check in appReloadModal.checks" :key="check.id">
<li class="flex items-start gap-3">
<span class="mt-1 flex h-2.5 w-2.5 flex-none rounded-full"
:class="reloadCheckIndicatorClass(check.status)"></span>
<div class="flex-1 text-sm">
<div class="flex items-center gap-2">
<div class="font-semibold text-slate-800" x-text="check.label"></div>
<span class="text-[10px] font-semibold uppercase tracking-wide"
:class="reloadCheckStatusClass(check.status)"
x-text="reloadCheckStatusLabel(check.status)"></span>
</div>
<div class="text-xs text-slate-500" x-text="check.detail || check.hint"></div>
</div>
</li>
</template>
</ul>
<template x-if="appReloadModal.error">
<div class="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-xs text-rose-700">
<div class="font-semibold"
x-text="appReloadModal.mode === 'check' ? 'Validation failed' : 'Reload failed'"></div>
<p class="mt-1" x-text="appReloadModal.error"></p>
</div>
</template>
<template x-if="appReloadModal.message && !appReloadModal.error">
<div
class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-xs text-emerald-700">
<div class="font-semibold" x-text="appReloadModal.mode === 'check'
? (appReloadModal.status === 'running' ? 'Validation in progress' : 'Validation result')
: 'Reload in progress'"></div>
<p class="mt-1" x-text="appReloadModal.message"></p>
</div>
</template>
</div>
<div class="flex items-center justify-between gap-3 border-t border-slate-200 px-6 py-4">
<div class="text-xs text-slate-500">
<span class="font-semibold" x-text="reloadStatusLabel"></span>
</div>
<div class="flex items-center gap-2">
<button type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
@click="closeReloadModal()" :disabled="appReloading && appReloadModal.status === 'running'">
{{ "settings.cancel" | t }}
</button>
<button type="button"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-sky-200 bg-white px-5 py-2 text-sm font-semibold text-sky-600 shadow-sm transition hover:border-sky-300 hover:bg-sky-50 focus:outline-none focus:ring-2 focus:ring-sky-200 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
@click="startReloadCheck()" :disabled="appReloading || appReloadModal.status === 'running'">
<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="M5 10l3 3 7-7" />
</svg>
<span
x-text="appReloadModal.mode === 'check' && appReloadModal.status === 'error' ? 'Retry check' : 'Validate configuration'"></span>
</button>
<template x-if="appReloadModal.mode === 'check' || appReloadModal.status !== 'success'">
<button type="button"
class="inline-flex items-center justify-center gap-2 rounded-lg bg-rose-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 whitespace-nowrap"
@click="startReloadApplication()"
:disabled="appReloading || appReloadModal.status === 'running'">
<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="M7 5l6 5-6 5" />
</svg>
<span x-text="reloadConfirmLabel"></span>
</button>
</template>
</div>
</div>
</div>
</div>
<div x-show="aclReloadModal.open" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 px-4 py-6"
@keydown.escape.window="closeAclReloadModal()" @click.self="closeAclReloadModal()">
<div class="w-full max-w-md rounded-2xl bg-white shadow-2xl ring-1 ring-black/10">
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<div>
<h3 class="text-base font-semibold text-slate-900">{{ "settings.reload_acl_rules" | t }}</h3>
<p class="text-xs text-slate-500">{{ "settings.reload_acl_rules_desc" | t }}
</p>
</div>
<button type="button" class="rounded-md p-2 text-slate-400 transition hover:text-slate-600"
@click="closeAclReloadModal()" :disabled="aclReloading">
<span class="sr-only">{{ "common.close" | t }}</span>
<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>
<div class="px-6 py-4">
<div class="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
<p>{{ "settings.live_reload_updates" | t }}</p>
</div>
<ul class="mt-4 space-y-3 text-sm text-slate-600">
<li class="flex items-start gap-3">
<span class="mt-1 flex h-2.5 w-2.5 flex-none rounded-full"
:class="aclReloadModal.status === 'success' ? 'bg-emerald-500' : (aclReloadModal.status === 'running' ? 'bg-sky-400 animate-pulse' : 'bg-slate-300')"></span>
<div>
<div class="font-semibold text-slate-800">{{ "settings.refresh_proxy_filters" | t }}
</div>
<div class="text-xs text-slate-500">{{ "settings.refresh_proxy_filters_desc" | t }}
</div>
</div>
</li>
<li class="flex items-start gap-3">
<span class="mt-1 flex h-2.5 w-2.5 flex-none rounded-full"
:class="aclReloadModal.status === 'success' ? 'bg-emerald-500' : (aclReloadModal.status === 'running' ? 'bg-sky-400 animate-pulse' : 'bg-slate-300')"></span>
<div>
<div class="font-semibold text-slate-800">{{ "settings.confirm_rule_summary" | t }}
</div>
<div class="text-xs text-slate-500">{{ "settings.confirm_rule_summary_desc" | t }}</div>
</div>
</li>
</ul>
<template x-if="aclReloadModal.error">
<div class="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-xs text-rose-700">
<div class="font-semibold">{{ "settings.reload_failed_msg" | t }}</div>
<p class="mt-1" x-text="aclReloadModal.error"></p>
</div>
</template>
<template x-if="aclReloadModal.status === 'success' && aclReloadModal.message">
<div
class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-xs text-emerald-700">
<div class="font-semibold">Reload complete</div>
<p class="mt-1" x-text="aclReloadModal.message"></p>
</div>
</template>
</div>
<div class="flex items-center justify-between gap-3 border-t border-slate-200 px-6 py-4">
<div class="text-xs text-slate-500">
<span class="font-semibold" x-text="aclReloadStatusLabel"></span>
</div>
<div class="flex items-center gap-2">
<button type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
@click="closeAclReloadModal()"
:disabled="aclReloading && aclReloadModal.status === 'running'">
Cancel
</button>
<template x-if="aclReloadModal.status !== 'success'">
<button type="button"
class="inline-flex items-center justify-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"
@click="startAclReload()"
:disabled="aclReloading || aclReloadModal.status === 'running'">
<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="M7 5l6 5-6 5" />
</svg>
<span
x-text="aclReloadModal.status === 'error' ? 'Retry reload' : 'Confirm reload'"></span>
</button>
</template>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-[11px] font-semibold uppercase tracking-wide text-sky-600">{{ "settings.control_plane" |
t }}</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ "settings.title" | t }}</h1>
<p class="text-sm text-slate-500">{{ "settings_page.subtitle" | t }}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<template x-if="lastOperation">
<span class="rounded-full bg-slate-900/5 px-3 py-1.5 text-xs font-semibold text-slate-600">
Last action · <span x-text="lastOperation.timestamp"></span>
</span>
</template>
<div class="text-xs font-semibold text-slate-500">
Version · <span class="font-mono text-slate-700"
x-text="platform.version || site_version || '—'"></span>
</div>
<span class="rounded-full bg-slate-900/5 px-3 py-1.5 text-xs font-semibold text-slate-600">
Uptime · <span class="font-mono text-slate-700" x-text="platform.uptime_pretty || '—'"></span>
</span>
</div>
</div>
<div class="rounded-xl bg-white p-3 shadow-sm ring-1 ring-black/5">
<nav class="inline-flex w-full flex-wrap gap-2 rounded-lg border border-slate-200 bg-slate-50 p-1 text-xs font-semibold text-slate-600"
role="tablist" aria-label="Settings sections">
<template x-for="tab in visibleTabs" :key="tab.id">
<button type="button" class="flex-1 rounded-md px-4 py-2 text-left transition sm:flex-none"
:class="activeTab === tab.id ? 'bg-white text-sky-700 shadow-sm' : 'text-slate-500 hover:text-slate-700'"
:aria-selected="activeTab === tab.id" :aria-controls="`settings-tab-${tab.id}`"
@click="setActiveTab(tab.id)">
<div class="flex flex-col">
<span class="text-sm" x-text="tab.label"></span>
<template x-if="tab.description">
<span class="text-[11px] font-normal text-slate-400" x-text="tab.description"></span>
</template>
</div>
</button>
</template>
</nav>
</div>
<section x-show="activeTab === 'platform'" x-cloak x-transition.opacity class="space-y-6"
id="settings-tab-platform" role="tabpanel" tabindex="0">
<div class="grid gap-6 lg:grid-cols-2">
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.runtime_overview" | t
}}</h2>
<p class="text-xs text-slate-500">{{ "settings_page.runtime_overview_desc" | t }}
</p>
</div>
</div>
<div class="mt-4 space-y-3 text-sm text-slate-600">
<div class="flex items-baseline justify-between">
<span class="text-xs uppercase tracking-wide text-slate-400">{{ "settings_page.address" | t
}}</span>
<span class="font-mono" x-text="proxyConfig.addr || '—'"></span>
</div>
<div class="flex items-baseline justify-between">
<span class="text-xs uppercase tracking-wide text-slate-400">{{ "settings_page.ports" | t
}}</span>
<div class="text-right font-mono text-xs">
<template x-for="(port, idx) in (proxyConfig.ports || [])" :key="'port-' + idx">
<div class="flex items-center gap-1 justify-end"
:class="port.primary ? 'font-semibold text-slate-900' : 'text-slate-500'">
<span x-show="port.primary" class="text-amber-500">⭐</span>
<span x-text="port.label ? port.label + ':' : ''"></span>
<span x-text="port.value"></span>
</div>
</template>
<span x-show="!(proxyConfig.ports || []).length">—</span>
</div>
</div>
<div class="flex items-baseline justify-between">
<span class="text-xs uppercase tracking-wide text-slate-400">{{ "settings_page.modules" | t
}}</span>
<span
x-text="(proxyConfig.modules || []).length ? proxyConfig.modules.join(', ') : '—'"></span>
</div>
<div class="flex items-baseline justify-between">
<span class="text-xs uppercase tracking-wide text-slate-400">{{ "settings_page.realms" | t
}}</span>
<span
x-text="(proxyConfig.realms || []).length ? proxyConfig.realms.join(', ') : '—'"></span>
</div>
<div class="flex items-baseline justify-between">
<span class="text-xs uppercase tracking-wide text-slate-400">{{ "settings_page.data_sources"
| t }}</span>
<span
x-text="proxyConfig.data_sources ? `Routes · ${proxyConfig.data_sources.routes}, Trunks · ${proxyConfig.data_sources.trunks}` : '—'"></span>
</div>
</div>
<div class="mt-4 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.transactions" | t }}
</div>
<div class="mt-3 grid grid-cols-3 gap-3 text-sm text-slate-700">
<div>
<div class="text-xs uppercase tracking-wide text-slate-400">{{ "settings_page.running" |
t }}</div>
<div class="mt-1 font-semibold text-slate-900"
x-text="displayMetric(stats.proxy?.transactions?.running)"></div>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-slate-400">{{ "settings_page.finished"
| t }}</div>
<div class="mt-1 font-semibold text-slate-900"
x-text="displayMetric(stats.proxy?.transactions?.finished)"></div>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-slate-400">{{
"settings_page.waiting_ack" | t }}</div>
<div class="mt-1 font-semibold text-slate-900"
x-text="displayMetric(stats.proxy?.transactions?.waiting_ack)"></div>
</div>
</div>
<div class="mt-3 border-t border-slate-200 pt-3 text-xs text-slate-500">
Active dialogs · <span class="font-semibold text-slate-700"
x-text="displayMetric(stats.proxy?.dialogs)"></span>
</div>
</div>
</div>
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.platform_settings" | t
}}</h2>
<p class="text-xs text-slate-500">{{ "settings_page.platform_settings_desc" | t }}
</p>
</div>
</div>
<form class="mt-4 space-y-4" @submit.prevent="savePlatformSettings">
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.log_level" | t }}</label>
<select x-model="platformDraft.log_level" :disabled="platformProcessing"
class="mt-1 w-full rounded-md 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="">{{ "common.default" | t }}</option>
<option value="trace">Trace</option>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.log_file" | t }}</label>
<input type="text" x-model="platformDraft.log_file" :disabled="platformProcessing"
class="mt-1 w-full rounded-md 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="/var/log/rustpbx.log" />
</div>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "settings_page.external_ip" | t }}</label>
<div class="mt-1 flex gap-2">
<label class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition"
:class="platformDraft.external_ip_mode === 'none' ? 'border-sky-300 bg-sky-50 text-sky-700' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300'">
<input type="radio" name="ext_ip_mode" value="none" class="sr-only"
x-model="platformDraft.external_ip_mode">
None
</label>
<label class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition"
:class="platformDraft.external_ip_mode === 'manual' ? 'border-sky-300 bg-sky-50 text-sky-700' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300'">
<input type="radio" name="ext_ip_mode" value="manual" class="sr-only"
x-model="platformDraft.external_ip_mode">
Manual
</label>
<label class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition"
:class="platformDraft.external_ip_mode === 'auto' ? 'border-sky-300 bg-sky-50 text-sky-700' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300'">
<input type="radio" name="ext_ip_mode" value="auto" class="sr-only"
x-model="platformDraft.external_ip_mode">
Auto
</label>
</div>
<div x-show="platformDraft.external_ip_mode === 'manual'">
<input type="text" x-model="platformDraft.external_ip" :disabled="platformProcessing"
class="mt-1 w-full rounded-md 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="1.2.3.4" />
</div>
<div x-show="platformDraft.external_ip_mode === 'auto'">
<div class="mt-1 flex gap-2">
<input type="text" x-model="platformDraft.auto_external_ip" :disabled="platformProcessing || platformDraft.auto_external_ip_testing"
class="flex-1 rounded-md 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="http://ifconfig.me" />
<button type="button" @click="testAutoExternalIp"
:disabled="platformProcessing || platformDraft.auto_external_ip_testing"
class="inline-flex items-center gap-1.5 rounded-md border border-sky-300 bg-sky-50 px-3 py-2 text-sm font-medium text-sky-700 transition hover:bg-sky-100 disabled:opacity-50 disabled:cursor-not-allowed">
<svg x-show="!platformDraft.auto_external_ip_testing" class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182"/>
</svg>
<svg x-show="platformDraft.auto_external_ip_testing" class="h-4 w-4 animate-spin" viewBox="0 0 20 20" fill="none">
<circle class="opacity-25" cx="10" cy="10" r="8" stroke="currentColor" stroke-width="3"/>
<path class="opacity-75" fill="currentColor" d="M10 2a8 8 0 018 8h-2a6 6 0 00-6-6V2z"/>
</svg>
<span>REFRESH</span>
</button>
</div>
<p x-show="platformDraft.auto_external_ip_result" class="mt-1 text-xs" :class="platformDraft.auto_external_ip_result?.startsWith('✓') ? 'text-green-600' : 'text-red-500'" x-text="platformDraft.auto_external_ip_result"></p>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.rtp_start_port" | t }}</label>
<input type="number" min="1" max="65535" x-model="platformDraft.rtp_start_port"
:disabled="platformProcessing"
class="mt-1 w-full rounded-md 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="12000" />
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.rtp_end_port" | t }}</label>
<input type="number" min="1" max="65535" x-model="platformDraft.rtp_end_port"
:disabled="platformProcessing"
class="mt-1 w-full rounded-md 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="42000" />
</div>
</div>
<div
class="flex items-center justify-between gap-2 border-t border-slate-200 pt-3 text-xs text-slate-500">
<span>{{ "settings_page.changes_apply_restart" | t }}</span>
<div class="flex gap-2">
<button type="button"
class="inline-flex items-center justify-center rounded-md border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
@click="resetPlatformDraft" :disabled="platformProcessing">
{{ "common.reset" | t }}
</button>
<button type="submit"
class="inline-flex items-center justify-center rounded-md bg-sky-600 px-3 py-2 text-xs 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"
:disabled="platformProcessing">
<span
x-text="platformProcessing ? tt('common.saving') : tt('common.save_changes')"></span>
</button>
</div>
</div>
</form>
</div>
</div>
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.key_config" | t }}</h2>
<p class="text-xs text-slate-500">{{ "settings_page.key_config_desc" | t }}</p>
</div>
</div>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead
class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-2">{{ "settings_page.key" | t }}</th>
<th class="px-4 py-2">{{ "settings_page.value" | t }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<template x-if="!keyConfigRows().length">
<tr>
<td class="px-4 py-3 text-xs text-slate-400" colspan="2">{{
"settings_page.no_config" | t }}</td>
</tr>
</template>
<template x-for="item in keyConfigRows()" :key="item.label">
<tr>
<td class="px-4 py-3 text-xs font-semibold uppercase tracking-wide text-slate-400"
x-text="item.label"></td>
<td class="px-4 py-3 text-sm text-slate-700">
<div class="font-mono whitespace-pre-line" x-text="item.value"></div>
<div class="text-xs text-slate-400" x-show="item.hint" x-text="item.hint">
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</section>
<section x-show="activeTab === 'logs'" x-cloak x-transition.opacity class="space-y-6" id="settings-tab-logs"
role="tabpanel" tabindex="0">
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900"
x-text="tt('settings_page.logs_viewer') || 'Log viewer'"></h2>
<p class="text-xs text-slate-500"
x-text="tt('settings_page.logs_viewer_desc') || 'Read recent server logs and follow new output in real time.'"></p>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full bg-slate-100 px-2.5 py-1 font-semibold text-slate-600"
x-text="logViewer.following ? (tt('settings_page.logs_following') || 'Following') : (tt('settings_page.logs_paused') || 'Paused')"></span>
<span class="rounded-full bg-slate-100 px-2.5 py-1 font-mono text-[11px] text-slate-600"
x-text="logViewer.path || '—'"></span>
</div>
</div>
<div class="mt-4 flex flex-wrap items-center gap-2 border-y border-slate-100 py-3">
<label class="inline-flex items-center gap-2 text-xs font-semibold text-slate-600">
<span x-text="tt('settings_page.logs_recent_count') || 'Recent lines'"></span>
<select x-model.number="logViewer.limit" @change="applyLogLimit()"
class="rounded-md border border-slate-200 px-2 py-1 text-xs text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option :value="100">100</option>
<option :value="200">200</option>
<option :value="500">500</option>
<option :value="1000">1000</option>
<option :value="2000">2000</option>
<option :value="3000">3000</option>
<option :value="4000">4000</option>
<option :value="5000">5000</option>
</select>
</label>
<button type="button" @click="refreshLogs()" :disabled="logViewer.loading"
class="inline-flex items-center justify-center rounded-md border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
x-text="tt('settings_page.logs_refresh') || 'Refresh'"></button>
<button type="button" @click="toggleLogFollow()" :disabled="logViewer.loading"
class="inline-flex items-center justify-center rounded-md px-3 py-1.5 text-xs font-semibold shadow-sm transition disabled:cursor-not-allowed disabled:opacity-60"
:class="logViewer.following ? 'border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100' : 'border border-sky-200 bg-sky-50 text-sky-700 hover:bg-sky-100'"
x-text="logViewer.following ? (tt('settings_page.logs_stop_follow') || 'Stop follow') : (tt('settings_page.logs_start_follow') || 'Start follow')"></button>
<button type="button" @click="clearLogLines()"
class="inline-flex items-center justify-center rounded-md border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:bg-slate-50"
x-text="tt('settings_page.logs_clear') || 'Clear'"></button>
</div>
<template x-if="logViewer.error">
<div class="mt-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-xs text-rose-700"
x-text="logViewer.error"></div>
</template>
<template x-if="!logViewer.error && logViewer.message">
<div class="mt-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600"
x-text="logViewer.message"></div>
</template>
<div class="mt-4 overflow-hidden rounded-xl border border-slate-200 bg-slate-950">
<div
class="flex items-center justify-between border-b border-slate-800 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-slate-300">
<span x-text="tt('settings_page.logs_output') || 'Log output'"></span>
<span class="font-mono normal-case" x-text="`${(logViewer.lines || []).length} lines`"></span>
<button type="button" @click="copyLogs()"
class="ml-2 rounded border border-slate-700 px-2 py-0.5 font-mono normal-case text-slate-400 transition hover:bg-slate-800 hover:text-slate-200"
x-text="logViewer.copied ? (tt('common.copied') || 'Copied!') : (tt('common.copy') || 'Copy')"></button>
</div>
<div class="max-h-[28rem] overflow-auto p-3 font-mono text-[11px] leading-5 text-slate-100"
x-ref="logOutput">
<template x-if="logViewer.loading && !(logViewer.lines || []).length">
<p class="text-slate-400" x-text="tt('common.loading') || 'Loading...'"></p>
</template>
<template x-if="!(logViewer.lines || []).length && !logViewer.loading">
<p class="text-slate-500"
x-text="tt('settings_page.logs_empty') || 'No logs available yet.'"></p>
</template>
<template x-for="(line, idx) in logViewer.lines" :key="`log-${idx}`">
<div class="whitespace-pre-wrap break-words" x-text="line"></div>
</template>
</div>
</div>
<template x-if="logViewer.truncated">
<p class="mt-3 text-xs text-amber-700"
x-text="tt('settings_page.logs_truncated') || 'Only part of the output is shown. Increase recent lines to see more.'"></p>
</template>
</div>
</section>
<section x-show="activeTab === 'proxy'" x-cloak x-transition.opacity class="space-y-6" id="settings-tab-proxy"
role="tabpanel" tabindex="0">
<div class="grid gap-6 lg:grid-cols-1">
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.proxy_config" | t }}
</h2>
<p class="text-xs text-slate-500">{{ "settings_page.proxy_config_desc" | t }}</p>
</div>
</div>
<form class="mt-6 space-y-8" @submit.prevent="saveProxySettings">
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-slate-900">{{
"settings_page.realms_label" | t }}</label>
<p class="mt-1 text-xs text-slate-500">{{ "settings_page.realms_desc" | t }}</p>
<textarea x-model="proxyDraft.realms" :disabled="proxyProcessing" rows="3"
class="mt-2 block w-full rounded-md 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="localhost example.com"></textarea>
</div>
</div>
<hr class="border-slate-100">
<div class="space-y-4">
<div>
<h3 class="text-sm font-semibold text-slate-900">{{ "settings_page.locator_webhook" | t
}}</h3>
<p class="text-xs text-slate-500">{{ "settings_page.locator_webhook_desc" | t }}</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-1">
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.webhook_url" | t }}</label>
<input type="url" x-model="proxyDraft.locator_webhook.url"
:disabled="proxyProcessing"
class="mt-1 block w-full rounded-md 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="https://api.example.com/sip-events">
</div>
<div class="md:col-span-1">
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.timeout_ms" | t }}</label>
<input type="number" x-model="proxyDraft.locator_webhook.timeout_ms"
:disabled="proxyProcessing"
class="mt-1 block w-full rounded-md 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="5000">
</div>
<div class="md:col-span-2">
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.custom_headers" | t }}</label>
<textarea x-model="proxyDraft.locator_webhook.headers" :disabled="proxyProcessing"
rows="2"
class="mt-1 block w-full rounded-md 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="X-API-Key: secret-key"></textarea>
</div>
</div>
<div class="flex justify-start">
<button type="button" @click="testLocatorWebhook"
:disabled="proxyProcessing || !proxyDraft.locator_webhook.url"
class="inline-flex items-center gap-2 rounded-md border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-50">
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Test webhook
</button>
</div>
</div>
<div class="space-y-4">
<div>
<h3 class="text-sm font-semibold text-slate-900">RWI Webhook</h3>
<p class="text-xs text-slate-500">Forward RWI events (call lifecycle, recording, queue, agent, conference, etc.) to an HTTP endpoint as JSON.</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-1">
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Webhook URL</label>
<input type="url" x-model="proxyDraft.rwi_webhook.url"
:disabled="proxyProcessing"
class="mt-1 block w-full rounded-md 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="https://api.example.com/rwi-events">
</div>
<div class="md:col-span-1">
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Timeout (ms)</label>
<input type="number" x-model="proxyDraft.rwi_webhook.timeout_ms"
:disabled="proxyProcessing"
class="mt-1 block w-full rounded-md 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="5000">
</div>
<div class="md:col-span-2">
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Custom Headers</label>
<textarea x-model="proxyDraft.rwi_webhook.headers" :disabled="proxyProcessing"
rows="2"
class="mt-1 block w-full rounded-md 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="X-API-Key: secret-key"></textarea>
</div>
<div class="md:col-span-2">
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Event Filter (optional, one per line)</label>
<textarea x-model="proxyDraft.rwi_webhook.events" :disabled="proxyProcessing"
rows="2"
class="mt-1 block w-full rounded-md 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="call_ringing call_hangup agent_state_changed"></textarea>
</div>
</div>
<div class="flex justify-start">
<button type="button" @click="testRwiWebhook"
:disabled="proxyProcessing || !proxyDraft.rwi_webhook.url"
class="inline-flex items-center gap-2 rounded-md border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-50">
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Test webhook
</button>
</div>
</div>
<hr class="border-slate-100">
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-slate-900">{{ "settings_page.user_backends" |
t }}</h3>
<p class="text-xs text-slate-500">{{ "settings_page.user_backends_desc" | t }}</p>
</div>
<div class="relative" x-data="{ open: false }" @click.away="open = false">
<button type="button" @click="open = !open"
class="inline-flex items-center gap-2 rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-sky-600 shadow-sm ring-1 ring-inset ring-sky-200 hover:bg-sky-50">
{{ "settings_page.add_backend" | t }}
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd" />
</svg>
</button>
<div x-show="open" x-cloak
class="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div class="py-1" role="menu">
<button type="button" @click="addUserBackend('memory'); open = false"
class="block w-full px-4 py-2 text-left text-xs text-slate-700 hover:bg-slate-100"
role="menuitem">{{ "settings_page.memory_config" | t }}</button>
<button type="button" @click="addUserBackend('http'); open = false"
class="block w-full px-4 py-2 text-left text-xs text-slate-700 hover:bg-slate-100"
role="menuitem">{{ "settings_page.http_webhook" | t }}</button>
<button type="button" @click="addUserBackend('database'); open = false"
class="block w-full px-4 py-2 text-left text-xs text-slate-700 hover:bg-slate-100"
role="menuitem">{{ "settings_page.database" | t }}</button>
<button type="button" @click="addUserBackend('plain'); open = false"
class="block w-full px-4 py-2 text-left text-xs text-slate-700 hover:bg-slate-100"
role="menuitem">{{ "settings_page.plain_file" | t }}</button>
<button type="button" @click="addUserBackend('extension'); open = false"
class="block w-full px-4 py-2 text-left text-xs text-slate-700 hover:bg-slate-100"
role="menuitem">{{ "settings_page.extension_db" | t }}</button>
</div>
</div>
</div>
</div>
<div class="space-y-4">
<template x-for="(backend, index) in proxyDraft.user_backends" :key="index">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="rounded bg-sky-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-sky-700"
x-text="backend.type"></span>
<span class="text-xs font-semibold text-slate-700"
x-text="`Backend #${index + 1}`"></span>
</div>
<button type="button" @click="removeUserBackend(index)"
class="text-slate-400 hover:text-rose-600">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="grid gap-4 md:grid-cols-2">
<template x-if="backend.type === 'http'">
<div class="md:col-span-2 space-y-4">
<div>
<label
class="block text-[10px] font-bold uppercase tracking-wider text-slate-400">URL</label>
<input type="url" x-model="backend.url"
class="mt-1 block w-full rounded border border-slate-200 px-2 py-1.5 text-xs text-slate-700 focus:ring-1 focus:ring-sky-300">
</div>
<div class="grid gap-4 md:grid-cols-3">
<div>
<label
class="block text-[10px] font-bold uppercase tracking-wider text-slate-400">Method</label>
<select x-model="backend.method"
class="mt-1 block w-full rounded border border-slate-200 px-2 py-1.5 text-xs text-slate-700">
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
</div>
<div>
<label
class="block text-[10px] font-bold uppercase tracking-wider text-slate-400">Username
Field</label>
<input type="text" x-model="backend.username_field"
placeholder="username"
class="mt-1 block w-full rounded border border-slate-200 px-2 py-1.5 text-xs text-slate-700">
</div>
<div>
<label
class="block text-[10px] font-bold uppercase tracking-wider text-slate-400">Realm
Field</label>
<input type="text" x-model="backend.realm_field"
placeholder="realm"
class="mt-1 block w-full rounded border border-slate-200 px-2 py-1.5 text-xs text-slate-700">
</div>
</div>
</div>
</template>
<template x-if="backend.type === 'database'">
<div class="md:col-span-2">
<label
class="block text-[10px] font-bold uppercase tracking-wider text-slate-400">Database
URL</label>
<input type="text" x-model="backend.url"
placeholder="mysql://user:pass@host/db"
class="mt-1 block w-full rounded border border-slate-200 px-2 py-1.5 text-xs text-slate-700">
</div>
</template>
<template x-if="backend.type === 'plain'">
<div class="md:col-span-2">
<label
class="block text-[10px] font-bold uppercase tracking-wider text-slate-400">File
Path</label>
<input type="text" x-model="backend.path" placeholder="./users.txt"
class="mt-1 block w-full rounded border border-slate-200 px-2 py-1.5 text-xs text-slate-700">
</div>
</template>
<template x-if="backend.type === 'memory'">
<div class="md:col-span-2">
<p class="text-xs text-slate-500 italic">Static users defined in the
configuration file.</p>
</div>
</template>
<template x-if="backend.type === 'extension'">
<div class="md:col-span-2">
<p class="text-xs text-slate-500 italic">Users managed through the
internal extension database.</p>
</div>
</template>
</div>
<div class="mt-4 flex justify-end">
<button type="button" @click="testUserBackend(index)"
class="text-[11px] font-semibold text-sky-600 hover:text-sky-700">Test
backend</button>
</div>
</div>
</template>
</div>
</div>
<hr class="border-slate-100">
<div class="space-y-4">
<div>
<h3 class="text-sm font-semibold text-slate-900">{{ "settings_page.router_backend" | t
}}</h3>
<p class="text-xs text-slate-500">{{ "settings_page.router_backend_desc" | t }}</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-1">
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.router_url" | t }}</label>
<input type="url" x-model="proxyDraft.http_router.url" :disabled="proxyProcessing"
class="mt-1 block w-full rounded-md 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="https://api.example.com/route-call">
</div>
<div class="md:col-span-1">
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.router_timeout" | t }}</label>
<input type="number" x-model="proxyDraft.http_router.timeout_ms"
:disabled="proxyProcessing"
class="mt-1 block w-full rounded-md 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="5000">
</div>
<div class="md:col-span-2">
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Custom
Headers</label>
<textarea x-model="proxyDraft.http_router.headers" :disabled="proxyProcessing"
rows="2"
class="mt-1 block w-full rounded-md 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="Authorization: Bearer ..."></textarea>
</div>
</div>
<div class="flex justify-start">
<button type="button" @click="testHttpRouter"
:disabled="proxyProcessing || !proxyDraft.http_router.url"
class="inline-flex items-center gap-2 rounded-md border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:bg-slate-50 disabled:opacity-50">
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Test router
</button>
</div>
</div>
<div class="flex items-center justify-end gap-2 border-t border-slate-200 pt-6">
<button type="button" @click="resetProxyDraft" :disabled="proxyProcessing"
class="rounded-md border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 hover:bg-slate-50">
{{ "common.reset" | t }}
</button>
<button type="submit" :disabled="proxyProcessing"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2">
<span
x-text="proxyProcessing ? tt('common.saving') : tt('settings_page.save_proxy_settings')"></span>
</button>
</div>
</form>
</div>
</div>
</section>
<section x-show="activeTab === 'storage'" x-cloak x-transition.opacity class="space-y-6"
id="settings-tab-storage" role="tabpanel" tabindex="0">
<div class="space-y-6">
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.storage_destinations" |
t }}</h2>
<p class="text-xs text-slate-500">{{ "settings_page.storage_destinations_desc" | t }}</p>
</div>
<span class="text-xs font-semibold uppercase tracking-wide text-slate-400">
{{ "common.active" | t }} · <span class="text-sky-600"
x-text="selectedStorageProfileData?.label || storageModeLabel(server.storage?.mode)"></span>
</span>
</div>
<form class="mt-4 space-y-4" @submit.prevent="saveStorageSettings">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.storage_backend" | t }}</label>
<select x-model="storageDraft.callrecord_mode" :disabled="storageProcessing"
class="mt-1 block w-full rounded-md border-slate-200 py-2 pl-3 pr-10 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="disabled">{{ "settings_page.disabled" | t }}</option>
<option value="local">{{ "settings_page.local_filesystem" | t }}</option>
<option value="s3">S3 Compatible Object Store</option>
</select>
</div>
<div x-show="storageDraft.callrecord_mode === 'local'" class="space-y-4">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Root
Path</label>
<input type="text" x-model="storageDraft.callrecord_root" :disabled="storageProcessing"
class="mt-1 w-full rounded-md 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="/var/lib/rustpbx/recordings">
</div>
</div>
<div x-show="storageDraft.callrecord_mode === 's3'" class="space-y-4">
<div class="grid gap-4 md:grid-cols-2">
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Vendor</label>
<select x-model="storageDraft.callrecord_s3_vendor" :disabled="storageProcessing"
class="mt-1 block w-full rounded-md border-slate-200 py-2 pl-3 pr-10 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="aws">AWS S3</option>
<option value="gcp">Google Cloud Storage</option>
<option value="azure">Azure Blob Storage</option>
<option value="minio">MinIO</option>
<option value="digitalocean">DigitalOcean Spaces</option>
<option value="aliyun">Aliyun OSS</option>
<option value="tencent">Tencent COS</option>
</select>
</div>
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Region</label>
<input type="text" x-model="storageDraft.callrecord_s3_region"
:disabled="storageProcessing"
class="mt-1 w-full rounded-md 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="us-east-1">
</div>
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Bucket</label>
<input type="text" x-model="storageDraft.callrecord_s3_bucket"
:disabled="storageProcessing"
class="mt-1 w-full rounded-md 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="my-recordings-bucket">
</div>
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Endpoint
(Optional)</label>
<input type="text" x-model="storageDraft.callrecord_s3_endpoint"
:disabled="storageProcessing"
class="mt-1 w-full rounded-md 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="https://s3.amazonaws.com">
</div>
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Access
Key</label>
<input type="password" x-model="storageDraft.callrecord_s3_access_key"
:disabled="storageProcessing"
class="mt-1 w-full rounded-md 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">
</div>
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Secret
Key</label>
<input type="password" x-model="storageDraft.callrecord_s3_secret_key"
:disabled="storageProcessing"
class="mt-1 w-full rounded-md 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">
</div>
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Root
Path (Prefix)</label>
<input type="text" x-model="storageDraft.callrecord_s3_root"
:disabled="storageProcessing"
class="mt-1 w-full rounded-md 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="recordings/">
</div>
<div class="col-span-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-6">
<label class="flex items-center gap-2 text-sm font-semibold text-slate-600">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
x-model="storageDraft.callrecord_s3_with_media"
:disabled="storageProcessing">
<span>{{ "settings_page.upload_media" | t }}</span>
</label>
<label class="flex items-center gap-2 text-sm font-semibold text-slate-600">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
x-model="storageDraft.callrecord_s3_keep_media_copy"
:disabled="storageProcessing">
<span>{{ "settings_page.keep_local_copy" | t }}</span>
</label>
</div>
</div>
<div class="flex items-center justify-end gap-3 border-t border-slate-100 pt-4">
<button type="button"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-600 shadow-sm transition hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-200 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
@click="testStorageConnection()" :disabled="storageProcessing">
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>{{ "settings_page.test_connection_btn" | t }}</span>
</button>
</div>
</div>
<div class="flex items-center justify-end gap-3 border-t border-slate-100 pt-4">
<button type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
@click="resetStorageDraft()" :disabled="storageProcessing">
{{ "common.reset" | t }}
</button>
<button type="submit"
class="inline-flex items-center justify-center 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"
:disabled="storageProcessing">
{{ "common.save" | t }}
</button>
</div>
</form>
</div>
</div>
</section>
<section x-show="activeTab === 'sipflow'" x-cloak x-transition.opacity class="space-y-6"
id="settings-tab-sipflow" role="tabpanel" tabindex="0">
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.sipflow_title" | t }}</h2>
<p class="text-xs text-slate-500">{{ "settings_page.sipflow_desc" | t }}</p>
</div>
<span class="text-xs font-semibold uppercase tracking-wide text-slate-400">
{{ "settings_page.recording_backend" | t }} · <span
:class="sipflowSettings.backend_type !== 'none' ? 'text-emerald-600' : 'text-slate-400'"
x-text="sipflowSettings.backend_type || 'none'"></span>
</span>
</div>
<div class="mt-4 rounded-lg border border-sky-100 bg-sky-50 px-4 py-3 text-xs text-sky-700">
<div class="flex items-start gap-2">
<svg class="h-4 w-4 flex-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p class="font-semibold">{{ "settings_page.better_io_performance" | t }}</p>
<p class="mt-1">{{ "settings_page.concurrent_calls_support" | t }}</p>
<p class="mt-1">When Recording policy is also enabled, SipFlow takes over media capture and upload — no local .wav file is written.</p>
</div>
</div>
</div>
<form class="mt-4 space-y-4" @submit.prevent="saveSipFlowSettings">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.recording_backend" | t }}</label>
<select x-model="sipflowDraft.backend_type" :disabled="sipflowProcessing"
class="mt-1 block w-full rounded-md border-slate-200 py-2 pl-3 pr-10 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="none">{{ "settings_page.disabled" | t }}</option>
<option value="local">{{ "settings_page.local_backend_opt" | t }}</option>
<option value="remote">{{ "settings_page.remote_backend_opt" | t }}</option>
</select>
<p class="mt-1 text-xs text-slate-500">{{ "settings_page.select_backend" | t }}</p>
</div>
<div x-show="sipflowDraft.backend_type !== 'none'" x-cloak
class="border-t border-slate-200 pt-4 space-y-4">
<div x-show="sipflowDraft.backend_type === 'local'"
class="space-y-4 rounded-lg border border-slate-200 bg-slate-50 p-4">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.data_dir" | t }}</label>
<input type="text" x-model="sipflowDraft.local_root" :disabled="sipflowProcessing"
class="mt-1 w-full rounded-md 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="./config/sipflow">
<p class="mt-1 text-xs text-slate-500">{{ "settings_page.data_dir_hint" | t }}</p>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.subdirectories" | t }}</label>
<select x-model="sipflowDraft.local_subdirs" :disabled="sipflowProcessing"
class="mt-1 block w-full rounded-md border-slate-200 py-2 pl-3 pr-10 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="none">{{ "settings_page.flat_structure" | t }}</option>
<option value="daily">{{ "settings_page.daily" | t }}</option>
<option value="hourly">{{ "settings_page.hourly" | t }}</option>
</select>
<p class="mt-1 text-xs text-slate-500">{{ "settings_page.subdir_desc" | t }}</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.flush_count_label" | t }}</label>
<input type="number" x-model.number="sipflowDraft.local_flush_count"
:disabled="sipflowProcessing"
class="mt-1 w-full rounded-md 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="1000" min="1">
<p class="mt-1 text-xs text-slate-500">{{ "settings_page.flush_buffer_desc_short" |
t }}</p>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.flush_interval_label" | t }}</label>
<input type="number" x-model.number="sipflowDraft.local_flush_interval_secs"
:disabled="sipflowProcessing"
class="mt-1 w-full rounded-md 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="5" min="1">
<p class="mt-1 text-xs text-slate-500">{{ "settings_page.flush_timeout_desc_short" |
t }}</p>
</div>
</div>
</div>
<div x-show="sipflowDraft.backend_type === 'remote'"
class="space-y-4 rounded-lg border border-slate-200 bg-slate-50 p-4">
<div>
<div class="flex items-center justify-between">
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.sipflow_nodes" | t }}</label>
<button type="button" @click="sipflowDraft.remote_nodes.push({ udp: '', http: '' })"
:disabled="sipflowProcessing"
class="inline-flex items-center gap-1 rounded-md bg-white px-2 py-1 text-xs font-medium text-sky-600 shadow-sm ring-1 ring-sky-200 transition hover:bg-sky-50 disabled:cursor-not-allowed disabled:opacity-50">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4" />
</svg>
{{ "settings_page.sipflow_add_node" | t }}
</button>
</div>
<p class="mt-1 text-xs text-slate-500">{{ "settings_page.udp_address_hint" | t }}</p>
</div>
<template x-for="(node, idx) in sipflowDraft.remote_nodes" :key="idx">
<div class="flex items-start gap-2 rounded-md border border-slate-200 bg-white p-3">
<div class="flex-1 space-y-2">
<div class="grid gap-2 md:grid-cols-2">
<div>
<label class="block text-xs font-medium text-slate-500">{{
"settings_page.udp_address" | t }}</label>
<input type="text" x-model="node.udp" :disabled="sipflowProcessing"
class="mt-0.5 w-full rounded-md border border-slate-200 px-2.5 py-1.5 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
placeholder="192.168.1.100:3000">
</div>
<div>
<label class="block text-xs font-medium text-slate-500">{{
"settings_page.http_address" | t }}</label>
<input type="text" x-model="node.http" :disabled="sipflowProcessing"
class="mt-0.5 w-full rounded-md border border-slate-200 px-2.5 py-1.5 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
placeholder="http://192.168.1.100:3001">
</div>
</div>
</div>
<button type="button" @click="sipflowDraft.remote_nodes.splice(idx, 1)"
:disabled="sipflowProcessing || sipflowDraft.remote_nodes.length <= 1"
class="mt-5 inline-flex flex-none items-center justify-center rounded-md border border-red-200 bg-white px-2 py-1.5 text-xs font-medium text-red-500 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</template>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.timeout_label" | t }}</label>
<input type="number" x-model.number="sipflowDraft.remote_timeout_secs"
:disabled="sipflowProcessing"
class="mt-1 w-full rounded-md 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="10" min="1">
<p class="mt-1 text-xs text-slate-500">{{ "settings_page.connection_timeout_hint" | t }}
</p>
</div>
</div>
</div>
<div
class="flex items-center justify-between gap-2 border-t border-slate-200 pt-3 text-xs text-slate-500">
<span>{{ "settings_page.restart_required_apply" | t }}</span>
<div class="flex gap-2">
<button type="button"
class="inline-flex items-center justify-center rounded-md border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
@click="resetSipFlowDraft" :disabled="sipflowProcessing">
{{ "common.reset" | t }}
</button>
<button type="submit"
class="inline-flex items-center justify-center rounded-md bg-sky-600 px-3 py-2 text-xs 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"
:disabled="sipflowProcessing">
<span
x-text="sipflowProcessing ? tt('common.saving') : tt('common.save_changes')"></span>
</button>
</div>
</div>
</form>
</div>
</section>
<section x-show="activeTab === 'recording'" x-cloak x-transition.opacity class="space-y-6"
id="settings-tab-recording" role="tabpanel" tabindex="0">
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.recording_policy" | t }}
</h2>
<p class="text-xs text-slate-500">{{ "settings_page.recording_policy_desc" | t }}</p>
<template x-if="sipflowSettings?.backend_type !== 'none'">
<div class="mt-2 rounded-md bg-sky-50 px-3 py-2 text-xs text-sky-700">
SipFlow backend is active. Enable <strong>Force file recorder</strong>
below to record audio to WAV files via the legacy recorder while
SipFlow captures SIP signalling only. Otherwise, SipFlow handles both
media capture and signalling.
</div>
</template>
</div>
</div>
<form class="mt-4 space-y-4" @submit.prevent="saveStorageSettings">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.policy_enabled" | t }}</label>
<p class="text-xs text-slate-500">{{ "settings_page.policy_enabled_desc" | t }}</p>
</div>
<label class="inline-flex items-center gap-2 text-sm font-semibold text-slate-600">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
x-model="storageDraft.recording_enabled" :disabled="storageProcessing">
<span>{{ "settings_page.enable_recording" | t }}</span>
</label>
</div>
<div x-show="storageDraft.recording_enabled" x-cloak
class="border-t border-slate-200 pt-4 space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex items-center gap-2 text-sm font-semibold text-slate-600">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
x-model="storageDraft.recording_auto_start" :disabled="storageProcessing">
<span>{{ "settings_page.auto_start" | t }}</span>
</label>
<label class="flex items-center gap-2 text-sm font-semibold text-slate-600">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
x-model="storageDraft.recording_force_file" :disabled="storageProcessing">
<span>Force file recorder</span>
<template x-if="sipflowSettings?.backend_type !== 'none'">
<span class="text-[11px] text-slate-400">(WAV file for media, SipFlow for signalling only)</span>
</template>
</label>
</div>
<div>
<span class="text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.directions" | t }}</span>
<div class="mt-2 grid gap-2 sm:grid-cols-3">
<label class="flex items-center gap-2 text-xs font-semibold text-slate-600">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
x-model="storageDraft.recording_direction_inbound"
:disabled="storageProcessing">
<span>{{ "settings_page.inbound" | t }}</span>
</label>
<label class="flex items-center gap-2 text-xs font-semibold text-slate-600">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
x-model="storageDraft.recording_direction_outbound"
:disabled="storageProcessing">
<span>{{ "settings_page.outbound" | t }}</span>
</label>
<label class="flex items-center gap-2 text-xs font-semibold text-slate-600">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
x-model="storageDraft.recording_direction_internal"
:disabled="storageProcessing">
<span>{{ "settings_page.internal" | t }}</span>
</label>
</div>
<p class="mt-1 text-[11px] text-slate-400">{{ "settings_page.directions_desc" | t }}</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.caller_allow_list" | t }}</label>
<textarea x-model="storageDraft.recording_caller_allow" :disabled="storageProcessing"
class="mt-1 w-full rounded-md 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"
rows="3" placeholder="alice@rustpbx.com"></textarea>
<p class="mt-1 text-[11px] text-slate-400">{{ "settings_page.pattern_glob_hint" | t }}
</p>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.caller_deny_list" | t }}</label>
<textarea x-model="storageDraft.recording_caller_deny" :disabled="storageProcessing"
class="mt-1 w-full rounded-md 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"
rows="3" placeholder="sales@*"></textarea>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.callee_allow_list" | t }}</label>
<textarea x-model="storageDraft.recording_callee_allow" :disabled="storageProcessing"
class="mt-1 w-full rounded-md 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"
rows="3" placeholder="support@rustpbx.com"></textarea>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.callee_deny_list" | t }}</label>
<textarea x-model="storageDraft.recording_callee_deny" :disabled="storageProcessing"
class="mt-1 w-full rounded-md 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"
rows="3" placeholder="test-*"></textarea>
</div>
</div>
<div class="grid gap-4 md:grid-cols-3">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.sample_rate" | t }}</label>
<input type="number" min="8000" step="1000" x-model="storageDraft.recording_samplerate"
:disabled="storageProcessing"
class="mt-1 w-full rounded-md 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="16000" />
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Packet
time (ms)</label>
<input type="number" min="10" step="10" x-model="storageDraft.recording_ptime"
:disabled="storageProcessing"
class="mt-1 w-full rounded-md 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="200" />
</div>
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Filename
pattern</label>
<input type="text" x-model="storageDraft.recording_filename_pattern"
:disabled="storageProcessing"
class="mt-1 w-full rounded-md 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="{timestamp}_{caller}_{callee}" />
<p class="mt-1 text-[11px] text-slate-400">Tokens: {session_id}, {caller}, {callee},
{direction}, {timestamp}</p>
</div>
</div>
</div>
<template x-if="!storageDraft.recording_enabled">
<div
class="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-500">
Recording is currently disabled. Enable the policy to configure capture rules. Storage
settings remain available below.
</div>
</template>
<div class="border-t border-slate-200 pt-4 space-y-4">
<div>
<h3 class="text-sm font-semibold text-slate-800">Recorder storage</h3>
<p class="text-xs text-slate-500">Manage recorder output paths and call record storage
targets.</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Recorder
root path</label>
<input type="text" x-model="storageDraft.recorder_path" :disabled="storageProcessing"
class="mt-1 w-full rounded-md 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="/tmp/recorder" />
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Call
record root path</label>
<input type="text" x-model="storageDraft.callrecord_root" :disabled="storageProcessing"
class="mt-1 w-full rounded-md 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="/tmp/cdr" />
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Media
cache path</label>
<input type="text" x-model="storageDraft.media_cache_path" :disabled="storageProcessing"
class="mt-1 w-full rounded-md 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="/tmp/mediacache" />
</div>
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Recorder
format</label>
<select x-model="storageDraft.recorder_format" :disabled="storageProcessing"
class="mt-1 w-full rounded-md 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="">Default</option>
<option value="wav">WAV (PCM)</option>
</select>
</div>
</div>
</div>
<div
class="flex items-center justify-between gap-2 border-t border-slate-200 pt-3 text-xs text-slate-500">
<span>{{ "settings_page.restart_required_apply" | t }}</span>
<div class="flex gap-2">
<button type="button"
class="inline-flex items-center justify-center rounded-md border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
@click="resetStorageDraft" :disabled="storageProcessing">
Reset
</button>
<button type="submit"
class="inline-flex items-center justify-center rounded-md bg-sky-600 px-3 py-2 text-xs 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"
:disabled="storageProcessing">
<span
x-text="storageProcessing ? tt('common.saving') : tt('common.save_changes')"></span>
</button>
</div>
</div>
</form>
</div>
</section>
<section x-show="activeTab === 'security'" x-cloak x-transition.opacity class="space-y-6"
id="settings-tab-security" role="tabpanel" tabindex="0">
<div class="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<div class="space-y-6">
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.acl_title" | t }}
</h2>
<p class="text-xs text-slate-500">{{ "settings_page.acl_desc" | t }}</p>
</div>
<div class="text-right text-xs text-slate-500">
<div class="text-sm font-semibold text-slate-800"
x-text="acl.active_rules.length ? acl.active_rules.length + ' ' + tt('settings_page.acl_active') : tt('settings_page.acl_no_rules')">
</div>
<div class="text-[11px] uppercase tracking-wide">{{ "settings_page.rule_status" | t }}
</div>
</div>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-3">
<div class="rounded-lg border border-slate-200 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.embedded_rules" | t }}</div>
<div class="mt-1 text-sm font-semibold text-slate-800" x-text="acl.embedded_count || 0">
</div>
<div class="text-xs text-slate-500">{{ "settings_page.embedded_rules_desc" | t }}</div>
</div>
<div class="rounded-lg border border-slate-200 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">{{
"settings_page.file_rules" | t }}</div>
<div class="mt-1 text-sm font-semibold text-slate-800"
x-text="acl.file_patterns.length ? acl.file_patterns.join(', ') : tt('settings_page.acl_inline_only')">
</div>
<div class="text-xs text-slate-500">{{ "settings_page.file_rules_desc" | t }}</div>
</div>
<div class="rounded-lg border border-slate-200 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-400"
x-text="tt('settings_page.reload_support')"></div>
<div class="mt-1 text-sm font-semibold"
:class="acl.reload_supported ? 'text-emerald-600' : 'text-rose-600'"
x-text="acl.reload_supported ? tt('settings_page.acl_available') : tt('settings_page.acl_proxy_offline')">
</div>
<div class="text-xs text-slate-500" x-text="tt('settings_page.reload_rules_desc')">
</div>
</div>
</div>
<template x-if="acl.metrics">
<div class="mt-4 rounded-lg border border-dashed border-slate-200 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-slate-400"
x-text="tt('settings_page.reload_metrics')"></div>
<dl class="mt-3 grid gap-3 text-xs text-slate-600 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt class="text-[11px] uppercase tracking-wide text-slate-400"
x-text="tt('settings_page.total_rules')"></dt>
<dd class="mt-1 text-sm font-semibold text-slate-800"
x-text="acl.metrics?.total ?? '—'"></dd>
</div>
<div>
<dt class="text-[11px] uppercase tracking-wide text-slate-400"
x-text="tt('settings_page.embedded_rules')"></dt>
<dd class="mt-1 text-sm font-semibold text-slate-800"
x-text="acl.metrics?.config_count ?? 0"></dd>
</div>
<div>
<dt class="text-[11px] uppercase tracking-wide text-slate-400"
x-text="tt('settings_page.file_rules')"></dt>
<dd class="mt-1 text-sm font-semibold text-slate-800"
x-text="acl.metrics?.file_count ?? 0"></dd>
</div>
<div>
<dt class="text-[11px] uppercase tracking-wide text-slate-400"
x-text="tt('settings_page.reload_duration')"></dt>
<dd class="mt-1 text-sm font-semibold text-slate-800"
x-text="formatDuration(acl.metrics?.duration_ms) || '—'"></dd>
</div>
</dl>
<template x-if="acl.metrics?.files?.length">
<div class="mt-3 text-xs text-slate-600">
<div class="text-[11px] uppercase tracking-wide text-slate-400"
x-text="tt('settings_page.loaded_files')"></div>
<ul class="mt-1 space-y-1">
<template x-for="file in acl.metrics.files" :key="file">
<li class="rounded bg-slate-100 px-2 py-1 font-mono text-[11px] text-slate-700"
x-text="file"></li>
</template>
</ul>
</div>
</template>
<template x-if="acl.metrics?.patterns?.length">
<div class="mt-3 text-xs text-slate-600">
<div class="text-[11px] uppercase tracking-wide text-slate-400"
x-text="tt('settings_page.file_patterns')"></div>
<ul class="mt-1 space-y-1">
<template x-for="pattern in acl.metrics.patterns" :key="pattern">
<li class="rounded bg-slate-100 px-2 py-1 font-mono text-[11px] text-slate-700"
x-text="pattern"></li>
</template>
</ul>
</div>
</template>
</div>
</template>
<template x-if="operations.length">
<div class="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-5">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-rose-700"
x-text="t.settings_page?.danger_zone || 'Danger zone'"></h3>
</div>
<p class="mt-2 text-xs text-slate-500"
x-text="t.settings_page?.danger_zone_desc || 'Reload actions apply immediately to the SIP proxy in-memory state.'">
</p>
<div class="mt-4 space-y-4 text-sm text-slate-600">
<template x-for="action in operations" :key="action.id">
<div class="rounded-lg border border-rose-200 bg-rose-50 p-4">
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-2">
<div class="text-sm font-semibold text-slate-700"
x-text="action.label">
</div>
<p class="text-xs text-slate-600" x-show="action.description"
x-text="action.description"></p>
</div>
<button type="button"
class="inline-flex items-center justify-center gap-2 rounded-md border border-rose-500 bg-rose-600 px-4 py-2 text-xs font-semibold text-white shadow-sm transition hover:bg-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-400 focus:ring-offset-2"
:class="pendingReload && (action.id === 'reload-app' || action.id === 'reload-acl') ? 'reload-glow' : ''"
:disabled="operationDisabled(action)"
@click="triggerOperation(action)">
<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="M7 5l6 5-6 5" />
</svg>
<span x-text="operationButtonLabel(action)"></span>
</button>
</div>
</div>
</template>
</div>
<template x-if="lastOperation">
<div
class="mt-4 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs text-slate-500">
<div class="font-semibold text-slate-700" x-text="lastOperation.label"></div>
<div x-text="lastOperation.timestamp"></div>
<div class="mt-1" x-text="lastOperation.status"></div>
</div>
</template>
</div>
</template>
</div>
</div>
<div class="space-y-6">
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900"
x-text="t.settings_page?.update_rules || 'Update rules'"></h2>
<p class="text-xs text-slate-500"
x-text="t.settings_page?.update_rules_desc || 'Edit ACL entries and blocked user agents. Restart required.'">
</p>
</div>
</div>
<form class="mt-4 space-y-6" @submit.prevent="saveSecuritySettings">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400"
x-text="t.settings_page?.acl_title || 'ACL rules'"></label>
<textarea rows="6" x-model="securityDraft.acl_rules" :disabled="securityProcessing"
class="mt-1 w-full rounded-md 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="allow all deny all"></textarea>
<p class="mt-1 text-xs text-slate-400">One rule per line, matching the ACL syntax.</p>
</div>
<hr class="border-slate-200">
<div>
<h3 class="text-sm font-semibold text-slate-800 mb-3">DoS Protection</h3>
<div class="grid gap-3 sm:grid-cols-2">
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" x-model="securityDraft.dos_enabled" :disabled="securityProcessing"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500">
<span>Enable per-IP rate limiting</span>
</label>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Max CPS per IP</label>
<input type="number" min="0" x-model.number="securityDraft.dos_max_cps_per_ip" :disabled="securityProcessing"
class="mt-1 w-full rounded-md 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">
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Max concurrent calls per IP</label>
<input type="number" min="0" x-model.number="securityDraft.dos_max_concurrent_per_ip" :disabled="securityProcessing"
class="mt-1 w-full rounded-md 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">
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Scan probe threshold</label>
<input type="number" min="0" x-model.number="securityDraft.dos_scan_probe_threshold" :disabled="securityProcessing"
class="mt-1 w-full rounded-md 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">
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Scan block duration (seconds)</label>
<input type="number" min="0" x-model.number="securityDraft.dos_scan_block_duration_secs" :disabled="securityProcessing"
class="mt-1 w-full rounded-md 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">
</div>
</div>
</div>
<hr class="border-slate-200">
<div>
<h3 class="text-sm font-semibold text-slate-800 mb-3">Channel Capacities (restart required)</h3>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Session cmd channel</label>
<input type="number" min="64" x-model.number="securityDraft.session_cmd_channel_capacity" :disabled="securityProcessing"
class="mt-1 w-full rounded-md 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">
<p class="mt-0.5 text-xs text-slate-400">SipSession command channel (per-session, default 256)</p>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Session state channel</label>
<input type="number" min="64" x-model.number="securityDraft.session_state_channel_capacity" :disabled="securityProcessing"
class="mt-1 w-full rounded-md 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">
<p class="mt-0.5 text-xs text-slate-400">SipSession state channel (per-session, default 256)</p>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Media engine cmd channel</label>
<input type="number" min="128" x-model.number="securityDraft.media_cmd_channel_capacity" :disabled="securityProcessing"
class="mt-1 w-full rounded-md 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">
<p class="mt-0.5 text-xs text-slate-400">MediaEngine command channel (global, default 512)</p>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Media engine event channel</label>
<input type="number" min="128" x-model.number="securityDraft.media_event_channel_capacity" :disabled="securityProcessing"
class="mt-1 w-full rounded-md 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">
<p class="mt-0.5 text-xs text-slate-400">MediaEngine event broadcast channel (global, default 1024)</p>
</div>
</div>
</div>
<hr class="border-slate-200">
<div>
<h3 class="text-sm font-semibold text-slate-800 mb-3">URI Normalization</h3>
<div class="grid gap-3 sm:grid-cols-2">
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" x-model="securityDraft.uri_reject_malformed" :disabled="securityProcessing"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500">
<span>Reject malformed or missing From URIs</span>
</label>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Max From URI length</label>
<input type="number" min="1" x-model.number="securityDraft.uri_max_length" :disabled="securityProcessing"
class="mt-1 w-full rounded-md 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">
</div>
</div>
</div>
<hr class="border-slate-200">
<div>
<h3 class="text-sm font-semibold text-slate-800 mb-3">Emergency Routing</h3>
<div class="grid gap-3 sm:grid-cols-2">
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" x-model="securityDraft.emergency_enabled" :disabled="securityProcessing"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500">
<span>Enable emergency number routing</span>
</label>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Emergency numbers</label>
<input type="text" x-model="securityDraft.emergency_numbers" :disabled="securityProcessing"
class="mt-1 w-full rounded-md 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="911, 999, 110">
<p class="mt-1 text-xs text-slate-400">Comma-separated list of emergency numbers.</p>
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">Emergency trunk URI</label>
<input type="text" x-model="securityDraft.emergency_trunk" :disabled="securityProcessing"
class="mt-1 w-full rounded-md 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="sip:emergency_trunk@pbx.company.com">
</div>
</div>
</div>
<div
class="flex items-center justify-between gap-2 border-t border-slate-200 pt-3 text-xs text-slate-500">
<span>{{ "settings_page.restart_required_apply" | t }}</span>
<div class="flex gap-2">
<button type="button"
class="inline-flex items-center justify-center rounded-md border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
@click="resetSecurityDraft" :disabled="securityProcessing">
<span x-text="tt('reset')"></span>
</button>
<button type="submit"
class="inline-flex items-center justify-center rounded-md bg-sky-600 px-3 py-2 text-xs 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"
:disabled="securityProcessing">
<span
x-text="securityProcessing ? tt('common.saving') : tt('common.save_changes')"></span>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</section>
<section x-show="activeTab === 'rwi'" x-cloak x-transition.opacity class="space-y-6" id="settings-tab-rwi"
role="tabpanel" tabindex="0">
<div class="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<div class="space-y-6">
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.rwi_settings" | t
}}</h2>
<p class="text-xs text-slate-500">{{ "settings_page.rwi_settings_desc" | t }}</p>
</div>
</div>
<form @submit.prevent="saveRwiSettings()" class="mt-6 space-y-6">
<div class="space-y-4">
<div class="flex items-center gap-3">
<input type="checkbox" id="rwi_enabled" x-model="rwiDraft.enabled"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500">
<label for="rwi_enabled" class="text-sm font-medium text-slate-700">
{{ "settings_page.rwi_enabled" | t }}
</label>
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
<p>{{ "settings_page.rwi_endpoint_desc" | t }}</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">
{{ "settings_page.rwi_max_connections" | t }}
</label>
<input type="number" x-model.number="rwiDraft.max_connections"
class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500">
</div>
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">
{{ "settings_page.rwi_max_calls_per_conn" | t }}
</label>
<input type="number" x-model.number="rwiDraft.max_calls_per_connection"
class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">
{{ "settings_page.rwi_orphan_hold_secs" | t }}
</label>
<input type="number" x-model.number="rwiDraft.orphan_hold_secs"
class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500">
</div>
<div>
<label
class="block text-xs font-semibold uppercase tracking-wide text-slate-400">
{{ "settings_page.rwi_originate_rate_limit" | t }}
</label>
<input type="number" x-model.number="rwiDraft.originate_rate_limit"
class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500">
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3 border-t border-slate-200 pt-4">
<button type="submit"
class="inline-flex items-center justify-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"
:disabled="rwiProcessing">
<span
x-text="rwiProcessing ? tt('common.saving') : tt('common.save_changes')"></span>
</button>
</div>
</form>
</div>
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.rwi_tokens" | t }}
</h2>
<p class="text-xs text-slate-500">{{ "settings_page.rwi_tokens_desc" | t }}</p>
</div>
<button type="button" @click="addRwiToken()"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:bg-slate-50">
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 5v10M5 10h10" />
</svg>
{{ "common.add" | t }}
</button>
</div>
<div class="mt-4 space-y-4">
<template x-for="(token, index) in rwiDraft.tokens" :key="index">
<div class="rounded-lg border border-slate-200 p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 space-y-3">
<div>
<label class="block text-xs font-medium text-slate-500">{{
"settings_page.rwi_token" | t }}</label>
<input type="text" x-model="token.token"
class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
placeholder="secret-token">
</div>
<div>
<label class="block text-xs font-medium text-slate-500">{{
"settings_page.rwi_scopes" | t }}</label>
<div class="mt-1 space-y-2">
<template
x-for="scope in ['call.control', 'queue.control', 'supervisor.control', 'media.stream', 'record.control']"
:key="scope">
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox"
:checked="token.scopes.includes(scope)"
@change="if ($event.target.checked) { token.scopes.push(scope) } else { token.scopes = token.scopes.filter(s => s !== scope) }"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500">
<span
x-text="tt('settings_page.rwi_scope_' + scope.replace('.', '_'))"></span>
</label>
</template>
</div>
<p class="mt-1 text-xs text-slate-400">{{
"settings_page.rwi_scopes_hint" | t }}</p>
</div>
</div>
<button type="button" @click="removeRwiToken(index)"
class="mt-6 rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-rose-500">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 6l8 8M14 6l-8 8" />
</svg>
</button>
</div>
</div>
</template>
<div x-show="rwiDraft.tokens.length === 0" class="text-center py-4 text-sm text-slate-500">
{{ "settings_page.rwi_no_tokens" | t }}
</div>
</div>
<div class="mt-4 flex justify-end">
<button type="button" @click="saveRwiSettings()"
class="inline-flex items-center justify-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"
:disabled="rwiProcessing">
<span x-text="rwiProcessing ? tt('common.saving') : tt('common.save_changes')"></span>
</button>
</div>
</div>
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.rwi_contexts" | t
}}</h2>
<p class="text-xs text-slate-500">{{ "settings_page.rwi_contexts_desc" | t }}</p>
</div>
<button type="button" @click="addRwiContext()"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:bg-slate-50">
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 5v10M5 10h10" />
</svg>
{{ "common.add" | t }}
</button>
</div>
<div class="mt-4 space-y-4">
<template x-for="(ctx, index) in rwiDraft.contexts" :key="index">
<div class="rounded-lg border border-slate-200 p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 space-y-3">
<div>
<label class="block text-xs font-medium text-slate-500">{{
"settings_page.rwi_context_name" | t }}</label>
<input type="text" x-model="ctx.name"
class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
placeholder="ivr_bot">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-500">{{
"settings_page.rwi_no_answer_timeout" | t }}</label>
<input type="number" x-model.number="ctx.no_answer_timeout_secs"
class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500">
</div>
<div>
<label class="block text-xs font-medium text-slate-500">{{
"settings_page.rwi_no_answer_action" | t }}</label>
<select x-model="ctx.no_answer_action"
class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500">
<option value="">--</option>
<option value="hangup">hangup</option>
<option value="transfer">transfer</option>
<option value="play_tone">play_tone</option>
</select>
</div>
</div>
<div x-show="ctx.no_answer_action === 'transfer'">
<label class="block text-xs font-medium text-slate-500">{{
"settings_page.rwi_transfer_target" | t }}</label>
<input type="text" x-model="ctx.no_answer_transfer_target"
class="mt-1 block w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
placeholder="sip:voicemail@local">
</div>
</div>
<button type="button" @click="removeRwiContext(index)"
class="mt-6 rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-rose-500">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 6l8 8M14 6l-8 8" />
</svg>
</button>
</div>
</div>
</template>
<div x-show="rwiDraft.contexts.length === 0"
class="text-center py-4 text-sm text-slate-500">
{{ "settings_page.rwi_no_contexts" | t }}
</div>
</div>
<div class="mt-4 flex justify-end">
<button type="button" @click="saveRwiSettings()"
class="inline-flex items-center justify-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"
:disabled="rwiProcessing">
<span x-text="rwiProcessing ? tt('common.saving') : tt('common.save_changes')"></span>
</button>
</div>
</div>
</div>
</div>
</section>
<section x-show="activeTab === 'cluster'" x-cloak x-transition.opacity class="space-y-6"
id="settings-tab-cluster" role="tabpanel" tabindex="0">
<div class="grid gap-6 lg:grid-cols-1">
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.cluster_peers" | t }}</h2>
<p class="text-xs text-slate-500">{{ "settings_page.cluster_peers_desc" | t }}</p>
</div>
<button type="button" @click="addClusterPeer()"
class="inline-flex items-center gap-2 rounded-lg border border-dashed border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-sky-300 hover:text-sky-700">
<svg class="h-3.5 w-3.5" 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>
{{ "settings_page.cluster_add_peer" | t }}
</button>
</div>
<div class="mt-4 space-y-4" x-data="{}">
<template x-for="(peer, index) in clusterDraft.peers" :key="index">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 grid gap-4 md:grid-cols-3">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "settings_page.cluster_peer_addr" | t }}</label>
<input type="text" x-model="peer.addr"
class="mt-1 w-full rounded-md 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="192.168.1.100">
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "settings_page.cluster_sip_port" | t }}</label>
<input type="number" min="1" max="65535" x-model="peer.sip_port"
class="mt-1 w-full rounded-md 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="5060">
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide text-slate-400">{{ "settings_page.cluster_ami_port" | t }}</label>
<input type="number" min="1" max="65535" x-model="peer.ami_port"
class="mt-1 w-full rounded-md 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="8080">
</div>
</div>
<button type="button" @click="removeClusterPeer(index)"
class="mt-6 rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-rose-500">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6l8 8M14 6l-8 8" />
</svg>
</button>
</div>
</div>
</template>
<template x-if="!clusterDraft.peers.length">
<div class="rounded-lg border border-dashed border-slate-200 px-4 py-6 text-center text-xs text-slate-400">
{{ "settings_page.cluster_no_peers" | t }}
</div>
</template>
</div>
<div class="mt-4 flex items-center justify-end gap-3 border-t border-slate-200 pt-4">
<button type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100"
@click="resetClusterDraft()">
{{ "common.reset" | t }}
</button>
<button type="button" @click="saveClusterSettings()"
class="inline-flex items-center justify-center 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="clusterProcessing">
<span x-text="clusterProcessing ? tt('common.saving') : tt('common.save_changes')"></span>
</button>
</div>
</div>
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5" x-data="{}">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.cluster_ping" | t }}</h2>
<p class="text-xs text-slate-500">{{ "settings_page.cluster_ping_desc" | t }}</p>
</div>
<button type="button" @click="pingClusterPeers()"
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="clusterPinging">
<span x-text="clusterPinging ? tt('common.loading') : tt('settings_page.cluster_ping_btn')"></span>
</button>
</div>
<template x-if="clusterPingResults.length">
<div class="mt-4 space-y-3">
<template x-for="result in clusterPingResults" :key="result.peer">
<div class="rounded-lg border px-4 py-3 text-sm"
:class="result.reachable ? 'border-emerald-200 bg-emerald-50' : 'border-rose-200 bg-rose-50'">
<div class="flex items-center justify-between">
<div>
<span class="font-semibold text-slate-800" x-text="result.peer"></span>
<span class="ml-2 text-xs text-slate-500" x-text="`AMI · ${result.ami_addr}`"></span>
</div>
<div class="flex items-center gap-3">
<span class="rounded-full px-2 py-0.5 text-[11px] font-semibold"
:class="result.reachable ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'"
x-text="result.reachable ? '{{ "settings_page.cluster_reachable" | t }}' : '{{ "settings_page.cluster_unreachable" | t }}'"></span>
<template x-if="result.latency_ms !== null && result.latency_ms !== undefined">
<span class="text-xs text-slate-500" x-text="`${result.latency_ms}ms`"></span>
</template>
</div>
</div>
<div x-show="result.error" class="mt-1 text-xs text-rose-600" x-text="result.error"></div>
</div>
</template>
</div>
</template>
<template x-if="clusterPingResults.length === 0 && clusterPingDone">
<div class="mt-4 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-500">
{{ "settings_page.cluster_ping_empty" | t }}
</div>
</template>
</div>
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.cluster_reload_config" | t }}</h2>
<p class="text-xs text-slate-500">{{ "settings_page.cluster_reload_config_desc" | t }}</p>
</div>
</div>
<div class="mt-4 space-y-3">
<label class="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-700 hover:border-sky-200 hover:bg-sky-50 cursor-pointer transition">
<input type="checkbox" class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500" x-model="clusterReloadDraft.trunks">
<span>Trunks</span>
</label>
<label class="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-700 hover:border-sky-200 hover:bg-sky-50 cursor-pointer transition">
<input type="checkbox" class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500" x-model="clusterReloadDraft.routes">
<span>Routes</span>
</label>
<template x-for="addon in reloadAddonList" :key="addon.id">
<label class="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm font-semibold text-slate-700 hover:border-sky-200 hover:bg-sky-50 cursor-pointer transition">
<input type="checkbox" class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500" x-model="clusterReloadDraft[addon.id]">
<span x-text="addon.name"></span>
</label>
</template>
</div>
<div class="mt-4 flex items-center justify-end gap-3 border-t border-slate-200 pt-4">
<button type="button" @click="clusterReloadDraft = { trunks: true, routes: true }; clusterReloadResults = []; for (const a of reloadAddonList) clusterReloadDraft[a.id] = true"
class="inline-flex items-center justify-center rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100">
{{ "common.reset" | t }}
</button>
<button type="button" @click="reloadClusterConfig()"
class="inline-flex items-center justify-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"
:class="pendingReload && !clusterReloading ? 'reload-glow reload-glow-accent' : ''"
:disabled="clusterReloading">
<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="M7 5l6 5-6 5" />
</svg>
<span x-text="clusterReloading ? tt('common.loading') : tt('settings_page.cluster_execute_reload')"></span>
</button>
</div>
<template x-if="clusterReloadResults.length">
<div class="mt-4 space-y-2 border-t border-slate-200 pt-4">
<template x-if="clusterReloadDone">
<div class="mb-3 rounded-lg border-2 px-4 py-3 text-sm font-semibold"
:class="clusterReloadOverall === 'ok' ? 'border-emerald-300 bg-emerald-50 text-emerald-800' : 'border-rose-300 bg-rose-50 text-rose-800'">
<div class="flex items-center justify-between">
<span x-text="clusterReloadOverall === 'ok' ? '✅ All nodes succeeded' : '❌ Some nodes failed'"></span>
<span class="rounded-full px-3 py-1 text-xs font-bold"
:class="clusterReloadOverall === 'ok' ? 'bg-emerald-200 text-emerald-800' : 'bg-rose-200 text-rose-800'"
x-text="clusterReloadOverall"></span>
</div>
</div>
</template>
<template x-for="result in clusterReloadResults" :key="(result.addon || 'node') + '-' + result.node">
<div class="rounded-lg border px-4 py-2 text-sm"
:class="result.status === 'ok' ? 'border-emerald-200 bg-emerald-50' : (result.status === 'running' ? 'border-sky-200 bg-sky-50' : (result.status === 'skipped' ? 'border-slate-200 bg-slate-50' : 'border-rose-200 bg-rose-50'))">
<div class="flex items-center justify-between">
<span class="font-semibold text-slate-700">
<span x-text="(result.addon || result.node)"></span>
<span x-show="result.addon === null && result.elapsed_ms != null" class="ml-2 text-xs font-normal text-slate-400" x-text="'(' + result.elapsed_ms + 'ms)'"></span>
</span>
<span class="rounded-full px-2 py-0.5 text-[11px] font-semibold"
:class="result.status === 'ok' ? 'bg-emerald-100 text-emerald-700' : (result.status === 'running' ? 'bg-sky-100 text-sky-700' : (result.status === 'skipped' ? 'bg-slate-200 text-slate-600' : 'bg-rose-100 text-rose-700'))"
x-text="result.status"></span>
</div>
<div x-show="result.message" class="mt-1 text-xs text-slate-500" x-text="result.message"></div>
</div>
</template>
</div>
</template>
</div>
</div>
</section>
<section x-show="activeTab === 'departments'" id="settings-tab-departments" role="tabpanel" tabindex="0">
<div class="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<div class="space-y-6">
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.departments" | t }}
</h2>
<p class="text-xs text-slate-500">{{ "settings_page.departments_desc" | t }}</p>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs font-semibold text-slate-500">
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-slate-600"
x-text="departmentSummary"></span>
<button type="button"
class="inline-flex items-center gap-2 rounded-lg border border-dashed border-slate-300 px-3 py-1.5 font-semibold text-slate-600 transition hover:border-sky-300 hover:text-sky-700"
@click="openDepartmentCreate()">
<svg class="h-3.5 w-3.5" 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>
New department
</button>
</div>
</div>
<div class="mt-4 space-y-4">
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
Search
<input type="search" x-model="departmentParams.q"
@input.debounce.400ms="applyDepartmentFilters()"
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="Name, label, or slug">
</label>
<div class="flex items-end justify-end gap-2">
<button type="button"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-slate-300 hover:text-slate-800"
@click="resetDepartmentFilters()">
Clear
</button>
<button type="button"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-sky-300 hover:text-sky-700"
@click="fetchDepartments()">
Refresh
</button>
</div>
</div>
<template x-if="departmentError">
<div class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700"
x-text="departmentError"></div>
</template>
<div class="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div class="space-y-2">
<template x-if="departmentLoading">
<div
class="rounded-lg border border-dashed border-slate-200 px-3 py-6 text-center text-xs text-slate-400">
Loading departments…
</div>
</template>
<template x-if="!departmentLoading && !(directory.departments || []).length">
<p
class="rounded-lg border border-dashed border-slate-200 px-3 py-6 text-center text-xs text-slate-400">
No departments yet. Create one to get started.
</p>
</template>
<template x-if="!departmentLoading">
<template x-for="dept in directory.departments || []" :key="dept.id">
<button type="button" @click="selectDepartment(dept.id)"
class="flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition"
:class="String(selectedDepartmentId) === String(dept.id) ? 'border-sky-300 bg-sky-50 text-sky-700 shadow-sm' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300'">
<div>
<div class="font-semibold text-slate-800"
x-text="dept.display_label || dept.name"></div>
<div class="text-xs text-slate-400">
Slug · <span x-text="dept.slug || '—'"></span>
</div>
</div>
<div class="flex flex-col items-end gap-1 text-right">
<span
class="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-600"
x-text="dept.manager_contact || tt('settings_page.no_contact')"></span>
<span class="text-[11px] text-slate-400"
x-text="'Updated · ' + formatDateTime(dept.updated_at)"></span>
</div>
</button>
</template>
</template>
</div>
<div class="space-y-4">
<div class="rounded-lg border border-slate-200 p-4 text-sm text-slate-600"
x-show="selectedDepartment">
<template x-if="selectedDepartment">
<div class="space-y-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-xs uppercase tracking-wide text-slate-400">
Department</div>
<div class="mt-1 text-base font-semibold text-slate-900"
x-text="selectedDepartment.display_label || selectedDepartment.name">
</div>
<div class="text-xs text-slate-500">
Name · <span x-text="selectedDepartment.name"></span>
</div>
</div>
<div class="flex flex-col items-end gap-2 text-xs">
<span
class="rounded-full bg-slate-100 px-2 py-0.5 font-semibold text-slate-600"
x-text="selectedDepartment.color || tt('settings_page.no_color')"></span>
<div class="flex items-center gap-2">
<button type="button"
class="inline-flex items-center gap-1 rounded-md border border-slate-200 px-2 py-1 font-semibold text-slate-600 transition hover:border-slate-300 hover:text-slate-800"
@click="openDepartmentEdit(selectedDepartment.id)">
<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="M4 13v3h3l9-9-3-3-9 9z" />
</svg>
Edit
</button>
<button type="button"
class="inline-flex items-center gap-1 rounded-md border border-rose-200 bg-rose-50 px-2 py-1 font-semibold text-rose-600 transition hover:border-rose-300 hover:bg-rose-100"
@click="deleteDepartment(selectedDepartment.id)">
<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="M6 6l8 8M6 14l8-8" />
</svg>
Remove
</button>
</div>
</div>
</div>
<div class="grid gap-3 text-xs text-slate-500">
<div class="flex items-center justify-between">
<span>Slug</span>
<span class="font-semibold text-slate-700"
x-text="selectedDepartment.slug || '—'"></span>
</div>
<div class="flex items-center justify-between">
<span>{{ "settings_page.department_manager_contact" | t
}}</span>
<span class="font-semibold text-slate-700"
x-text="selectedDepartment.manager_contact || tt('settings_page.not_set')"></span>
</div>
<div class="flex items-center justify-between">
<span>Created</span>
<span class="font-semibold text-slate-700"
x-text="formatDateTime(selectedDepartment.created_at)"></span>
</div>
<div class="flex items-center justify-between">
<span>Updated</span>
<span class="font-semibold text-slate-700"
x-text="formatDateTime(selectedDepartment.updated_at)"></span>
</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-slate-400">
Description</div>
<p class="mt-1 text-xs text-slate-500"
x-text="selectedDepartment.description || 'No description provided.'">
</p>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-slate-400">{{
"settings_page.department_metadata" | t }}
</div>
<pre class="mt-1 max-h-40 overflow-auto rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600"
x-text="formatMetadata(selectedDepartment.metadata)"></pre>
</div>
</div>
</template>
</div>
<template x-if="departmentFormVisible">
<div
class="rounded-xl border border-dashed border-slate-300 bg-slate-50/40 p-5 text-sm text-slate-600">
<form class="space-y-4" @submit.prevent="saveDepartment">
<header class="flex items-center justify-between">
<div>
<div class="text-xs uppercase tracking-wide text-slate-400"
x-text="departmentFormMode === 'edit' ? tt('settings_page.update_department') : tt('settings_page.create_department')">
</div>
<h3 class="text-base font-semibold text-slate-900"
x-text="departmentFormMode === 'edit' ? tt('settings_page.edit_department') : tt('settings_page.new_department')">
</h3>
</div>
<button type="button"
class="text-xs font-semibold text-slate-400 transition hover:text-slate-600"
@click="cancelDepartmentForm()">{{ "common.cancel" | t
}}</button>
</header>
<div class="grid gap-4 md:grid-cols-2">
<label
class="flex flex-col gap-1 text-sm font-medium text-slate-700">
{{ "settings_page.department_name" | t }}
<input type="text" x-model="departmentDraft.name" required
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="e.g. Customer success">
</label>
<label
class="flex flex-col gap-1 text-sm font-medium text-slate-700">
{{ "settings_page.department_display_label" | t }}
<input type="text" x-model="departmentDraft.display_label"
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="Optional friendly name">
</label>
<label
class="flex flex-col gap-1 text-sm font-medium text-slate-700">
{{ "settings_page.department_slug" | t }}
<input type="text" x-model="departmentDraft.slug"
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="auto-generated if left blank">
</label>
<label
class="flex flex-col gap-1 text-sm font-medium text-slate-700">
{{ "settings_page.department_color" | t }}
<input type="text" x-model="departmentDraft.color"
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="#0EA5E9">
</label>
<label
class="flex flex-col gap-1 text-sm font-medium text-slate-700 md:col-span-2">
{{ "settings_page.department_manager_contact" | t }}
<input type="text" x-model="departmentDraft.manager_contact"
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="Name or email">
</label>
</div>
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
{{ "settings_page.department_description" | t }}
<textarea rows="3" x-model="departmentDraft.description"
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="Purpose, responsibilities, or escalation details."></textarea>
</label>
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
Metadata (JSON)
<textarea rows="4" x-model="departmentDraft.metadataInput"
class="rounded-lg border border-slate-200 px-3 py-2 font-mono text-xs text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
placeholder='{"timezone": "UTC"}'></textarea>
<span class="text-xs font-normal text-slate-400">Optional key/value
data stored with the department.</span>
</label>
<div class="flex items-center justify-end gap-3">
<button type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100"
@click="cancelDepartmentForm()">{{ "common.cancel" | t
}}</button>
<button type="submit"
class="inline-flex items-center justify-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="departmentProcessing.save">
<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="M5 10l3 3 7-7" />
</svg>
<span
x-text="departmentProcessing.save ? tt('common.saving') : tt('settings_page.save_department')"></span>
</button>
</div>
</form>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section x-show="activeTab === 'users' && canManageUsers()" x-cloak x-transition.opacity class="space-y-6"
id="settings-tab-users" role="tabpanel" tabindex="0">
<div class="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<div class="space-y-6">
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">{{ "settings_page.users_title" | t }}
</h2>
<p class="text-xs text-slate-500">{{ "settings_page.users_desc" | t }}</p>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs font-semibold text-slate-500">
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-slate-600"
x-text="userSummary"></span>
<button type="button"
x-show="can('users:write')"
class="inline-flex items-center gap-2 rounded-lg border border-dashed border-slate-300 px-3 py-1.5 font-semibold text-slate-600 transition hover:border-sky-300 hover:text-sky-700"
@click="openUserCreate()">
<svg class="h-3.5 w-3.5" 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>
Add team member
</button>
</div>
</div>
<div class="mt-4 space-y-4">
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
Search
<input type="search" x-model="userParams.q"
@input.debounce.400ms="applyUserFilters()"
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="Email or username">
</label>
<div
class="flex flex-col items-end gap-2 text-xs font-semibold text-slate-500 md:flex-row md:items-center">
<label
class="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-1.5 text-xs text-slate-600 transition hover:border-slate-300 hover:text-slate-800">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-400"
x-model="userParams.activeOnly" @change="applyUserFilters()">
Active only
</label>
<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 text-xs font-semibold text-slate-600 transition hover:border-slate-300 hover:text-slate-800"
@click="resetUserFilters()">
Clear
</button>
<button type="button"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-sky-300 hover:text-sky-700"
@click="fetchUsers()">
Refresh
</button>
</div>
</div>
</div>
<template x-if="userError">
<div class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700"
x-text="userError"></div>
</template>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div class="space-y-2">
<template x-if="userLoading">
<div
class="rounded-lg border border-dashed border-slate-200 px-3 py-6 text-center text-xs text-slate-400">
Loading users…
</div>
</template>
<template x-if="!userLoading && !(directory.users || []).length">
<p
class="rounded-lg border border-dashed border-slate-200 px-3 py-6 text-center text-xs text-slate-400">
No users match the current filters.
</p>
</template>
<template x-if="!userLoading">
<template x-for="user in directory.users || []" :key="user.id">
<button type="button" @click="selectUser(user.id)"
class="flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition"
:class="String(selectedUserId) === String(user.id) ? 'border-sky-300 bg-sky-50 text-sky-700 shadow-sm' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300'">
<div>
<div class="font-semibold text-slate-800" x-text="user.username">
</div>
<div class="text-xs text-slate-400" x-text="user.email"></div>
</div>
<div class="flex flex-col items-end gap-1 text-right">
<span class="rounded-full px-2 py-0.5 text-[11px] font-semibold"
:class="userStatusClasses(user)"
x-text="userStatusLabel(user)"></span>
<span class="text-[11px] text-slate-400"
x-text="lastLoginLabel(user)"></span>
</div>
</button>
</template>
</template>
</div>
<div class="space-y-4">
<div class="rounded-lg border border-slate-200 p-4 text-sm text-slate-600"
x-show="selectedUser">
<template x-if="selectedUser">
<div class="space-y-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-xs uppercase tracking-wide text-slate-400">User
</div>
<div class="mt-1 text-base font-semibold text-slate-900"
x-text="selectedUser.username"></div>
<div class="text-xs text-slate-500" x-text="selectedUser.email">
</div>
</div>
<div class="flex flex-col items-end gap-2 text-xs">
<span class="rounded-full px-2 py-0.5 text-[11px] font-semibold"
:class="userStatusClasses(selectedUser)"
x-text="userStatusLabel(selectedUser)"></span>
<div class="flex items-center gap-2">
<button type="button"
x-show="can('users:write')"
class="inline-flex items-center gap-1 rounded-md border border-slate-200 px-2 py-1 font-semibold text-slate-600 transition hover:border-slate-300 hover:text-slate-800"
@click="openUserEdit(selectedUser.id)">
<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="M4 13v3h3l9-9-3-3-9 9z" />
</svg>
Edit
</button>
<button type="button"
x-show="can('users:delete')"
class="inline-flex items-center gap-1 rounded-md border border-rose-200 bg-rose-50 px-2 py-1 font-semibold text-rose-600 transition hover:border-rose-300 hover:bg-rose-100"
@click="deleteUser(selectedUser.id)">
<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="M6 6l8 8M6 14l8-8" />
</svg>
Remove
</button>
</div>
</div>
</div>
<div class="grid gap-3 text-xs text-slate-500">
<div class="flex items-center justify-between">
<span>Last login</span>
<span class="font-semibold text-slate-700"
x-text="selectedUser.last_login_at ? formatDateTime(selectedUser.last_login_at) : '—'"></span>
</div>
<div class="flex items-center justify-between">
<span>Last IP</span>
<span class="font-semibold text-slate-700"
x-text="selectedUser.last_login_ip || '—'"></span>
</div>
<div class="flex items-center justify-between">
<span>Created</span>
<span class="font-semibold text-slate-700"
x-text="formatDateTime(selectedUser.created_at)"></span>
</div>
<div class="flex items-center justify-between">
<span>Updated</span>
<span class="font-semibold text-slate-700"
x-text="formatDateTime(selectedUser.updated_at)"></span>
</div>
</div>
<div class="flex flex-wrap gap-2 text-[11px]">
<span class="rounded-full px-2 py-0.5 font-semibold"
:class="selectedUser.is_active ? 'bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100' : 'bg-amber-50 text-amber-600 ring-1 ring-amber-100'"
x-text="selectedUser.is_active ? tt('settings_page.login_enabled') : tt('settings_page.login_disabled')"></span>
<span class="rounded-full px-2 py-0.5 font-semibold"
:class="selectedUser.is_staff ? 'bg-sky-50 text-sky-600 ring-1 ring-sky-100' : 'bg-slate-100 text-slate-600 ring-1 ring-slate-200'"
x-text="selectedUser.is_staff ? tt('settings_page.staff_privileges') : tt('settings_page.standard_user')"></span>
<span class="rounded-full px-2 py-0.5 font-semibold"
:class="selectedUser.is_superuser ? 'bg-purple-50 text-purple-600 ring-1 ring-purple-100' : 'bg-slate-100 text-slate-600 ring-1 ring-slate-200'"
x-text="selectedUser.is_superuser ? tt('common.superadmin') : tt('settings_page.user_limited')"></span>
</div>
</div>
</template>
</div>
<template x-if="userFormVisible">
<div
class="rounded-xl border border-dashed border-slate-300 bg-slate-50/40 p-5 text-sm text-slate-600">
<form class="space-y-4" @submit.prevent="saveUser">
<header class="flex items-center justify-between">
<div>
<div class="text-xs uppercase tracking-wide text-slate-400"
x-text="userFormMode === 'edit' ? tt('settings_page.update_teammate') : tt('settings_page.invite_teammate')">
</div>
<h3 class="text-base font-semibold text-slate-900"
x-text="userFormMode === 'edit' ? tt('settings_page.edit_user') : tt('settings_page.new_user')">
</h3>
</div>
<button type="button"
class="text-xs font-semibold text-slate-400 transition hover:text-slate-600"
@click="cancelUserForm()">{{ "common.cancel" | t }}</button>
</header>
<div class="grid gap-4 md:grid-cols-2">
<label
class="flex flex-col gap-1 text-sm font-medium text-slate-700">
{{ "settings_page.user_email_field" | t }}
<input type="email" x-model="userDraft.email" required
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="name@rustpbx.com">
</label>
<label
class="flex flex-col gap-1 text-sm font-medium text-slate-700">
{{ "settings_page.user_username_field" | t }}
<input type="text" x-model="userDraft.username" required
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="short-handle">
</label>
</div>
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
{{ "settings_page.user_password" | t }}
<input type="password" x-model="userDraft.password"
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="userFormMode === 'edit' ? tt('settings_page.leave_blank_password') : tt('settings_page.set_initial_password')">
<span class="text-xs font-normal text-slate-400"
x-text="userFormMode === 'edit' ? tt('settings_page.rotate_password') : tt('settings_page.password_required')"></span>
</label>
<div class="grid gap-3 md:grid-cols-3">
<label
class="flex items-center gap-2 text-xs font-semibold text-slate-600">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-400"
x-model="userDraft.is_active">
{{ "settings_page.user_active" | t }}
</label>
<label
class="flex items-center gap-2 text-xs font-semibold text-slate-600">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-400"
x-model="userDraft.is_staff">
{{ "settings_page.user_staff_access" | t }}
</label>
<label
class="flex items-center gap-2 text-xs font-semibold text-slate-600">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-400"
x-model="userDraft.is_superuser">
{{ "settings_page.user_superuser_access" | t }}
</label>
</div>
<div class="space-y-2" x-show="can('users:manage')">
<div class="text-xs font-semibold text-slate-500">Roles</div>
<div class="flex flex-wrap gap-2">
<template x-for="role in availableRoles" :key="role.id">
<label class="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium cursor-pointer transition"
:class="userDraft.role_ids.includes(role.id) ? 'border-sky-300 bg-sky-50 text-sky-700' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300'">
<input type="checkbox" class="sr-only"
:checked="userDraft.role_ids.includes(role.id)"
@change="toggleUserRole(role.id)">
<span x-text="role.name"></span>
<span x-show="role.is_system" class="text-[10px] text-purple-500">(system)</span>
</label>
</template>
</div>
<p class="text-[11px] text-slate-400">Assign roles to control user permissions. Superusers bypass all permission checks.</p>
</div>
<div class="flex items-center justify-end gap-3">
<button type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100"
@click="cancelUserForm()">{{ "common.cancel" | t }}</button>
<button type="submit"
class="inline-flex items-center justify-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="userProcessing.save">
<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="M5 10l3 3 7-7" />
</svg>
<span
x-text="userProcessing.save ? tt('common.saving') : tt('settings_page.save_user')"></span>
</button>
</div>
</form>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section x-show="activeTab === 'roles' && canManageUsers()" x-cloak x-transition.opacity class="space-y-6"
id="settings-tab-roles" role="tabpanel" tabindex="0">
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-base font-semibold text-slate-900">Roles</h2>
<p class="text-xs text-slate-500">Manage roles and their permissions. System roles cannot be deleted.</p>
</div>
<button type="button" x-show="can('users:manage')"
class="inline-flex items-center gap-2 rounded-lg border border-dashed border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-sky-300 hover:text-sky-700"
@click="$dispatch('roles:create')">
<svg class="h-3.5 w-3.5" 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>
New Role
</button>
</div>
<div class="mt-4 space-y-2">
<template x-if="rolesLoading">
<div class="py-6 text-center text-xs text-slate-400">Loading roles…</div>
</template>
<template x-if="rolesError">
<div class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700" x-text="rolesError"></div>
</template>
<template x-if="!rolesLoading">
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div class="space-y-2">
<template x-for="role in roles" :key="role.id">
<div class="flex items-center justify-between rounded-lg border px-3 py-2 text-sm cursor-pointer transition"
:class="selectedRole && selectedRole.id === role.id ? 'border-sky-300 bg-sky-50 text-sky-700 shadow-sm' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300'"
@click="selectRole(role.id)">
<div>
<div class="font-semibold text-slate-800" x-text="role.name"></div>
<div class="text-xs text-slate-400" x-text="role.description || ''"></div>
</div>
<div class="flex items-center gap-2 text-xs">
<span x-show="role.is_system"
class="rounded-full bg-purple-50 px-2 py-0.5 font-semibold text-purple-600 ring-1 ring-purple-100">
System
</span>
<button type="button" x-show="!role.is_system && can('users:manage')"
class="rounded-md border border-rose-200 bg-rose-50 px-2 py-0.5 font-semibold text-rose-600 transition hover:bg-rose-100"
@click.stop="deleteRole(role.id)">
Delete
</button>
</div>
</div>
</template>
<template x-if="roleCreateOpen">
<div class="rounded-xl border border-dashed border-slate-300 bg-slate-50/40 p-4 text-sm text-slate-600">
<div class="space-y-3">
<label class="flex flex-col gap-1 text-xs font-semibold text-slate-500">
Role name
<input type="text" x-model="newRoleName"
class="rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
placeholder="e.g. billing_admin">
</label>
<label class="flex flex-col gap-1 text-xs font-semibold text-slate-500">
Description
<input type="text" x-model="newRoleDesc"
class="rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
placeholder="Optional description">
</label>
<div class="flex items-center justify-end gap-2">
<button type="button" @click="roleCreateOpen = false"
class="text-xs font-semibold text-slate-400 hover:text-slate-600">Cancel</button>
<button type="button" @click="saveRole()" :disabled="roleSaving"
class="inline-flex items-center gap-1 rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-sky-500 disabled:opacity-60">
<span x-text="roleSaving ? 'Saving…' : 'Create role'"></span>
</button>
</div>
</div>
</div>
</template>
</div>
<div>
<template x-if="selectedRole">
<div class="rounded-lg border border-slate-200 p-4 space-y-4 text-sm text-slate-600">
<div>
<div class="text-xs uppercase tracking-wide text-slate-400">Role</div>
<div class="mt-1 text-base font-semibold text-slate-900" x-text="selectedRole.name"></div>
<div class="text-xs text-slate-500" x-text="selectedRole.description || ''"></div>
<span x-show="selectedRole.is_system"
class="mt-1 inline-block rounded-full bg-purple-50 px-2 py-0.5 text-[10px] font-semibold text-purple-600 ring-1 ring-purple-100">
System role
</span>
</div>
<div x-show="can('users:manage')">
<div class="text-xs uppercase tracking-wide text-slate-400 mb-3">Permissions</div>
<div class="space-y-3 max-h-80 overflow-y-auto pr-1">
<template x-for="group in permissionGroups" :key="group.resource">
<div class="rounded-lg border border-slate-100 bg-slate-50/50 p-2">
<div class="text-xs font-semibold text-slate-500 mb-2" x-text="group.label"></div>
<div class="flex flex-wrap gap-1.5">
<template x-for="action in group.permissions" :key="group.resource + ':' + action">
<label class="inline-flex items-center gap-1 rounded-lg border px-2 py-1 text-[11px] font-medium cursor-pointer transition"
:class="hasPermissionSelected(group.resource, action) ? 'border-sky-300 bg-sky-50 text-sky-700' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300'">
<input type="checkbox" class="sr-only"
:checked="hasPermissionSelected(group.resource, action)"
@change="togglePermission(group.resource, action)">
<span x-text="action"></span>
</label>
</template>
</div>
</div>
</template>
</div>
<div class="mt-3 flex items-center gap-2">
<span x-show="roleSaving" class="text-xs text-slate-400">Saving...</span>
</div>
</div>
<template x-if="!can('users:manage') && selectedRole.permissions && selectedRole.permissions.length">
<div>
<div class="text-xs uppercase tracking-wide text-slate-400 mb-2">Permissions</div>
<div class="flex flex-wrap gap-1.5">
<template x-for="perm in selectedRole.permissions" :key="perm.id || (perm.resource + ':' + perm.action)">
<span class="rounded-full bg-sky-50 px-2 py-0.5 text-[11px] font-semibold text-sky-700 ring-1 ring-sky-100"
x-text="perm.resource + ':' + perm.action"></span>
</template>
</div>
</div>
</template>
<template x-if="!can('users:manage') && (!selectedRole.permissions || !selectedRole.permissions.length)">
<div class="text-xs text-slate-400">No permissions assigned.</div>
</template>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</section>
</div>
</div>
<script>
window._settingsTranslations = window.__i18n_t || {};
document.addEventListener('alpine:init', () => {
Alpine.data('settingsConsole', (options = {}) => ({
basePath: options?.basePath || '/console',
apiBase: '{{ api_prefix | safe }}',
rawSettings: options?.settings || {},
amiEndpoint: options?.amiEndpoint || (options?.ami_path || '/ami/v1'),
currentUser: options?.currentUser || null,
t: options?.t || {},
tabs: (() => {
const tget = (obj, key) => {
const parts = key.split('.');
let v = obj;
for (const p of parts) { if (v == null || typeof v !== 'object') return null; v = v[p]; }
return typeof v === 'string' ? v : null;
};
const tt = options?.t || {};
return [
{ id: 'platform', label: tget(tt, 'settings_page.platform_overview') || 'Platform overview' },
{ id: 'proxy', label: tget(tt, 'settings_page.proxy_settings') || 'Proxy settings' },
{ id: 'sipflow', label: tget(tt, 'settings_page.sipflow_recording') || 'SipFlow recording' },
{ id: 'storage', label: tget(tt, 'settings_page.storage_destinations') || 'Storage' },
{ id: 'recording', label: tget(tt, 'settings_page.recording_policy') || 'Recording policy' },
{ id: 'security', label: tget(tt, 'settings_page.security_posture') || 'Security posture' },
{ id: 'logs', label: tget(tt, 'settings_page.logs_viewer') || 'Logs', requiresSuperuser: true },
{ id: 'rwi', label: tget(tt, 'settings_page.rwi_settings') || 'RWI' },
{ id: 'cluster', label: tget(tt, 'settings_page.cluster') || 'Cluster', requireClusterData: true },
{ id: 'departments', label: tget(tt, 'settings_page.departments') || 'Departments' },
{ id: 'users', label: tget(tt, 'settings_page.users_title') || 'Users', requiresPermission: 'users:manage' },
{ id: 'roles', label: tget(tt, 'settings_page.roles_title') || 'Roles', requiresPermission: 'users:manage' },
];
})(),
activeTab: 'platform',
platform: {},
proxyConfig: {},
stats: {},
configMeta: { key_items: [] },
acl: { active_rules: [], embedded_count: 0, file_patterns: [], reload_supported: false, metrics: null },
operations: [],
platformDraft: {
log_level: '',
log_file: '',
external_ip: '',
auto_external_ip: '',
external_ip_mode: 'none',
auto_external_ip_testing: false,
auto_external_ip_result: '',
rtp_start_port: '',
rtp_end_port: '',
},
platformProcessing: false,
proxyDraft: {
realms: '',
locator_webhook: {
url: '',
timeout_ms: '',
headers: '',
},
rwi_webhook: {
url: '',
timeout_ms: '',
headers: '',
events: '',
},
http_router: {
url: '',
timeout_ms: '',
headers: '',
},
user_backends: [],
},
proxyProcessing: false,
storageDraft: {
recorder_path: '',
recorder_format: 'wav',
media_cache_path: '',
callrecord_mode: 'local',
callrecord_root: '',
recording_enabled: false,
recording_auto_start: true,
recording_force_file: false,
recording_direction_inbound: true,
recording_direction_outbound: true,
recording_direction_internal: true,
recording_caller_allow: '',
recording_caller_deny: '',
recording_callee_allow: '',
recording_callee_deny: '',
recording_samplerate: '',
recording_ptime: '',
recording_filename_pattern: '',
},
storageProcessing: false,
sipflowDraft: {
backend_type: 'none',
dir_root: '',
dir_subdirs: 'hourly',
local_root: '',
local_flush_count: 1000,
local_flush_interval_secs: 5,
remote_nodes: [{ udp: '', http: '' }],
remote_timeout_secs: 10,
},
sipflowProcessing: false,
sipflowSettings: { backend_type: 'none', config: {} },
securityDraft: {
acl_rules: '',
dos_enabled: false,
dos_max_cps_per_ip: 100,
dos_max_concurrent_per_ip: 500,
dos_scan_probe_threshold: 50,
dos_scan_block_duration_secs: 600,
uri_max_length: 256,
uri_reject_malformed: false,
emergency_enabled: false,
emergency_numbers: '',
emergency_trunk: '',
session_cmd_channel_capacity: 256,
session_state_channel_capacity: 256,
media_cmd_channel_capacity: 512,
media_event_channel_capacity: 1024,
},
securityProcessing: false,
pendingReload: false,
aclReloading: false,
aclReloadModal: {
open: false,
status: 'idle',
error: null,
message: '',
action: null,
},
appReloading: false,
appReloadModal: {
open: false,
status: 'idle',
error: null,
message: '',
action: null,
mode: 'reload',
checks: [],
},
server: {},
recordingPolicy: null,
retention: {},
security: {},
rwi: {},
rwiDraft: {
enabled: false,
listen: '0.0.0.0:8088',
max_connections: 2000,
max_calls_per_connection: 200,
orphan_hold_secs: 30,
originate_rate_limit: 10,
tokens: [],
contexts: [],
},
rwiProcessing: false,
clusterDraft: { peers: [] },
clusterProcessing: false,
clusterPinging: false,
clusterPingResults: [],
clusterPingDone: false,
reloadAddonList: [],
clusterReloadDraft: { trunks: true, routes: true },
clusterReloading: false,
clusterReloadResults: [],
clusterReloadOverall: null,
clusterReloadDone: false,
logViewer: {
lines: [],
limit: 200,
loading: false,
following: false,
eventSource: null,
error: null,
message: '',
path: '',
nextPosition: 0,
truncated: false,
copied: false,
},
directory: { departments: [], users: [], pending_invites: [] },
selectedStorageProfile: '',
selectedDepartmentId: '',
selectedUserId: '',
lastOperation: null,
departmentLoading: false,
departmentError: null,
departmentPagination: null,
departmentParams: { page: 1, perPage: 20, q: '' },
departmentProcessing: { save: false, delete: null },
departmentFormVisible: false,
departmentFormMode: 'create',
departmentEditingId: '',
departmentDraft: {
name: '',
display_label: '',
slug: '',
color: '',
manager_contact: '',
description: '',
metadataInput: '',
},
userLoading: false,
userError: null,
userPagination: null,
userParams: { page: 1, perPage: 20, q: '', activeOnly: false },
userProcessing: { save: false, delete: null },
userFormVisible: false,
userFormMode: 'create',
userEditingId: '',
userDraft: {
email: '',
username: '',
password: '',
is_active: true,
is_staff: false,
is_superuser: false,
role_ids: [],
},
availableRoles: [],
roles: [],
rolesLoading: true,
rolesError: null,
selectedRole: null,
roleCreateOpen: false,
newRoleName: '',
newRoleDesc: '',
roleSaving: false,
permissionGroups: [
{ resource: 'system', label: 'System', permissions: ['read', 'write', 'write:platform', 'write:proxy', 'write:storage', 'write:security', 'write:rwi'] },
{ resource: 'users', label: 'Users', permissions: ['read', 'write', 'delete', 'manage'] },
{ resource: 'departments', label: 'Departments', permissions: ['read', 'write', 'delete'] },
{ resource: 'extensions', label: 'Extensions', permissions: ['read', 'write', 'delete'] },
{ resource: 'trunks', label: 'SIP Trunks', permissions: ['read', 'write', 'delete'] },
{ resource: 'routes', label: 'Routes', permissions: ['read', 'write', 'delete'] },
{ resource: 'queues', label: 'Queues', permissions: ['read', 'write', 'delete', 'export', 'realtime'] },
{ resource: 'ivr', label: 'IVR', permissions: ['read', 'write', 'publish', 'delete'] },
{ resource: 'cdr', label: 'CDR/Recordings', permissions: ['read', 'read:recording', 'write', 'delete', 'export'] },
{ resource: 'calls', label: 'Calls', permissions: ['read', 'control'] },
{ resource: 'ami', label: 'AMI', permissions: ['access'] },
{ resource: 'voicemail', label: 'Voicemail', permissions: ['read', 'read:audio', 'write', 'delete', 'settings'] },
{ resource: 'endpoints', label: 'Endpoints', permissions: ['read', 'write', 'delete', 'reboot', 'settings'] },
{ resource: 'wholesale', label: 'Wholesale', permissions: ['read', 'write', 'delete', 'billing', 'settings', 'agent'] },
{ resource: 'metrics', label: 'Metrics', permissions: ['read'] },
{ resource: 'diagnostics', label: 'Diagnostics', permissions: ['read'] },
],
canManageUsers() {
return window.can('users:manage');
},
ensureCanManageUsers(notify = false) {
if (this.canManageUsers()) {
return true;
}
if (notify) {
this.$dispatch('toast', {
title: 'Limited access',
message: 'Superuser privileges required.',
});
}
return false;
},
init() {
const data = typeof this.rawSettings === 'string'
? JSON.parse(this.rawSettings || '{}')
: (this.rawSettings || {});
this.currentUser = options?.currentUser || data.current_user || this.currentUser || null;
if (!this.canManageUsers() && this.activeTab === 'users') {
this.activeTab = 'platform';
}
this.amiEndpoint = options?.amiEndpoint || data.ami_endpoint || (data.proxy?.ami_path) || '/ami/v1';
this.platform = data.platform || {};
this.proxyConfig = data.proxy || {};
if (!Array.isArray(this.proxyConfig.realms)) {
if (typeof this.proxyConfig.realms === 'string' && this.proxyConfig.realms.length) {
this.proxyConfig.realms = [this.proxyConfig.realms];
} else {
this.proxyConfig.realms = [];
}
}
this.stats = data.stats || {};
this.configMeta = data.config || { key_items: [] };
if (!Array.isArray(this.configMeta.key_items)) {
this.configMeta.key_items = [];
}
this.acl = data.acl || { active_rules: [], embedded_count: 0, file_patterns: [], reload_supported: false, metrics: null };
if (!Array.isArray(this.acl.active_rules)) {
this.acl.active_rules = [];
}
if (!Array.isArray(this.acl.file_patterns)) {
this.acl.file_patterns = [];
}
this.operations = Array.isArray(data.operations)
? data.operations
: (Array.isArray((data.server || {}).operations) ? data.server.operations : []);
this.server = data.server || {};
this.recordingPolicy = data.recording || null;
if (!this.operations.length && Array.isArray(this.server.operations)) {
this.operations = this.server.operations;
}
this.retention = data.retention || {};
this.security = data.security || { trusted_ips: [], blocked_user_agents: [], rate_limits: {}, threat_feed: [], audit: {} };
if (!this.security || typeof this.security !== 'object') {
this.security = {};
}
if (!this.security.rate_limits || typeof this.security.rate_limits !== 'object') {
this.security.rate_limits = {};
}
if (this.security.rate_limits.max_call_concurrency === undefined || this.security.rate_limits.max_call_concurrency === null) {
this.security.rate_limits.max_call_concurrency = 0;
}
if (this.security.rate_limits.max_registration_concurrency === undefined || this.security.rate_limits.max_registration_concurrency === null) {
this.security.rate_limits.max_registration_concurrency = 0;
}
this.security.acl_rules = this.formatAclRules(this.acl.active_rules);
this.rwi = data.rwi || { enabled: false, max_connections: 2000, max_calls_per_connection: 200, orphan_hold_secs: 30, originate_rate_limit: 10, tokens: [], contexts: [] };
this.rwiDraft = {
enabled: this.rwi.enabled || false,
max_connections: this.rwi.max_connections || 2000,
max_calls_per_connection: this.rwi.max_calls_per_connection || 200,
orphan_hold_secs: this.rwi.orphan_hold_secs || 30,
originate_rate_limit: this.rwi.originate_rate_limit || 10,
tokens: (this.rwi.tokens || []).map(t => ({
token: t.token || '',
scopes: t.scopes || []
})),
contexts: this.rwi.contexts || [],
};
const storageMeta = this.server.storage || {};
this.selectedStorageProfile = storageMeta.active_profile
|| storageMeta.mode
|| (this.server.storage_profiles?.[0]?.id || '');
if (this.storageProfiles.length) {
const exists = this.storageProfiles.find((profile) => profile.id === this.selectedStorageProfile);
if (!exists) {
this.selectedStorageProfile = this.storageProfiles[0].id;
}
}
this.initPlatformDraft();
this.initProxyDraft();
this.initStorageDraft();
this.initSecurityDraft();
this.fetchSipFlowSettings();
this.initClusterDraft();
this.directory = { departments: [], users: [], pending_invites: [] };
this.departmentPagination = null;
this.userPagination = null;
this.departmentError = null;
this.userError = null;
this.departmentParams.page = 1;
this.departmentParams.perPage = 20;
this.departmentParams.q = '';
this.userParams.page = 1;
this.userParams.perPage = 20;
this.userParams.q = '';
this.userParams.activeOnly = false;
this.departmentDraft = this.defaultDepartmentDraft();
this.userDraft = this.defaultUserDraft();
this.departmentFormMode = 'create';
this.departmentFormVisible = false;
this.departmentEditingId = '';
this.userFormMode = 'create';
this.userFormVisible = false;
this.userEditingId = '';
this.selectedDepartmentId = '';
this.selectedUserId = '';
this.fetchDepartments();
this.fetchUsers();
this.fetchRoles();
this.resetAclReloadModal();
this.resetReloadModal();
window.addEventListener('roles:create', () => this.openRoleCreate());
window.addEventListener('beforeunload', () => this.stopLogFollow());
this.fetchPendingReload();
},
get visibleTabs() {
return this.tabs.filter((tab) => {
if (tab.requiresPermission && !window.can(tab.requiresPermission)) {
return false;
}
if (tab.requiresSuperuser && !this.currentUser?.is_superuser) {
return false;
}
if (tab.requireClusterData && !this.rawSettings?.cluster) {
return false;
}
return true;
});
},
setActiveTab(tabId) {
if (this.activeTab === tabId) {
return;
}
if (tabId === 'logs') {
if (!this.currentUser?.is_superuser) {
this.$dispatch('toast', {
title: this.tt('settings_page.logs_superuser_only') || 'Restricted',
message: this.tt('settings_page.logs_superuser_only_desc') || 'Only superusers can view runtime logs.',
});
return;
}
this.activeTab = tabId;
this.initLogsTab();
return;
}
if (this.activeTab === 'logs') {
this.stopLogFollow();
}
this.activeTab = tabId;
},
get storageProfiles() {
return Array.isArray(this.server.storage_profiles) ? this.server.storage_profiles : [];
},
selectStorageProfile(id) {
this.selectedStorageProfile = id;
},
get selectedStorageProfileData() {
return this.storageProfiles.find((item) => item.id === this.selectedStorageProfile) || null;
},
profileConfig(profile) {
if (!profile || typeof profile.config !== 'object') {
return [];
}
return Object.entries(profile.config).map(([key, value]) => {
let display;
if (value === null || value === undefined) {
display = '—';
} else if (typeof value === 'number') {
display = value.toString();
} else if (typeof value === 'boolean') {
display = value ? 'true' : 'false';
} else if (typeof value === 'object') {
try {
display = JSON.stringify(value, null, 2);
} catch (err) {
display = String(value);
}
} else {
display = String(value);
}
return {
key,
label: this.labelize(key),
value: display,
};
});
},
labelize(key) {
return String(key)
.replace(/[_-]/g, ' ')
.replace(/\b\w/g, (match) => match.toUpperCase());
},
storageModeLabel(mode) {
switch ((mode || '').toLowerCase()) {
case 'local':
return 'Local only';
case 'hybrid':
return 'Hybrid';
case 'http':
return 'HTTP webhook';
case 's3':
case 'remote':
return 'Remote (S3)';
default:
return mode || 'Unknown';
}
},
get selectedDepartment() {
return (this.directory.departments || []).find((dept) => String(dept.id) === String(this.selectedDepartmentId)) || null;
},
selectDepartment(id) {
this.selectedDepartmentId = id;
},
get departmentSummary() {
const total = this.departmentPagination?.total_items;
if (typeof total === 'number') {
return `${total} ${total === 1 ? 'department' : 'departments'}`;
}
const count = (this.directory.departments || []).length;
return `${count} ${count === 1 ? 'department' : 'departments'}`;
},
get selectedUser() {
return (this.directory.users || []).find((user) => String(user.id) === String(this.selectedUserId)) || null;
},
selectUser(id) {
this.selectedUserId = id;
},
get userSummary() {
const total = this.userPagination?.total_items;
if (typeof total === 'number') {
return `${total} ${total === 1 ? 'user' : 'users'}`;
}
const count = (this.directory.users || []).length;
return `${count} ${count === 1 ? 'user' : 'users'}`;
},
userStatusClasses(user) {
if (!user) {
return 'bg-slate-100 text-slate-600 ring-1 ring-slate-200';
}
if (!user.is_active) {
return 'bg-amber-50 text-amber-600 ring-1 ring-amber-200';
}
if (user.is_superuser) {
return 'bg-purple-50 text-purple-600 ring-1 ring-purple-200';
}
if (user.is_staff) {
return 'bg-sky-50 text-sky-600 ring-1 ring-sky-200';
}
return 'bg-emerald-50 text-emerald-600 ring-1 ring-emerald-200';
},
userStatusLabel(user) {
if (!user) {
return 'Unknown';
}
if (!user.is_active) {
return 'Inactive';
}
if (user.is_superuser) {
return 'Superuser';
}
if (user.is_staff) {
return 'Staff';
}
return 'Active';
},
lastLoginLabel(user) {
if (!user) {
return 'Last login · —';
}
const label = user.last_login_at ? this.formatDateTime(user.last_login_at) : '—';
return `Last login · ${label}`;
},
tt(key) {
const parts = key.split('.');
let val = this.t;
for (const p of parts) {
if (val == null || typeof val !== 'object') return key;
val = val[p];
}
return (typeof val === 'string' && val) ? val : key;
},
formatDateTime(value) {
if (!value) {
return '—';
}
try {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
} catch (err) {
return value;
}
},
capitalize(input) {
if (!input) {
return '';
}
return String(input).charAt(0).toUpperCase() + String(input).slice(1);
},
formatMetadata(value) {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch (err) {
return String(value);
}
},
departmentEndpoint() {
return `${this.apiBase}/settings/departments`;
},
departmentDetailEndpoint(id) {
return `${this.departmentEndpoint()}/${id}`;
},
buildDepartmentParams() {
return {
page: this.departmentParams.page || 1,
per_page: this.departmentParams.perPage || 20,
filters: {
q: (this.departmentParams.q || '').trim(),
},
};
},
applyDepartmentFilters() {
this.departmentParams.page = 1;
this.fetchDepartments();
},
resetDepartmentFilters() {
this.departmentParams.q = '';
this.departmentParams.page = 1;
this.fetchDepartments();
},
async fetchDepartments() {
this.departmentLoading = true;
this.departmentError = null;
try {
const response = await fetch(`${this.departmentEndpoint()}`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(this.buildDepartmentParams()),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to load departments');
}
const items = Array.isArray(data?.items) ? data.items : [];
const perPageRaw = Number(data?.per_page);
const totalItemsRaw = Number(data?.total_items);
const totalPagesRaw = Number(data?.total_pages);
const currentPageRaw = Number(data?.page);
const fallbackPerPage = this.departmentParams.perPage || 20;
const perPage = Number.isFinite(perPageRaw) && perPageRaw > 0 ? perPageRaw : fallbackPerPage;
const totalItems = Number.isFinite(totalItemsRaw) && totalItemsRaw >= 0 ? totalItemsRaw : items.length;
const inferredTotalPages = Math.max(Math.ceil(totalItems / Math.max(perPage, 1)), 1);
const totalPages = Number.isFinite(totalPagesRaw) && totalPagesRaw >= 1
? Math.max(Math.min(totalPagesRaw, inferredTotalPages || 1), 1)
: inferredTotalPages;
const currentPage = Number.isFinite(currentPageRaw) && currentPageRaw >= 1
? Math.min(currentPageRaw, totalPages)
: Math.min(this.departmentParams.page || 1, totalPages);
const hasPrevApi = typeof data?.has_prev === 'boolean' ? data.has_prev : null;
const hasNextApi = typeof data?.has_next === 'boolean' ? data.has_next : null;
const resultsCount = items.length;
const showingFrom = resultsCount ? ((currentPage - 1) * perPage) + 1 : 0;
const showingTo = resultsCount ? Math.min(showingFrom + resultsCount - 1, totalItems) : 0;
const hasPrev = hasPrevApi !== null ? hasPrevApi : currentPage > 1;
const hasNext = hasNextApi !== null ? hasNextApi : currentPage < totalPages;
const prevPage = hasPrev ? Math.max(currentPage - 1, 1) : null;
const nextPage = hasNext ? Math.min(currentPage + 1, totalPages) : null;
this.directory.departments = items;
this.departmentPagination = {
current_page: currentPage,
per_page: perPage,
total_items: totalItems,
total_pages: totalPages,
has_prev: hasPrev,
has_next: hasNext,
prev_page: prevPage,
next_page: nextPage,
showing_from: showingFrom,
showing_to: showingTo,
};
this.departmentParams.page = currentPage;
this.departmentParams.perPage = perPage;
if (!items.some((item) => String(item.id) === String(this.selectedDepartmentId))) {
this.selectedDepartmentId = items[0]?.id ?? '';
}
} catch (err) {
console.error(err);
this.departmentError = err.message || 'Failed to load departments';
this.directory.departments = [];
this.departmentPagination = null;
this.selectedDepartmentId = '';
} finally {
this.departmentLoading = false;
}
},
async loadDepartmentDetail(id) {
if (id === undefined || id === null) {
return null;
}
try {
const response = await fetch(this.departmentDetailEndpoint(id), {
method: 'GET',
headers: {
Accept: 'application/json',
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to load department');
}
let updated = false;
this.directory.departments = (this.directory.departments || []).map((dept) => {
if (String(dept.id) === String(id)) {
updated = true;
return data;
}
return dept;
});
if (!updated) {
this.directory.departments.push(data);
}
return data;
} catch (err) {
console.error(err);
this.$dispatch('toast', {
title: 'Unable to load',
message: err.message || 'Failed to load department details.',
});
return null;
}
},
metadataToInput(value) {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch (_) {
return '';
}
},
parseMetadataInput(input) {
const raw = (input || '').trim();
if (!raw.length) {
return null;
}
try {
return JSON.parse(raw);
} catch (_) {
throw new Error('Metadata must be valid JSON.');
}
},
normalizeOptionalString(value) {
if (value === null || value === undefined) {
return null;
}
const trimmed = String(value).trim();
return trimmed.length ? trimmed : null;
},
normalizeOptionalNumber(value) {
const trimmed = this.normalizeOptionalString(value);
if (trimmed === null) {
return null;
}
const numeric = Number(trimmed);
if (!Number.isFinite(numeric) || numeric <= 0) {
return null;
}
return Math.floor(numeric);
},
listFromTextarea(value) {
if (value === null || value === undefined) {
return [];
}
const items = String(value)
.split(/\n|,/)
.map((line) => line.trim())
.filter((line) => line.length);
return items;
},
slugify(value) {
return String(value || '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 120);
},
defaultDepartmentDraft() {
return {
name: '',
display_label: '',
slug: '',
color: '',
manager_contact: '',
description: '',
metadataInput: '',
};
},
departmentToDraft(dept) {
return {
name: dept?.name || '',
display_label: dept?.display_label || '',
slug: dept?.slug || '',
color: dept?.color || '',
manager_contact: dept?.manager_contact || '',
description: dept?.description || '',
metadataInput: this.metadataToInput(dept?.metadata),
};
},
openDepartmentCreate() {
this.departmentFormMode = 'create';
this.departmentEditingId = '';
this.departmentDraft = this.defaultDepartmentDraft();
this.departmentFormVisible = true;
},
async openDepartmentEdit(id) {
const detail = await this.loadDepartmentDetail(id);
if (!detail) {
return;
}
this.departmentFormMode = 'edit';
this.departmentEditingId = id;
this.departmentDraft = this.departmentToDraft(detail);
this.departmentFormVisible = true;
},
cancelDepartmentForm() {
this.departmentFormVisible = false;
this.departmentEditingId = '';
this.departmentFormMode = 'create';
this.departmentDraft = this.defaultDepartmentDraft();
},
async saveDepartment() {
if (this.departmentProcessing.save) {
return;
}
const draft = this.departmentDraft || this.defaultDepartmentDraft();
const name = (draft.name || '').trim();
if (!name.length) {
this.$dispatch('toast', {
title: 'Missing information',
message: 'Department name is required.',
});
return;
}
let metadata;
try {
metadata = this.parseMetadataInput(draft.metadataInput);
} catch (err) {
this.$dispatch('toast', {
title: 'Invalid metadata',
message: err.message || 'Metadata must be valid JSON.',
});
return;
}
const body = {
name,
display_label: this.normalizeOptionalString(draft.display_label),
description: this.normalizeOptionalString(draft.description),
color: this.normalizeOptionalString(draft.color),
manager_contact: this.normalizeOptionalString(draft.manager_contact),
metadata,
};
const slugInput = (draft.slug || '').trim();
if (this.departmentFormMode === 'edit') {
body.slug = slugInput.length ? slugInput : null;
} else {
body.slug = slugInput.length ? slugInput : this.slugify(name);
}
this.departmentProcessing.save = true;
try {
const method = this.departmentFormMode === 'edit' ? 'PATCH' : 'PUT';
const endpoint = method === 'PATCH'
? this.departmentDetailEndpoint(this.departmentEditingId)
: this.departmentEndpoint();
const response = await fetch(endpoint, {
method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to save department');
}
if (method === 'PUT' && data?.id !== undefined) {
this.selectedDepartmentId = data.id;
}
this.$dispatch('toast', {
title: 'Saved',
message: method === 'PUT' ? 'Department created successfully.' : 'Department updated successfully.',
});
this.cancelDepartmentForm();
await this.fetchDepartments();
} catch (err) {
console.error(err);
this.$dispatch('toast', {
title: 'Save failed',
message: err.message || 'Unable to save department.',
});
} finally {
this.departmentProcessing.save = false;
}
},
deleteDepartment(id) {
const dept = (this.directory.departments || []).find((item) => String(item.id) === String(id));
if (!dept) {
return;
}
window.dispatchEvent(new CustomEvent('console:confirm', {
detail: {
title: 'Delete department',
message: `Delete ${dept.name}? This action cannot be undone.`,
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
destructive: true,
onConfirm: () => this.performDeleteDepartment(id),
},
}));
},
async performDeleteDepartment(id) {
this.departmentProcessing.delete = id;
this.departmentError = null;
try {
const response = await fetch(this.departmentDetailEndpoint(id), {
method: 'DELETE',
headers: {
Accept: 'application/json',
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to delete department');
}
this.$dispatch('toast', {
title: 'Deleted',
message: 'Department removed successfully.',
});
if (String(this.selectedDepartmentId) === String(id)) {
this.selectedDepartmentId = '';
}
await this.fetchDepartments();
} catch (err) {
console.error(err);
this.departmentError = err.message || 'Failed to delete department';
this.$dispatch('toast', {
title: 'Delete failed',
message: this.departmentError,
});
} finally {
this.departmentProcessing.delete = null;
}
},
userEndpoint() {
return `${this.apiBase}/settings/users`;
},
userDetailEndpoint(id) {
return `${this.userEndpoint()}/${id}`;
},
buildUserParams() {
return {
filters: {
q: (this.userParams.q || '').trim(),
active: this.userParams.activeOnly ? true : undefined,
},
page: this.userParams.page || 1,
per_page: this.userParams.perPage || 20,
};
},
applyUserFilters() {
this.userParams.page = 1;
this.fetchUsers();
},
resetUserFilters() {
this.userParams.q = '';
this.userParams.activeOnly = false;
this.userParams.page = 1;
this.fetchUsers();
},
async fetchUsers() {
this.userError = null;
if (!this.canManageUsers()) {
this.userLoading = false;
this.directory.users = [];
this.userPagination = null;
this.selectedUserId = '';
return;
}
this.userLoading = true;
try {
const response = await fetch(`${this.userEndpoint()}`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(this.buildUserParams()),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to load users');
}
const items = Array.isArray(data?.items) ? data.items : [];
const perPageRaw = Number(data?.per_page);
const totalItemsRaw = Number(data?.total_items);
const totalPagesRaw = Number(data?.total_pages);
const currentPageRaw = Number(data?.page);
const fallbackPerPage = this.userParams.perPage || 20;
const perPage = Number.isFinite(perPageRaw) && perPageRaw > 0 ? perPageRaw : fallbackPerPage;
const totalItems = Number.isFinite(totalItemsRaw) && totalItemsRaw >= 0 ? totalItemsRaw : items.length;
const inferredTotalPages = Math.max(Math.ceil(totalItems / Math.max(perPage, 1)), 1);
const totalPages = Number.isFinite(totalPagesRaw) && totalPagesRaw >= 1
? Math.max(Math.min(totalPagesRaw, inferredTotalPages || 1), 1)
: inferredTotalPages;
const currentPage = Number.isFinite(currentPageRaw) && currentPageRaw >= 1
? Math.min(currentPageRaw, totalPages)
: Math.min(this.userParams.page || 1, totalPages);
const hasPrevApi = typeof data?.has_prev === 'boolean' ? data.has_prev : null;
const hasNextApi = typeof data?.has_next === 'boolean' ? data.has_next : null;
const resultsCount = items.length;
const showingFrom = resultsCount ? ((currentPage - 1) * perPage) + 1 : 0;
const showingTo = resultsCount ? Math.min(showingFrom + resultsCount - 1, totalItems) : 0;
const hasPrev = hasPrevApi !== null ? hasPrevApi : currentPage > 1;
const hasNext = hasNextApi !== null ? hasNextApi : currentPage < totalPages;
const prevPage = hasPrev ? Math.max(currentPage - 1, 1) : null;
const nextPage = hasNext ? Math.min(currentPage + 1, totalPages) : null;
this.directory.users = items;
this.userPagination = {
current_page: currentPage,
per_page: perPage,
total_items: totalItems,
total_pages: totalPages,
has_prev: hasPrev,
has_next: hasNext,
prev_page: prevPage,
next_page: nextPage,
showing_from: showingFrom,
showing_to: showingTo,
};
this.userParams.page = currentPage;
this.userParams.perPage = perPage;
if (!items.some((item) => String(item.id) === String(this.selectedUserId))) {
this.selectedUserId = items[0]?.id ?? '';
}
} catch (err) {
console.error(err);
this.userError = err.message || 'Failed to load users';
this.directory.users = [];
this.userPagination = null;
this.selectedUserId = '';
} finally {
this.userLoading = false;
}
},
async loadUserDetail(id) {
if (!this.canManageUsers()) {
return null;
}
if (id === undefined || id === null) {
return null;
}
try {
const response = await fetch(this.userDetailEndpoint(id), {
method: 'GET',
headers: {
Accept: 'application/json',
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to load user');
}
let updated = false;
this.directory.users = (this.directory.users || []).map((user) => {
if (String(user.id) === String(id)) {
updated = true;
return data;
}
return user;
});
if (!updated) {
this.directory.users.push(data);
}
return data;
} catch (err) {
console.error(err);
this.$dispatch('toast', {
title: 'Unable to load',
message: err.message || 'Failed to load user details.',
});
return null;
}
},
async fetchAvailableRoles() {
try {
const resp = await fetch(this.apiBase + '/settings/roles');
if (resp.ok) {
const data = await resp.json();
this.availableRoles = data.roles || data.items || data || [];
}
} catch (e) {
console.error('Failed to load available roles:', e);
}
},
async fetchRoles() {
this.rolesLoading = true;
this.rolesError = null;
try {
const r = await fetch(this.apiBase + '/settings/roles');
const d = await r.json();
this.roles = d.roles || d.items || d || [];
} catch (e) {
this.rolesError = e.message;
} finally {
this.rolesLoading = false;
}
},
openRoleCreate() {
this.roleCreateOpen = true;
this.newRoleName = '';
this.newRoleDesc = '';
},
async saveRole() {
if (!this.newRoleName.trim()) return;
this.roleSaving = true;
try {
const r = await fetch(this.apiBase + '/settings/roles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: this.newRoleName.trim(), description: this.newRoleDesc.trim() }),
});
if (!r.ok) { const d = await r.json(); throw new Error(d.error || 'Failed to create role'); }
this.roleCreateOpen = false;
await this.fetchRoles();
} catch (e) {
this.$dispatch('toast', { title: 'Error', message: e.message });
} finally {
this.roleSaving = false;
}
},
async deleteRole(id) {
if (!confirm('Delete this role?')) return;
try {
const r = await fetch(this.apiBase + '/settings/roles/' + id, { method: 'DELETE' });
if (!r.ok) { const d = await r.json(); throw new Error(d.error || 'Failed'); }
if (this.selectedRole && this.selectedRole.id === id) this.selectedRole = null;
await this.fetchRoles();
} catch (e) {
this.$dispatch('toast', { title: 'Error', message: e.message });
}
},
async selectRole(id) {
try {
const r = await fetch(this.apiBase + '/settings/roles/' + id);
this.selectedRole = await r.json();
} catch (e) {
this.$dispatch('toast', { title: 'Error', message: e.message });
}
},
hasPermissionSelected(resource, action) {
if (!this.selectedRole || !this.selectedRole.permissions) return false;
return this.selectedRole.permissions.some(p => p.resource === resource && p.action === action);
},
async togglePermission(resource, action) {
if (!this.selectedRole) return;
const hasIt = this.hasPermissionSelected(resource, action);
if (hasIt) {
this.selectedRole.permissions = this.selectedRole.permissions.filter(p => !(p.resource === resource && p.action === action));
} else {
if (!this.selectedRole.permissions) this.selectedRole.permissions = [];
this.selectedRole.permissions.push({ resource, action });
}
await this.saveRolePermissions();
},
async saveRolePermissions() {
if (!this.selectedRole) return;
this.roleSaving = true;
try {
const r = await fetch(this.apiBase + '/settings/roles/' + this.selectedRole.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: this.selectedRole.name,
description: this.selectedRole.description,
permissions: (this.selectedRole.permissions || []).map(p => ({ resource: p.resource, action: p.action })),
}),
});
if (!r.ok) { const d = await r.json(); throw new Error(d.error || 'Failed to save permissions'); }
this.$dispatch('toast', { title: 'Success', message: 'Permissions saved' });
} catch (e) {
this.$dispatch('toast', { title: 'Error', message: e.message });
} finally {
this.roleSaving = false;
}
},
defaultUserDraft() {
return {
email: '',
username: '',
password: '',
is_active: true,
is_staff: false,
is_superuser: false,
role_ids: [],
};
},
userToDraft(user) {
return {
email: user?.email || '',
username: user?.username || '',
password: '',
is_active: !!user?.is_active,
is_staff: !!user?.is_staff,
is_superuser: !!user?.is_superuser,
role_ids: user?.role_ids || [],
};
},
toggleUserRole(roleId) {
if (!this.userDraft.role_ids) {
this.userDraft.role_ids = [];
}
const idx = this.userDraft.role_ids.indexOf(roleId);
if (idx >= 0) {
this.userDraft.role_ids.splice(idx, 1);
} else {
this.userDraft.role_ids.push(roleId);
}
},
openUserCreate() {
if (!this.ensureCanManageUsers(true)) {
return;
}
this.userFormMode = 'create';
this.userEditingId = '';
this.userDraft = this.defaultUserDraft();
this.userFormVisible = true;
this.fetchAvailableRoles();
},
async openUserEdit(id) {
if (!this.ensureCanManageUsers(true)) {
return;
}
const detail = await this.loadUserDetail(id);
if (!detail) {
return;
}
await this.fetchAvailableRoles();
try {
const resp = await fetch(this.apiBase + '/settings/users/' + id + '/roles');
if (resp.ok) {
const roleData = await resp.json();
detail.role_ids = (roleData.roles || roleData || []).map(r => r.id);
}
} catch (e) {
console.error('Failed to load user roles:', e);
detail.role_ids = [];
}
this.userFormMode = 'edit';
this.userEditingId = id;
this.userDraft = this.userToDraft(detail);
this.userFormVisible = true;
},
cancelUserForm() {
this.userFormVisible = false;
this.userEditingId = '';
this.userFormMode = 'create';
this.userDraft = this.defaultUserDraft();
},
async saveUser() {
if (!this.ensureCanManageUsers(true)) {
return;
}
if (this.userProcessing.save) {
return;
}
const draft = this.userDraft || this.defaultUserDraft();
const email = (draft.email || '').trim();
const username = (draft.username || '').trim();
if (!email.length || !username.length) {
this.$dispatch('toast', {
title: 'Missing information',
message: 'Email and username are required.',
});
return;
}
const body = {
email,
username,
is_active: !!draft.is_active,
is_staff: !!draft.is_staff,
is_superuser: !!draft.is_superuser,
};
const password = (draft.password || '').trim();
if (this.userFormMode === 'edit') {
if (password.length) {
body.password = password;
}
} else if (!password.length) {
this.$dispatch('toast', {
title: 'Missing password',
message: 'Set an initial password for new users.',
});
return;
} else {
body.password = password;
}
this.userProcessing.save = true;
try {
const method = this.userFormMode === 'edit' ? 'PATCH' : 'PUT';
const endpoint = method === 'PATCH'
? this.userDetailEndpoint(this.userEditingId)
: this.userEndpoint();
const response = await fetch(endpoint, {
method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to save user');
}
let userId = this.userEditingId;
if (method === 'PUT' && data?.id !== undefined) {
userId = data.id;
this.selectedUserId = data.id;
}
if (userId && draft.role_ids) {
try {
await fetch(this.apiBase + '/settings/users/' + userId + '/roles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role_ids: draft.role_ids }),
});
} catch (roleErr) {
console.error('Failed to save user roles:', roleErr);
}
}
this.$dispatch('toast', {
title: 'Saved',
message: method === 'PUT' ? 'User created successfully.' : 'User updated successfully.',
});
this.cancelUserForm();
await this.fetchUsers();
} catch (err) {
console.error(err);
this.$dispatch('toast', {
title: 'Save failed',
message: err.message || 'Unable to save user.',
});
} finally {
this.userProcessing.save = false;
}
},
deleteUser(id) {
if (!this.ensureCanManageUsers(true)) {
return;
}
const user = (this.directory.users || []).find((item) => String(item.id) === String(id));
if (!user) {
return;
}
window.dispatchEvent(new CustomEvent('console:confirm', {
detail: {
title: 'Delete user',
message: `Delete ${user.username || user.email}? This action cannot be undone.`,
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
destructive: true,
onConfirm: () => this.performDeleteUser(id),
},
}));
},
async performDeleteUser(id) {
if (!this.ensureCanManageUsers(true)) {
return;
}
this.userProcessing.delete = id;
this.userError = null;
try {
const response = await fetch(this.userDetailEndpoint(id), {
method: 'DELETE',
headers: {
Accept: 'application/json',
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to delete user');
}
this.$dispatch('toast', {
title: 'Deleted',
message: 'User removed successfully.',
});
if (String(this.selectedUserId) === String(id)) {
this.selectedUserId = '';
}
await this.fetchUsers();
} catch (err) {
console.error(err);
this.userError = err.message || 'Failed to delete user';
this.$dispatch('toast', {
title: 'Delete failed',
message: this.userError,
});
} finally {
this.userProcessing.delete = null;
}
},
generateId(prefix = 'id') {
if (typeof window !== 'undefined' && window.crypto && typeof window.crypto.randomUUID === 'function') {
return `${prefix}-${window.crypto.randomUUID()}`;
}
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
},
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 || d.pending?.trunks || d.pending?.sbc_routes || d.pending?.sbc_trunks || d.pending?.queues || d.pending?.app || d.pending?.acl);
}
} catch (_) {}
},
operationDisabled(action) {
if (!action) {
return true;
}
if (action.id === 'reload-acl') {
return !this.acl.reload_supported || this.aclReloading || this.aclReloadModal.open;
}
if (action.id === 'reload-app') {
return this.appReloading || this.appReloadModal.open;
}
return false;
},
operationLabel(action) {
if (!action) return '';
const key = `settings_page.${action.id}_label`;
const translated = this.tt(key);
return translated || action.label || '';
},
operationDescription(action) {
if (!action) return '';
const key = `settings_page.${action.id}_desc`;
const translated = this.tt(key);
return translated || action.description || '';
},
operationButtonLabel(action) {
if (!action) {
return this.tt('settings_page.run_action_label') || 'Run action';
}
if (action.id === 'reload-acl') {
if (!this.acl.reload_supported) {
return this.tt('settings_page.acl_proxy_offline');
}
if (this.aclReloading) {
return this.tt('settings_page.reloading_label') || 'Reloading...';
}
if (this.aclReloadModal.open) {
return this.tt('settings_page.confirming_label') || 'Confirming...';
}
return this.tt('settings_page.run_action_label') || 'Run action';
}
if (action.id === 'reload-app') {
return this.appReloading ? (this.tt('settings_page.reloading_label') || 'Reloading...') : (this.tt('settings_page.run_action_label') || 'Run action');
}
return this.tt('settings_page.run_action_label') || 'Run action';
},
async triggerOperation(action) {
if (!action) {
return;
}
if (action.id === 'reload-acl') {
this.showAclReloadModal(action);
return;
}
if (action.id === 'reload-app') {
this.showReloadModal(action);
return;
}
const timestamp = new Date().toLocaleTimeString();
this.lastOperation = {
label: action.label,
timestamp,
status: 'Operation acknowledged (no handler configured).',
};
this.$dispatch('toast', {
title: 'Operation queued',
message: `${action.label || 'Operation'} acknowledged.`,
});
},
showAclReloadModal(action) {
this.resetAclReloadModal();
this.aclReloadModal.open = true;
this.aclReloadModal.action = action;
},
closeAclReloadModal() {
if (this.aclReloading) {
return;
}
this.aclReloadModal.open = false;
},
resetAclReloadModal() {
this.aclReloadModal = {
open: false,
status: 'idle',
error: null,
message: '',
action: null,
};
},
get aclReloadStatusLabel() {
switch (this.aclReloadModal.status) {
case 'running':
return 'Reload in progress…';
case 'success':
return 'Rules refreshed successfully';
case 'error':
return 'Reload request failed';
default:
return 'Awaiting confirmation';
}
},
async startAclReload() {
if (this.aclReloading || this.aclReloadModal.status === 'running') {
return;
}
this.aclReloadModal.status = 'running';
this.aclReloadModal.error = null;
this.aclReloadModal.message = '';
await this.reloadAcl();
},
async reloadAcl() {
if (!this.acl.reload_supported) {
this.$dispatch('toast', {
title: 'Not available',
message: 'ACL reload is only available while the SIP proxy is running.',
});
if (this.aclReloadModal.open) {
this.aclReloadModal.status = 'error';
this.aclReloadModal.error = 'SIP proxy is offline. Reload unavailable.';
}
return;
}
if (this.aclReloading) {
return;
}
this.pendingReload = false;
this.aclReloading = true;
try {
if (this.aclReloadModal.open && this.aclReloadModal.status !== 'running') {
this.aclReloadModal.status = 'running';
this.aclReloadModal.error = null;
this.aclReloadModal.message = '';
}
const endpoint = `${this.amiEndpoint.replace(/\/$/, '')}/reload/acl`;
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 || 'Failed to reload ACL rules');
}
if (Array.isArray(data?.active_rules)) {
this.acl.active_rules = data.active_rules;
}
if (data?.metrics) {
this.acl.metrics = data.metrics;
}
this.security.acl_rules = this.formatAclRules(this.acl.active_rules);
const total = Number.isFinite(Number(data?.acl_rules_reloaded))
? Number(data.acl_rules_reloaded)
: (data?.metrics?.total ?? this.acl.active_rules.length);
const durationText = this.formatDuration(data?.metrics?.duration_ms);
const message = durationText
? `Loaded ${total} rule${total === 1 ? '' : 's'} in ${durationText}.`
: `Loaded ${total} rule${total === 1 ? '' : 's'}.`;
this.$dispatch('toast', {
title: 'ACL reloaded',
message,
});
if (this.aclReloadModal.open) {
this.aclReloadModal.status = 'success';
this.aclReloadModal.error = null;
this.aclReloadModal.message = message;
}
this.lastOperation = {
label: 'Reload ACL rules',
timestamp: new Date().toLocaleString(),
status: message,
};
} catch (err) {
if (this.aclReloadModal.open) {
this.aclReloadModal.status = 'error';
this.aclReloadModal.error = err?.message || 'Unable to reload ACL rules.';
this.aclReloadModal.message = '';
}
this.$dispatch('toast', {
title: 'Reload failed',
message: err?.message || 'Unable to reload ACL rules.',
});
} finally {
this.aclReloading = false;
}
},
showReloadModal(action) {
this.resetReloadModal();
this.appReloadModal.open = true;
this.appReloadModal.action = action;
this.appReloadModal.mode = 'reload';
this.appReloadModal.checks = this.buildReloadChecklist();
},
closeReloadModal() {
if (this.appReloading) {
return;
}
this.appReloadModal.open = false;
},
resetReloadModal() {
this.appReloadModal = {
open: false,
status: 'idle',
error: null,
message: '',
action: null,
mode: 'reload',
checks: [],
};
},
buildReloadChecklist() {
return [
{
id: 'config-parse',
label: 'Load configuration file',
hint: 'Ensure the TOML file is parseable and present.',
status: 'pending',
},
{
id: 'console-enabled',
label: 'Console availability',
hint: 'Console must stay enabled to manage reloads.',
status: 'pending',
},
{
id: 'ports-available',
label: 'Port availability',
hint: 'New bindings must be free before restart.',
status: 'pending',
},
{
id: 'restart-services',
label: 'Restart services',
hint: 'Stop running services and relaunch with new config.',
status: 'pending',
},
];
},
reloadCheckIndicatorClass(status) {
switch (status) {
case 'success':
return 'bg-emerald-500';
case 'running':
return 'bg-sky-400 animate-pulse';
case 'error':
return 'bg-rose-500';
default:
return 'bg-slate-300';
}
},
reloadCheckStatusClass(status) {
switch (status) {
case 'success':
return 'text-emerald-600';
case 'running':
return 'text-sky-600';
case 'error':
return 'text-rose-600';
default:
return 'text-slate-400';
}
},
reloadCheckStatusLabel(status) {
switch (status) {
case 'success':
return 'Done';
case 'running':
return 'Running';
case 'error':
return 'Failed';
default:
return 'Pending';
}
},
get reloadStatusLabel() {
const mode = this.appReloadModal.mode || 'reload';
switch (this.appReloadModal.status) {
case 'running':
return mode === 'check' ? 'Validating configuration…' : 'Running preflight checks…';
case 'success':
return mode === 'check'
? 'Configuration valid. Ready to reload.'
: 'Reload succeeded. Services restarting…';
case 'error':
return mode === 'check'
? 'Validation failed. Review configuration.'
: 'Reload request failed.';
default:
return 'Awaiting confirmation';
}
},
get reloadConfirmLabel() {
if (this.appReloadModal.mode === 'check') {
if (this.appReloadModal.status === 'success') {
return 'Reload now';
}
return 'Confirm reload';
}
return this.appReloadModal.status === 'error' ? 'Retry reload' : 'Confirm reload';
},
markCheck(id, status, detail = null) {
this.appReloadModal.checks = this.appReloadModal.checks.map((item) =>
item.id === id ? { ...item, status, detail: detail || item.detail } : item
);
},
async startReloadApplication() {
if (this.appReloading || this.appReloadModal.status === 'running') {
return;
}
await this.performReload({ checkOnly: false });
},
async startReloadCheck() {
if (this.appReloading || this.appReloadModal.status === 'running') {
return;
}
await this.performReload({ checkOnly: true });
},
async performReload({ checkOnly = false } = {}) {
const action = this.appReloadModal.action || {};
const baseEndpoint = action.endpoint || `${this.amiEndpoint.replace(/\/$/, '')}/reload/app`;
const method = action.method || 'POST';
let endpoint = baseEndpoint;
if (checkOnly) {
const separator = endpoint.includes('?') ? '&' : '?';
endpoint = `${endpoint}${separator}mode=check`;
}
this.pendingReload = false;
this.appReloading = true;
this.appReloadModal.mode = checkOnly ? 'check' : 'reload';
this.appReloadModal.status = 'running';
this.appReloadModal.error = null;
this.appReloadModal.message = checkOnly ? 'Validating configuration…' : '';
this.markCheck('config-parse', 'running');
this.markCheck('console-enabled', 'pending');
this.markCheck('ports-available', 'pending');
this.markCheck('restart-services', 'pending');
try {
const response = await fetch(endpoint, {
method,
headers: {
Accept: 'application/json',
},
credentials: 'include',
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const message = this.handleReloadErrors(data);
throw new Error(message);
}
this.markCheck('config-parse', 'success');
this.markCheck('console-enabled', 'success');
this.markCheck('ports-available', 'success');
if (checkOnly) {
this.markCheck('restart-services', 'success', 'Ready to restart once you confirm reload.');
const message = data?.message || 'Configuration validated. Services not restarted.';
this.appReloadModal.status = 'success';
this.appReloadModal.message = message;
this.$dispatch('toast', {
title: 'Configuration valid',
message,
});
this.lastOperation = {
label: 'Check',
timestamp: new Date().toLocaleString(),
status: message,
};
return;
}
this.markCheck('restart-services', 'running');
this.markCheck('restart-services', 'success');
const message = data?.message || 'Configuration validated. Services will restart automatically.';
this.appReloadModal.status = 'success';
this.appReloadModal.message = message;
this.$dispatch('toast', {
title: 'Reloading',
message,
});
this.lastOperation = {
label: action.label || 'Reload application',
timestamp: new Date().toLocaleString(),
status: 'Restart scheduled. Waiting for services to return…',
};
window.setTimeout(() => {
window.location.reload();
}, 2500);
} catch (err) {
const fallback = checkOnly ? 'Unable to validate configuration.' : 'Unable to reload application.';
const message = err?.message || fallback;
this.appReloadModal.status = 'error';
this.appReloadModal.error = message;
this.appReloadModal.message = '';
this.$dispatch('toast', {
title: checkOnly ? 'Validation failed' : 'Reload failed',
message,
});
this.lastOperation = {
label: checkOnly ? 'Check' : (action.label || 'Reload'),
timestamp: new Date().toLocaleString(),
status: message,
};
} finally {
this.appReloading = false;
}
},
handleReloadErrors(payload) {
const errors = Array.isArray(payload?.errors) ? payload.errors : [];
let message = this.formatOperationError(payload);
if (!errors.length) {
this.markCheck('config-parse', 'error', message);
return message;
}
const lowered = (value) => (value || '').toString().toLowerCase();
let consoleSet = false;
let portsSet = false;
let configSet = false;
for (const issue of errors) {
const field = lowered(issue.field);
const detail = issue.message || message;
if (field.includes('console')) {
this.markCheck('console-enabled', 'error', detail);
consoleSet = true;
} else if (field.includes('port') || field.includes('addr') || field.includes('http')) {
this.markCheck('ports-available', 'error', detail);
portsSet = true;
} else if (field.includes('config')) {
this.markCheck('config-parse', 'error', detail);
configSet = true;
}
}
if (!configSet) {
this.markCheck('config-parse', 'success');
}
if (!consoleSet) {
this.markCheck('console-enabled', 'success');
}
if (!portsSet) {
this.markCheck('ports-available', 'success');
}
this.markCheck('restart-services', 'error', message);
return message;
},
formatAclRules(rules) {
if (!Array.isArray(rules) || !rules.length) {
return 'allow all';
}
return rules.join('\n');
},
formatOperationError(payload) {
if (!payload || typeof payload !== 'object') {
return 'Request failed.';
}
if (Array.isArray(payload.errors) && payload.errors.length) {
return payload.errors
.map((item) => {
if (!item || typeof item !== 'object') {
return String(item);
}
const field = item.field ? `${item.field}: ` : '';
return `${field}${item.message || 'Invalid configuration'}`;
})
.join('; ');
}
return payload.message || payload.error || 'Request failed.';
},
keyConfigRows() {
const items = Array.isArray(this.configMeta?.key_items) ? this.configMeta.key_items : [];
return items.map((item) => {
const label = item?.label || '—';
const rawValue = item?.value;
const value = rawValue === undefined || rawValue === null
? '—'
: (typeof rawValue === 'string' ? rawValue : JSON.stringify(rawValue));
const hint = item?.hint || null;
return { label, value, hint };
});
},
displayMetric(value) {
return value === undefined || value === null ? '—' : value;
},
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`;
},
logsRecentEndpoint() {
return `${this.apiBase}/settings/logs/recent`;
},
logsStreamEndpoint() {
return `${this.apiBase}/settings/logs/stream`;
},
ensureLogVisibleTail() {
this.$nextTick(() => {
const el = this.$refs.logOutput;
if (!el) {
return;
}
el.scrollTop = el.scrollHeight;
});
},
clearLogLines() {
this.logViewer.lines = [];
this.logViewer.error = null;
this.logViewer.message = '';
this.logViewer.truncated = false;
},
async copyLogs() {
const text = (this.logViewer.lines || []).join('\n');
if (!text) return;
try {
await navigator.clipboard.writeText(text);
this.logViewer.copied = true;
setTimeout(() => { this.logViewer.copied = false; }, 2000);
} catch {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
this.logViewer.copied = true;
setTimeout(() => { this.logViewer.copied = false; }, 2000);
}
},
async initLogsTab() {
if (!this.currentUser?.is_superuser) {
return;
}
this.stopLogFollow();
this.logViewer.error = null;
await this.loadRecentLogs();
this.startLogFollow();
},
applyLogLimit() {
const parsed = Number(this.logViewer.limit);
if (!Number.isFinite(parsed) || parsed <= 0) {
this.logViewer.limit = 200;
} else {
this.logViewer.limit = Math.min(Math.max(Math.floor(parsed), 1), 5000);
}
this.refreshLogs();
},
async refreshLogs() {
this.stopLogFollow();
await this.loadRecentLogs();
},
toggleLogFollow() {
if (this.logViewer.following) {
this.stopLogFollow();
return;
}
this.startLogFollow();
},
startLogFollow() {
if (!this.currentUser?.is_superuser || this.logViewer.following) {
return;
}
this.logViewer.following = true;
this.logViewer.error = null;
const url = new URL(this.logsStreamEndpoint(), window.location.origin);
url.searchParams.set('position', String(this.logViewer.nextPosition || 0));
url.searchParams.set('limit', String(this.logViewer.limit || 200));
const source = new EventSource(url.toString(), { withCredentials: true });
source.onmessage = (event) => {
this.handleLogStreamEvent(event);
};
source.addEventListener('logs', (event) => {
this.handleLogStreamEvent(event);
});
source.onerror = () => {
this.logViewer.error = this.tt('settings_page.logs_stream_lost') || 'Log stream disconnected.';
this.stopLogFollow();
};
this.logViewer.eventSource = source;
},
stopLogFollow() {
if (this.logViewer.eventSource) {
this.logViewer.eventSource.close();
this.logViewer.eventSource = null;
}
this.logViewer.following = false;
},
async loadRecentLogs() {
if (!this.currentUser?.is_superuser) {
return;
}
this.logViewer.loading = true;
this.logViewer.error = null;
this.logViewer.message = '';
try {
const url = new URL(this.logsRecentEndpoint(), window.location.origin);
url.searchParams.set('limit', String(this.logViewer.limit || 200));
const response = await fetch(url.toString(), {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to fetch logs');
}
this.logViewer.path = data?.path || '';
this.logViewer.lines = Array.isArray(data?.lines) ? data.lines : [];
this.logViewer.nextPosition = Number(data?.next_position || 0);
this.logViewer.truncated = !!data?.truncated;
this.logViewer.message = data?.message || '';
this.ensureLogVisibleTail();
} catch (err) {
console.error(err);
this.logViewer.error = err?.message || 'Failed to fetch logs';
} finally {
this.logViewer.loading = false;
}
},
handleLogStreamEvent(event) {
try {
const data = JSON.parse(event?.data || '{}');
if (data?.status === 'error') {
throw new Error(data?.message || 'Failed to follow logs');
}
this.logViewer.path = data?.path || this.logViewer.path;
this.logViewer.nextPosition = Number(data?.next_position || 0);
this.logViewer.truncated = !!data?.truncated;
this.logViewer.message = data?.message || '';
const incoming = Array.isArray(data?.lines) ? data.lines : [];
if (data?.reset) {
this.logViewer.lines = incoming;
} else if (incoming.length) {
this.logViewer.lines = [...(this.logViewer.lines || []), ...incoming];
const maxLines = Math.min(Math.max(Number(this.logViewer.limit) || 200, 1), 5000);
if (this.logViewer.lines.length > maxLines) {
this.logViewer.lines = this.logViewer.lines.slice(-maxLines);
}
}
if (incoming.length || data?.reset) {
this.ensureLogVisibleTail();
}
} catch (err) {
console.error(err);
this.logViewer.error = err?.message || 'Failed to follow logs';
this.stopLogFollow();
}
},
initPlatformDraft() {
const rtp = this.proxyConfig?.rtp || {};
const toStringOrEmpty = (value) => (value === undefined || value === null || value === ''
? ''
: String(value));
const manualIp = rtp?.external_ip || '';
const autoUrl = rtp?.auto_external_ip || '';
this.platformDraft.external_ip = manualIp;
this.platformDraft.auto_external_ip = autoUrl;
this.platformDraft.external_ip_mode = autoUrl ? 'auto' : (manualIp ? 'manual' : 'none');
this.platformDraft.auto_external_ip_testing = false;
this.platformDraft.auto_external_ip_result = '';
this.platformDraft.rtp_start_port = toStringOrEmpty(rtp?.start_port);
this.platformDraft.rtp_end_port = toStringOrEmpty(rtp?.end_port);
},
initProxyDraft() {
const proxy = this.proxyConfig || {};
this.proxyDraft = {
realms: (proxy.realms || []).join('\n'),
locator_webhook: {
url: proxy.locator_webhook?.url || '',
timeout_ms: proxy.locator_webhook?.timeout_ms || '',
headers: this.formatHeaders(proxy.locator_webhook?.headers),
},
rwi_webhook: {
url: proxy.rwi_webhook?.url || '',
timeout_ms: proxy.rwi_webhook?.timeout_ms || '',
headers: this.formatHeaders(proxy.rwi_webhook?.headers),
events: (proxy.rwi_webhook?.events || []).join('\n'),
},
http_router: {
url: proxy.http_router?.url || '',
timeout_ms: proxy.http_router?.timeout_ms || '',
headers: this.formatHeaders(proxy.http_router?.headers),
},
user_backends: JSON.parse(JSON.stringify(proxy.user_backends || [])),
};
},
resetProxyDraft() {
this.initProxyDraft();
},
addUserBackend(type) {
const backend = { type };
if (type === 'memory') backend.users = [];
else if (type === 'http') { backend.url = ''; backend.method = 'GET'; }
else if (type === 'database') { backend.url = ''; }
else if (type === 'plain') { backend.path = ''; }
else if (type === 'extension') { backend.database_url = ''; }
this.proxyDraft.user_backends.push(backend);
},
removeUserBackend(index) {
this.proxyDraft.user_backends.splice(index, 1);
},
async saveProxySettings() {
if (this.proxyProcessing) return;
const body = {
realms: this.listFromTextarea(this.proxyDraft.realms),
user_backends: this.proxyDraft.user_backends,
};
body.locator_webhook = {
url: this.proxyDraft.locator_webhook.url,
timeout_ms: this.normalizeOptionalNumber(this.proxyDraft.locator_webhook.timeout_ms),
headers: this.parseHeaders(this.proxyDraft.locator_webhook.headers),
};
body.rwi_webhook = {
url: this.proxyDraft.rwi_webhook.url,
timeout_ms: this.normalizeOptionalNumber(this.proxyDraft.rwi_webhook.timeout_ms),
headers: this.parseHeaders(this.proxyDraft.rwi_webhook.headers),
events: this.listFromTextarea(this.proxyDraft.rwi_webhook.events),
};
body.http_router = {
url: this.proxyDraft.http_router.url,
timeout_ms: this.normalizeOptionalNumber(this.proxyDraft.http_router.timeout_ms),
headers: this.parseHeaders(this.proxyDraft.http_router.headers),
};
this.proxyProcessing = true;
try {
const response = await fetch(`${this.apiBase}/settings/config/proxy`, {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) throw new Error(data?.message || 'Failed to update proxy settings');
this.$dispatch('toast', { title: 'Settings saved', message: data.message });
this.proxyConfig.realms = body.realms;
this.proxyConfig.user_backends = body.user_backends;
this.proxyConfig.locator_webhook = body.locator_webhook;
this.proxyConfig.rwi_webhook = body.rwi_webhook;
this.proxyConfig.http_router = body.http_router;
this.initProxyDraft();
} catch (err) {
console.error(err);
this.$dispatch('toast', { title: 'Update failed', message: err.message });
} finally {
this.proxyProcessing = false;
}
},
async testLocatorWebhook() {
const draft = this.proxyDraft.locator_webhook;
if (!draft.url) return;
this.proxyProcessing = true;
try {
const response = await fetch(`${this.apiBase}/settings/config/proxy/locator-webhook/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: draft.url, headers: this.parseHeaders(draft.headers) }),
});
const data = await response.json();
this.$dispatch('toast', { title: response.ok ? 'Success' : 'Failed', message: data.message });
} catch (err) {
this.$dispatch('toast', { title: 'Test failed', message: err.message });
} finally {
this.proxyProcessing = false;
}
},
async testRwiWebhook() {
const draft = this.proxyDraft.rwi_webhook;
if (!draft.url) return;
this.proxyProcessing = true;
try {
const response = await fetch(`${this.apiBase}/settings/config/proxy/rwi-webhook/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: draft.url, headers: this.parseHeaders(draft.headers) }),
});
const data = await response.json();
this.$dispatch('toast', { title: response.ok ? 'Success' : 'Failed', message: data.message });
} catch (err) {
this.$dispatch('toast', { title: 'Test failed', message: err.message });
} finally {
this.proxyProcessing = false;
}
},
async testHttpRouter() {
const draft = this.proxyDraft.http_router;
if (!draft.url) return;
this.proxyProcessing = true;
try {
const response = await fetch(`${this.apiBase}/settings/config/proxy/http-router/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: draft.url, headers: this.parseHeaders(draft.headers) }),
});
const data = await response.json();
this.$dispatch('toast', { title: response.ok ? 'Success' : 'Failed', message: data.message });
} catch (err) {
this.$dispatch('toast', { title: 'Test failed', message: err.message });
} finally {
this.proxyProcessing = false;
}
},
async testUserBackend(index) {
const backend = this.proxyDraft.user_backends[index];
this.proxyProcessing = true;
try {
const response = await fetch(`${this.apiBase}/settings/config/proxy/user-backend/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backend }),
});
const data = await response.json();
this.$dispatch('toast', { title: response.ok ? 'Success' : 'Failed', message: data.message });
} catch (err) {
this.$dispatch('toast', { title: 'Test failed', message: err.message });
} finally {
this.proxyProcessing = false;
}
},
parseHeaders(text) {
const headers = {};
(text || '').split('\n').forEach(line => {
const idx = line.indexOf(':');
if (idx > 0) {
const key = line.substring(0, idx).trim();
const val = line.substring(idx + 1).trim();
if (key && val) headers[key] = val;
}
});
return Object.keys(headers).length ? headers : null;
},
formatHeaders(headers) {
if (!headers || typeof headers !== 'object') return '';
return Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join('\n');
},
resetPlatformDraft() {
this.initPlatformDraft();
},
parsePortValue(value) {
const trimmed = String(value ?? '').trim();
if (!trimmed.length) {
return null;
}
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
return undefined;
}
return parsed;
},
async testAutoExternalIp() {
if (this.platformDraft.auto_external_ip_testing) return;
this.platformDraft.auto_external_ip_testing = true;
this.platformDraft.auto_external_ip_result = '';
const url = this.platformDraft.auto_external_ip.trim() || null;
try {
const response = await fetch(`${this.apiBase}/settings/config/platform/auto-external-ip/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const data = await response.json().catch(() => ({}));
if (response.ok && data?.status === 'ok') {
this.platformDraft.auto_external_ip_result = `✓ Detected: ${data.ip}`;
} else {
this.platformDraft.auto_external_ip_result = `✗ ${data?.message || 'Test failed'}`;
}
} catch (e) {
this.platformDraft.auto_external_ip_result = `✗ ${e.message || 'Request failed'}`;
} finally {
this.platformDraft.auto_external_ip_testing = false;
}
},
async savePlatformSettings() {
if (this.platformProcessing) {
return;
}
const startPortValue = this.parsePortValue(this.platformDraft.rtp_start_port);
if (startPortValue === undefined) {
this.$dispatch('toast', {
title: 'Invalid value',
message: 'Start port must be a number between 1 and 65535.',
});
return;
}
const endPortValue = this.parsePortValue(this.platformDraft.rtp_end_port);
if (endPortValue === undefined) {
this.$dispatch('toast', {
title: 'Invalid value',
message: 'End port must be a number between 1 and 65535.',
});
return;
}
if (startPortValue !== null && endPortValue !== null && startPortValue > endPortValue) {
this.$dispatch('toast', {
title: 'Invalid range',
message: 'Start port must be less than or equal to end port.',
});
return;
}
const body = {
log_level: this.normalizeOptionalString(this.platformDraft.log_level),
log_file: this.normalizeOptionalString(this.platformDraft.log_file),
external_ip: this.platformDraft.external_ip_mode === 'manual'
? this.normalizeOptionalString(this.platformDraft.external_ip)
: null,
auto_external_ip: this.platformDraft.external_ip_mode === 'auto'
? this.normalizeOptionalString(this.platformDraft.auto_external_ip)
: null,
rtp_start_port: startPortValue,
rtp_end_port: endPortValue,
};
this.platformProcessing = true;
try {
const response = await fetch(`${this.apiBase}/settings/config/platform`, {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to update platform settings');
}
this.platform.log_level = data?.platform?.log_level ?? null;
this.platform.log_file = data?.platform?.log_file ?? null;
const rtp = data?.rtp || {};
if (!this.proxyConfig.rtp || typeof this.proxyConfig.rtp !== 'object') {
this.proxyConfig.rtp = {};
}
this.proxyConfig.rtp.external_ip = rtp.external_ip ?? null;
this.proxyConfig.rtp.auto_external_ip = rtp.auto_external_ip ?? null;
this.proxyConfig.rtp.start_port = rtp.start_port ?? null;
this.proxyConfig.rtp.end_port = rtp.end_port ?? null;
this.initPlatformDraft();
this.$dispatch('toast', {
title: 'Settings saved',
message: data?.message || 'Platform settings saved. Restart required to apply.',
});
this.lastOperation = {
label: 'Platform settings',
timestamp: new Date().toLocaleString(),
status: 'Saved. Restart required.',
};
} catch (err) {
console.error(err);
this.$dispatch('toast', {
title: 'Update failed',
message: err?.message || 'Unable to update platform settings.',
});
} finally {
this.platformProcessing = false;
}
},
initStorageDraft() {
const meta = this.server?.storage || {};
const recorderPath = meta?.recorder_path || '';
const recorderFormat = (meta?.recorder_format || 'wav').toString();
const mediaCache = meta?.media_cache_path || '';
const recording = meta?.recording || this.recordingPolicy || {};
const normalizeList = (list) => Array.isArray(list) ? list.join('\n') : '';
const directionList = Array.isArray(recording?.directions)
? recording.directions.map((d) => String(d || '').toLowerCase())
: null;
const directionDefault = directionList === null;
const directionInbound = directionDefault ? true : directionList.includes('inbound');
const directionOutbound = directionDefault ? true : directionList.includes('outbound');
const directionInternal = directionDefault ? true : directionList.includes('internal');
const crProfile = Array.isArray(this.server?.storage_profiles)
? this.server.storage_profiles.find(p => String(p?.id || '').startsWith('callrecord'))
: null;
const crConfig = crProfile?.config || {};
const crType = crConfig?.type || (meta?.mode === 's3' ? 's3' : (meta?.mode === 'local' ? 'local' : 'disabled'));
this.storageDraft = {
recorder_path: recorderPath,
recorder_format: recorderFormat || 'wav',
media_cache_path: mediaCache,
callrecord_mode: crType,
callrecord_root: crConfig?.root || '',
callrecord_s3_vendor: crConfig?.vendor || 'aws',
callrecord_s3_bucket: crConfig?.bucket || '',
callrecord_s3_region: crConfig?.region || '',
callrecord_s3_endpoint: crConfig?.endpoint || '',
callrecord_s3_access_key: '',
callrecord_s3_secret_key: '',
callrecord_s3_root: crConfig?.root || '',
callrecord_s3_with_media: !!crConfig?.with_media,
callrecord_s3_keep_media_copy: !!crConfig?.keep_media_copy,
recording_enabled: !!recording?.enabled,
recording_auto_start: recording?.auto_start !== undefined ? !!recording.auto_start : true,
recording_force_file: !!recording?.force_file,
recording_direction_inbound: directionInbound,
recording_direction_outbound: directionOutbound,
recording_direction_internal: directionInternal,
recording_caller_allow: normalizeList(recording?.caller_allow),
recording_caller_deny: normalizeList(recording?.caller_deny),
recording_callee_allow: normalizeList(recording?.callee_allow),
recording_callee_deny: normalizeList(recording?.callee_deny),
recording_samplerate: recording?.samplerate ? String(recording.samplerate) : '',
recording_ptime: recording?.ptime ? String(recording.ptime) : '',
recording_filename_pattern: recording?.filename_pattern || '',
};
},
resetStorageDraft() {
this.initStorageDraft();
},
async saveStorageSettings() {
if (this.storageProcessing) {
return;
}
let recorderFormat = this.normalizeOptionalString(this.storageDraft.recorder_format);
if (recorderFormat) {
recorderFormat = recorderFormat.toLowerCase();
if (recorderFormat !== 'wav') {
this.$dispatch('toast', {
title: 'Invalid value',
message: 'Recorder format must be "wav".',
});
return;
}
}
if (
this.storageDraft.recording_enabled &&
!(
this.storageDraft.recording_direction_inbound ||
this.storageDraft.recording_direction_outbound ||
this.storageDraft.recording_direction_internal
)
) {
this.$dispatch('toast', {
title: 'Select a direction',
message: 'Choose at least one call direction when recording is enabled.',
});
return;
}
const recordingDirections = [];
if (this.storageDraft.recording_direction_inbound) {
recordingDirections.push('inbound');
}
if (this.storageDraft.recording_direction_outbound) {
recordingDirections.push('outbound');
}
if (this.storageDraft.recording_direction_internal) {
recordingDirections.push('internal');
}
const recordingPayload = {
enabled: !!this.storageDraft.recording_enabled,
auto_start: !!this.storageDraft.recording_auto_start,
force_file: !!this.storageDraft.recording_force_file,
caller_allow: this.listFromTextarea(this.storageDraft.recording_caller_allow),
caller_deny: this.listFromTextarea(this.storageDraft.recording_caller_deny),
callee_allow: this.listFromTextarea(this.storageDraft.recording_callee_allow),
callee_deny: this.listFromTextarea(this.storageDraft.recording_callee_deny),
samplerate: this.normalizeOptionalNumber(this.storageDraft.recording_samplerate),
ptime: this.normalizeOptionalNumber(this.storageDraft.recording_ptime),
filename_pattern: this.normalizeOptionalString(this.storageDraft.recording_filename_pattern),
};
if (recordingDirections.length && recordingDirections.length < 3) {
recordingPayload.directions = recordingDirections;
}
let callrecordPayload = null;
const crMode = this.storageDraft.callrecord_mode || 'disabled';
if (crMode === 'disabled') {
callrecordPayload = { mode: 'disabled' };
} else if (crMode === 'local') {
callrecordPayload = {
mode: 'local',
root: this.normalizeOptionalString(this.storageDraft.callrecord_root),
};
} else if (crMode === 's3') {
const s3Vendor = this.normalizeOptionalString(this.storageDraft.callrecord_s3_vendor);
const s3Bucket = this.normalizeOptionalString(this.storageDraft.callrecord_s3_bucket);
const s3Region = this.normalizeOptionalString(this.storageDraft.callrecord_s3_region);
const s3AccessKey = this.normalizeOptionalString(this.storageDraft.callrecord_s3_access_key);
const s3SecretKey = this.normalizeOptionalString(this.storageDraft.callrecord_s3_secret_key);
if (!s3Vendor || !s3Bucket || !s3Region || !s3AccessKey || !s3SecretKey) {
this.$dispatch('toast', {
title: 'Missing S3 fields',
message: 'Vendor, bucket, region, access key, and secret key are required.',
});
return;
}
callrecordPayload = {
mode: 's3',
vendor: s3Vendor,
bucket: s3Bucket,
region: s3Region,
access_key: s3AccessKey,
secret_key: s3SecretKey,
endpoint: this.normalizeOptionalString(this.storageDraft.callrecord_s3_endpoint),
root: this.normalizeOptionalString(this.storageDraft.callrecord_s3_root),
with_media: !!this.storageDraft.callrecord_s3_with_media,
keep_media_copy: !!this.storageDraft.callrecord_s3_keep_media_copy,
};
}
const body = {
recorder_path: this.normalizeOptionalString(this.storageDraft.recorder_path),
media_cache_path: this.normalizeOptionalString(this.storageDraft.media_cache_path),
recorder_format: recorderFormat,
recording_policy: recordingPayload,
callrecord: callrecordPayload,
};
this.storageProcessing = true;
try {
const response = await fetch(`${this.apiBase}/settings/config/storage`, {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to update storage settings');
}
this.server.storage = data?.storage || {};
this.server.storage_profiles = Array.isArray(data?.storage_profiles) ? data.storage_profiles : [];
this.recordingPolicy = this.server.storage?.recording || null;
const storageMeta = this.server.storage || {};
this.selectedStorageProfile = storageMeta.active_profile || storageMeta.mode || (this.storageProfiles[0]?.id || '');
this.initStorageDraft();
this.$dispatch('toast', {
title: 'Settings saved',
message: data?.message || 'Storage settings saved. Restart required to apply.',
});
this.lastOperation = {
label: 'Storage settings',
timestamp: new Date().toLocaleString(),
status: 'Saved. Restart required.',
};
} catch (err) {
console.error(err);
this.$dispatch('toast', {
title: 'Update failed',
message: err?.message || 'Unable to update storage settings.',
});
} finally {
this.storageProcessing = false;
}
},
async fetchSipFlowSettings() {
try {
const response = await fetch(`${this.apiBase}/sipflow/settings`, {
method: 'GET',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
console.warn('Failed to fetch sipflow settings');
return;
}
const data = await response.json();
this.sipflowSettings = {
backend_type: data.backend_type || 'none',
config: data.config || {},
};
this.initSipFlowDraft();
} catch (err) {
console.error('Error fetching sipflow settings:', err);
}
},
initSipFlowDraft() {
const config = this.sipflowSettings.config || {};
const backendType = this.sipflowSettings.backend_type || 'none';
let remoteNodes = [{ udp: '', http: '' }];
if (backendType === 'remote') {
if (Array.isArray(config.nodes) && config.nodes.length > 0) {
remoteNodes = config.nodes.map(n => ({ udp: n.udp || '', http: n.http || '' }));
} else if (config.udp_addr || config.http_addr) {
remoteNodes = [{ udp: config.udp_addr || '', http: config.http_addr || '' }];
}
}
this.sipflowDraft = {
backend_type: backendType,
local_root: backendType === 'local' ? (config.root || '') : '',
local_subdirs: backendType === 'local' ? (config.subdirs || 'daily') : 'daily',
local_flush_count: backendType === 'local' ? (config.flush_count || 1000) : 1000,
local_flush_interval_secs: backendType === 'local' ? (config.flush_interval_secs || 5) : 5,
remote_nodes: remoteNodes,
remote_timeout_secs: backendType === 'remote' ? (config.timeout_secs || 10) : 10,
};
},
resetSipFlowDraft() {
this.initSipFlowDraft();
},
async saveSipFlowSettings() {
if (this.sipflowProcessing) {
return;
}
const backendType = this.sipflowDraft.backend_type || 'none';
let config = {};
if (backendType === 'local') {
const root = (this.sipflowDraft.local_root || '').trim();
if (!root) {
this.$dispatch('toast', {
title: this.tt('settings_page.missing_information'),
message: this.tt('settings_page.sipflow_data_dir_required'),
});
return;
}
config = {
root,
subdirs: this.sipflowDraft.local_subdirs || 'daily',
flush_count: this.sipflowDraft.local_flush_count || 1000,
flush_interval_secs: this.sipflowDraft.local_flush_interval_secs || 5,
};
} else if (backendType === 'remote') {
const nodes = (this.sipflowDraft.remote_nodes || [])
.map(n => ({ udp: (n.udp || '').trim(), http: (n.http || '').trim() }))
.filter(n => n.udp && n.http);
if (nodes.length === 0) {
this.$dispatch('toast', {
title: this.tt('settings_page.missing_information'),
message: this.tt('settings_page.sipflow_remote_required'),
});
return;
}
config = {
nodes,
timeout_secs: this.sipflowDraft.remote_timeout_secs || 10,
};
}
const body = {
enabled: backendType !== 'none',
backend_type: backendType,
config,
};
this.sipflowProcessing = true;
try {
const response = await fetch(`${this.apiBase}/sipflow/settings`, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || data?.error || this.tt('settings_page.sipflow_failed_default'));
}
this.sipflowSettings = {
backend_type: backendType,
config,
};
this.$dispatch('toast', {
title: this.tt('settings_page.settings_saved'),
message: data?.message || this.tt('settings_page.sipflow_saved_msg'),
});
this.lastOperation = {
label: this.tt('settings_page.sipflow_recording'),
timestamp: new Date().toLocaleString(),
status: this.tt('settings_page.restart_required_apply'),
};
} catch (err) {
console.error(err);
this.$dispatch('toast', {
title: this.tt('settings_page.update_failed'),
message: err?.message || this.tt('settings_page.sipflow_failed_default'),
});
} finally {
this.sipflowProcessing = false;
}
},
async testStorageConnection() {
if (this.storageProcessing) {
return;
}
const vendor = this.normalizeOptionalString(this.storageDraft.callrecord_s3_vendor);
const bucket = this.normalizeOptionalString(this.storageDraft.callrecord_s3_bucket);
const region = this.normalizeOptionalString(this.storageDraft.callrecord_s3_region);
const access_key = this.normalizeOptionalString(this.storageDraft.callrecord_s3_access_key);
const secret_key = this.normalizeOptionalString(this.storageDraft.callrecord_s3_secret_key);
const endpoint = this.normalizeOptionalString(this.storageDraft.callrecord_s3_endpoint);
const root = this.normalizeOptionalString(this.storageDraft.callrecord_s3_root);
if (!vendor || !bucket || !region || !access_key || !secret_key) {
this.$dispatch('toast', {
title: 'Missing information',
message: 'Vendor, bucket, region, access key, and secret key are required for S3 test.',
});
return;
}
const body = {
vendor,
bucket,
region,
access_key,
secret_key,
endpoint,
root,
};
this.storageProcessing = true;
try {
const response = await fetch(`${this.apiBase}/settings/config/storage/test`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Storage connection test failed');
}
this.$dispatch('toast', {
title: 'Connection successful',
message: data?.message || 'Successfully connected to storage, created and deleted a test file.',
});
} catch (err) {
console.error(err);
this.$dispatch('toast', {
title: 'Test failed',
message: err?.message || 'Unable to connect to storage.',
});
} finally {
this.storageProcessing = false;
}
},
normalizeMultilineList(value) {
if (value === undefined || value === null) {
return null;
}
const entries = String(value)
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length);
if (!entries.length) {
return null;
}
return entries.join('\n');
},
initSecurityDraft() {
this.securityDraft.acl_rules = this.formatAclRules(this.acl.active_rules || []);
const p = this.proxyConfig || {};
const emg = p.emergency || {};
this.securityDraft.dos_enabled = p.dos_enabled ?? false;
this.securityDraft.dos_max_cps_per_ip = p.dos_max_cps_per_ip ?? 100;
this.securityDraft.dos_max_concurrent_per_ip = p.dos_max_concurrent_per_ip ?? 500;
this.securityDraft.dos_scan_probe_threshold = p.dos_scan_probe_threshold ?? 50;
this.securityDraft.dos_scan_block_duration_secs = p.dos_scan_block_duration_secs ?? 600;
this.securityDraft.uri_max_length = p.uri_max_length ?? 256;
this.securityDraft.uri_reject_malformed = p.uri_reject_malformed ?? false;
this.securityDraft.emergency_enabled = emg.enabled ?? false;
this.securityDraft.emergency_numbers = Array.isArray(emg.numbers) ? emg.numbers.join(', ') : '';
this.securityDraft.emergency_trunk = emg.emergency_trunk || '';
this.securityDraft.session_cmd_channel_capacity = p.session_cmd_channel_capacity ?? 256;
this.securityDraft.session_state_channel_capacity = p.session_state_channel_capacity ?? 256;
this.securityDraft.media_cmd_channel_capacity = p.media_cmd_channel_capacity ?? 512;
this.securityDraft.media_event_channel_capacity = p.media_event_channel_capacity ?? 1024;
},
resetSecurityDraft() {
this.initSecurityDraft();
},
async saveSecuritySettings() {
if (this.securityProcessing) {
return;
}
const body = {
acl_rules: this.normalizeMultilineList(this.securityDraft.acl_rules),
dos_enabled: this.securityDraft.dos_enabled,
dos_max_cps_per_ip: Number(this.securityDraft.dos_max_cps_per_ip),
dos_max_concurrent_per_ip: Number(this.securityDraft.dos_max_concurrent_per_ip),
dos_scan_probe_threshold: Number(this.securityDraft.dos_scan_probe_threshold),
dos_scan_block_duration_secs: Number(this.securityDraft.dos_scan_block_duration_secs),
session_cmd_channel_capacity: Number(this.securityDraft.session_cmd_channel_capacity),
session_state_channel_capacity: Number(this.securityDraft.session_state_channel_capacity),
media_cmd_channel_capacity: Number(this.securityDraft.media_cmd_channel_capacity),
media_event_channel_capacity: Number(this.securityDraft.media_event_channel_capacity),
uri_max_length: Number(this.securityDraft.uri_max_length),
uri_reject_malformed: this.securityDraft.uri_reject_malformed,
emergency: {
enabled: this.securityDraft.emergency_enabled,
numbers: (this.securityDraft.emergency_numbers || '').split(',').map(s => s.trim()).filter(Boolean),
emergency_trunk: this.securityDraft.emergency_trunk || '',
},
};
this.securityProcessing = true;
try {
const response = await fetch(`${this.apiBase}/settings/config/security`, {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to update security settings');
}
const acl_rules = Array.isArray(data?.security?.acl_rules) ? data.security.acl_rules : [];
this.acl.active_rules = acl_rules;
this.securityDraft.acl_rules = this.formatAclRules(this.acl.active_rules);
this.$dispatch('toast', {
title: 'Settings saved',
message: data?.message || 'Security settings saved. Restart required to apply.',
});
this.lastOperation = {
label: 'Security settings',
timestamp: new Date().toLocaleString(),
status: 'Saved. Restart required.',
};
} catch (err) {
console.error(err);
this.$dispatch('toast', {
title: 'Update failed',
message: err?.message || 'Unable to update security settings.',
});
} finally {
this.securityProcessing = false;
}
},
addRwiToken() {
this.rwiDraft.tokens.push({ token: '', scopes: [] });
},
removeRwiToken(index) {
this.rwiDraft.tokens.splice(index, 1);
},
addRwiContext() {
this.rwiDraft.contexts.push({
name: '',
no_answer_timeout_secs: null,
no_answer_action: '',
no_answer_transfer_target: ''
});
},
removeRwiContext(index) {
this.rwiDraft.contexts.splice(index, 1);
},
async saveRwiSettings() {
if (this.rwiProcessing) {
return;
}
const body = {
enabled: this.rwiDraft.enabled,
listen: this.rwiDraft.listen,
max_connections: this.rwiDraft.max_connections,
max_calls_per_connection: this.rwiDraft.max_calls_per_connection,
orphan_hold_secs: this.rwiDraft.orphan_hold_secs,
originate_rate_limit: this.rwiDraft.originate_rate_limit,
tokens: this.rwiDraft.tokens.filter(t => t.token.trim()).map(t => ({
token: t.token,
scopes: t.scopes || []
})),
contexts: this.rwiDraft.contexts.filter(c => c.name.trim()).map(c => ({
name: c.name,
no_answer_timeout_secs: c.no_answer_timeout_secs,
no_answer_action: c.no_answer_action || null,
no_answer_transfer_target: c.no_answer_transfer_target || null
})),
};
this.rwiProcessing = true;
try {
const response = await fetch(`${this.apiBase}/settings/config/rwi`, {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to update RWI settings');
}
this.rwi = data.rwi || this.rwiDraft;
this.$dispatch('toast', {
title: 'Settings saved',
message: data?.message || 'RWI settings saved.',
});
this.lastOperation = {
label: 'RWI settings',
timestamp: new Date().toLocaleString(),
};
} catch (err) {
this.$dispatch('toast', {
type: 'error',
title: 'Error',
message: err?.message || 'Unable to update RWI settings.',
});
} finally {
this.rwiProcessing = false;
}
},
initClusterDraft() {
const cluster = this.rawSettings?.cluster;
this.clusterDraft = {
peers: (cluster?.peers || []).map(p => ({
addr: p.addr || '',
sip_port: p.sip_port || 5060,
ami_port: p.ami_port || 8080,
})),
};
this.fetchReloadAddons();
},
async fetchReloadAddons() {
try {
const resp = await fetch(`${this.apiBase}/settings/config/cluster/reload-addons`);
const data = await resp.json();
this.reloadAddonList = data.addons || [];
this.clusterReloadDraft = {};
for (const a of this.reloadAddonList) {
this.clusterReloadDraft[a.id] = true;
}
this.clusterReloadDraft.trunks = true;
} catch (e) {
console.warn('Failed to fetch reload addons', e);
}
},
addClusterPeer() {
this.clusterDraft.peers.push({ addr: '', sip_port: 5060, ami_port: 8080 });
},
removeClusterPeer(index) {
this.clusterDraft.peers.splice(index, 1);
},
resetClusterDraft() {
this.initClusterDraft();
},
async saveClusterSettings() {
if (this.clusterProcessing) return;
const body = {
peers: this.clusterDraft.peers
.filter(p => p.addr.trim())
.map(p => ({
addr: p.addr.trim(),
sip_port: Number(p.sip_port) || 5060,
ami_port: Number(p.ami_port) || 8080,
})),
};
this.clusterProcessing = true;
try {
const response = await fetch(`${this.apiBase}/settings/config/cluster`, {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || 'Failed to update cluster settings');
}
this.rawSettings.cluster = data.cluster || body;
this.initClusterDraft();
this.$dispatch('toast', {
title: 'Settings saved',
message: data?.message || 'Cluster settings saved.',
});
this.lastOperation = {
label: 'Cluster settings',
timestamp: new Date().toLocaleString(),
};
} catch (err) {
this.$dispatch('toast', {
type: 'error',
title: 'Error',
message: err?.message || 'Unable to update cluster settings.',
});
} finally {
this.clusterProcessing = false;
}
},
async pingClusterPeers() {
if (this.clusterPinging) return;
this.clusterPinging = true;
this.clusterPingResults = [];
this.clusterPingDone = false;
try {
const response = await fetch(`${this.amiEndpoint}/cluster/ping`, {
method: 'POST',
headers: { Accept: 'application/json' },
});
const data = await response.json().catch(() => ({}));
if (response.ok) {
this.clusterPingResults = data.peers || [];
} else {
this.$dispatch('toast', {
type: 'error',
title: 'Ping failed',
message: data?.message || 'Failed to ping cluster peers.',
});
}
} catch (err) {
this.$dispatch('toast', {
type: 'error',
title: 'Error',
message: err?.message || 'Unable to ping cluster peers.',
});
} finally {
this.clusterPinging = false;
this.clusterPingDone = true;
}
},
async reloadClusterConfig() {
if (this.clusterReloading) return;
const draft = this.clusterReloadDraft;
const trunkSelected = draft.trunks;
const routesSelected = draft.routes;
const addonIds = this.reloadAddonList.map(a => a.id).filter(id => draft[id]);
if (!trunkSelected && !routesSelected && !addonIds.length) {
this.$dispatch('toast', { title: 'No addons selected', message: 'Please select at least one addon to reload.' });
return;
}
this.clusterReloading = true;
this.clusterReloadResults = [];
this.clusterReloadOverall = null;
this.clusterReloadDone = false;
const params = new URLSearchParams();
if (trunkSelected) params.set('trunks', 'true');
if (routesSelected) params.set('routes', 'true');
for (const id of addonIds) params.append('addons', id);
try {
const response = await fetch(`${this.apiBase}/settings/config/cluster/reload?${params.toString()}`);
if (!response.ok) throw new Error('Failed to connect');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
this.handleReloadEvent(data);
} catch (e) { }
}
}
}
} catch (err) {
this.$dispatch('toast', { type: 'error', title: 'Error', message: err?.message || 'Unable to reload config.' });
} finally {
this.clusterReloading = false;
}
},
handleReloadEvent(data) {
if (!data) return;
const t = data.type;
if (t === 'addon_start') {
this.clusterReloadResults.push({ addon: data.addon, node: data.node, status: 'running' });
} else if (t === 'addon_complete') {
const existing = this.clusterReloadResults.find(r => r.addon === data.addon && r.node === data.node);
if (existing) Object.assign(existing, { status: data.result?.status || 'ok', result: data.result, message: data.result?.message || '' });
else this.clusterReloadResults.push({ addon: data.addon, node: data.node, status: data.result?.status || 'ok', result: data.result, message: data.result?.message || '' });
} else if (t === 'node_start') {
this.clusterReloadResults.push({ addon: null, node: data.node, status: 'running' });
} else if (t === 'node_complete' || t === 'node_error') {
const existing = this.clusterReloadResults.find(r => r.addon === null && r.node === data.node);
const nodeStatus = t === 'node_complete' ? (data.result?.status || 'ok') : 'error';
const nodeMsg = data.error || '';
const elapsed_ms = data.elapsed_ms;
if (existing) Object.assign(existing, { status: nodeStatus, result: data.result, message: nodeMsg, elapsed_ms });
else this.clusterReloadResults.push({ addon: null, node: data.node, status: nodeStatus, result: data.result, message: nodeMsg, elapsed_ms });
} else if (t === 'complete') {
this.clusterReloadOverall = data.overall_status || 'ok';
this.clusterReloadDone = true;
this.$dispatch('toast', { title: 'Reload complete', message: 'All selected addons have been reloaded.' });
}
},
}));
});
</script>
{% endblock %}