Pilots: one form (add/edit merged), inline deactivate, unify access grant with slot assign

Admin Pilots tab:
- list and create/edit form merged into one - "+ Добавить пилота" at
  the top of the list clears the form into create mode, clicking a
  pilot switches it into edit mode (title, Delete button and icon
  box toggle accordingly) instead of keeping two separate stacked
  forms
- each list row gets an inline on/off toggle (⏻/▶) to flip active
  without opening the form
- categories and the login/password hint field removed from the
  pilot form per earlier feedback - not applicable to pilots

Access model: assigning a pilot's RDP slot to a user is now the
single action that grants them the pilot - it creates/updates their
UserServiceAccess row (with the optional expiry typed right next to
the assign dropdown) in the same call, and unassigning revokes it.
Previously slot assignment and the ACL grant were two separate steps
an admin could forget to pair up, leaving a user "granted" with no
working slot or a slot with no visible access.

Consequently: pilots are no longer listed in the Users tab's ACL
grid at all - the Pilots tab is now the only place pilot access is
managed. set_acl() was fixed to never touch pilot grants regardless
of what's posted (it used to free a user's pilot slot whenever it
saw a pilot id missing from service_ids - which happens on every
save now that the grid never includes pilots, so saving any other
product's ACL would have silently stripped every pilot grant).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 07:07:38 +00:00
parent c3521f605f
commit 684be97041
2 changed files with 136 additions and 128 deletions
+65 -90
View File
@@ -32,9 +32,16 @@
--av-good:#1f9d63; --av-warn:#b5680a; --av-bad:#d3453f;
}
.pilot-badge{
body.admin-page-v2 .pilot-add-item{
width:100%;text-align:center;font-weight:700;color:var(--av-accent) !important;
border-style:dashed !important;
}
body.admin-page-v2 .pilot-toggle-btn{
flex:0 0 auto;width:30px;height:30px;padding:0;border-radius:7px;font-size:14px;line-height:1;
}
.pilot-inactive-tag{
display:inline-block;font:700 10px/1 "Ubuntu Mono",monospace;letter-spacing:.04em;
color:#fff;background:var(--av-accent);border-radius:4px;padding:2px 5px;vertical-align:middle;margin-left:4px;
color:var(--av-fg-faint);background:var(--av-line-soft);border-radius:4px;padding:2px 5px;vertical-align:middle;
}
body.admin-page-v2{
@@ -263,13 +270,12 @@
<div style="margin-top:1rem;">
<div class="list-title">ACL выбранного пользователя</div>
<div class="field-help">Пилоты сюда не входят — доступ к ним назначается на вкладке «Pilots» (там же, где и слот).</div>
<div class="acl-grid">
{% for s in services %}
{% for s in services if s.id not in pilot_service_ids %}
<label>
<input type="checkbox" class="acl_service" value="{{s.id}}" data-stype="{{s.type.value}}" {% if s.id in pilot_service_ids %}data-pilot="1" onchange="toggleAclExpiryInput(this)"{% endif %} />
{{s.name}} ({{s.slug}}){% if s.id in pilot_service_ids %} <span class="pilot-badge">ПИЛОТ</span>
<input type="date" class="acl-expiry" data-service="{{s.id}}" title="Доступ к пилоту истекает (необязательно — иначе по сроку аккаунта)" style="display:none;margin-left:.4rem;width:auto" />
{% endif %}
<input type="checkbox" class="acl_service" value="{{s.id}}" data-stype="{{s.type.value}}" />
{{s.name}} ({{s.slug}})
<span class="acl-owner"></span>
</label>
{% endfor %}
@@ -620,22 +626,28 @@
<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">
<button class="list-item pilot-add-item" onclick="startNewPilot()">+ Добавить пилота</button>
{% 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}})'>
<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 class="list-item service-row pilot-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" style="justify-content:space-between;cursor:pointer" 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}})'>
<div class="service-row" style="min-width:0">
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div style="min-width:0">
<div>{{s.name}}{% if not s.active %} <span class="pilot-inactive-tag">выключен</span>{% endif %}</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>
</div>
</button>
<button type="button" class="pilot-toggle-btn" title="{{ 'Деактивировать' if s.active else 'Активировать' }}" onclick='event.stopPropagation(); togglePilotActive({{s.id}}, {{(not s.active)|tojson}})'>{{ "⏻" if s.active else "▶" }}</button>
</div>
{% else %}
<div class="list-item" style="opacity:.6;cursor:default">Пилотов пока нет</div>
{% endfor %}
</div>
</div>
<div>
<div class="list-title">Редактирование пилота</div>
<div class="list-title" id="pilot_form_title">Новый пилот</div>
<div class="field-help">Используйте поля host/port/user. Поле target собирается автоматически.</div>
<div class="form-grid">
<input id="p_id" type="hidden" />
@@ -658,11 +670,11 @@
</div>
<div class="actions">
<button onclick="savePilotService()">Save</button>
<button onclick="deleteService('p_id')">Delete</button>
<button id="pilot_delete_btn" onclick="deleteService('p_id')" style="display:none;">Delete</button>
<button onclick="clearPilotForm()">Clear</button>
</div>
<div class="icon-box" style="margin-top:1rem;">
<div class="icon-box" id="p_icon_box" style="display:none; 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" />
@@ -677,10 +689,10 @@
</div>
<div id="pilot_slots_box" style="display:none; margin-top:1rem;">
<div class="list-title">Слоты пилота (закреплены за пользователем)</div>
<div class="field-help">Каждый слот — отдельный контейнер. Назначьте его конкретному пользователю из списка.</div>
<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>
<thead><tr><th>Логин RDP</th><th>Контейнер</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;">
@@ -689,29 +701,6 @@
<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="Пароль сервиса (необязательно)" />
<select id="new_p_active"><option value="true">active</option><option value="false">inactive</option></select>
</div>
<button onclick="createPilotService()">Add Pilot</button>
</div>
</div>
</section>
@@ -853,7 +842,6 @@
<script>
const csrf = "{{ csrf_token }}";
const aclMap = {{ acl | tojson }};
const aclExpiresMap = {{ acl_expires | tojson }};
const serviceCategoryMap = {{ service_category_map | tojson }};
const rdpSlotsMap = {{ rdp_slots | tojson }};
const usersList = {{ users_json | tojson }};
@@ -1029,27 +1017,14 @@
document.querySelectorAll('.user-item').forEach((el) => el.classList.remove('selected-item'));
}
function toggleAclExpiryInput(box) {
const input = box.closest('label').querySelector('.acl-expiry');
if (input) input.style.display = box.checked ? 'inline-block' : 'none';
}
function syncAclForSelectedUser() {
const userId = parseInt(document.getElementById('u_id').value || '0', 10);
const allowed = new Set((aclMap[userId] || []));
const expiresMap = aclExpiresMap[userId] || {};
document.querySelectorAll('.acl_service').forEach((box) => {
const sid = parseInt(box.value, 10);
box.checked = allowed.has(sid);
box.disabled = false;
box.closest('label').style.opacity = '';
if (box.dataset.pilot) {
const input = box.closest('label').querySelector('.acl-expiry');
if (input) {
input.value = expiresMap[sid] ? expiresMap[sid].slice(0, 10) : '';
input.style.display = box.checked ? 'inline-block' : 'none';
}
}
});
}
@@ -1057,14 +1032,7 @@
const userId = document.getElementById('u_id').value;
if (!userId) return alert('Сначала выберите пользователя');
const serviceIds = [...document.querySelectorAll('.acl_service:checked')].map(x => parseInt(x.value, 10));
const expiresAtByService = {};
document.querySelectorAll('.acl-expiry').forEach((input) => {
const sid = input.dataset.service;
if (serviceIds.includes(parseInt(sid, 10))) {
expiresAtByService[sid] = input.value ? `${input.value}T23:59:59+00:00` : null;
}
});
await api(`/api/admin/users/${userId}/acl`, 'PUT', {service_ids: serviceIds, expires_at_by_service: expiresAtByService});
await api(`/api/admin/users/${userId}/acl`, 'PUT', {service_ids: serviceIds});
location.reload();
}
@@ -1292,7 +1260,7 @@
box.style.display = 'block';
tbody.innerHTML = '';
if (!slots.length) {
tbody.innerHTML = '<tr><td colspan="5" style="color:#888">Нет слотов. Добавьте слот ниже и назначьте его пользователю.</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" style="color:#888">Нет слотов. Добавьте слот ниже и назначьте его пользователю.</td></tr>';
return;
}
slots.forEach(s => {
@@ -1304,20 +1272,24 @@
.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><select id="pilot_slot_user_${s.id}" onchange="assignPilotSlot(${s.id})">${options}</select></td>` +
`<td><input type="date" id="pilot_slot_exp_${s.id}" value="${s.assigned_expires_at ? s.assigned_expires_at.slice(0, 10) : ''}" ${s.assigned_user_id ? '' : 'disabled'} onchange="assignPilotSlot(${s.id})" style="width:140px" title="Необязательно — иначе по сроку аккаунта пользователя" /></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});
async function assignPilotSlot(slotId) {
const userId = document.getElementById(`pilot_slot_user_${slotId}`).value;
const expInput = document.getElementById(`pilot_slot_exp_${slotId}`);
const expires_at = (userId && expInput.value) ? `${expInput.value}T23:59:59+00:00` : null;
await api(`/api/admin/rdp-slots/${slotId}/assign`, 'PUT', {user_id: userId || null, expires_at});
location.reload();
}
async function addPilotSlot() {
const serviceId = document.getElementById('p_id').value;
if (!serviceId) return alert('Выберите пилота');
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');
@@ -1340,34 +1312,29 @@
document.getElementById('p_svc_password').value = svcPassword || '';
document.getElementById('p_active').value = String(active);
document.getElementById('p_icon_preview').src = iconPath || placeholderIcon;
document.getElementById('pilot_form_title').textContent = `Пилот: ${name}`;
document.getElementById('pilot_delete_btn').style.display = '';
document.getElementById('p_icon_box').style.display = '';
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,
active: document.getElementById('new_p_active').value === 'true',
is_pilot: true,
});
function startNewPilot() {
clearPilotForm();
document.getElementById('p_name').focus();
}
async function togglePilotActive(id, nextActive) {
await api(`/api/admin/services/${id}`, 'PUT', {active: nextActive});
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', {
const payload = {
name: document.getElementById('p_name').value,
slug: document.getElementById('p_slug').value,
slug: document.getElementById('p_slug').value || slugifyRu(document.getElementById('p_name').value),
type: 'RDP',
target,
comment: document.getElementById('p_comment').value,
@@ -1375,7 +1342,12 @@
svc_password: document.getElementById('p_svc_password').value,
active: document.getElementById('p_active').value === 'true',
is_pilot: true,
});
};
if (id) {
await api(`/api/admin/services/${id}`, 'PUT', payload);
} else {
await api('/api/admin/services', 'POST', payload);
}
location.reload();
}
@@ -1385,6 +1357,9 @@
document.getElementById('p_sec').value = '';
document.getElementById('p_active').value = 'true';
document.getElementById('p_icon_preview').src = placeholderIcon;
document.getElementById('p_icon_box').style.display = 'none';
document.getElementById('pilot_delete_btn').style.display = 'none';
document.getElementById('pilot_form_title').textContent = 'Новый пилот';
document.querySelectorAll('.pilot-item').forEach((el) => el.classList.remove('selected-item'));
}