752 lines
40 KiB
HTML
752 lines
40 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ t.configs_heading }} | {{ t.site_name }}{% endblock title %}
|
|
|
|
{% block head_extra %}
|
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
<style>
|
|
* { box-sizing: border-box; }
|
|
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; }
|
|
.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; }
|
|
@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>
|
|
<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>
|
|
</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,
|
|
isAdmin: {% if is_admin %}true{% else %}false{% endif %},
|
|
init() {
|
|
this.load();
|
|
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;
|
|
},
|
|
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 %}
|