feat: redesign portal UX and stabilize web session runtime
This commit is contained in:
871
app/templates/admin.html
Normal file
871
app/templates/admin.html
Normal file
@@ -0,0 +1,871 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Администрирование</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div style="display:flex; align-items:center; gap:0.6rem;">
|
||||
<img src="/static/logo.png" alt="MONT" class="header-logo" />
|
||||
<div>МОНТ - инфра полигон | Админ: {{ admin.username }}</div>
|
||||
</div>
|
||||
<a href="/" class="btn-link secondary">Главная панель</a>
|
||||
</header>
|
||||
|
||||
<main class="admin-layout">
|
||||
<section class="panel">
|
||||
<div class="admin-intro">
|
||||
Основной режим: <b>WEB</b>. Пользователь выбирает сервис, а портал открывает нужный URL в заранее прогретом браузере.
|
||||
Поле <b>pool size</b> задаёт, сколько таких прогретых контейнеров держать для конкретного сервиса.
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="tab-row">
|
||||
<button class="tab-btn" data-tab="users" onclick="showTab('users')">Users</button>
|
||||
<button class="tab-btn" data-tab="web" onclick="showTab('web')">WEB</button>
|
||||
<button class="tab-btn" data-tab="rdp" onclick="showTab('rdp')">RDP</button>
|
||||
<button class="tab-btn" data-tab="stats" onclick="showTab('stats')">Stats</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-users" class="panel admin-tab">
|
||||
<h3>Пользователи</h3>
|
||||
<div class="split">
|
||||
<div>
|
||||
<div class="list-title">Список пользователей</div>
|
||||
<input class="list-search" id="users_search" placeholder="Поиск пользователя..." oninput="filterList('users_search', '#users_list .user-item')" />
|
||||
<div class="list-box" id="users_list">
|
||||
{% for u in users %}
|
||||
<button class="list-item user-item" data-user-id="{{u.id}}" data-filter="{{u.username|lower}}" onclick='selectUser({{u.id}}, {{u.username|tojson}}, {{u.active|tojson}}, {{u.is_admin|tojson}}, {{u.expires_at.isoformat()|tojson}})'>
|
||||
<div>{{u.username}}</div>
|
||||
<small class="user-days" data-exp="{{u.expires_at.isoformat()}}"></small>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="list-title">Редактирование пользователя</div>
|
||||
<div class="form-grid">
|
||||
<input id="u_id" type="hidden" />
|
||||
<input id="u_name" placeholder="username" />
|
||||
<input id="u_exp" type="date" required />
|
||||
<input id="u_pwd" placeholder="new password (optional)" type="password" />
|
||||
<select id="u_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
<select id="u_admin"><option value="false">user</option><option value="true">admin</option></select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick="saveUser()">Save</button>
|
||||
<button onclick="deleteUser()">Delete</button>
|
||||
<button onclick="clearUserForm()">Clear</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1rem;">
|
||||
<div class="list-title">ACL выбранного пользователя</div>
|
||||
<div class="acl-grid">
|
||||
{% for s in services %}
|
||||
<label><input type="checkbox" class="acl_service" value="{{s.id}}" /> {{s.name}} ({{s.slug}})</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button onclick="saveAclForSelectedUser()">Save ACL</button>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="list-title">Добавить пользователя</div>
|
||||
<div class="form-grid">
|
||||
<input id="new_u_name" placeholder="username" />
|
||||
<input id="new_u_pwd" placeholder="password" type="password" />
|
||||
<input id="new_u_exp" type="date" required />
|
||||
<select id="new_u_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
<select id="new_u_admin"><option value="false">user</option><option value="true">admin</option></select>
|
||||
</div>
|
||||
<button onclick="createUser()">Add User</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-web" class="panel admin-tab" style="display:none;">
|
||||
<h3>WEB сервисы</h3>
|
||||
<div class="admin-intro">
|
||||
<b>Как читать статусы:</b> в строке сервиса <b>прогрето X / Y</b> и <b>занято: N</b> относятся к этому конкретному сервису.
|
||||
</div>
|
||||
<div class="summary-strip">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">WEB сервисов</div>
|
||||
<div class="summary-value">{{ web_totals.services }}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Всего прогрето</div>
|
||||
<div class="summary-value">{{ web_totals.running }} / {{ web_totals.desired }}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Сейчас занято</div>
|
||||
<div class="summary-value">{{ web_totals.active_sessions }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div>
|
||||
<div class="list-title">Список WEB</div>
|
||||
<input class="list-search" id="web_search" placeholder="Поиск WEB сервиса..." oninput="filterList('web_search', '#web_list .web-item')" />
|
||||
<div class="list-box" id="web_list">
|
||||
{% for s in web_services %}
|
||||
<button class="list-item service-row web-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectWebService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}})'>
|
||||
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
|
||||
<div>
|
||||
<div>{{s.name}}</div>
|
||||
<small>
|
||||
<span class="status-dot status-{{ service_health[s.id].health }}"></span>
|
||||
{{service_health[s.id].health}} | прогрето: {{service_health[s.id].running}} / {{service_health[s.id].desired}} | занято: {{service_health[s.id].active_sessions}}
|
||||
</small>
|
||||
</div>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="list-title">Редактирование WEB</div>
|
||||
<div class="field-help">Для пользователей показывается только название и описание. Технический тип скрыт.</div>
|
||||
<div class="form-grid labelled-grid">
|
||||
<input id="w_id" type="hidden" />
|
||||
<input id="w_slug" type="hidden" />
|
||||
|
||||
<label class="field-col">
|
||||
<span>Название сервиса</span>
|
||||
<input id="w_name" placeholder="Например: CRM, Router, Wiki" oninput="autogenSlug('w_name','w_slug')" />
|
||||
</label>
|
||||
|
||||
<label class="field-col">
|
||||
<span>URL для открытия</span>
|
||||
<input id="w_target" placeholder="Например: https://crm.company.local" />
|
||||
</label>
|
||||
|
||||
<label class="field-col">
|
||||
<span>Описание для пользователя</span>
|
||||
<textarea id="w_comment" placeholder="Коротко: что это за сервис"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="field-col">
|
||||
<span>Сколько держать прогретых</span>
|
||||
<input id="w_pool" type="number" min="0" placeholder="Например: 2" />
|
||||
</label>
|
||||
|
||||
<label class="field-col">
|
||||
<span>Статус</span>
|
||||
<select id="w_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick="saveWebService()">Save</button>
|
||||
<button onclick="prewarmNow('w_id')">Prewarm now</button>
|
||||
<button onclick="deleteService('w_id')">Delete</button>
|
||||
<button onclick="clearWebForm()">Clear</button>
|
||||
</div>
|
||||
|
||||
<div class="health-box" id="w_health_box" style="display:none; margin-top:1rem;">
|
||||
<div class="list-title">Container Health</div>
|
||||
<div id="w_health_summary"></div>
|
||||
<div class="actions" style="margin-top:0.5rem;">
|
||||
<button onclick="refreshSelectedServiceStatus('web')">Refresh status</button>
|
||||
<button id="w_health_toggle" onclick="toggleHealthDetails('w')">Показать детали контейнеров</button>
|
||||
</div>
|
||||
<div class="container-table-wrap" id="w_health_table_wrap" style="display:none;">
|
||||
<table class="admin-table" id="w_health_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
<th>state</th>
|
||||
<th>status</th>
|
||||
<th>image</th>
|
||||
<th>labels</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="icon-box" style="margin-top:1rem;">
|
||||
<div class="list-title">Иконка сервиса</div>
|
||||
<div class="icon-row">
|
||||
<img id="w_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" />
|
||||
<div>
|
||||
<input id="w_icon_file" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<div class="actions" style="margin-top:0.4rem;">
|
||||
<button onclick="uploadServiceIcon('w')">Upload</button>
|
||||
<button onclick="pasteServiceIcon('w')">Paste</button>
|
||||
<button onclick="removeServiceIcon('w')">Remove icon</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="list-title">Добавить WEB</div>
|
||||
<div class="field-help">Рекомендуется начинать с pool size = 2-3 для часто используемых сервисов.</div>
|
||||
<div class="form-grid labelled-grid">
|
||||
<input id="new_w_slug" type="hidden" />
|
||||
|
||||
<label class="field-col">
|
||||
<span>Название сервиса</span>
|
||||
<input id="new_w_name" placeholder="Например: CRM, Router, Wiki" oninput="autogenSlug('new_w_name','new_w_slug')" />
|
||||
</label>
|
||||
|
||||
<label class="field-col">
|
||||
<span>URL для открытия</span>
|
||||
<input id="new_w_target" placeholder="Например: https://crm.company.local" />
|
||||
</label>
|
||||
|
||||
<label class="field-col">
|
||||
<span>Описание для пользователя</span>
|
||||
<textarea id="new_w_comment" placeholder="Коротко: что это за сервис"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="field-col">
|
||||
<span>Сколько держать прогретых</span>
|
||||
<input id="new_w_pool" type="number" min="0" value="2" placeholder="Например: 2" />
|
||||
</label>
|
||||
|
||||
<label class="field-col">
|
||||
<span>Статус</span>
|
||||
<select id="new_w_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<button onclick="createWebService()">Add WEB</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-rdp" class="panel admin-tab" style="display:none;">
|
||||
<h3>RDP сервисы</h3>
|
||||
<div class="split">
|
||||
<div>
|
||||
<div class="list-title">Список RDP</div>
|
||||
<input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" />
|
||||
<div class="list-box" id="rdp_list">
|
||||
{% for s in rdp_services %}
|
||||
<button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}})'>
|
||||
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
|
||||
<div>
|
||||
<div>{{s.name}}</div>
|
||||
<small>
|
||||
<span class="status-dot status-{{ service_health[s.id].health }}"></span>
|
||||
{{service_health[s.id].health}} | {{service_health[s.id].running}} / {{service_health[s.id].desired}} | active: {{service_health[s.id].active_sessions}}
|
||||
</small>
|
||||
</div>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="list-title">Редактирование RDP</div>
|
||||
<div class="field-help">Используйте поля host/port/user. Поле target собирается автоматически.</div>
|
||||
<div class="form-grid">
|
||||
<input id="r_id" type="hidden" />
|
||||
<input id="r_name" placeholder="Название сервиса (что увидит user)" oninput="autogenSlug('r_name','r_slug')" />
|
||||
<input id="r_slug" placeholder="Системный slug" />
|
||||
<input id="r_host" placeholder="RDP host (например 192.168.1.60)" />
|
||||
<input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" />
|
||||
<input id="r_user" placeholder="Логин (опционально)" />
|
||||
<input id="r_pass" placeholder="Пароль (опционально)" type="password" />
|
||||
<input id="r_domain" placeholder="Домен (опционально)" />
|
||||
<select id="r_sec">
|
||||
<option value="">auto</option>
|
||||
<option value="nla">nla</option>
|
||||
<option value="tls">tls</option>
|
||||
<option value="rdp">rdp</option>
|
||||
</select>
|
||||
<input id="r_target" placeholder="Собранный target (авто)" />
|
||||
<textarea id="r_comment" placeholder="Описание для пользователя"></textarea>
|
||||
<input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" />
|
||||
<select id="r_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick="saveRdpService()">Save</button>
|
||||
<button onclick="prewarmNow('r_id')">Prewarm now</button>
|
||||
<button onclick="deleteService('r_id')">Delete</button>
|
||||
<button onclick="clearRdpForm()">Clear</button>
|
||||
</div>
|
||||
|
||||
<div class="health-box" id="r_health_box" style="display:none; margin-top:1rem;">
|
||||
<div class="list-title">Container Health</div>
|
||||
<div id="r_health_summary"></div>
|
||||
<div class="actions" style="margin-top:0.5rem;">
|
||||
<button onclick="refreshSelectedServiceStatus('rdp')">Refresh status</button>
|
||||
</div>
|
||||
<div class="container-table-wrap">
|
||||
<table class="admin-table" id="r_health_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
<th>state</th>
|
||||
<th>status</th>
|
||||
<th>image</th>
|
||||
<th>labels</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="icon-box" style="margin-top:1rem;">
|
||||
<div class="list-title">Иконка сервиса</div>
|
||||
<div class="icon-row">
|
||||
<img id="r_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" />
|
||||
<div>
|
||||
<input id="r_icon_file" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<div class="actions" style="margin-top:0.4rem;">
|
||||
<button onclick="uploadServiceIcon('r')">Upload</button>
|
||||
<button onclick="pasteServiceIcon('r')">Paste</button>
|
||||
<button onclick="removeServiceIcon('r')">Remove icon</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="list-title">Добавить RDP</div>
|
||||
<div class="field-help">Для большинства кейсов достаточно host + user + password.</div>
|
||||
<div class="form-grid">
|
||||
<input id="new_r_name" placeholder="Название сервиса" oninput="autogenSlug('new_r_name','new_r_slug')" />
|
||||
<input id="new_r_slug" placeholder="Системный slug" />
|
||||
<input id="new_r_host" placeholder="RDP host" />
|
||||
<input id="new_r_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" />
|
||||
<input id="new_r_user" placeholder="Логин (опционально)" />
|
||||
<input id="new_r_pass" placeholder="Пароль (опционально)" type="password" />
|
||||
<input id="new_r_domain" placeholder="Домен (опционально)" />
|
||||
<select id="new_r_sec">
|
||||
<option value="">auto</option>
|
||||
<option value="nla">nla</option>
|
||||
<option value="tls">tls</option>
|
||||
<option value="rdp">rdp</option>
|
||||
</select>
|
||||
<input id="new_r_target" placeholder="Собранный target (авто)" />
|
||||
<textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea>
|
||||
<input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" />
|
||||
<select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
</div>
|
||||
<button onclick="createRdpService()">Add RDP</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-stats" class="panel admin-tab" style="display:none;">
|
||||
<h3>Статистика открытий</h3>
|
||||
<div class="admin-intro">
|
||||
Здесь видно кто, когда и какой сервис открывал, а также агрегат по количеству запусков.
|
||||
</div>
|
||||
<div class="split">
|
||||
<div>
|
||||
<div class="list-title">Топ запусков (user x service)</div>
|
||||
<div class="container-table-wrap" style="max-height:520px;">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>user</th>
|
||||
<th>service</th>
|
||||
<th>slug</th>
|
||||
<th>opens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in open_stats %}
|
||||
<tr>
|
||||
<td>{{ row.username }}</td>
|
||||
<td>{{ row.service_name }}</td>
|
||||
<td>{{ row.service_slug }}</td>
|
||||
<td>{{ row.opens }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4">Нет данных</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="list-title">Последние открытия</div>
|
||||
<div class="container-table-wrap" style="max-height:520px;">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>user</th>
|
||||
<th>service</th>
|
||||
<th>status</th>
|
||||
<th>created</th>
|
||||
<th>last_access</th>
|
||||
<th>session_id</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in recent_sessions %}
|
||||
<tr>
|
||||
<td>{{ row.username }}</td>
|
||||
<td>{{ row.service_name }} ({{ row.service_slug }})</td>
|
||||
<td>{{ row.status }}</td>
|
||||
<td>{{ row.created_at }}</td>
|
||||
<td>{{ row.last_access_at }}</td>
|
||||
<td>{{ row.id }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6">Нет данных</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const csrf = "{{ csrf_token }}";
|
||||
const aclMap = {{ acl | tojson }};
|
||||
const placeholderIcon = '/static/service-placeholder.svg';
|
||||
let activeTab = 'users';
|
||||
|
||||
function showTab(tab) {
|
||||
activeTab = tab;
|
||||
localStorage.setItem('admin_active_tab', tab);
|
||||
document.querySelectorAll('.admin-tab').forEach(x => x.style.display = 'none');
|
||||
document.getElementById(`tab-${tab}`).style.display = 'block';
|
||||
document.querySelectorAll('.tab-btn').forEach(x => x.classList.remove('active-tab'));
|
||||
document.querySelector(`.tab-btn[data-tab="${tab}"]`).classList.add('active-tab');
|
||||
}
|
||||
|
||||
function filterList(inputId, itemsSelector) {
|
||||
const needle = (document.getElementById(inputId)?.value || '').trim().toLowerCase();
|
||||
document.querySelectorAll(itemsSelector).forEach((item) => {
|
||||
const hay = (item.dataset.filter || item.textContent || '').toLowerCase();
|
||||
item.style.display = hay.includes(needle) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function markSelected(selector, attr, value) {
|
||||
document.querySelectorAll(selector).forEach((el) => {
|
||||
el.classList.toggle('selected-item', String(el.getAttribute(attr)) === String(value));
|
||||
});
|
||||
}
|
||||
|
||||
function slugifyRu(text) {
|
||||
const map = {
|
||||
'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh','з':'z','и':'i','й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r','с':'s','т':'t','у':'u','ф':'f','х':'h','ц':'ts','ч':'ch','ш':'sh','щ':'sch','ъ':'','ы':'y','ь':'','э':'e','ю':'yu','я':'ya'
|
||||
};
|
||||
const lower = (text || '').toLowerCase();
|
||||
let out = '';
|
||||
for (const ch of lower) out += map[ch] !== undefined ? map[ch] : ch;
|
||||
return out.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/-{2,}/g, '-');
|
||||
}
|
||||
|
||||
function autogenSlug(srcId, dstId) {
|
||||
const src = document.getElementById(srcId);
|
||||
const dst = document.getElementById(dstId);
|
||||
if (!dst.value || !dst.dataset.touched) dst.value = slugifyRu(src.value);
|
||||
}
|
||||
|
||||
function dateFromIso(iso) {
|
||||
return (iso || '').slice(0, 10);
|
||||
}
|
||||
|
||||
function expiryToApi(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
return `${dateStr}T23:59:59+00:00`;
|
||||
}
|
||||
|
||||
function daysLeft(expIso) {
|
||||
const now = new Date();
|
||||
const exp = new Date(expIso);
|
||||
const diff = Math.ceil((exp - now) / (1000 * 60 * 60 * 24));
|
||||
if (Number.isNaN(diff)) return 'дата некорректна';
|
||||
if (diff < 0) return `просрочен ${Math.abs(diff)} дн`;
|
||||
return `до деактивации: ${diff} дн`;
|
||||
}
|
||||
|
||||
function renderUserDays() {
|
||||
document.querySelectorAll('.user-days').forEach(el => {
|
||||
el.textContent = daysLeft(el.dataset.exp || '');
|
||||
});
|
||||
}
|
||||
|
||||
async function api(url, method, body) {
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
alert(`Request failed: ${r.status} ${t}`);
|
||||
throw new Error(t);
|
||||
}
|
||||
return r.status === 204 ? {} : r.json();
|
||||
}
|
||||
|
||||
async function apiForm(url, formData) {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {'X-CSRF-Token': csrf},
|
||||
body: formData,
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
alert(`Request failed: ${r.status} ${t}`);
|
||||
throw new Error(t);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function selectUser(id, username, active, isAdmin, expiresIso) {
|
||||
document.getElementById('u_id').value = id;
|
||||
document.getElementById('u_name').value = username;
|
||||
document.getElementById('u_exp').value = dateFromIso(expiresIso);
|
||||
document.getElementById('u_pwd').value = '';
|
||||
document.getElementById('u_active').value = String(active);
|
||||
document.getElementById('u_admin').value = String(isAdmin);
|
||||
markSelected('.user-item', 'data-user-id', id);
|
||||
syncAclForSelectedUser();
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
const expDate = document.getElementById('new_u_exp').value;
|
||||
if (!expDate) return alert('Выберите дату деактивации');
|
||||
await api('/api/admin/users', 'POST', {
|
||||
username: document.getElementById('new_u_name').value,
|
||||
password: document.getElementById('new_u_pwd').value,
|
||||
expires_at: expiryToApi(expDate),
|
||||
active: document.getElementById('new_u_active').value === 'true',
|
||||
is_admin: document.getElementById('new_u_admin').value === 'true',
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
const id = document.getElementById('u_id').value;
|
||||
if (!id) return alert('Выберите пользователя');
|
||||
const expDate = document.getElementById('u_exp').value;
|
||||
if (!expDate) return alert('Выберите дату деактивации');
|
||||
const payload = {
|
||||
username: document.getElementById('u_name').value,
|
||||
expires_at: expiryToApi(expDate),
|
||||
active: document.getElementById('u_active').value === 'true',
|
||||
is_admin: document.getElementById('u_admin').value === 'true',
|
||||
};
|
||||
const pwd = document.getElementById('u_pwd').value.trim();
|
||||
if (pwd) payload.password = pwd;
|
||||
await api(`/api/admin/users/${id}`, 'PUT', payload);
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
const id = document.getElementById('u_id').value;
|
||||
if (!id) return alert('Выберите пользователя');
|
||||
if (!confirm('Удалить пользователя?')) return;
|
||||
await api(`/api/admin/users/${id}`, 'DELETE', {});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function clearUserForm() {
|
||||
['u_id','u_name','u_exp','u_pwd'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('u_active').value = 'true';
|
||||
document.getElementById('u_admin').value = 'false';
|
||||
document.querySelectorAll('.acl_service').forEach(x => x.checked = false);
|
||||
document.querySelectorAll('.user-item').forEach((el) => el.classList.remove('selected-item'));
|
||||
}
|
||||
|
||||
function syncAclForSelectedUser() {
|
||||
const userId = parseInt(document.getElementById('u_id').value || '0', 10);
|
||||
const allowed = new Set((aclMap[userId] || []));
|
||||
document.querySelectorAll('.acl_service').forEach((box) => {
|
||||
box.checked = allowed.has(parseInt(box.value, 10));
|
||||
});
|
||||
}
|
||||
|
||||
async function saveAclForSelectedUser() {
|
||||
const userId = document.getElementById('u_id').value;
|
||||
if (!userId) return alert('Сначала выберите пользователя');
|
||||
const serviceIds = [...document.querySelectorAll('.acl_service:checked')].map(x => parseInt(x.value, 10));
|
||||
await api(`/api/admin/users/${userId}/acl`, 'PUT', {service_ids: serviceIds});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function selectWebService(id, name, slug, target, comment, iconPath, active, pool) {
|
||||
document.getElementById('w_id').value = id;
|
||||
document.getElementById('w_name').value = name;
|
||||
document.getElementById('w_slug').value = slug;
|
||||
document.getElementById('w_target').value = target;
|
||||
document.getElementById('w_comment').value = comment || '';
|
||||
document.getElementById('w_active').value = String(active);
|
||||
document.getElementById('w_pool').value = pool;
|
||||
document.getElementById('w_icon_preview').src = iconPath || placeholderIcon;
|
||||
document.getElementById('w_health_box').style.display = 'block';
|
||||
markSelected('.web-item', 'data-service-id', id);
|
||||
refreshSelectedServiceStatus('web');
|
||||
}
|
||||
|
||||
async function createWebService() {
|
||||
const slug = document.getElementById('new_w_slug').value || slugifyRu(document.getElementById('new_w_name').value);
|
||||
await api('/api/admin/services', 'POST', {
|
||||
name: document.getElementById('new_w_name').value,
|
||||
slug,
|
||||
type: 'WEB',
|
||||
target: document.getElementById('new_w_target').value,
|
||||
comment: document.getElementById('new_w_comment').value,
|
||||
warm_pool_size: parseInt(document.getElementById('new_w_pool').value || '0', 10),
|
||||
active: document.getElementById('new_w_active').value === 'true',
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function saveWebService() {
|
||||
const id = document.getElementById('w_id').value;
|
||||
if (!id) return alert('Выберите WEB сервис');
|
||||
const slug = document.getElementById('w_slug').value || slugifyRu(document.getElementById('w_name').value);
|
||||
await api(`/api/admin/services/${id}`, 'PUT', {
|
||||
name: document.getElementById('w_name').value,
|
||||
slug,
|
||||
type: 'WEB',
|
||||
target: document.getElementById('w_target').value,
|
||||
comment: document.getElementById('w_comment').value,
|
||||
warm_pool_size: parseInt(document.getElementById('w_pool').value || '0', 10),
|
||||
active: document.getElementById('w_active').value === 'true',
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function clearWebForm() {
|
||||
['w_id','w_name','w_slug','w_target','w_comment','w_pool'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('w_active').value = 'true';
|
||||
document.getElementById('w_icon_preview').src = placeholderIcon;
|
||||
document.getElementById('w_health_box').style.display = 'none';
|
||||
document.querySelectorAll('.web-item').forEach((el) => el.classList.remove('selected-item'));
|
||||
}
|
||||
|
||||
function parseRdpTarget(target) {
|
||||
const raw = (target || '').trim();
|
||||
if (!raw) return {host: '', port: '3389', user: '', pass: '', domain: '', sec: ''};
|
||||
let url;
|
||||
try {
|
||||
url = raw.includes('://') ? new URL(raw) : new URL(`rdp://${raw}`);
|
||||
} catch (e) {
|
||||
return {host: '', port: '3389', user: '', pass: '', domain: '', sec: ''};
|
||||
}
|
||||
const params = url.searchParams;
|
||||
return {
|
||||
host: url.hostname || '',
|
||||
port: url.port || '3389',
|
||||
user: decodeURIComponent(url.username || params.get('u') || params.get('user') || ''),
|
||||
pass: decodeURIComponent(url.password || params.get('p') || params.get('password') || ''),
|
||||
domain: params.get('domain') || params.get('d') || '',
|
||||
sec: params.get('sec') || params.get('security') || '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildRdpTarget(prefix) {
|
||||
const host = (document.getElementById(`${prefix}_host`)?.value || '').trim();
|
||||
const port = (document.getElementById(`${prefix}_port`)?.value || '').trim() || '3389';
|
||||
const user = (document.getElementById(`${prefix}_user`)?.value || '').trim();
|
||||
const pass = (document.getElementById(`${prefix}_pass`)?.value || '').trim();
|
||||
const domain = (document.getElementById(`${prefix}_domain`)?.value || '').trim();
|
||||
const sec = (document.getElementById(`${prefix}_sec`)?.value || '').trim();
|
||||
const targetInput = document.getElementById(`${prefix}_target`);
|
||||
if (!host) return (targetInput?.value || '').trim();
|
||||
const creds = user ? `${encodeURIComponent(user)}${pass ? `:${encodeURIComponent(pass)}` : ''}@` : '';
|
||||
const query = new URLSearchParams();
|
||||
if (domain) query.set('domain', domain);
|
||||
if (sec) query.set('sec', sec);
|
||||
const q = query.toString();
|
||||
const target = `rdp://${creds}${host}:${port}${q ? `?${q}` : ''}`;
|
||||
if (targetInput) targetInput.value = target;
|
||||
return target;
|
||||
}
|
||||
|
||||
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool) {
|
||||
const cfg = parseRdpTarget(target);
|
||||
document.getElementById('r_id').value = id;
|
||||
document.getElementById('r_name').value = name;
|
||||
document.getElementById('r_slug').value = slug;
|
||||
document.getElementById('r_target').value = target;
|
||||
document.getElementById('r_host').value = cfg.host;
|
||||
document.getElementById('r_port').value = cfg.port;
|
||||
document.getElementById('r_user').value = cfg.user;
|
||||
document.getElementById('r_pass').value = cfg.pass;
|
||||
document.getElementById('r_domain').value = cfg.domain;
|
||||
document.getElementById('r_sec').value = cfg.sec;
|
||||
document.getElementById('r_comment').value = comment || '';
|
||||
document.getElementById('r_active').value = String(active);
|
||||
document.getElementById('r_pool').value = pool;
|
||||
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
|
||||
document.getElementById('r_health_box').style.display = 'block';
|
||||
markSelected('.rdp-item', 'data-service-id', id);
|
||||
refreshSelectedServiceStatus('rdp');
|
||||
}
|
||||
|
||||
async function createRdpService() {
|
||||
const slug = document.getElementById('new_r_slug').value || slugifyRu(document.getElementById('new_r_name').value);
|
||||
const target = buildRdpTarget('new_r');
|
||||
await api('/api/admin/services', 'POST', {
|
||||
name: document.getElementById('new_r_name').value,
|
||||
slug,
|
||||
type: 'RDP',
|
||||
target,
|
||||
comment: document.getElementById('new_r_comment').value,
|
||||
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
|
||||
active: document.getElementById('new_r_active').value === 'true',
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function saveRdpService() {
|
||||
const id = document.getElementById('r_id').value;
|
||||
if (!id) return alert('Выберите RDP сервис');
|
||||
const target = buildRdpTarget('r');
|
||||
await api(`/api/admin/services/${id}`, 'PUT', {
|
||||
name: document.getElementById('r_name').value,
|
||||
slug: document.getElementById('r_slug').value,
|
||||
type: 'RDP',
|
||||
target,
|
||||
comment: document.getElementById('r_comment').value,
|
||||
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
|
||||
active: document.getElementById('r_active').value === 'true',
|
||||
});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function clearRdpForm() {
|
||||
['r_id','r_name','r_slug','r_target','r_host','r_port','r_user','r_pass','r_domain','r_comment','r_pool'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('r_sec').value = '';
|
||||
document.getElementById('r_active').value = 'true';
|
||||
document.getElementById('r_icon_preview').src = placeholderIcon;
|
||||
document.getElementById('r_health_box').style.display = 'none';
|
||||
document.querySelectorAll('.rdp-item').forEach((el) => el.classList.remove('selected-item'));
|
||||
}
|
||||
|
||||
async function deleteService(sourceId) {
|
||||
const id = document.getElementById(sourceId).value;
|
||||
if (!id) return alert('Выберите сервис');
|
||||
if (!confirm('Удалить сервис?')) return;
|
||||
await api(`/api/admin/services/${id}`, 'DELETE', {});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function prewarmNow(sourceId) {
|
||||
const id = document.getElementById(sourceId).value;
|
||||
if (!id) return alert('Выберите сервис');
|
||||
await api(`/api/admin/services/${id}/prewarm`, 'POST', {});
|
||||
const kind = activeTab === 'rdp' ? 'rdp' : 'web';
|
||||
await refreshSelectedServiceStatus(kind);
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function renderStatusInto(prefix, data) {
|
||||
const summary = document.getElementById(`${prefix}_health_summary`);
|
||||
const tableBody = document.querySelector(`#${prefix}_health_table tbody`);
|
||||
summary.innerHTML = `
|
||||
<div><span class="status-dot status-${data.health}"></span> health: <b>${data.health}</b></div>
|
||||
<div>running/desired: <b>${data.running}</b> / <b>${data.desired}</b>, total: ${data.total}</div>
|
||||
<div>active sessions: <b>${data.active_sessions ?? 0}</b></div>
|
||||
<div class="muted">updated: ${data.updated_at}</div>
|
||||
`;
|
||||
tableBody.innerHTML = '';
|
||||
(data.containers || []).forEach((c) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${c.name}</td>
|
||||
<td>${c.state}</td>
|
||||
<td>${c.status}</td>
|
||||
<td>${c.image || ''}</td>
|
||||
<td>${c.labels_ok ? 'ok' : 'mismatch'}</td>
|
||||
`;
|
||||
tableBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleHealthDetails(prefix) {
|
||||
const wrap = document.getElementById(`${prefix}_health_table_wrap`);
|
||||
const btn = document.getElementById(`${prefix}_health_toggle`);
|
||||
if (!wrap || !btn) return;
|
||||
const opened = wrap.style.display !== 'none';
|
||||
wrap.style.display = opened ? 'none' : 'block';
|
||||
btn.textContent = opened ? 'Показать детали контейнеров' : 'Скрыть детали контейнеров';
|
||||
}
|
||||
|
||||
async function refreshSelectedServiceStatus(kind) {
|
||||
const kindToPrefix = {web: 'w', rdp: 'r'};
|
||||
const prefix = kindToPrefix[kind];
|
||||
if (!prefix) return;
|
||||
const serviceId = document.getElementById(`${prefix}_id`).value;
|
||||
if (!serviceId) return;
|
||||
const data = await api(`/api/admin/services/${serviceId}/containers/status`, 'GET');
|
||||
renderStatusInto(prefix, data);
|
||||
}
|
||||
|
||||
async function uploadServiceIcon(prefix) {
|
||||
const serviceId = document.getElementById(`${prefix}_id`).value;
|
||||
if (!serviceId) return alert('Сначала выберите сервис');
|
||||
const input = document.getElementById(`${prefix}_icon_file`);
|
||||
if (!input.files || !input.files[0]) return alert('Выберите файл');
|
||||
const form = new FormData();
|
||||
form.append('file', input.files[0]);
|
||||
const data = await apiForm(`/api/admin/services/${serviceId}/icon`, form);
|
||||
document.getElementById(`${prefix}_icon_preview`).src = data.icon_path || placeholderIcon;
|
||||
input.value = '';
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function removeServiceIcon(prefix) {
|
||||
const serviceId = document.getElementById(`${prefix}_id`).value;
|
||||
if (!serviceId) return alert('Сначала выберите сервис');
|
||||
await api(`/api/admin/services/${serviceId}/icon`, 'DELETE', {});
|
||||
document.getElementById(`${prefix}_icon_preview`).src = placeholderIcon;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function pasteServiceIcon(prefix) {
|
||||
const input = document.getElementById(`${prefix}_icon_file`);
|
||||
if (!navigator.clipboard || !navigator.clipboard.read) {
|
||||
return alert('Clipboard API недоступен в этом браузере');
|
||||
}
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
for (const item of items) {
|
||||
const imageType = item.types.find((t) => t.startsWith('image/'));
|
||||
if (!imageType) continue;
|
||||
const blob = await item.getType(imageType);
|
||||
const ext = imageType.split('/')[1] || 'png';
|
||||
const file = new File([blob], `clipboard.${ext}`, {type: imageType});
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
input.files = dt.files;
|
||||
await uploadServiceIcon(prefix);
|
||||
return;
|
||||
}
|
||||
alert('В буфере нет изображения');
|
||||
} catch (e) {
|
||||
alert(`Не удалось вставить изображение: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
if (activeTab === 'web' && document.getElementById('w_id').value) {
|
||||
refreshSelectedServiceStatus('web').catch(() => {});
|
||||
}
|
||||
if (activeTab === 'rdp' && document.getElementById('r_id').value) {
|
||||
refreshSelectedServiceStatus('rdp').catch(() => {});
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
const hashTab = (window.location.hash || '').replace('#', '');
|
||||
const savedTab = localStorage.getItem('admin_active_tab');
|
||||
const initialTab = ['users', 'web', 'rdp', 'stats'].includes(hashTab)
|
||||
? hashTab
|
||||
: (['users', 'web', 'rdp', 'stats'].includes(savedTab || '') ? savedTab : 'users');
|
||||
showTab(initialTab);
|
||||
renderUserDays();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user