Files
amnezia-fellow/templates/configs.html
T
Ultradesu 9ccab69836
Build and Publish / Build and Publish Docker Image (push) Successful in 3m11s
AWG: Added migration
2026-07-01 13:53:03 +03:00

1025 lines
54 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ t.configs_heading }} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
* { box-sizing: border-box; }
[x-cloak] { display: none !important; }
body { margin: 0; min-height: 100vh; background: #f6f7f8; color: #1d252d; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
a { color: inherit; }
.shell { min-height: 100vh; display: grid; grid-template-columns: 220px minmax(0, 1fr); }
.sidebar { background: #17202a; color: #f4f7fb; padding: 1rem; }
.sidebar h1 { font-size: 1.05rem; line-height: 1.25; margin: 0 0 1.25rem; }
.sidebar a { display: block; text-decoration: none; color: #d8dee6; padding: .5rem .55rem; border-radius: 4px; margin-bottom: .2rem; }
.sidebar a:hover, .sidebar a.active { background: #263544; color: #fff; }
.main-wrap { min-width: 0; display: flex; flex-direction: column; }
.topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; flex-wrap: wrap; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; }
.user-info { font-size: .875rem; color: #53606d; }
.app-version { font-size: .75rem; color: #9aa4ae; white-space: nowrap; }
.telegram-status-button { min-width: 36px; width: 36px; height: 36px; min-height: 36px; padding: 0; display: inline-grid; place-items: center; position: relative; border-color: #cbd3db; background: #fff; color: #17202a; }
.telegram-status-button .telegram-mark { font-size: .7rem; font-weight: 900; letter-spacing: 0; }
.telegram-dot { position: absolute; right: 4px; top: 4px; width: .55rem; height: .55rem; border-radius: 999px; border: 2px solid #fff; background: #7b8793; }
.telegram-status-button.tone-connected .telegram-dot { background: #2f8f4e; }
.telegram-status-button.tone-pending .telegram-dot { background: #d39a00; }
.telegram-status-button.tone-empty .telegram-dot { background: #69777f; }
.telegram-status-button.tone-disabled .telegram-dot { background: #9d2323; }
.logout-link, .lang-switch a { font-size: .875rem; text-decoration: none; color: #53606d; padding: .25rem .45rem; border-radius: 4px; }
.logout-link:hover, .lang-switch a:hover { background: #eef1f4; color: #1d252d; }
.lang-switch a.active { color: #1d252d; font-weight: 700; }
.main { width: min(1180px, 100%); padding: 1.5rem; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.toolbar h2 { font-size: 1.5rem; margin: 0; }
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .6rem; }
.panel-head h3 { font-size: 1rem; margin: 0; color: #34414f; }
.actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.row-actions { display: flex; gap: .45rem; align-items: center; flex-wrap: wrap; }
input { min-height: 36px; border: 1px solid #cbd3db; border-radius: 4px; padding: .45rem .55rem; background: #fff; color: #1d252d; }
button { min-height: 36px; border: 1px solid #17202a; border-radius: 4px; padding: .45rem .75rem; background: #17202a; color: #fff; cursor: pointer; }
button.secondary { background: #fff; color: #17202a; border-color: #cbd3db; }
button.danger { background: #9d2323; border-color: #9d2323; }
button:disabled { opacity: .55; cursor: default; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; overflow: hidden; }
th, td { text-align: left; padding: .65rem .75rem; border-bottom: 1px solid #edf0f2; font-size: .92rem; vertical-align: middle; }
th { background: #eef1f4; font-weight: 650; color: #34414f; }
tr:last-child td { border-bottom: 0; }
.owner-row td { background: #f8fafb; color: #34414f; font-weight: 750; border-top: 1px solid #dde2e6; }
.owner-meta { color: #53606d; font-weight: 500; margin-left: .35rem; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .84rem; background: #f1f3f5; padding: .15rem .35rem; border-radius: 3px; }
.status { margin: .75rem 0; min-height: 1.25rem; color: #53606d; }
.error { color: #9d2323; }
.empty { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: 1rem; color: #53606d; }
.notice { margin: .75rem 0; border: 1px solid #dde2e6; border-left-width: 4px; border-radius: 6px; background: #fff; padding: .7rem .8rem; display: grid; gap: .25rem; }
.notice strong { color: #1d252d; }
.notice span { color: #53606d; font-size: .9rem; }
.notice details { color: #6a7682; font-size: .82rem; }
.notice summary { cursor: pointer; font-weight: 700; }
.notice.tone-success { border-left-color: #2f8f4e; }
.notice.tone-warning { border-left-color: #d39a00; }
.notice.tone-error { border-left-color: #9d2323; }
.rollout { margin: 1rem 0 1.25rem; }
.rollout-meta { color: #53606d; font-size: .88rem; margin-bottom: .5rem; }
.rollout-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .55rem; }
.rollout-tile { position: relative; min-height: 86px; border: 1px solid #dde2e6; border-left-width: 4px; border-radius: 6px; background: #fff; padding: .65rem .7rem; display: grid; gap: .45rem; align-content: space-between; cursor: pointer; }
.rollout-tile:focus { outline: 2px solid #7aa7c7; outline-offset: 2px; }
.rollout-tile::after { content: attr(data-tooltip); position: absolute; left: .25rem; top: calc(100% + .45rem); z-index: 5; width: max-content; max-width: min(380px, 80vw); padding: .55rem .65rem; border-radius: 4px; background: #17202a; color: #fff; font-size: .78rem; line-height: 1.35; white-space: pre-line; box-shadow: 0 8px 24px rgba(23, 32, 42, .22); opacity: 0; pointer-events: none; transform: translateY(-3px); transition: opacity .12s ease, transform .12s ease; }
.rollout-tile:hover::after, .rollout-tile:focus-within::after { opacity: 1; transform: translateY(0); }
.rollout-tile-applied { border-left-color: #2f8f4e; }
.rollout-tile-starting { border-left-color: #d39a00; }
.rollout-tile-pending_apply { border-left-color: #d39a00; }
.rollout-tile-pending_restart { border-left-color: #d39a00; }
.rollout-tile-error { border-left-color: #c83f31; }
.rollout-tile-unknown { border-left-color: #7b8793; }
.rollout-tile-head { display: flex; align-items: center; min-width: 0; gap: .45rem; }
.rollout-dot { width: .55rem; height: .55rem; border-radius: 999px; flex: 0 0 auto; background: #7b8793; }
.rollout-dot-applied { background: #2f8f4e; }
.rollout-dot-starting { background: #d39a00; }
.rollout-dot-pending_apply { background: #d39a00; }
.rollout-dot-pending_restart { background: #d39a00; }
.rollout-dot-error { background: #c83f31; }
.rollout-dot-unknown { background: #7b8793; }
.rollout-server { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; color: #1d252d; }
.rollout-badge { display: inline-flex; align-items: center; min-height: 24px; padding: .15rem .45rem; border-radius: 4px; font-size: .82rem; font-weight: 700; white-space: nowrap; }
.rollout-applied { background: #dcefe4; color: #175c31; }
.rollout-starting { background: #fff0c2; color: #6f4e00; }
.rollout-pending_apply { background: #fff0c2; color: #6f4e00; }
.rollout-pending_restart { background: #fff0c2; color: #6f4e00; }
.rollout-error { background: #ffe3df; color: #8b2116; }
.rollout-unknown { background: #e8ecef; color: #53606d; }
.rollout-foot { display: flex; align-items: center; justify-content: space-between; gap: .5rem; color: #53606d; font-size: .82rem; }
.server-name { font-weight: 650; color: #34414f; overflow-wrap: anywhere; }
.server-state { color: #53606d; }
.modal-backdrop { position: fixed; inset: 0; background: rgba(23,32,42,.35); display: grid; place-items: center; padding: 1rem; z-index: 20; }
.modal { width: min(420px, 100%); background: #fff; border-radius: 6px; border: 1px solid #dde2e6; box-shadow: 0 16px 48px rgba(23,32,42,.24); padding: 1rem; }
.modal h3 { margin: 0 0 .75rem; font-size: 1.1rem; }
.servers-modal { width: min(820px, 100%); max-height: min(760px, calc(100vh - 2rem)); display: flex; flex-direction: column; gap: .75rem; }
.modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
.modal-title { display: grid; gap: .2rem; min-width: 0; }
.modal-title h3 { margin: 0; }
.modal-subtitle { color: #53606d; font-size: .86rem; overflow-wrap: anywhere; }
.server-filter { width: 100%; }
.server-picker-list { display: grid; gap: .6rem; overflow: auto; padding-right: .15rem; }
.server-card { border: 1px solid #dde2e6; border-radius: 8px; padding: .8rem; display: grid; gap: .7rem; align-items: start; }
.server-card-main { display: grid; gap: .25rem; min-width: 0; }
.server-card-actions { display: grid; grid-template-columns: repeat(3, minmax(118px, 1fr)); gap: .45rem; align-items: center; }
.server-card-actions button { min-width: 0; }
.detail-list { display: grid; gap: .55rem; overflow: auto; }
.detail-row { display: grid; grid-template-columns: minmax(120px, .45fr) minmax(0, 1fr); gap: .75rem; align-items: start; padding: .55rem 0; border-bottom: 1px solid #edf0f2; }
.detail-row:last-child { border-bottom: 0; }
.detail-label { color: #53606d; font-size: .84rem; }
.detail-value { color: #1d252d; overflow-wrap: anywhere; }
.qr-box { display: grid; place-items: center; padding: .75rem; border: 1px solid #dde2e6; border-radius: 6px; background: #fff; }
.qr-box svg { width: min(280px, 100%); height: auto; display: block; }
.modal-actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: .75rem; flex-wrap: wrap; }
.telegram-modal { width: min(480px, 100%); display: grid; gap: .8rem; }
.telegram-copy { color: #53606d; line-height: 1.45; margin: 0; }
.guide-list { margin: 0; padding-left: 1.2rem; color: #34414f; display: grid; gap: .45rem; line-height: 1.4; }
.guide-list a { color: #17202a; font-weight: 800; }
.guide-list a.disabled { color: #6a7682; pointer-events: none; text-decoration: none; }
.secret-box { display: grid; gap: .35rem; border: 1px solid #dde2e6; border-radius: 6px; background: #f8fafb; padding: .7rem; }
.secret-box code { display: block; padding: .55rem .65rem; font-size: .92rem; white-space: normal; overflow-wrap: anywhere; }
@media (max-width: 760px) {
.shell { grid-template-columns: 1fr; }
.sidebar { display: flex; align-items: center; gap: .75rem; overflow-x: auto; }
.sidebar h1 { margin: 0; white-space: nowrap; }
.sidebar a { margin: 0; white-space: nowrap; }
.toolbar { align-items: stretch; flex-direction: column; }
.actions { align-items: stretch; }
.actions input, .actions button { width: 100%; }
.main { padding: 1rem; }
table { display: block; overflow-x: auto; }
.row-actions { align-items: stretch; }
.servers-modal { width: 100%; max-height: calc(100vh - 2rem); }
.server-card-actions { grid-template-columns: 1fr; }
.server-card-actions button { flex: 1 1 100%; }
.detail-row { grid-template-columns: 1fr; gap: .2rem; }
.rollout-grid { grid-template-columns: 1fr; }
}
</style>
{% endblock head_extra %}
{% block body %}
<div class="shell" x-data="configsPage()" x-init="init()">
<nav class="sidebar">
<h1>{{ t.site_name }}</h1>
<a href="/configs" class="active">{{ t.nav_configs }}</a>
{% if is_admin %}
<a href="/admin/">{{ t.nav_admin }}</a>
{% endif %}
</nav>
<div class="main-wrap">
<div class="topbar">
<span class="app-version">v{{ app_version }}</span>
<span class="user-info">{{ user_name }} ({{ user_role }})</span>
<button type="button" x-cloak x-show="telegram.enabled" class="telegram-status-button" :class="`tone-${telegramStatusTone()}`" @click="openTelegramManage()" :title="telegramStatusTitle()" :aria-label="telegramStatusTitle()">
<span class="telegram-mark">TG</span>
<span class="telegram-dot"></span>
</button>
<div class="lang-switch">
<a href="#"{% if t.lang.code() == "en" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=en&next='+encodeURIComponent(location.pathname);return false">EN</a>
<a href="#"{% if t.lang.code() == "ru" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=ru&next='+encodeURIComponent(location.pathname);return false">RU</a>
</div>
<a class="logout-link" href="/logout">{{ t.nav_logout }}</a>
</div>
<main class="main">
<div class="toolbar">
<h2>{{ t.configs_heading }}</h2>
<div class="actions">
<input x-model="newName" placeholder="{{ t.configs_name_placeholder }}">
<button @click="createClient()" :disabled="busy">{{ t.configs_create }}</button>
{% if is_admin %}
<button class="secondary" @click="sync()" :disabled="busy">{{ t.configs_sync }}</button>
{% endif %}
</div>
</div>
<template x-if="notice">
<section class="notice" :class="`tone-${notice.kind}`">
<strong x-text="notice.title"></strong>
<span x-text="notice.message"></span>
<details x-show="notice.detail">
<summary>{{ t.notice_detail }}</summary>
<span x-text="notice.detail"></span>
</details>
</section>
</template>
<section class="rollout">
<div class="panel-head">
<h3>{{ t.configs_rollout_heading }}</h3>
<button class="secondary" @click="loadRolloutStatus()" :disabled="rolloutBusy">{{ t.configs_refresh }}</button>
</div>
<template x-if="rollout">
<div class="rollout-meta">
{{ t.configs_config_updated }}:
<span x-text="formatTime(rollout.config_updated_at_ms)"></span>
</div>
</template>
<template x-if="rolloutError">
<div class="status error" x-text="rolloutError"></div>
</template>
<template x-if="!rollout && rolloutBusy">
<div class="empty">{{ t.configs_loading_status }}</div>
</template>
<template x-if="rollout && rollout.pods.length === 0">
<div class="empty">{{ t.configs_no_pods }}</div>
</template>
<template x-if="rollout && rollout.pods.length > 0">
<div class="rollout-grid">
<template x-for="pod in rollout.pods" :key="pod.name">
<div class="rollout-tile" :class="`rollout-tile-${pod.rollout_status}`" :data-tooltip="podTooltip(pod)" :title="podTooltip(pod)" tabindex="0" role="button" @click="openRollout(pod)" @keydown.enter.prevent="openRollout(pod)" @keydown.space.prevent="openRollout(pod)">
<div class="rollout-tile-head">
<span class="rollout-dot" :class="`rollout-dot-${pod.rollout_status}`"></span>
<span class="rollout-server" x-text="pod.endpoint_name || pod.node_name || pod.name"></span>
</div>
<span class="rollout-badge" :class="`rollout-${pod.rollout_status}`" x-text="rolloutStatusLabel(pod.rollout_status)"></span>
<div class="rollout-foot">
<span x-text="formatDuration(pod.uptime_seconds)"></span>
<span x-text="pod.ready ? '{{ t.configs_ready }}' : '{{ t.configs_not_ready }}'"></span>
</div>
</div>
</template>
</div>
</template>
</section>
<template x-if="!busy && clients.length === 0">
<div class="empty">{{ t.configs_empty }}</div>
</template>
<template x-if="clients.length > 0">
<table>
<thead>
<tr>
<th>{{ t.configs_name }}</th>
{% if is_admin %}
<th>{{ t.configs_owner }}</th>
{% endif %}
<th>{{ t.configs_address }}</th>
<th>{{ t.configs_public_key }}</th>
<th>{{ t.configs_enabled }}</th>
<th>{{ t.users_actions }}</th>
</tr>
</thead>
<template x-for="group in groupedClients()" :key="group.key">
<tbody>
{% if is_admin %}
<tr class="owner-row">
<td colspan="6">
<span x-text="group.label"></span>
<span class="owner-meta" x-text="`(${group.clients.length})`"></span>
</td>
</tr>
{% endif %}
<template x-for="client in group.clients" :key="client.id">
<tr>
<td x-text="client.name"></td>
{% if is_admin %}
<td x-text="ownerLabel(client)"></td>
{% endif %}
<td><code x-text="client.address + '/32'"></code></td>
<td><code x-text="shortKey(client.public_key)"></code></td>
<td x-text="client.enabled ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td>
<td>
<div class="row-actions">
<button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button>
<button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
<button class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
</div>
</td>
</tr>
</template>
</tbody>
</template>
</table>
</template>
</main>
</div>
<template x-if="rolloutModal">
<div class="modal-backdrop" @click.self="closeRollout()">
<div class="modal servers-modal">
<div class="modal-head">
<div class="modal-title">
<h3 x-text="podServerName(rolloutModal)"></h3>
<div class="modal-subtitle" x-text="rolloutModal.endpoint || rolloutModal.node_name || rolloutModal.name"></div>
</div>
<button class="secondary" @click="closeRollout()">{{ t.admin_close }}</button>
</div>
<div class="detail-list">
<div class="detail-row"><span class="detail-label">{{ t.configs_rollout }}</span><span class="detail-value"><span class="rollout-badge" :class="`rollout-${rolloutModal.rollout_status}`" x-text="rolloutStatusLabel(rolloutModal.rollout_status)"></span></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_pod }}</span><span class="detail-value"><code x-text="rolloutModal.name"></code></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_node }}</span><span class="detail-value"><code x-text="rolloutModal.node_name || '{{ t.configs_never }}'"></code></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_endpoint }}</span><span class="detail-value"><code x-text="rolloutModal.endpoint || '{{ t.configs_never }}'"></code></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_ready }}</span><span class="detail-value" x-text="rolloutModal.ready ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_phase }}</span><span class="detail-value" x-text="rolloutModal.phase || '{{ t.configs_status_unknown }}'"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_uptime }}</span><span class="detail-value" x-text="formatDuration(rolloutModal.uptime_seconds)"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_restarts }}</span><span class="detail-value" x-text="rolloutModal.restart_count"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_config_updated }}</span><span class="detail-value" x-text="formatTime(rollout && rollout.config_updated_at_ms)"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_config_applied }}</span><span class="detail-value" x-text="formatTime(rolloutModal.config_applied_at_ms)"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_reload_status }}</span><span class="detail-value" x-text="reloadStatusLabel(rolloutModal.config_reload_status)"></span></div>
<div class="detail-row"><span class="detail-label">{{ t.configs_config_hash }}</span><span class="detail-value"><code x-text="shortHash(rolloutModal.config_applied_hash) || '{{ t.configs_never }}'"></code></span></div>
<template x-if="rolloutModal.message">
<div class="detail-row"><span class="detail-label">{{ t.configs_message }}</span><span class="detail-value" x-text="rolloutModal.message"></span></div>
</template>
</div>
</div>
</div>
</template>
<template x-if="serverModal">
<div class="modal-backdrop" @click.self="closeServers()">
<div class="modal servers-modal">
<div class="modal-head">
<div class="modal-title">
<h3>{{ t.configs_servers }}</h3>
<div class="modal-subtitle" x-text="serverModal.client.name"></div>
</div>
<button class="secondary" @click="closeServers()">{{ t.admin_close }}</button>
</div>
<input class="server-filter" x-model="serverModal.filter" placeholder="{{ t.configs_server_search }}">
<template x-if="serverModal.loading">
<div class="empty">{{ t.configs_loading_servers }}</div>
</template>
<template x-if="!serverModal.loading && filteredServers().length === 0">
<div class="empty">{{ t.configs_no_servers }}</div>
</template>
<template x-if="!serverModal.loading && filteredServers().length > 0">
<div class="server-picker-list">
<template x-for="server in filteredServers()" :key="server.endpoint_id || server.endpoint_name">
<div class="server-card">
<div class="server-card-main">
<span class="server-name" x-text="server.endpoint_name"></span>
<code x-text="server.endpoint"></code>
</div>
<div class="server-card-actions">
<button class="secondary" @click="copyVpnUrl(serverModal.client, server)" :disabled="busy">{{ t.configs_copy_vpn_url }}</button>
<button class="secondary" @click="openQr(serverModal.client, server)" :disabled="busy">QR</button>
<button class="secondary" @click="downloadConfig(serverModal.client, server)" :disabled="busy">{{ t.configs_download }}</button>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</template>
<template x-if="qrModal">
<div class="modal-backdrop" @click.self="closeQr()">
<div class="modal">
<h3 x-text="qrModal.title"></h3>
<div class="qr-box" x-html="qrModal.svg"></div>
<div class="modal-actions">
<button class="secondary" @click="copyText(qrModal.url)">{{ t.configs_copy_vpn_url }}</button>
<button @click="closeQr()">{{ t.admin_close }}</button>
</div>
</div>
</div>
</template>
<template x-if="telegramModal">
<div class="modal-backdrop" @click.self="telegramModal = null">
<div class="modal telegram-modal">
<div class="modal-head">
<div class="modal-title">
<h3>{{ t.telegram_link_title }}</h3>
<div class="modal-subtitle" x-text="telegramBotLabel()"></div>
</div>
<button class="secondary" @click="telegramModal = null">{{ t.admin_close }}</button>
</div>
<template x-if="telegramModal === 'manage'">
<div>
<p class="telegram-copy" x-text="telegramManageMessage()"></p>
<template x-if="telegram.linked">
<div class="detail-row">
<span class="detail-label">{{ t.telegram_status_connected }}</span>
<span class="detail-value"><code x-text="telegram.telegram_id || ''"></code></span>
</div>
</template>
<template x-if="telegram.pending">
<div class="secret-box">
<span>{{ t.telegram_secret_label }}</span>
<code x-text="telegram.pending_secret || ''"></code>
</div>
</template>
<div class="modal-actions">
<template x-if="telegram.pending">
<button type="button" class="secondary" @click="copyTelegramSecret()" :disabled="telegramBusy || !telegram.pending_secret">{{ t.telegram_copy_secret }}</button>
</template>
<template x-if="telegram.pending">
<button type="button" class="secondary" @click="refreshTelegramStatus()" :disabled="telegramBusy">{{ t.telegram_refresh_status }}</button>
</template>
<button type="button" class="secondary" @click="startTelegramBotLink()" :disabled="telegramBusy" x-text="telegram.linked || telegram.pending ? '{{ t.telegram_change }}' : '{{ t.telegram_connect }}'"></button>
<template x-if="telegram.linked || telegram.pending || telegram.declined">
<button type="button" class="danger" @click="deleteTelegramLink()" :disabled="telegramBusy">{{ t.telegram_delete }}</button>
</template>
</div>
</div>
</template>
<template x-if="telegramModal === 'webapp'">
<div>
<p class="telegram-copy">{{ t.telegram_webapp_message }}</p>
<div class="modal-actions">
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
<button type="button" @click="linkTelegramWebApp()" :disabled="telegramBusy">{{ t.telegram_save }}</button>
</div>
</div>
</template>
<template x-if="telegramModal === 'manualPrompt'">
<div>
<p class="telegram-copy">{{ t.telegram_manual_message }}</p>
<div class="modal-actions">
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
<button type="button" @click="startTelegramBotLink()" :disabled="telegramBusy">{{ t.telegram_connect }}</button>
</div>
</div>
</template>
<template x-if="telegramModal === 'manualGuide'">
<div>
<p class="telegram-copy">{{ t.telegram_guide_title }}</p>
<ol class="guide-list">
<li>{{ t.telegram_guide_start }} <a :href="telegramBotUrl()" target="_blank" rel="noreferrer" :class="{ disabled: !telegram.bot_url }">{{ t.telegram_open_bot }}</a></li>
<li>{{ t.telegram_guide_get_id }}</li>
<li>{{ t.telegram_guide_paste }}</li>
</ol>
<div class="secret-box">
<span>{{ t.telegram_secret_label }}</span>
<code x-text="telegram.pending_secret || ''"></code>
</div>
<div class="modal-actions">
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
<button type="button" class="secondary" @click="copyTelegramSecret()" :disabled="telegramBusy || !telegram.pending_secret">{{ t.telegram_copy_secret }}</button>
<button type="button" @click="refreshTelegramStatus()" :disabled="telegramBusy">{{ t.telegram_refresh_status }}</button>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
<script>
function configsPage() {
return {
clients: [],
newName: '',
busy: false,
status: '',
error: '',
notice: null,
rollout: null,
rolloutBusy: false,
rolloutError: '',
rolloutTimer: null,
rolloutModal: null,
serverConfigs: {},
serverModal: null,
qrModal: null,
telegram: {
enabled: false,
bot_username: '',
bot_url: '',
linked: false,
declined: false,
pending: false,
pending_secret: null,
pending_expires_at: null,
telegram_id: null,
isWebApp: false,
initData: '',
},
telegramModal: null,
telegramBusy: false,
telegramPromptChecked: false,
isAdmin: {% if is_admin %}true{% else %}false{% endif %},
init() {
this.initTelegramWebApp();
this.load();
this.loadTelegramStatus();
if (this.isAdmin) {
this.loadRolloutStatus();
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
}
},
async request(url, options = {}) {
const response = await fetch(url, {
headers: { 'content-type': 'application/json' },
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data.error || this.httpStatusMessage(response.status));
error.title = data.title || this.httpStatusTitle(response.status);
error.detail = data.detail || '';
error.code = data.code || '';
error.status = response.status;
throw error;
}
return data;
},
initTelegramWebApp() {
const webApp = window.Telegram && window.Telegram.WebApp;
if (!webApp) return;
try {
webApp.ready();
webApp.expand();
} catch (_) {}
this.telegram.isWebApp = Boolean(webApp.initData);
this.telegram.initData = webApp.initData || '';
},
async loadTelegramStatus() {
try {
const status = await this.request('/api/telegram-link/status');
this.applyTelegramStatus(status);
this.maybePromptTelegram();
} catch (e) {
console.warn('telegram status failed', e);
}
},
applyTelegramStatus(status) {
this.telegram = {
...this.telegram,
enabled: Boolean(status.enabled),
bot_username: status.bot_username || '',
bot_url: status.bot_url || '',
linked: Boolean(status.linked),
declined: Boolean(status.declined),
pending: Boolean(status.pending),
pending_secret: status.pending_secret || null,
pending_expires_at: status.pending_expires_at || null,
telegram_id: status.telegram_id || null,
};
},
maybePromptTelegram() {
if (this.telegramPromptChecked) return;
this.telegramPromptChecked = true;
if (!this.telegram.enabled || this.telegram.linked || this.telegram.pending || this.telegram.declined) return;
this.telegramModal = (this.telegram.isWebApp && this.telegram.initData) ? 'webapp' : 'manualPrompt';
},
openTelegramManage() {
if (!this.telegram.enabled) return;
if (this.telegram.linked || this.telegram.pending || this.telegram.declined) {
this.telegramModal = 'manage';
return;
}
this.telegramModal = (this.telegram.isWebApp && this.telegram.initData) ? 'webapp' : 'manualPrompt';
},
async linkTelegramWebApp() {
this.telegramBusy = true;
this.clearNotice();
try {
const data = await this.request('/api/telegram-link/webapp', {
method: 'POST',
body: JSON.stringify({ init_data: this.telegram.initData }),
});
this.applyTelegramStatus(data.status || {});
this.telegramModal = null;
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
} catch (e) {
this.showError(e);
} finally {
this.telegramBusy = false;
}
},
async startTelegramBotLink() {
this.telegramBusy = true;
this.clearNotice();
try {
const data = await this.request('/api/telegram-link/start', { method: 'POST' });
this.applyTelegramStatus(data.status || {});
this.telegramModal = 'manualGuide';
} catch (e) {
this.showError(e);
} finally {
this.telegramBusy = false;
}
},
async refreshTelegramStatus() {
this.telegramBusy = true;
try {
await this.loadTelegramStatus();
if (this.telegram.linked) {
this.telegramModal = 'manage';
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
}
} finally {
this.telegramBusy = false;
}
},
async copyTelegramSecret() {
if (!this.telegram.pending_secret) return;
this.clearNotice();
try {
await this.copyText(this.telegram.pending_secret);
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_secret_copied }}');
} catch (e) {
this.showError(e);
}
},
async declineTelegramLink() {
this.telegramBusy = true;
this.clearNotice();
try {
const data = await this.request('/api/telegram-link/decline', { method: 'POST' });
this.applyTelegramStatus(data.status || {});
this.telegramModal = null;
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_declined }}');
} catch (e) {
this.showError(e);
} finally {
this.telegramBusy = false;
}
},
async deleteTelegramLink() {
if (!confirm('{{ t.telegram_delete_confirm }}')) return;
this.telegramBusy = true;
this.clearNotice();
try {
const data = await this.request('/api/telegram-link/unlink', { method: 'POST' });
this.applyTelegramStatus(data.status || {});
this.telegramModal = null;
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_unlinked }}');
} catch (e) {
this.showError(e);
} finally {
this.telegramBusy = false;
}
},
telegramBotUrl() {
return this.telegram.bot_url || '#';
},
telegramBotLabel() {
return this.telegram.bot_username ? `@${this.telegram.bot_username}` : '{{ t.telegram_bot_unconfigured }}';
},
telegramStatusTone() {
if (!this.telegram.enabled) return 'disabled';
if (this.telegram.pending) return 'pending';
if (this.telegram.linked) return 'connected';
return 'empty';
},
telegramStatusTitle() {
if (!this.telegram.enabled) return '{{ t.telegram_status_disabled }}';
if (this.telegram.pending) return '{{ t.telegram_status_pending }}';
if (this.telegram.linked) return '{{ t.telegram_status_connected }}';
return '{{ t.telegram_status_empty }}';
},
telegramManageMessage() {
if (this.telegram.pending) return '{{ t.telegram_pending_message }}';
if (this.telegram.linked) return '{{ t.telegram_connected_message }}';
return '{{ t.telegram_not_connected_message }}';
},
async load() {
this.error = '';
this.busy = true;
try {
const data = await this.request('/api/vpn-clients');
this.clients = data.clients;
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async createClient() {
this.clearNotice();
this.busy = true;
try {
const data = await this.request('/api/vpn-clients', {
method: 'POST',
body: JSON.stringify({ name: this.newName }),
});
this.clients.push(data.client);
delete this.serverConfigs[data.client.id];
this.newName = '';
this.applyResponseNotice(data, '{{ t.notice_create_success }}');
if (this.isAdmin) await this.loadRolloutStatus();
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
async setEnabled(client, enabled) {
this.clearNotice();
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}/enabled`, {
method: 'POST',
body: JSON.stringify({ enabled }),
});
const index = this.clients.findIndex((item) => item.id === client.id);
if (index !== -1) this.clients[index] = data.client;
delete this.serverConfigs[client.id];
this.applyResponseNotice(data, '{{ t.notice_update_success }}');
if (this.isAdmin) await this.loadRolloutStatus();
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
async deleteClient(client) {
if (!confirm('{{ t.users_delete_confirm }}')) return;
this.clearNotice();
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}`, {
method: 'DELETE',
});
this.clients = this.clients.filter((item) => item.id !== client.id);
delete this.serverConfigs[client.id];
this.applyResponseNotice(data, '{{ t.notice_delete_success }}');
if (this.isAdmin) await this.loadRolloutStatus();
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
async openServers(client) {
this.clearNotice();
const cached = this.serverConfigs[client.id];
this.serverModal = {
client,
filter: '',
loading: !cached || cached.loading,
servers: cached ? cached.servers : [],
};
try {
const servers = await this.loadClientServers(client);
if (this.serverModal && this.serverModal.client.id === client.id) {
this.serverModal.loading = false;
this.serverModal.servers = servers;
}
} catch (e) {
if (this.serverModal && this.serverModal.client.id === client.id) {
this.serverModal.loading = false;
this.serverModal.servers = [];
}
this.showError(e, {
title: '{{ t.notice_server_list_error_title }}',
message: '{{ t.notice_server_list_error_message }}',
});
}
},
async loadClientServers(client) {
const cached = this.serverConfigs[client.id];
if (cached && !cached.loading) return cached.servers || [];
this.serverConfigs[client.id] = { loading: true, servers: [] };
try {
const data = await this.request(`/api/vpn-clients/${client.id}/config`);
const servers = data.servers || [];
this.serverConfigs[client.id] = { loading: false, servers };
return servers;
} catch (e) {
this.serverConfigs[client.id] = { loading: false, servers: [] };
throw e;
}
},
closeServers() {
this.serverModal = null;
},
filteredServers() {
if (!this.serverModal) return [];
const query = this.serverModal.filter.trim().toLowerCase();
if (!query) return this.serverModal.servers;
return this.serverModal.servers.filter((server) => {
return [server.endpoint_name, server.endpoint, server.endpoint_id]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query));
});
},
async downloadConfig(client, server) {
this.clearNotice();
this.busy = true;
try {
this.saveConfigFile(client, server);
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
saveConfigFile(client, server) {
const blob = new Blob([server.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.fileSlug(client.name || 'amnezia-client')}-${this.fileSlug(server.endpoint_name)}.conf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: ${server.endpoint}`);
},
async copyVpnUrl(client, server) {
this.clearNotice();
this.busy = true;
try {
await this.copyText(server.vpn_url);
this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: {{ t.configs_vpn_url_copied }}`);
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
openQr(client, server) {
this.qrModal = {
title: `${client.name || 'amnezia-client'} · ${server.endpoint_name}`,
svg: server.qr_svg,
url: server.vpn_url,
};
},
closeQr() {
this.qrModal = null;
},
async copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand('copy');
textarea.remove();
if (!copied) {
throw new Error('copy failed');
}
},
async sync() {
this.clearNotice();
this.busy = true;
try {
const data = await this.request('/api/vpn-clients/sync', { method: 'POST' });
this.setNotice('success', '{{ t.notice_success_title }}', data.message);
await this.loadRolloutStatus();
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
async loadRolloutStatus() {
if (!this.isAdmin) return;
this.rolloutError = '';
this.rolloutBusy = true;
try {
this.rollout = await this.request('/api/vpn-status');
} catch (e) {
this.rolloutError = e.message;
} finally {
this.rolloutBusy = false;
}
},
openRollout(pod) {
this.rolloutModal = pod;
},
closeRollout() {
this.rolloutModal = null;
},
clientServers(client) {
return (this.serverConfigs[client.id] && this.serverConfigs[client.id].servers) || [];
},
isLoadingServers(client) {
return Boolean(this.serverConfigs[client.id] && this.serverConfigs[client.id].loading);
},
ownerLabel(client) {
return client.owner_display_name || client.owner_username || `#${client.owner_user_id}`;
},
groupedClients() {
const groups = new Map();
for (const client of this.clients) {
const key = String(client.owner_user_id);
if (!groups.has(key)) {
groups.set(key, {
key,
label: this.ownerLabel(client),
clients: [],
});
}
groups.get(key).clients.push(client);
}
return Array.from(groups.values()).sort((left, right) => left.label.localeCompare(right.label));
},
shortKey(key) {
if (!key || key.length <= 16) return key || '';
return `${key.slice(0, 8)}...${key.slice(-8)}`;
},
shortHash(hash) {
if (!hash) return '';
if (hash.length <= 16) return hash;
return `${hash.slice(0, 12)}...${hash.slice(-8)}`;
},
fileSlug(value) {
return String(value || 'amnezia').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'amnezia';
},
clearNotice() {
this.error = '';
this.status = '';
this.notice = null;
},
setNotice(kind, title, message, detail = '') {
this.notice = { kind, title, message, detail };
this.error = kind === 'error' ? message : '';
this.status = kind === 'error' ? '' : message;
},
applyResponseNotice(data, fallbackMessage) {
if (data.notice) {
const notice = this.normalizeNotice(data.notice);
this.setNotice(notice.kind, notice.title, notice.message, notice.detail || '');
return;
}
this.setNotice('success', '{{ t.notice_success_title }}', (data.sync && data.sync.message) || fallbackMessage);
},
normalizeNotice(notice) {
if (notice.code === 'secret_sync_failed') {
return {
kind: 'warning',
title: '{{ t.notice_sync_warning_title }}',
message: '{{ t.notice_sync_warning_message }}',
detail: notice.detail || '',
};
}
return {
kind: notice.kind || 'warning',
title: notice.title || '{{ t.notice_warning_title }}',
message: notice.message || '',
detail: notice.detail || '',
};
},
showError(error, fallback = {}) {
this.setNotice(
'error',
fallback.title || error.title || '{{ t.notice_error_title }}',
fallback.message || error.message || '{{ t.notice_error_title }}',
error.detail || ''
);
},
httpStatusTitle(status) {
if (status === 401) return '{{ t.login_heading }}';
if (status === 403) return '{{ t.notice_error_title }}';
if (status === 404) return '{{ t.configs_empty }}';
if (status === 409) return '{{ t.notice_warning_title }}';
if (status === 503) return '{{ t.notice_server_list_error_title }}';
return '{{ t.notice_error_title }}';
},
httpStatusMessage(status) {
if (status === 401) return 'not authenticated';
if (status === 403) return 'access denied';
if (status === 404) return 'not found';
if (status === 409) return '{{ t.configs_no_servers }}';
if (status === 503) return '{{ t.notice_server_list_error_message }}';
return '{{ t.notice_error_title }}';
},
rolloutStatusLabel(value) {
const labels = {
applied: '{{ t.configs_status_applied }}',
starting: '{{ t.configs_status_starting }}',
pending_apply: '{{ t.configs_status_pending_apply }}',
pending_restart: '{{ t.configs_status_pending_apply }}',
error: '{{ t.configs_status_error }}',
unknown: '{{ t.configs_status_unknown }}',
};
return labels[value] || value || '{{ t.configs_status_unknown }}';
},
reloadStatusLabel(value) {
if (!value) return '{{ t.configs_never }}';
return this.rolloutStatusLabel(value);
},
podServerName(pod) {
return pod.endpoint_name || pod.node_name || pod.name || '{{ t.configs_status_unknown }}';
},
podTooltip(pod) {
return [
`{{ t.configs_server }}: ${this.podServerName(pod)}`,
`{{ t.configs_pod }}: ${pod.name}`,
pod.endpoint ? `{{ t.configs_endpoint }}: ${pod.endpoint}` : null,
`{{ t.configs_rollout }}: ${this.rolloutStatusLabel(pod.rollout_status)}`,
`{{ t.configs_ready }}: ${pod.ready ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'}`,
`{{ t.configs_phase }}: ${pod.phase || '{{ t.configs_status_unknown }}'}`,
`{{ t.configs_uptime }}: ${this.formatDuration(pod.uptime_seconds)}`,
`{{ t.configs_restarts }}: ${pod.restart_count}`,
`{{ t.configs_config_updated }}: ${this.formatTime(this.rollout && this.rollout.config_updated_at_ms)}`,
`{{ t.configs_config_applied }}: ${this.formatTime(pod.config_applied_at_ms)}`,
pod.config_reload_status ? `{{ t.configs_reload_status }}: ${this.reloadStatusLabel(pod.config_reload_status)}` : null,
pod.message ? `{{ t.configs_message }}: ${pod.message}` : null,
].filter(Boolean).join('\n');
},
formatTime(ms) {
if (!ms) return '{{ t.configs_never }}';
return new Date(ms).toLocaleString();
},
formatDuration(seconds) {
if (seconds === null || seconds === undefined) return '{{ t.configs_never }}';
let remaining = Number(seconds);
const days = Math.floor(remaining / 86400);
remaining %= 86400;
const hours = Math.floor(remaining / 3600);
remaining %= 3600;
const minutes = Math.floor(remaining / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
},
};
}
</script>
{% endblock body %}