Pilots: separate admin tab, collapsible dashboard section

Admin: moved pilot management out of the RDP tab into its own
"Pilots" tab - dedicated list, create/edit form and slot table with
a per-slot user-assign dropdown (always on, no more is_pilot
checkbox toggling the RDP tab's slot table between two modes). The
RDP tab goes back to exactly its pre-pilots shape.

Dashboard: a granted pilot no longer sits inside the "Ваши сервисы"
grid - it gets its own <details> section above it, open by default
whenever the user has at least one pilot (and simply absent when
they have none, rather than showing empty and collapsed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 06:51:18 +00:00
parent 46e5b5a41e
commit ae630f9115
3 changed files with 282 additions and 44 deletions
+236 -38
View File
@@ -221,6 +221,7 @@
<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="pilots" onclick="showTab('pilots')">Pilots</button>
<button class="tab-btn" data-tab="categories" onclick="showTab('categories')">Categories</button>
<button class="tab-btn" data-tab="stats" onclick="showTab('stats')">Stats</button>
</div>
@@ -477,10 +478,10 @@
<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}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}}, {{(s.id in pilot_service_ids)|tojson}})'>
<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}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div>
<div>{{s.name}}{% if s.id in pilot_service_ids %} <span class="pilot-badge">ПИЛОТ</span>{% endif %}</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}}
@@ -513,10 +514,6 @@
<input id="r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" />
<select id="r_active"><option value="true">active</option><option value="false">inactive</option></select>
<label class="field-col" style="flex-direction:row;align-items:center;gap:.4rem">
<input id="r_is_pilot" type="checkbox" style="width:auto" onchange="renderRdpSlots(document.getElementById('r_id').value)" />
<span>Это пилот (слоты закрепляются за конкретным пользователем, продукт скрыт из самостоятельного запроса доступа)</span>
</label>
</div>
<div class="list-title">Категории</div>
<div class="acl-grid compact-grid" id="r_categories">
@@ -603,10 +600,6 @@
<input id="new_r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<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>
<label class="field-col" style="flex-direction:row;align-items:center;gap:.4rem">
<input id="new_r_is_pilot" type="checkbox" style="width:auto" />
<span>Это пилот</span>
</label>
</div>
<div class="list-title">Категории</div>
<div class="acl-grid compact-grid" id="new_r_categories">
@@ -619,6 +612,124 @@
</div>
</section>
<section id="tab-pilots" class="panel admin-tab" style="display:none;">
<h3>Пилоты</h3>
<div class="field-help">Пилот — это RDP-сервис, скрытый из самостоятельного запроса доступа. Каждый слот (контейнер) закрепляется за одним конкретным пользователем, а не раздаётся из общего пула.</div>
<div class="split">
<div>
<div class="list-title">Список пилотов</div>
<input class="list-search" id="pilot_search" placeholder="Поиск пилота..." oninput="filterList('pilot_search', '#pilot_list .pilot-item')" />
<div class="list-box" id="pilot_list">
{% for s in pilot_services %}
<button class="list-item service-row pilot-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectPilotService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|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}} | active: {{service_health[s.id].active_sessions}}
</small>
</div>
</button>
{% endfor %}
</div>
</div>
<div>
<div class="list-title">Редактирование пилота</div>
<div class="field-help">Используйте поля host/port/user. Поле target собирается автоматически.</div>
<div class="form-grid">
<input id="p_id" type="hidden" />
<input id="p_name" placeholder="Название пилота (что увидит user)" oninput="autogenSlug('p_name','p_slug')" />
<input id="p_slug" placeholder="Системный slug" />
<input id="p_host" placeholder="RDP host (например 192.168.1.60)" oninput="buildRdpTarget('p')" />
<input id="p_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" oninput="buildRdpTarget('p')" />
<input id="p_domain" placeholder="Домен (опционально)" oninput="buildRdpTarget('p')" />
<select id="p_sec" onchange="buildRdpTarget('p')">
<option value="">auto</option>
<option value="nla">nla</option>
<option value="tls">tls</option>
<option value="rdp">rdp</option>
</select>
<input id="p_target" placeholder="Собранный target (авто)" readonly style="background:rgba(255,255,255,.05);color:#888;cursor:default" />
<textarea id="p_comment" placeholder="Описание пилота для пользователя (что это, какой проект)"></textarea>
<input id="p_svc_login" placeholder="Логин сервиса (показывается на карточке)" />
<input id="p_svc_password" placeholder="Пароль сервиса (показывается на карточке)" />
<input id="p_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<select id="p_active"><option value="true">active</option><option value="false">inactive</option></select>
</div>
<div class="list-title">Категории</div>
<div class="acl-grid compact-grid" id="p_categories">
{% for c in categories %}
<label><input type="checkbox" class="p_cat" value="{{c.id}}" /> {{c.name}}</label>
{% endfor %}
</div>
<div class="actions">
<button onclick="savePilotService()">Save</button>
<button onclick="deleteService('p_id')">Delete</button>
<button onclick="clearPilotForm()">Clear</button>
</div>
<div class="icon-box" style="margin-top:1rem;">
<div class="list-title">Иконка пилота</div>
<div class="icon-row">
<img id="p_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" />
<div>
<input id="p_icon_file" type="file" accept="image/png,image/jpeg,image/webp" onchange="autoUploadIcon('p')" />
<div class="actions" style="margin-top:0.4rem;">
<button onclick="pasteServiceIcon('p')">Paste</button>
<button onclick="removeServiceIcon('p')">Remove icon</button>
</div>
</div>
</div>
</div>
<div id="pilot_slots_box" style="display:none; margin-top:1rem;">
<div class="list-title">Слоты пилота (закреплены за пользователем)</div>
<div class="field-help">Каждый слот — отдельный контейнер. Назначьте его конкретному пользователю из списка.</div>
<table class="admin-table" id="pilot_slots_table" style="margin-bottom:.7rem">
<thead><tr><th>Логин RDP</th><th>Контейнер</th><th>Статус</th><th>Закреплён за</th><th></th></tr></thead>
<tbody></tbody>
</table>
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap;">
<input id="new_pilot_slot_user" placeholder="Логин RDP" style="max-width:160px" />
<input id="new_pilot_slot_pass" type="password" placeholder="Пароль RDP" style="max-width:160px" />
<button onclick="addPilotSlot()">+ Добавить слот</button>
</div>
</div>
<hr>
<div class="list-title">Добавить пилота</div>
<div class="field-help">Для большинства кейсов достаточно host + user + password.</div>
<div class="form-grid">
<input id="new_p_name" placeholder="Название пилота" oninput="autogenSlug('new_p_name','new_p_slug')" />
<input id="new_p_slug" placeholder="Системный slug" />
<input id="new_p_host" placeholder="RDP host" oninput="buildRdpTarget('new_p')" />
<input id="new_p_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" oninput="buildRdpTarget('new_p')" />
<input id="new_p_domain" placeholder="Домен (опционально)" oninput="buildRdpTarget('new_p')" />
<select id="new_p_sec" onchange="buildRdpTarget('new_p')">
<option value="">auto</option>
<option value="nla">nla</option>
<option value="tls">tls</option>
<option value="rdp">rdp</option>
</select>
<input id="new_p_target" placeholder="Собранный target (авто)" readonly style="background:rgba(255,255,255,.05);color:#888;cursor:default" />
<textarea id="new_p_comment" placeholder="Описание пилота для пользователя"></textarea>
<input id="new_p_svc_login" placeholder="Логин сервиса (необязательно)" />
<input id="new_p_svc_password" placeholder="Пароль сервиса (необязательно)" />
<input id="new_p_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<select id="new_p_active"><option value="true">active</option><option value="false">inactive</option></select>
</div>
<div class="list-title">Категории</div>
<div class="acl-grid compact-grid" id="new_p_categories">
{% for c in categories %}
<label><input type="checkbox" class="new_p_cat" value="{{c.id}}" /> {{c.name}}</label>
{% endfor %}
</div>
<button onclick="createPilotService()">Add Pilot</button>
</div>
</div>
</section>
<section id="tab-categories" class="panel admin-tab" style="display:none;">
<h3>Категории</h3>
<div class="admin-intro">
@@ -1077,14 +1188,9 @@
function renderRdpSlots(serviceId) {
const box = document.getElementById('rdp_slots_box');
const thead = document.querySelector('#rdp_slots_table thead tr');
const tbody = document.querySelector('#rdp_slots_table tbody');
const slots = rdpSlotsMap[serviceId] || [];
const isPilot = document.getElementById('r_is_pilot').checked;
box.style.display = 'block';
thead.innerHTML = isPilot
? '<th>Логин RDP</th><th>Контейнер</th><th>Статус</th><th>Закреплён за</th><th></th>'
: '<th>Логин RDP</th><th>Контейнер</th><th>Статус</th><th>Занят</th><th></th>';
tbody.innerHTML = '';
if (!slots.length) {
tbody.innerHTML = '<tr><td colspan="5" style="color:#888">Нет слотов. Добавьте RDP пользователей ниже.</td></tr>';
@@ -1094,29 +1200,15 @@
const statusBadge = s.running
? '<span style="color:#4caf50">&#9679; running</span>'
: '<span style="color:#e07b39">&#9679; stopped</span>';
const occupiedCell = s.occupied_username
? `<span style="color:#e07b39">${s.occupied_username}</span>`
: '<span style="color:#888">свободен</span>';
const tr = document.createElement('tr');
if (isPilot) {
const options = ['<option value="">— свободен —</option>']
.concat(usersList.map(u => `<option value="${u.id}" ${s.assigned_user_id === u.id ? 'selected' : ''}>${u.label}</option>`))
.join('');
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td>` +
`<td><select onchange="assignRdpSlot(${s.id}, this.value)">${options}</select></td>` +
`<td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
} else {
const occupiedCell = s.occupied_username
? `<span style="color:#e07b39">${s.occupied_username}</span>`
: '<span style="color:#888">свободен</span>';
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td><td>${occupiedCell}</td><td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
}
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td><td>${occupiedCell}</td><td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
tbody.appendChild(tr);
});
}
async function assignRdpSlot(slotId, userId) {
await api(`/api/admin/rdp-slots/${slotId}/assign`, 'PUT', {user_id: userId || null});
location.reload();
}
async function addRdpSlot() {
const serviceId = document.getElementById('r_id').value;
if (!serviceId) return alert('Выберите RDP сервис');
@@ -1133,7 +1225,7 @@
location.reload();
}
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool, svcLogin, svcPassword, svcCredHint, isPilot) {
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool, svcLogin, svcPassword, svcCredHint) {
const cfg = parseRdpTarget(target);
document.getElementById('r_id').value = id;
document.getElementById('r_name').value = name;
@@ -1149,7 +1241,6 @@
document.getElementById('r_svc_cred_hint').value = svcCredHint || '';
document.getElementById('r_active').value = String(active);
document.getElementById('r_pool').value = pool;
document.getElementById('r_is_pilot').checked = !!isPilot;
setCategoryChecks('.r_cat', serviceCategoryMap[id] || []);
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
document.getElementById('r_health_box').style.display = 'block';
@@ -1173,7 +1264,6 @@
category_ids: checkedCategoryIds('.new_r_cat'),
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
active: document.getElementById('new_r_active').value === 'true',
is_pilot: document.getElementById('new_r_is_pilot').checked,
});
location.reload();
}
@@ -1194,7 +1284,6 @@
category_ids: checkedCategoryIds('.r_cat'),
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
active: document.getElementById('r_active').value === 'true',
is_pilot: document.getElementById('r_is_pilot').checked,
});
location.reload();
}
@@ -1204,13 +1293,122 @@
document.getElementById('rdp_slots_box').style.display = 'none';
document.getElementById('r_sec').value = '';
document.getElementById('r_active').value = 'true';
document.getElementById('r_is_pilot').checked = false;
setCategoryChecks('.r_cat', []);
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'));
}
function renderPilotSlots(serviceId) {
const box = document.getElementById('pilot_slots_box');
const tbody = document.querySelector('#pilot_slots_table tbody');
const slots = rdpSlotsMap[serviceId] || [];
box.style.display = 'block';
tbody.innerHTML = '';
if (!slots.length) {
tbody.innerHTML = '<tr><td colspan="5" style="color:#888">Нет слотов. Добавьте слот ниже и назначьте его пользователю.</td></tr>';
return;
}
slots.forEach(s => {
const statusBadge = s.running
? '<span style="color:#4caf50">&#9679; running</span>'
: '<span style="color:#e07b39">&#9679; stopped</span>';
const options = ['<option value="">— свободен —</option>']
.concat(usersList.map(u => `<option value="${u.id}" ${s.assigned_user_id === u.id ? 'selected' : ''}>${u.label}</option>`))
.join('');
const tr = document.createElement('tr');
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td>` +
`<td><select onchange="assignPilotSlot(${s.id}, this.value)">${options}</select></td>` +
`<td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
tbody.appendChild(tr);
});
}
async function assignPilotSlot(slotId, userId) {
await api(`/api/admin/rdp-slots/${slotId}/assign`, 'PUT', {user_id: userId || null});
location.reload();
}
async function addPilotSlot() {
const serviceId = document.getElementById('p_id').value;
if (!serviceId) return alert('Выберите пилота');
const rdp_username = document.getElementById('new_pilot_slot_user').value.trim();
const rdp_password = document.getElementById('new_pilot_slot_pass').value.trim();
if (!rdp_username) return alert('Введите логин RDP');
await api(`/api/admin/services/${serviceId}/rdp-slots`, 'POST', {rdp_username, rdp_password});
location.reload();
}
function selectPilotService(id, name, slug, target, comment, iconPath, active, svcLogin, svcPassword, svcCredHint) {
const cfg = parseRdpTarget(target);
document.getElementById('p_id').value = id;
document.getElementById('p_name').value = name;
document.getElementById('p_slug').value = slug;
document.getElementById('p_target').value = target;
document.getElementById('p_host').value = cfg.host;
document.getElementById('p_port').value = cfg.port;
document.getElementById('p_domain').value = cfg.domain;
document.getElementById('p_sec').value = cfg.sec;
document.getElementById('p_comment').value = comment || '';
document.getElementById('p_svc_login').value = svcLogin || '';
document.getElementById('p_svc_password').value = svcPassword || '';
document.getElementById('p_svc_cred_hint').value = svcCredHint || '';
document.getElementById('p_active').value = String(active);
setCategoryChecks('.p_cat', serviceCategoryMap[id] || []);
document.getElementById('p_icon_preview').src = iconPath || placeholderIcon;
markSelected('.pilot-item', 'data-service-id', id);
renderPilotSlots(id);
}
async function createPilotService() {
const slug = document.getElementById('new_p_slug').value || slugifyRu(document.getElementById('new_p_name').value);
const target = buildRdpTarget('new_p');
await api('/api/admin/services', 'POST', {
name: document.getElementById('new_p_name').value,
slug,
type: 'RDP',
target,
comment: document.getElementById('new_p_comment').value,
svc_login: document.getElementById('new_p_svc_login').value,
svc_password: document.getElementById('new_p_svc_password').value,
svc_cred_hint: document.getElementById('new_p_svc_cred_hint').value,
category_ids: checkedCategoryIds('.new_p_cat'),
active: document.getElementById('new_p_active').value === 'true',
is_pilot: true,
});
location.reload();
}
async function savePilotService() {
const id = document.getElementById('p_id').value;
if (!id) return alert('Выберите пилота');
const target = buildRdpTarget('p');
await api(`/api/admin/services/${id}`, 'PUT', {
name: document.getElementById('p_name').value,
slug: document.getElementById('p_slug').value,
type: 'RDP',
target,
comment: document.getElementById('p_comment').value,
svc_login: document.getElementById('p_svc_login').value,
svc_password: document.getElementById('p_svc_password').value,
svc_cred_hint: document.getElementById('p_svc_cred_hint').value,
category_ids: checkedCategoryIds('.p_cat'),
active: document.getElementById('p_active').value === 'true',
is_pilot: true,
});
location.reload();
}
function clearPilotForm() {
['p_id','p_name','p_slug','p_target','p_host','p_port','p_domain','p_comment','p_svc_login','p_svc_password','p_svc_cred_hint'].forEach(id => document.getElementById(id).value = '');
document.getElementById('pilot_slots_box').style.display = 'none';
document.getElementById('p_sec').value = '';
document.getElementById('p_active').value = 'true';
setCategoryChecks('.p_cat', []);
document.getElementById('p_icon_preview').src = placeholderIcon;
document.querySelectorAll('.pilot-item').forEach((el) => el.classList.remove('selected-item'));
}
async function deleteService(sourceId) {
const id = document.getElementById(sourceId).value;
if (!id) return alert('Выберите сервис');