Files
amnezia-fellow/templates/admin/app.html
T
Ultradesu 3a4bc23a58
Build and Publish / Build and Publish Docker Image (push) Successful in 3m8s
Added telegram bot
2026-06-30 18:36:53 +03:00

531 lines
29 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ t.nav_admin }} | {{ 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, .sidebar button { width: 100%; display: block; text-align: left; text-decoration: none; color: #d8dee6; padding: .5rem .55rem; border-radius: 4px; margin-bottom: .2rem; border: 0; background: transparent; cursor: pointer; font: inherit; }
.sidebar a:hover, .sidebar button:hover, .sidebar .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; 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; }
.actions, .row-actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
input, select { min-height: 36px; border: 1px solid #cbd3db; border-radius: 4px; padding: .45rem .55rem; background: #fff; color: #1d252d; width: 100%; }
input[type="checkbox"] { width: auto; min-height: auto; }
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; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .84rem; background: #f1f3f5; padding: .15rem .35rem; border-radius: 3px; overflow-wrap: anywhere; }
.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; }
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
.summary-tile { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .8rem; }
.summary-value { display: block; font-size: 1.6rem; font-weight: 750; line-height: 1.1; }
.summary-label { color: #53606d; font-size: .85rem; }
.section-tabs { display: flex; gap: .4rem; margin: .5rem 0 1rem; flex-wrap: wrap; }
.section-tabs button { min-height: 32px; padding: .35rem .6rem; }
.section-tabs button.active { background: #17202a; color: #fff; border-color: #17202a; }
.settings-grid { display: grid; grid-template-columns: minmax(180px, .7fr) minmax(240px, 1fr) auto; gap: .55rem .75rem; align-items: center; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .75rem; }
.settings-row { display: contents; }
.settings-info-row { grid-column: 1 / -1; border: 1px solid #dde2e6; border-left: 4px solid #2d6cdf; border-radius: 6px; background: #f8fafc; padding: .75rem; display: grid; gap: .45rem; }
.settings-info-title { color: #1d2733; font-weight: 750; }
.settings-info-text { margin: 0; color: #34414f; white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .84rem; line-height: 1.45; }
.field-name { font-weight: 650; color: #34414f; overflow-wrap: anywhere; }
.field-help { color: #53606d; font-size: .78rem; margin-top: .15rem; overflow-wrap: anywhere; }
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 4px; font-size: .8rem; font-weight: 650; }
.badge-default { background: #e8ecef; color: #53606d; }
.badge-database { background: #d7edf1; color: #0f5560; }
.badge-env { background: #fff0c2; color: #6f4e00; }
.servers-list { display: grid; gap: .75rem; }
.server-admin-row { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .8rem; display: grid; grid-template-columns: minmax(220px, 1fr) minmax(260px, 1.2fr) auto; gap: .75rem; align-items: center; }
.server-admin-name { display: grid; gap: .25rem; min-width: 0; }
.server-admin-name label { font-weight: 650; color: #34414f; font-size: .85rem; }
.server-admin-meta { display: grid; gap: .25rem; min-width: 0; }
.server-admin-meta span { color: #53606d; font-size: .78rem; }
.toggle-row { display: inline-flex; align-items: center; gap: .45rem; color: #34414f; font-weight: 650; white-space: nowrap; }
.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(560px, 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; }
.form-grid { display: grid; gap: .7rem; }
.form-row label { display: block; font-weight: 650; color: #34414f; font-size: .9rem; margin-bottom: .25rem; }
.hint { color: #53606d; font-size: .78rem; margin-top: .2rem; }
.debug-grid { display: grid; gap: 1rem; }
.debug-build { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .55rem; }
@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, .sidebar button { margin: 0; white-space: nowrap; width: auto; }
.toolbar { align-items: stretch; flex-direction: column; }
.actions { align-items: stretch; }
.actions button { width: 100%; }
.main { padding: 1rem; }
table { display: block; overflow-x: auto; }
.settings-grid { grid-template-columns: 1fr; }
.server-admin-row { grid-template-columns: 1fr; align-items: stretch; }
.toggle-row { justify-content: space-between; }
}
</style>
{% endblock head_extra %}
{% block body %}
<div class="shell" x-data="adminApp('{{ initial_view }}')" x-init="init()">
<nav class="sidebar">
<h1>{{ t.site_name }}</h1>
<a href="/configs">{{ t.nav_configs }}</a>
<button :class="{ active: view === 'dashboard' }" @click="navigate('dashboard')">{{ t.nav_dashboard }}</button>
<button :class="{ active: view === 'users' }" @click="navigate('users')">{{ t.nav_users }}</button>
<button :class="{ active: view === 'servers' }" @click="navigate('servers')">{{ t.nav_servers }}</button>
<button :class="{ active: view === 'settings' }" @click="navigate('settings')">{{ t.nav_settings }}</button>
<button :class="{ active: view === 'debug' }" @click="navigate('debug')">{{ t.nav_debug }}</button>
</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 x-text="title()"></h2>
<div class="actions">
<button class="secondary" @click="reload()" :disabled="busy">{{ t.configs_refresh }}</button>
<template x-if="view === 'users'">
<button @click="openUserModal(null)" :disabled="busy">{{ t.users_add }}</button>
</template>
<template x-if="view === 'settings'">
<button @click="saveSettings()" :disabled="busy">{{ t.settings_save }}</button>
</template>
<template x-if="view === 'servers'">
<button @click="saveServers()" :disabled="busy">{{ t.settings_save }}</button>
</template>
</div>
</div>
<div class="status" :class="{ error: error }" x-text="error || status"></div>
<section x-show="view === 'dashboard'">
<template x-if="summary">
<div>
<div class="summary-grid">
<div class="summary-tile"><span class="summary-value" x-text="summary.users_count"></span><span class="summary-label">{{ t.nav_users }}</span></div>
<div class="summary-tile"><span class="summary-value" x-text="summary.admin_users_count"></span><span class="summary-label">admin</span></div>
<div class="summary-tile"><span class="summary-value" x-text="summary.client_users_count"></span><span class="summary-label">client</span></div>
<div class="summary-tile"><span class="summary-value" x-text="summary.active_users_count"></span><span class="summary-label">{{ t.users_active }}</span></div>
</div>
<div class="debug-build">
<div class="summary-tile"><span class="summary-label">Version</span><br><code x-text="summary.build.pkg_version"></code></div>
<div class="summary-tile"><span class="summary-label">Profile</span><br><code x-text="summary.build.profile"></code></div>
<div class="summary-tile"><span class="summary-label">Target</span><br><code x-text="summary.build.target"></code></div>
</div>
</div>
</template>
</section>
<section x-show="view === 'users'">
<template x-if="users.length === 0 && !busy">
<div class="empty">{{ t.users_heading }}: 0</div>
</template>
<template x-if="users.length > 0">
<table>
<thead>
<tr>
<th>{{ t.users_username }}</th>
<th>{{ t.users_email }}</th>
<th>{{ t.users_display_name }}</th>
<th>{{ t.users_role }}</th>
<th>{{ t.users_active }}</th>
<th>{{ t.users_actions }}</th>
</tr>
</thead>
<tbody>
<template x-for="user in users" :key="user.id">
<tr>
<td x-text="user.username"></td>
<td x-text="user.email"></td>
<td x-text="user.display_name"></td>
<td><code x-text="user.role"></code></td>
<td x-text="user.active ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td>
<td>
<div class="row-actions">
<button class="secondary" @click="openUserModal(user)">{{ t.users_edit }}</button>
<button class="danger" @click="deleteUser(user)">{{ t.users_delete }}</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</template>
</section>
<section x-show="view === 'settings'">
<div class="section-tabs">
<template x-for="section in settingSections" :key="section">
<button class="secondary" :class="{ active: settingsSection === section }" @click="settingsSection = section" x-text="sectionLabel(section)"></button>
</template>
</div>
<template x-if="settings.fields.length > 0">
<div class="settings-grid">
<template x-for="field in visibleSettings()" :key="field.key">
<div :class="field.kind === 'info' ? 'settings-info-row' : 'settings-row'">
<template x-if="field.kind === 'info'">
<div>
<div class="settings-info-title" x-text="fieldLabel(field.key)"></div>
<pre class="settings-info-text" x-text="field.value"></pre>
</div>
</template>
<template x-if="field.kind !== 'info'">
<div class="field-name">
<span x-text="fieldLabel(field.key)"></span>
<div class="field-help" x-text="field.env_var"></div>
</div>
</template>
<template x-if="field.kind !== 'info'">
<div>
<template x-if="field.kind === 'bool'">
<input type="checkbox" x-model="settingsForm[field.key]">
</template>
<template x-if="field.kind !== 'bool'">
<input :type="field.kind === 'password' ? 'password' : field.kind" x-model="settingsForm[field.key]">
</template>
<div class="hint">default: <code x-text="field.default_value || '(empty)'"></code></div>
</div>
</template>
<template x-if="field.kind !== 'info'">
<span class="badge" :class="`badge-${field.source}`" x-text="field.source"></span>
</template>
</div>
</template>
</div>
</template>
</section>
<section x-show="view === 'servers'">
<template x-if="servers.length === 0 && !busy">
<div class="empty">{{ t.servers_empty }}</div>
</template>
<template x-if="servers.length > 0">
<div class="servers-list">
<template x-for="server in servers" :key="server.name">
<div class="server-admin-row">
<div class="server-admin-name">
<label>{{ t.servers_display_name }}</label>
<input x-model="server.display_name" :placeholder="server.name">
</div>
<div class="server-admin-meta">
<span>{{ t.servers_technical_name }}</span>
<code x-text="server.name"></code>
<span>{{ t.servers_endpoint }}</span>
<code x-text="server.endpoint"></code>
</div>
<label class="toggle-row">
<span>{{ t.configs_enabled }}</span>
<input type="checkbox" x-model="server.enabled">
</label>
</div>
</template>
</div>
</template>
</section>
<section x-show="view === 'debug'" class="debug-grid">
<template x-if="debug">
<div>
<div class="debug-build">
<div class="summary-tile"><span class="summary-label">Package</span><br><code x-text="debug.build.pkg_name"></code></div>
<div class="summary-tile"><span class="summary-label">Version</span><br><code x-text="debug.build.pkg_version"></code></div>
<div class="summary-tile"><span class="summary-label">Rustc</span><br><code x-text="debug.build.rustc_version"></code></div>
<div class="summary-tile"><span class="summary-label">{{ t.debug_db_status }}</span><br><code x-text="debug.db_status"></code></div>
</div>
<table>
<thead>
<tr>
<th>{{ t.debug_field }}</th>
<th>{{ t.debug_value }}</th>
<th>{{ t.debug_source }}</th>
</tr>
</thead>
<tbody>
<template x-for="entry in debug.config_entries" :key="entry.key">
<tr>
<td><code x-text="entry.key"></code><div class="hint" x-text="entry.env_var"></div></td>
<td><code x-text="entry.value || '(empty)'"></code></td>
<td><span class="badge" :class="`badge-${entry.source}`" x-text="entry.source"></span></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</section>
</main>
</div>
<template x-if="userModalOpen">
<div class="modal-backdrop" @click.self="closeUserModal()">
<div class="modal">
<h3 x-text="userForm.id ? '{{ t.users_edit_heading }}' : '{{ t.users_new_heading }}'"></h3>
<div class="form-grid">
<div class="form-row"><label>{{ t.users_username }}</label><input x-model="userForm.username"></div>
<div class="form-row"><label>{{ t.users_email }}</label><input type="email" x-model="userForm.email"></div>
<div class="form-row"><label>{{ t.users_display_name }}</label><input x-model="userForm.display_name"></div>
<div class="form-row">
<label>{{ t.login_password }}</label>
<input type="password" x-model="userForm.password">
<template x-if="userForm.id"><div class="hint">{{ t.users_password_hint }}</div></template>
</div>
<div class="form-row">
<label>{{ t.users_role }}</label>
<select x-model="userForm.role">
<option value="client">client</option>
<option value="admin">admin</option>
</select>
</div>
<div class="actions">
<button @click="saveUser()">{{ t.settings_save }}</button>
<button class="secondary" @click="closeUserModal()">{{ t.admin_close }}</button>
</div>
</div>
</div>
</div>
</template>
</div>
<script>
function adminApp(initialView) {
return {
view: initialView || 'dashboard',
busy: false,
status: '',
error: '',
summary: null,
users: [],
servers: [],
settings: { fields: [] },
settingsForm: {},
settingsSection: 'auth',
settingSections: ['auth', 'oidc', 'kubernetes', 'vpn', 'api', 'telegram'],
debug: null,
userModalOpen: false,
userForm: {},
init() {
this.loadForView();
window.addEventListener('popstate', () => {
this.view = this.viewFromPath(location.pathname);
this.loadForView();
});
},
async request(url, options = {}) {
const response = await fetch(url, {
headers: { 'content-type': 'application/json' },
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || response.statusText);
return data;
},
navigate(view) {
this.view = view;
history.pushState({}, '', this.pathForView(view));
this.loadForView();
},
pathForView(view) {
return {
dashboard: '/admin/',
users: '/admin/users',
servers: '/admin/servers',
settings: '/admin/settings',
debug: '/admin/debug',
}[view] || '/admin/';
},
viewFromPath(path) {
if (path.endsWith('/users')) return 'users';
if (path.endsWith('/servers')) return 'servers';
if (path.endsWith('/settings')) return 'settings';
if (path.endsWith('/debug')) return 'debug';
return 'dashboard';
},
title() {
return {
dashboard: '{{ t.nav_dashboard }}',
users: '{{ t.nav_users }}',
servers: '{{ t.nav_servers }}',
settings: '{{ t.nav_settings }}',
debug: '{{ t.nav_debug }}',
}[this.view] || '{{ t.nav_admin }}';
},
async loadForView() {
this.error = '';
if (this.view === 'dashboard') await this.loadSummary();
if (this.view === 'users') await this.loadUsers();
if (this.view === 'servers') await this.loadServers();
if (this.view === 'settings') await this.loadSettings();
if (this.view === 'debug') await this.loadDebug();
},
async reload() {
await this.loadForView();
},
async loadSummary() {
this.busy = true;
try { this.summary = await this.request('/admin/api/summary'); }
catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async loadUsers() {
this.busy = true;
try { this.users = (await this.request('/admin/api/users')).users; }
catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async loadServers() {
this.busy = true;
try { this.servers = (await this.request('/admin/api/servers')).servers; }
catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async loadSettings() {
this.busy = true;
try {
this.settings = await this.request('/admin/api/settings');
const form = {};
for (const field of this.settings.fields) {
if (field.kind === 'info') continue;
form[field.key] = field.kind === 'bool' ? field.value === 'true' : field.value;
}
this.settingsForm = form;
} catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async loadDebug() {
this.busy = true;
try { this.debug = await this.request('/admin/api/debug'); }
catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
visibleSettings() {
return this.settings.fields.filter((field) => field.section === this.settingsSection);
},
async saveSettings() {
this.error = '';
this.status = '';
this.busy = true;
try {
const body = { ...this.settingsForm, vpn_mtu: Number(this.settingsForm.vpn_mtu || 0) };
this.settings = await this.request('/admin/api/settings', {
method: 'POST',
body: JSON.stringify(body),
});
this.status = '{{ t.settings_saved }}';
} catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async saveServers() {
this.error = '';
this.status = '';
this.busy = true;
try {
const data = await this.request('/admin/api/servers', {
method: 'POST',
body: JSON.stringify({ servers: this.servers }),
});
this.servers = data.servers;
this.status = '{{ t.settings_saved }}';
} catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
openUserModal(user) {
this.userForm = user ? { ...user, password: '' } : {
id: null, username: '', email: '', display_name: '', password: '', role: 'client',
};
this.userModalOpen = true;
},
closeUserModal() {
this.userModalOpen = false;
this.userForm = {};
},
async saveUser() {
this.error = '';
this.status = '';
const isEdit = Boolean(this.userForm.id);
const url = isEdit ? `/admin/api/users/${this.userForm.id}` : '/admin/api/users';
try {
const data = await this.request(url, {
method: 'POST',
body: JSON.stringify(this.userForm),
});
if (isEdit) {
const index = this.users.findIndex((user) => user.id === data.user.id);
if (index !== -1) this.users[index] = data.user;
} else {
this.users.push(data.user);
}
this.closeUserModal();
this.status = '{{ t.settings_saved }}';
} catch (e) { this.error = e.message; }
},
async deleteUser(user) {
if (!confirm('{{ t.users_delete_confirm }}')) return;
this.error = '';
this.status = '';
try {
await this.request(`/admin/api/users/${user.id}/delete`, { method: 'POST' });
this.users = this.users.filter((item) => item.id !== user.id);
this.status = '{{ t.settings_saved }}';
} catch (e) { this.error = e.message; }
},
sectionLabel(section) {
return {
auth: '{{ t.settings_auth }}',
oidc: '{{ t.settings_oidc }}',
kubernetes: '{{ t.settings_kubernetes }}',
vpn: '{{ t.settings_vpn }}',
api: '{{ t.settings_api }}',
telegram: '{{ t.settings_telegram }}',
}[section] || section;
},
fieldLabel(key) {
return {
auth_password_enabled: '{{ t.settings_password_login }}',
auth_sso_enabled: '{{ t.settings_sso_login }}',
oidc_button_text: '{{ t.settings_oidc_button }}',
oidc_admin_groups: '{{ t.settings_oidc_admin_groups }}',
oidc_client_groups: '{{ t.settings_oidc_client_groups }}',
swagger_enabled: '{{ t.settings_swagger }}',
telegram_bot_enabled: '{{ t.settings_telegram_enabled }}',
telegram_bot_username: '{{ t.settings_telegram_username }}',
telegram_bot_token: '{{ t.settings_telegram_token }}',
telegram_setup_instructions: '{{ t.settings_telegram_setup_instructions }}',
}[key] || key;
},
};
}
</script>
{% endblock body %}