feat: categories, runtime nav, and UX updates
This commit is contained in:
+107
-7
@@ -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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>МОНТ - инфра полигон</title>
|
||||
<title>МОНТ - инфрастуктурный полигон</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -17,28 +17,50 @@
|
||||
<a href="/admin" class="btn-link secondary">Администрирование</a>
|
||||
{% endif %}
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Logout</button>
|
||||
<button type="submit">Выход</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<main class="admin-layout">
|
||||
<section class="panel">
|
||||
<div class="admin-intro">
|
||||
Выберите нужный сервис. После клика откроется готовый браузер/сеанс с заранее заданным адресом.
|
||||
<div class="admin-intro">Добро пожаловать в инфрастуктурный полигон</div>
|
||||
{% if categories %}
|
||||
<div class="category-strip">
|
||||
<a class="category-chip {% if not selected_category_slug %}active{% endif %}" href="/">Все сервисы</a>
|
||||
{% for category in categories %}
|
||||
<a class="category-chip {% if selected_category_slug == category.slug %}active{% endif %}" href="/?category={{ category.slug }}">{{ category.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<section class="grid">
|
||||
<section class="grid service-grid">
|
||||
{% for service in services %}
|
||||
{% set svc_cats = service_categories.get(service.id, []) %}
|
||||
<a class="tile" href="/go/{{ service.slug }}">
|
||||
<img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
|
||||
<div class="tile-icon-box">
|
||||
<img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
|
||||
</div>
|
||||
<h3>{{ service.name }}</h3>
|
||||
<p>Открыть сервис</p>
|
||||
{% if service.comment %}
|
||||
<small>{{ service.comment }}</small>
|
||||
{% endif %}
|
||||
{% if svc_cats %}
|
||||
<div class="service-categories">
|
||||
{% for category in svc_cats %}
|
||||
<span class="service-cat-badge">{{ category.name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="tile">Нет назначенных сервисов</div>
|
||||
<div class="tile">
|
||||
{% if selected_category_slug %}
|
||||
Нет сервисов в выбранной категории
|
||||
{% else %}
|
||||
Нет назначенных сервисов
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>МОНТ - инфра полигон</title>
|
||||
<title>МОНТ - инфрастуктурный полигон</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="center-box login-page">
|
||||
<img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" />
|
||||
<h1 class="login-title">МОНТ - инфра полигон</h1>
|
||||
<h1 class="login-title">МОНТ - инфрастуктурный полигон</h1>
|
||||
{% if login_error %}<div class="auth-error">{{ login_error }}</div>{% endif}
|
||||
<form method="post" action="/login" class="panel">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label>Login</label>
|
||||
|
||||
Reference in New Issue
Block a user