feat: categories, runtime nav, and UX updates

This commit is contained in:
2026-04-21 11:43:43 +00:00
parent 9eb3403f8c
commit 52d1991092
12 changed files with 560 additions and 51 deletions
+107 -7
View File
@@ -10,7 +10,7 @@
<header class="header">
<div style="display:flex; align-items:center; gap:0.6rem;">
<img src="/static/logo.png" alt="MONT" class="header-logo" />
<div>МОНТ - инфра полигон | Админ: {{ admin.username }}</div>
<div>МОНТ - инфрастуктурный полигон | Админ: {{ admin.username }}</div>
</div>
<a href="/" class="btn-link secondary">Главная панель</a>
</header>
@@ -27,6 +27,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="categories" onclick="showTab('categories')">Categories</button>
<button class="tab-btn" data-tab="stats" onclick="showTab('stats')">Stats</button>
</div>
</section>
@@ -160,6 +161,12 @@
<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 onclick="deleteService('w_id')">Delete</button>
@@ -194,9 +201,8 @@
<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" />
<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="uploadServiceIcon('w')">Upload</button>
<button onclick="pasteServiceIcon('w')">Paste</button>
<button onclick="removeServiceIcon('w')">Remove icon</button>
</div>
@@ -230,6 +236,12 @@
<select id="new_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="new_w_categories">
{% for c in categories %}
<label><input type="checkbox" class="new_w_cat" value="{{c.id}}" /> {{c.name}}</label>
{% endfor %}
</div>
<button onclick="createWebService()">Add WEB</button>
</div>
</div>
@@ -279,6 +291,12 @@
<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 onclick="prewarmNow('r_id')">Prewarm now</button>
@@ -313,9 +331,8 @@
<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" />
<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="uploadServiceIcon('r')">Upload</button>
<button onclick="pasteServiceIcon('r')">Paste</button>
<button onclick="removeServiceIcon('r')">Remove icon</button>
</div>
@@ -345,11 +362,50 @@
<input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" />
<select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div>
<div class="list-title">Категории</div>
<div class="acl-grid compact-grid" id="new_r_categories">
{% for c in categories %}
<label><input type="checkbox" class="new_r_cat" value="{{c.id}}" /> {{c.name}}</label>
{% endfor %}
</div>
<button onclick="createRdpService()">Add RDP</button>
</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">
@@ -421,6 +477,7 @@
<script>
const csrf = "{{ csrf_token }}";
const aclMap = {{ acl | tojson }};
const serviceCategoryMap = {{ service_category_map | tojson }};
const placeholderIcon = '/static/service-placeholder.svg';
let activeTab = 'users';
@@ -447,6 +504,19 @@
});
}
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'
@@ -598,6 +668,7 @@
document.getElementById('w_comment').value = comment || '';
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';
markSelected('.web-item', 'data-service-id', id);
refreshSelectedServiceStatus('web');
@@ -617,6 +688,7 @@
type: 'WEB',
target: document.getElementById('new_w_target').value,
comment: document.getElementById('new_w_comment').value,
category_ids: checkedCategoryIds('.new_w_cat'),
active: document.getElementById('new_w_active').value === 'true',
});
location.reload();
@@ -632,6 +704,7 @@
type: 'WEB',
target: document.getElementById('w_target').value,
comment: document.getElementById('w_comment').value,
category_ids: checkedCategoryIds('.w_cat'),
active: document.getElementById('w_active').value === 'true',
});
location.reload();
@@ -640,6 +713,7 @@
function clearWebForm() {
['w_id','w_name','w_slug','w_target','w_comment'].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.querySelectorAll('.web-item').forEach((el) => el.classList.remove('selected-item'));
@@ -699,6 +773,7 @@
document.getElementById('r_comment').value = comment || '';
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';
markSelected('.rdp-item', 'data-service-id', id);
@@ -714,6 +789,7 @@
type: 'RDP',
target,
comment: document.getElementById('new_r_comment').value,
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',
});
@@ -730,6 +806,7 @@
type: 'RDP',
target,
comment: document.getElementById('r_comment').value,
category_ids: checkedCategoryIds('.r_cat'),
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
active: document.getElementById('r_active').value === 'true',
});
@@ -740,6 +817,7 @@
['r_id','r_name','r_slug','r_target','r_host','r_port','r_user','r_pass','r_domain','r_comment','r_pool'].forEach(id => document.getElementById(id).value = '');
document.getElementById('r_sec').value = '';
document.getElementById('r_active').value = 'true';
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'));
@@ -804,6 +882,12 @@
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('Сначала выберите сервис');
@@ -850,6 +934,22 @@
}
}
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(() => {});
@@ -861,9 +961,9 @@
const hashTab = (window.location.hash || '').replace('#', '');
const savedTab = localStorage.getItem('admin_active_tab');
const initialTab = ['users', 'web', 'rdp', 'stats'].includes(hashTab)
const initialTab = ['users', 'web', 'rdp', 'categories', 'stats'].includes(hashTab)
? hashTab
: (['users', 'web', 'rdp', 'stats'].includes(savedTab || '') ? savedTab : 'users');
: (['users', 'web', 'rdp', 'categories', 'stats'].includes(savedTab || '') ? savedTab : 'users');
showTab(initialTab);
renderUserDays();
</script>