Files
Stend_mont/app/templates/admin.html

872 lines
40 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 в заранее прогретом браузере.
Для WEB используется <b>общий пул</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> прогрето X / Y — это состояние общего WEB-пула, занято: N — активные сессии конкретного сервиса.
</div>
<div class="panel" style="padding:0.8rem;">
<div class="list-title">Настройки общего WEB pool</div>
<div class="actions">
<input id="web_pool_size" type="number" min="0" value="{{ web_pool_size }}" style="max-width:220px;" />
<button onclick="saveWebPoolSize()">Save WEB pool size</button>
</div>
<small>Автодоращивание: active + {{ web_pool_buffer }}</small>
</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}})'>
<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>
<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="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">Горячий пул задается сверху и общий для всех WEB сервисов.</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>
<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) {
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_icon_preview').src = iconPath || placeholderIcon;
document.getElementById('w_health_box').style.display = 'block';
markSelected('.web-item', 'data-service-id', id);
refreshSelectedServiceStatus('web');
}
async function saveWebPoolSize() {
const size = parseInt(document.getElementById('web_pool_size').value || '0', 10);
await api('/api/admin/web-pool-size', 'PUT', {size});
location.reload();
}
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,
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,
active: document.getElementById('w_active').value === 'true',
});
location.reload();
}
function clearWebForm() {
['w_id','w_name','w_slug','w_target','w_comment'].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>