Files
Stend_mont/app/templates/admin.html
T
ruslan a476627c34 Merge add/edit forms for WEB, RDP, Users (same pattern as Pilots)
Same restructure as the Pilots tab: one form instead of two stacked
ones, a + button at the top of each list to switch it into create
mode, and an inline on/off toggle per list row. Applies to the WEB,
RDP and Users tabs - save() now POSTs when the hidden id is empty
and PUTs otherwise, delete/prewarm/icon/health/ACL boxes show only
once something real is selected.

No backend changes - the create and edit endpoints already existed
independently, this only changes which one the frontend calls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 07:51:59 +00:00

1468 lines
74 KiB
HTML
Raw 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?v=2" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon_32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/static/favicon_192.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/static/favicon_192.png" />
<link rel="manifest" href="/static/manifest.json" />
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,"script","https://mc.yandex.ru/metrika/tag.js?id=109119977", "ym");
ym(109119977, "init", {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
<style>
:root{
--av-bg:#eef1f5; --av-panel:#fff; --av-raised:#fff;
--av-line:#d7dee6; --av-line-soft:#e3e8ee;
--av-fg:#16202c; --av-fg-dim:#5c6b7c; --av-fg-faint:#93a1b0;
--av-accent:#c96c14; --av-accent-dim:rgba(201,108,20,.10); --av-accent-ink:#2a1400;
--av-good:#1f9d63; --av-warn:#b5680a; --av-bad:#d3453f;
}
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:var(--av-fg-faint);background:var(--av-line-soft);border-radius:4px;padding:2px 5px;vertical-align:middle;
}
body.admin-page-v2{
background:var(--av-bg) !important; color:var(--av-fg);
font-family:"Ubuntu","IBM Plex Sans",system-ui,sans-serif;
}
body.admin-page-v2 *{ box-sizing:border-box; min-width:0; }
/* header */
body.admin-page-v2 .header{
background:var(--av-panel) !important; border-bottom:1px solid var(--av-line);
backdrop-filter:none;
}
body.admin-page-v2 .header-logo{ height:28px; }
body.admin-page-v2 .header > div > div{
font:600 13px/1 "Ubuntu Mono",monospace; color:var(--av-fg-dim); letter-spacing:.02em;
}
body.admin-page-v2 .btn-link.secondary{
background:transparent !important; border:1px solid var(--av-line); color:var(--av-fg-dim) !important;
font:600 13px/1 "Ubuntu",sans-serif; padding:9px 16px; border-radius:7px;
}
body.admin-page-v2 .btn-link.secondary:hover{ border-color:var(--av-accent); color:var(--av-fg) !important; }
/* layout / panels */
body.admin-page-v2 .admin-layout{ max-width:1500px; }
body.admin-page-v2 .panel{
background:var(--av-panel) !important; border:1px solid var(--av-line); box-shadow:none;
border-radius:12px;
}
body.admin-page-v2 .admin-intro{
background:var(--av-accent-dim) !important; border:1px solid var(--av-line);
border-left:3px solid var(--av-accent); color:var(--av-fg-dim) !important;
font:500 13px/1.55 "Ubuntu",sans-serif; letter-spacing:normal;
}
body.admin-page-v2 .admin-intro b{ color:var(--av-fg); }
/* tabs */
body.admin-page-v2 .tab-row{ gap:6px; padding-bottom:2px; }
body.admin-page-v2 .tab-btn{
background:transparent !important; color:var(--av-fg-dim) !important;
border:1px solid var(--av-line); border-radius:8px;
font:600 13px/1 "Ubuntu",sans-serif; padding:9px 16px;
}
body.admin-page-v2 .tab-btn:hover{ border-color:var(--av-fg-faint); color:var(--av-fg) !important; }
body.admin-page-v2 .tab-btn.active-tab{
background:var(--av-accent-dim) !important; border-color:var(--av-accent);
color:var(--av-fg) !important;
}
/* split / lists */
body.admin-page-v2 .split{ grid-template-columns:minmax(280px,340px) minmax(0,1fr); }
@media (max-width:900px){ body.admin-page-v2 .split{ grid-template-columns:minmax(0,1fr); } }
body.admin-page-v2 .list-title{
font:700 11px/1 "Ubuntu Mono",monospace; letter-spacing:.1em; text-transform:uppercase;
color:var(--av-fg-faint);
}
body.admin-page-v2 .list-box{
background:var(--av-raised) !important; border:1px solid var(--av-line); border-radius:10px;
}
body.admin-page-v2 .list-search{
background:var(--av-panel) !important; border:1px solid var(--av-line) !important;
color:var(--av-fg); border-radius:8px;
}
body.admin-page-v2 .list-search::placeholder{ color:var(--av-fg-faint); }
body.admin-page-v2 .list-item{
background:var(--av-panel) !important; border:1px solid var(--av-line) !important;
color:var(--av-fg) !important; border-radius:8px; transition:border-color .12s;
}
body.admin-page-v2 .list-item:hover{ border-color:var(--av-fg-faint) !important; }
body.admin-page-v2 .list-item.selected-item{
border-color:var(--av-accent) !important; background:var(--av-accent-dim) !important;
box-shadow:none !important;
}
body.admin-page-v2 .list-item small{ color:var(--av-fg-faint); }
body.admin-page-v2 .user-days{ color:var(--av-fg-faint) !important; }
/* forms */
body.admin-page-v2 input,
body.admin-page-v2 button,
body.admin-page-v2 textarea,
body.admin-page-v2 select{
background:var(--av-panel); border:1px solid var(--av-line); color:var(--av-fg);
border-radius:8px; font:400 13.5px/1.4 "Ubuntu",sans-serif;
}
body.admin-page-v2 input::placeholder,
body.admin-page-v2 textarea::placeholder{ color:var(--av-fg-faint); }
body.admin-page-v2 input:focus,
body.admin-page-v2 textarea:focus,
body.admin-page-v2 select:focus{ outline:none; border-color:var(--av-accent); }
body.admin-page-v2 input[readonly]{ opacity:.6; cursor:default; }
body.admin-page-v2 button{
background:var(--av-accent); color:var(--av-accent-ink); border:none;
font-weight:700; cursor:pointer; padding:8px 16px;
}
body.admin-page-v2 button:hover{ filter:brightness(1.06); }
body.admin-page-v2 .field-col > span{
font:600 11.5px/1 "Ubuntu",sans-serif; color:var(--av-fg-faint);
}
body.admin-page-v2 .field-help{ color:var(--av-fg-faint); }
body.admin-page-v2 .actions{ flex-wrap:wrap; }
/* acl / category grids */
body.admin-page-v2 .acl-grid label,
body.admin-page-v2 .compact-grid label{
display:flex; align-items:center; gap:8px; padding:6px 8px;
border:1px solid var(--av-line); border-radius:7px; background:var(--av-panel);
font:400 13px/1.3 "Ubuntu",sans-serif; color:var(--av-fg-dim);
}
body.admin-page-v2 .acl-grid input[type=checkbox],
body.admin-page-v2 .compact-grid input[type=checkbox]{ accent-color:var(--av-accent); }
body.admin-page-v2 .acl-owner{ color:var(--av-accent) !important; }
/* tables */
body.admin-page-v2 .admin-table{ color:var(--av-fg-dim); }
body.admin-page-v2 .admin-table th{
color:var(--av-fg-faint); font:700 10.5px/1 "Ubuntu Mono",monospace; letter-spacing:.06em;
text-transform:uppercase; border-bottom:1px solid var(--av-line) !important;
}
body.admin-page-v2 .admin-table td{ border-bottom:1px solid var(--av-line) !important; }
body.admin-page-v2 .container-table-wrap{
background:var(--av-panel) !important; border:1px solid var(--av-line) !important;
border-radius:10px;
}
/* summary strip */
body.admin-page-v2 .summary-card{
background:var(--av-panel) !important; border:1px solid var(--av-line);
border-radius:10px;
}
body.admin-page-v2 .summary-label{
color:var(--av-fg-faint) !important; font:600 10.5px/1 "Ubuntu Mono",monospace;
letter-spacing:.06em; text-transform:uppercase;
}
body.admin-page-v2 .summary-value{
color:var(--av-fg) !important; font:700 19px/1 "Ubuntu Mono",monospace; margin-top:6px;
font-variant-numeric:tabular-nums;
}
/* health / icon boxes */
body.admin-page-v2 .health-box,
body.admin-page-v2 .icon-box{
background:var(--av-raised) !important; border:1px solid var(--av-line) !important;
border-radius:10px;
}
body.admin-page-v2 .service-thumb,
body.admin-page-v2 .service-icon-preview{
background:var(--av-panel) !important; border:1px solid var(--av-line-soft) !important;
border-radius:9px;
}
body.admin-page-v2 .status-ok{ background:var(--av-good) !important; }
body.admin-page-v2 .status-degraded{ background:var(--av-warn) !important; }
body.admin-page-v2 .status-down{ background:var(--av-bad) !important; }
/* categories tab rows */
body.admin-page-v2 .category-item-row small{ color:var(--av-fg-faint); }
body.admin-page-v2 .category-item-row button{
background:transparent; border:1px solid var(--av-bad); color:var(--av-bad);
font-weight:600; padding:6px 12px;
}
body.admin-page-v2 .category-item-row button:hover{ background:rgba(255,107,107,.12); }
body.admin-page-v2 .muted{ color:var(--av-fg-faint) !important; }
body.admin-page-v2 hr{ border:none; border-top:1px solid var(--av-line); margin:1.2rem 0; }
</style>
</head>
<body class="admin-page-v2">
<header class="header">
<div style="display:flex; align-items:center; gap:0.6rem;">
<a href="https://4mont.ru"><img src="/static/logo.png?v=4" alt="MONT" class="header-logo" /></a>
<div>MONT - инфрастуктурный полигон | Админ: {{ 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="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>
</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">
<button class="list-item pilot-add-item" onclick="startNewUser()">+ Добавить пользователя</button>
{% for u in users %}
<div class="list-item user-item" data-user-id="{{u.id}}" data-filter="{{u.username|lower}}" style="display:flex;align-items:center;justify-content:space-between;cursor:pointer" onclick='selectUser({{u.id}}, {{u.username|tojson}}, {{u.active|tojson}}, {{u.is_admin|tojson}}, {{u.expires_at.isoformat()|tojson}}, {{u.first_name|tojson}}, {{u.last_name|tojson}})'>
<div style="min-width:0">
<div>{{u.username}}{% if u.first_name or u.last_name %} <small style="opacity:.6">— {{ (u.first_name + ' ' + u.last_name)|trim }}</small>{% endif %}{% if not u.active %} <span class="pilot-inactive-tag">выключен</span>{% endif %}</div>
<small class="user-days" data-exp="{{u.expires_at.isoformat()}}"></small>
</div>
<button type="button" class="pilot-toggle-btn" title="{{ 'Деактивировать' if u.active else 'Активировать' }}" onclick='event.stopPropagation(); toggleUserActive({{u.id}}, {{(not u.active)|tojson}})'>{{ "⏻" if u.active else "▶" }}</button>
</div>
{% endfor %}
</div>
</div>
<div>
<div class="list-title" id="user_form_title">Новый пользователь</div>
<div class="field-help" id="user_pwd_help">Пароль не задаётся вручную — он сгенерируется и уйдёт пользователю на почту (username) сразу после создания.</div>
<div class="form-grid">
<input id="u_id" type="hidden" />
<input id="u_name" placeholder="username (email)" />
<input id="u_first_name" placeholder="Имя" />
<input id="u_last_name" placeholder="Фамилия" />
<input id="u_exp" type="date" required />
<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 id="user_reset_pwd_btn" onclick="resetUserPassword()" style="display:none;">🔑 Сбросить пароль</button>
<button id="user_delete_btn" onclick="deleteUser()" style="display:none;">Delete</button>
<button onclick="clearUserForm()">Clear</button>
</div>
<div id="user_acl_box" style="display:none;margin-top:1rem;">
<div class="list-title">ACL выбранного пользователя</div>
<div class="field-help">Пилоты сюда не входят — доступ к ним назначается на вкладке «Pilots» (там же, где и слот).</div>
<div class="acl-grid">
{% 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}}" />
{{s.name}} ({{s.slug}})
<span class="acl-owner"></span>
</label>
{% endfor %}
</div>
<button onclick="saveAclForSelectedUser()">Save ACL</button>
</div>
</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">
<button class="list-item pilot-add-item" onclick="startNewWeb()">+ Добавить WEB</button>
{% for s in web_services %}
<div class="list-item service-row web-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" style="justify-content:space-between;cursor:pointer" onclick='selectWebService({{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}})'>
<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}} | занято: {{service_health[s.id].active_sessions}}
</small>
</div>
</div>
<button type="button" class="pilot-toggle-btn" title="{{ 'Деактивировать' if s.active else 'Активировать' }}" onclick='event.stopPropagation(); toggleServiceActive({{s.id}}, {{(not s.active)|tojson}})'>{{ "⏻" if s.active else "▶" }}</button>
</div>
{% else %}
<div class="list-item" style="opacity:.6;cursor:default">WEB-сервисов пока нет</div>
{% endfor %}
</div>
</div>
<div>
<div class="list-title" id="web_form_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_svc_login" placeholder="Например: admin" />
</label>
<label class="field-col">
<span>Пароль сервиса (показывается на карточке)</span>
<input id="w_svc_password" placeholder="Пароль для входа в сервис" />
</label>
<label class="field-col">
<span>Подсказка к логину/паролю (необязательно)</span>
<input id="w_svc_cred_hint" placeholder="Например: учётная запись гостя, сбрасывается раз в месяц" />
</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="list-title">Категории</div>
<div class="acl-grid compact-grid" id="w_categories">
{% for c in categories %}
<label><input type="checkbox" class="w_cat" value="{{c.id}}" /> {{c.name}}</label>
{% endfor %}
</div>
<div class="actions">
<button onclick="saveWebService()">Save</button>
<button id="w_delete_btn" onclick="deleteService('w_id')" style="display:none;">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" id="w_icon_box" style="display:none; 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" onchange="autoUploadIcon('w')" />
<div class="actions" style="margin-top:0.4rem;">
<button onclick="pasteServiceIcon('w')">Paste</button>
<button onclick="removeServiceIcon('w')">Remove icon</button>
</div>
</div>
</div>
</div>
</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">
<button class="list-item pilot-add-item" onclick="startNewRdp()">+ Добавить RDP</button>
{% for s in rdp_services %}
<div class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" style="justify-content:space-between;cursor:pointer" 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}})'>
<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 type="button" class="pilot-toggle-btn" title="{{ 'Деактивировать' if s.active else 'Активировать' }}" onclick='event.stopPropagation(); toggleServiceActive({{s.id}}, {{(not s.active)|tojson}})'>{{ "⏻" if s.active else "▶" }}</button>
</div>
{% else %}
<div class="list-item" style="opacity:.6;cursor:default">RDP-сервисов пока нет</div>
{% endfor %}
</div>
</div>
<div>
<div class="list-title" id="rdp_form_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)" oninput="buildRdpTarget('r')" />
<input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" oninput="buildRdpTarget('r')" />
<input id="r_domain" placeholder="Домен (опционально)" oninput="buildRdpTarget('r')" />
<select id="r_sec" onchange="buildRdpTarget('r')">
<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 (авто)" readonly style="background:rgba(255,255,255,.05);color:#888;cursor:default" />
<textarea id="r_comment" placeholder="Описание для пользователя"></textarea>
<input id="r_svc_login" placeholder="Логин сервиса (показывается на карточке)" />
<input id="r_svc_password" placeholder="Пароль сервиса (показывается на карточке)" />
<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>
</div>
<div class="list-title">Категории</div>
<div class="acl-grid compact-grid" id="r_categories">
{% for c in categories %}
<label><input type="checkbox" class="r_cat" value="{{c.id}}" /> {{c.name}}</label>
{% endfor %}
</div>
<div class="actions">
<button onclick="saveRdpService()">Save</button>
<button id="r_prewarm_btn" onclick="prewarmNow('r_id')" style="display:none;">Prewarm now</button>
<button id="r_delete_btn" onclick="deleteService('r_id')" style="display:none;">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" id="r_icon_box" style="display:none; 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" onchange="autoUploadIcon('r')" />
<div class="actions" style="margin-top:0.4rem;">
<button onclick="pasteServiceIcon('r')">Paste</button>
<button onclick="removeServiceIcon('r')">Remove icon</button>
</div>
</div>
</div>
</div>
<div id="rdp_slots_box" style="display:none; margin-top:1rem;">
<div class="list-title">RDP пользователи (слоты пула)</div>
<div class="field-help">Каждый пользователь — отдельный контейнер. Пользователи портала берут свободный слот.</div>
<table class="admin-table" id="rdp_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_slot_user" placeholder="Логин RDP" style="max-width:160px" />
<input id="new_slot_pass" type="password" placeholder="Пароль RDP" style="max-width:160px" />
<button onclick="addRdpSlot()">+ Добавить слот</button>
</div>
</div>
</div>
</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">
<button class="list-item pilot-add-item" onclick="startNewPilot()">+ Добавить пилота</button>
{% for s in pilot_services %}
<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 type="button" class="pilot-toggle-btn" title="{{ 'Деактивировать' if s.active else 'Активировать' }}" onclick='event.stopPropagation(); toggleServiceActive({{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" id="pilot_form_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="Пароль сервиса (показывается на карточке)" />
<select id="p_active"><option value="true">active</option><option value="false">inactive</option></select>
</div>
<div class="actions">
<button onclick="savePilotService()">Save</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" 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" />
<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><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>
</div>
</div>
</section>
<section id="tab-categories" class="panel admin-tab" style="display:none;">
<h3>Категории</h3>
<div class="admin-intro">
Добавляйте и удаляйте категории, чтобы аккуратно группировать сервисы на главной панели.
</div>
<div class="split">
<div>
<div class="list-title">Существующие категории</div>
<div class="list-box" id="categories_list">
{% for c in categories %}
<div class="list-item category-item-row">
<div>
<div>{{ c.name }}</div>
<small>{{ c.slug }}</small>
</div>
<button onclick='deleteCategory({{ c.id }}, {{ c.name|tojson }})'>Delete</button>
</div>
{% else %}
<div class="list-item">Категории пока не созданы</div>
{% endfor %}
</div>
</div>
<div>
<div class="list-title">Добавить категорию</div>
<div class="form-grid">
<input id="cat_name" placeholder="Название категории" oninput="autogenSlug('cat_name','cat_slug')" />
<input id="cat_slug" placeholder="slug (латиницей)" />
</div>
<button onclick="createCategory()">Add Category</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>
<div style="margin-top:0.8rem;">
<div class="list-title">Сейчас онлайн (активные сессии)</div>
<div class="container-table-wrap" style="max-height:420px;">
<table class="admin-table">
<thead>
<tr>
<th>user</th>
<th>service</th>
<th>type</th>
<th>container</th>
<th>last_access</th>
<th>created</th>
<th>session_id</th>
</tr>
</thead>
<tbody>
{% for row in online_sessions %}
<tr>
<td>{{ row.username }}</td>
<td>{{ row.service_name }} ({{ row.service_slug }})</td>
<td>{{ row.service_type }}</td>
<td>{{ row.container_id }}</td>
<td>{{ row.last_access_at }}</td>
<td>{{ row.created_at }}</td>
<td>{{ row.id }}</td>
</tr>
{% else %}
<tr><td colspan="7">Сейчас нет активных сессий</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
</main>
<script>
const csrf = "{{ csrf_token }}";
const aclMap = {{ acl | tojson }};
const serviceCategoryMap = {{ service_category_map | tojson }};
const rdpSlotsMap = {{ rdp_slots | tojson }};
const usersList = {{ users_json | 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 checkedCategoryIds(selector) {
return [...document.querySelectorAll(selector)]
.filter((x) => x.checked)
.map((x) => parseInt(x.value, 10));
}
function setCategoryChecks(selector, categoryIds) {
const setIds = new Set((categoryIds || []).map((x) => parseInt(x, 10)));
document.querySelectorAll(selector).forEach((box) => {
box.checked = setIds.has(parseInt(box.value, 10));
});
}
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, firstName, lastName) {
document.getElementById('u_id').value = id;
document.getElementById('u_name').value = username;
document.getElementById('u_first_name').value = firstName || '';
document.getElementById('u_last_name').value = lastName || '';
document.getElementById('u_exp').value = dateFromIso(expiresIso);
document.getElementById('u_active').value = String(active);
document.getElementById('u_admin').value = String(isAdmin);
document.getElementById('user_reset_pwd_btn').style.display = '';
document.getElementById('user_delete_btn').style.display = '';
document.getElementById('user_acl_box').style.display = '';
document.getElementById('user_pwd_help').style.display = 'none';
document.getElementById('user_form_title').textContent = username;
markSelected('.user-item', 'data-user-id', id);
syncAclForSelectedUser();
}
function startNewUser() {
clearUserForm();
document.getElementById('u_name').focus();
}
async function saveUser() {
const id = document.getElementById('u_id').value;
const expDate = document.getElementById('u_exp').value;
if (!expDate) return alert('Выберите дату деактивации');
const payload = {
username: document.getElementById('u_name').value,
first_name: document.getElementById('u_first_name').value,
last_name: document.getElementById('u_last_name').value,
expires_at: expiryToApi(expDate),
active: document.getElementById('u_active').value === 'true',
is_admin: document.getElementById('u_admin').value === 'true',
};
if (id) {
await api(`/api/admin/users/${id}`, 'PUT', payload);
location.reload();
} else {
const result = await api('/api/admin/users', 'POST', payload);
if (result.password) {
alert(`Письмо не отправлено (${result.email_status}). Пароль: ${result.password}\nПередайте его пользователю вручную.`);
}
location.reload();
}
}
async function resetUserPassword() {
const id = document.getElementById('u_id').value;
if (!id) return alert('Выберите пользователя');
if (!confirm('Сгенерировать новый пароль и отправить его пользователю на почту?')) return;
const result = await api(`/api/admin/users/${id}/reset-password`, 'POST', {});
if (result.password) {
alert(`Письмо не отправлено (${result.email_status}). Новый пароль: ${result.password}\nПередайте его пользователю вручную.`);
} else {
alert('Новый пароль отправлен пользователю на почту.');
}
}
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_first_name','u_last_name','u_exp'].forEach(id => document.getElementById(id).value = '');
document.getElementById('u_active').value = 'true';
document.getElementById('u_admin').value = 'false';
document.getElementById('user_reset_pwd_btn').style.display = 'none';
document.getElementById('user_delete_btn').style.display = 'none';
document.getElementById('user_acl_box').style.display = 'none';
document.getElementById('user_pwd_help').style.display = '';
document.getElementById('user_form_title').textContent = 'Новый пользователь';
document.querySelectorAll('.acl_service').forEach(x => x.checked = false);
document.querySelectorAll('.user-item').forEach((el) => el.classList.remove('selected-item'));
}
async function toggleUserActive(id, nextActive) {
await api(`/api/admin/users/${id}`, 'PUT', {active: nextActive});
location.reload();
}
function syncAclForSelectedUser() {
const userId = parseInt(document.getElementById('u_id').value || '0', 10);
const allowed = new Set((aclMap[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 = '';
});
}
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, svcLogin, svcPassword, svcCredHint) {
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_svc_login').value = svcLogin || '';
document.getElementById('w_svc_password').value = svcPassword || '';
document.getElementById('w_svc_cred_hint').value = svcCredHint || '';
document.getElementById('w_active').value = String(active);
document.getElementById('w_icon_preview').src = iconPath || placeholderIcon;
setCategoryChecks('.w_cat', serviceCategoryMap[id] || []);
document.getElementById('w_health_box').style.display = 'block';
document.getElementById('w_icon_box').style.display = '';
document.getElementById('w_delete_btn').style.display = '';
document.getElementById('web_form_title').textContent = `WEB: ${name}`;
markSelected('.web-item', 'data-service-id', id);
refreshSelectedServiceStatus('web');
}
function startNewWeb() {
clearWebForm();
document.getElementById('w_name').focus();
}
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 saveWebService() {
const id = document.getElementById('w_id').value;
const slug = document.getElementById('w_slug').value || slugifyRu(document.getElementById('w_name').value);
const payload = {
name: document.getElementById('w_name').value,
slug,
type: 'WEB',
target: document.getElementById('w_target').value,
comment: document.getElementById('w_comment').value,
svc_login: document.getElementById('w_svc_login').value,
svc_password: document.getElementById('w_svc_password').value,
svc_cred_hint: document.getElementById('w_svc_cred_hint').value,
category_ids: checkedCategoryIds('.w_cat'),
active: document.getElementById('w_active').value === 'true',
};
if (id) {
await api(`/api/admin/services/${id}`, 'PUT', payload);
} else {
await api('/api/admin/services', 'POST', payload);
}
location.reload();
}
function clearWebForm() {
['w_id','w_name','w_slug','w_target','w_comment','w_svc_login','w_svc_password','w_svc_cred_hint'].forEach(id => document.getElementById(id).value = '');
document.getElementById('w_active').value = 'true';
setCategoryChecks('.w_cat', []);
document.getElementById('w_icon_preview').src = placeholderIcon;
document.getElementById('w_health_box').style.display = 'none';
document.getElementById('w_icon_box').style.display = 'none';
document.getElementById('w_delete_btn').style.display = 'none';
document.getElementById('web_form_title').textContent = 'Новый WEB-сервис';
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 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 query = new URLSearchParams();
if (domain) query.set('domain', domain);
if (sec) query.set('sec', sec);
const q = query.toString();
const target = `rdp://${host}:${port}${q ? `?${q}` : ''}`;
if (targetInput) targetInput.value = target;
return target;
}
function renderRdpSlots(serviceId) {
const box = document.getElementById('rdp_slots_box');
const tbody = document.querySelector('#rdp_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">Нет слотов. Добавьте RDP пользователей ниже.</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 occupiedCell = s.occupied_username
? `<span style="color:#e07b39">${s.occupied_username}</span>`
: '<span style="color:#888">свободен</span>';
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>${occupiedCell}</td><td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
tbody.appendChild(tr);
});
}
async function addRdpSlot() {
const serviceId = document.getElementById('r_id').value;
if (!serviceId) return alert('Выберите RDP сервис');
const rdp_username = document.getElementById('new_slot_user').value.trim();
const rdp_password = document.getElementById('new_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();
}
async function deleteRdpSlot(slotId) {
if (!confirm('Удалить RDP слот и остановить контейнер?')) return;
await api(`/api/admin/rdp-slots/${slotId}`, 'DELETE', {});
location.reload();
}
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;
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_domain').value = cfg.domain;
document.getElementById('r_sec').value = cfg.sec;
document.getElementById('r_comment').value = comment || '';
document.getElementById('r_svc_login').value = svcLogin || '';
document.getElementById('r_svc_password').value = svcPassword || '';
document.getElementById('r_svc_cred_hint').value = svcCredHint || '';
document.getElementById('r_active').value = String(active);
document.getElementById('r_pool').value = pool;
setCategoryChecks('.r_cat', serviceCategoryMap[id] || []);
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
document.getElementById('r_health_box').style.display = 'block';
document.getElementById('r_icon_box').style.display = '';
document.getElementById('r_delete_btn').style.display = '';
document.getElementById('r_prewarm_btn').style.display = '';
document.getElementById('rdp_form_title').textContent = `RDP: ${name}`;
markSelected('.rdp-item', 'data-service-id', id);
renderRdpSlots(id);
refreshSelectedServiceStatus('rdp');
}
function startNewRdp() {
clearRdpForm();
document.getElementById('r_name').focus();
}
async function saveRdpService() {
const id = document.getElementById('r_id').value;
const target = buildRdpTarget('r');
const payload = {
name: document.getElementById('r_name').value,
slug: document.getElementById('r_slug').value || slugifyRu(document.getElementById('r_name').value),
type: 'RDP',
target,
comment: document.getElementById('r_comment').value,
svc_login: document.getElementById('r_svc_login').value,
svc_password: document.getElementById('r_svc_password').value,
svc_cred_hint: document.getElementById('r_svc_cred_hint').value,
category_ids: checkedCategoryIds('.r_cat'),
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
active: document.getElementById('r_active').value === 'true',
};
if (id) {
await api(`/api/admin/services/${id}`, 'PUT', payload);
} else {
await api('/api/admin/services', 'POST', payload);
}
location.reload();
}
function clearRdpForm() {
['r_id','r_name','r_slug','r_target','r_host','r_port','r_domain','r_comment','r_pool','r_svc_login','r_svc_password','r_svc_cred_hint'].forEach(id => document.getElementById(id).value = '');
document.getElementById('rdp_slots_box').style.display = 'none';
document.getElementById('r_sec').value = '';
document.getElementById('r_active').value = 'true';
setCategoryChecks('.r_cat', []);
document.getElementById('r_icon_preview').src = placeholderIcon;
document.getElementById('r_health_box').style.display = 'none';
document.getElementById('r_icon_box').style.display = 'none';
document.getElementById('r_delete_btn').style.display = 'none';
document.getElementById('r_prewarm_btn').style.display = 'none';
document.getElementById('rdp_form_title').textContent = 'Новый RDP-сервис';
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="6" 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 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) {
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;
const result = await api(`/api/admin/rdp-slots/${slotId}/assign`, 'PUT', {user_id: userId || null, expires_at});
if (result.email_status && !result.email_status.startsWith('Email отправлен')) {
alert(`Слот назначен, но письмо пользователю не ушло: ${result.email_status}`);
}
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) {
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_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);
}
function startNewPilot() {
clearPilotForm();
document.getElementById('p_name').focus();
}
async function toggleServiceActive(id, nextActive) {
await api(`/api/admin/services/${id}`, 'PUT', {active: nextActive});
location.reload();
}
async function savePilotService() {
const id = document.getElementById('p_id').value;
const target = buildRdpTarget('p');
const payload = {
name: document.getElementById('p_name').value,
slug: document.getElementById('p_slug').value || slugifyRu(document.getElementById('p_name').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,
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();
}
function clearPilotForm() {
['p_id','p_name','p_slug','p_target','p_host','p_port','p_domain','p_comment','p_svc_login','p_svc_password'].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';
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'));
}
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 autoUploadIcon(prefix) {
const input = document.getElementById(`${prefix}_icon_file`);
if (!input.files || !input.files[0]) return;
await uploadServiceIcon(prefix);
}
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}`);
}
}
async function createCategory() {
const name = (document.getElementById('cat_name').value || '').trim();
const slugInput = document.getElementById('cat_slug');
const slug = (slugInput.value || slugifyRu(name)).trim();
if (!name) return alert('Введите название категории');
if (!slug) return alert('Введите slug категории');
await api('/api/admin/categories', 'POST', {name, slug});
location.reload();
}
async function deleteCategory(id, name) {
if (!confirm(`Удалить категорию ${name}?`)) return;
await api(`/api/admin/categories/${id}`, 'DELETE', {});
location.reload();
}
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', 'categories', 'stats'].includes(hashTab)
? hashTab
: (['users', 'web', 'rdp', 'categories', 'stats'].includes(savedTab || '') ? savedTab : 'users');
showTab(initialTab);
renderUserDays();
</script>
</body>
</html>