Files
Inv_web/templates/client_test.html
2026-06-27 16:53:45 +03:00

665 lines
26 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.
{% extends "base.html" %}
{% block body_class %}page-bg bg-devices{% endblock %}
{% block content %}
<style>
body.bg-devices {
background-color: #B3B3DA;
background-image:
linear-gradient(rgba(179, 179, 218, 0.75), rgba(179, 179, 218, 0.75)),
url('{{ url_for("static", filename="bg/bg_notebooks.png", v=1) }}');
background-size: auto 40vh;
}
.client-test-page .card {
background-color: #B3B3DA;
border: 1px solid #B3B3DA;
box-shadow: none;
}
.client-test-page .card-body {
background-color: transparent;
}
.client-test-page .table td,
.client-test-page .table th {
white-space: normal;
word-break: break-word;
vertical-align: middle;
padding: 3px 4px;
font-size: 0.72rem;
line-height: 1.15;
}
.client-test-page .table {
table-layout: fixed;
}
.client-test-page .table th,
.client-test-page .table td {
overflow-wrap: anywhere;
}
.client-test-col-inv { width: 120px; }
.client-test-col-type { width: 150px; }
.client-test-col-model { width: 200px; }
.client-test-col-building { width: 80px; }
.client-test-col-cabinet { width: 80px; }
.client-test-col-position { width: 75px; }
.client-test-col-full-name { width: 150px; }
.client-test-col-updated { width: 105px; }
.client-test-col-serial { width: 90px; }
.client-test-col-hostname { width: 200px; }
.client-test-col-status { width: 50px; }
.client-test-password-field {
margin-bottom: 0.8rem;
}
.client-test-password-field label {
display: block;
margin-bottom: 0.3rem;
font-size: 0.85rem;
font-weight: 700;
}
.client-test-password-fields[hidden] {
display: none;
}
.client-test-password-status {
font-size: 0.85rem;
color: #5f5f5f;
min-height: 1.3rem;
margin-bottom: 0.65rem;
}
.client-test-password-status.is-error {
color: #c62828;
}
.client-test-password-status.is-success {
color: #17853b;
}
.edit-modal__dialog.client-test-password-dialog {
width: min(500px, 100%);
}
.client-test-password-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
padding-top: 0.8rem;
border-top: 1px solid #dee2e6;
}
.client-test-password-delete {
color: #c62828 !important;
background-color: #d9d9d9 !important;
}
.client-test-password-actions [hidden] {
display: none !important;
}
.client-test-summary {
text-align: center;
margin-top: 8px;
margin-bottom: 8px;
color: #5f5f5f;
font-weight: 500;
}
.client-test-status {
font-weight: 700;
}
.client-test-status.is-online {
color: #17853b;
}
.client-test-status.is-offline {
color: #c62828;
}
.client-test-row {
cursor: pointer;
}
.row-actions {
background: #f8f9fa;
}
.row-actions__inner {
max-height: 0;
opacity: 0;
transform: translateY(-6px);
transition: max-height 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
overflow: hidden;
padding: 0 0.75rem;
}
.row-actions.show .row-actions__inner {
max-height: 60px;
opacity: 1;
transform: translateY(0);
}
.edit-modal {
position: fixed;
inset: 0;
background: rgba(15, 23, 32, 0.55);
display: none;
align-items: center;
justify-content: center;
z-index: 1050;
padding: 1rem;
}
.edit-modal.show {
display: flex;
}
.edit-modal__dialog {
background: #fff;
border-radius: 0.75rem;
width: min(1000px, 100%);
max-height: 85vh;
overflow: auto;
padding: 1.25rem 1.5rem;
box-shadow: 0 24px 48px rgba(15, 23, 32, 0.25);
}
.edit-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.edit-modal__title {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
}
.specs-modal__name {
margin-bottom: 0.7rem;
font-weight: 600;
}
.specs-modal__list {
margin: 0;
padding-left: 1rem;
}
.specs-modal__list li {
margin-bottom: 0.2rem;
}
.specs-modal__status {
font-weight: 700;
}
.specs-modal__status.is-online {
color: #17853b;
}
.specs-modal__status.is-offline {
color: #c62828;
}
</style>
<div class="client-test-page">
<div class="d-flex align-items-center justify-content-between mb-2">
<h3 class="mb-0">Клиенты</h3>
{% if session.get('role') in ('admin','storekeeper') %}
<button class="btn btn-outline-primary btn-sm" type="button" id="toggleRustdeskPassword">
Пароль удаленного доступа
</button>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-striped align-middle">
<colgroup>
<col class="client-test-col-inv">
<col class="client-test-col-type">
<col class="client-test-col-model">
<col class="client-test-col-serial">
<col class="client-test-col-hostname">
<col class="client-test-col-building">
<col class="client-test-col-cabinet">
<col class="client-test-col-position">
<col class="client-test-col-full-name">
<col class="client-test-col-status">
<col class="client-test-col-updated">
</colgroup>
<thead>
<tr>
<th>Инв. №</th>
<th>Тип</th>
<th>Модель</th>
<th>Серийный №</th>
<th>Имя компьютера</th>
<th>Здание</th>
<th>Кабинет</th>
<th>Должность</th>
<th>ФИО</th>
<th>Статус</th>
<th>Обновлено</th>
</tr>
</thead>
<tbody>
{% for id, inventory_number, device_type, model, serial_number, hostname, cpu, gpu, motherboard, memory, storage, os_info, ip_address, mac_address, rustdesk_id, rustdesk_password, rustdesk_installed, building, cabinet_name, position, full_name, is_online, last_seen, date_added in rows %}
{% set updated_short = last_seen.strftime('%Y-%m-%d %H:%M') if last_seen and last_seen.strftime else (last_seen|string)[:16] if last_seen else '' %}
<tr class="client-test-row js-client-test-row"
data-id="{{ id }}"
data-inventory-number="{{ inventory_number or '' }}"
data-device-type="{{ device_type or '' }}"
data-model="{{ model or '' }}"
data-serial-number="{{ serial_number or '' }}"
data-hostname="{{ hostname or '' }}"
data-cpu="{{ cpu or '' }}"
data-gpu="{{ gpu or '' }}"
data-motherboard="{{ motherboard or '' }}"
data-memory="{{ memory or '' }}"
data-storage="{{ storage or '' }}"
data-os-info="{{ os_info or '' }}"
data-ip-address="{{ ip_address or '' }}"
data-mac-address="{{ mac_address or '' }}"
data-rustdesk-id="{{ rustdesk_id or '' }}"
data-rustdesk-installed="{{ '1' if rustdesk_installed else '0' }}"
data-building="{{ building or '' }}"
data-cabinet-name="{{ cabinet_name or '' }}"
data-position="{{ position or '' }}"
data-full-name="{{ full_name or '' }}"
data-status="{{ 'Online' if is_online else 'Offline' }}"
data-last-seen="{{ updated_short }}">
<td>{{ inventory_number }}</td>
<td>{{ device_type or '—' }}</td>
<td>{{ model or '—' }}</td>
<td>{{ serial_number or '—' }}</td>
<td>{{ hostname or '—' }}</td>
<td>{{ building or '—' }}</td>
<td>{{ cabinet_name or '—' }}</td>
<td>{{ position or '—' }}</td>
<td>{{ full_name or '—' }}</td>
<td>
<span class="client-test-status {{ 'is-online' if is_online else 'is-offline' }}" data-client-test-status>
{{ 'Online' if is_online else 'Offline' }}
</span>
</td>
<td>{{ updated_short or '—' }}</td>
</tr>
{% else %}
<tr>
<td colspan="11" class="text-center text-muted">Данных от клиентов пока нет</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="client-test-summary">
Всего: {{ rows|length }}
{% for type_label, type_count in client_type_counts.items() %}
<span aria-hidden="true">·</span> {{ type_label }}: {{ type_count }}
{% endfor %}
</div>
</div>
{% if session.get('role') in ('admin','storekeeper') %}
<div
class="edit-modal"
id="rustdeskPasswordModal"
aria-hidden="true"
data-has-password="{{ '1' if has_rustdesk_password else '0' }}"
>
<div class="edit-modal__dialog client-test-password-dialog" role="dialog" aria-modal="true" aria-labelledby="rustdeskPasswordTitle">
<div class="edit-modal__header">
<h4 class="edit-modal__title" id="rustdeskPasswordTitle">Пароль удаленного доступа</h4>
<button type="button" class="btn btn-sm btn-outline-secondary" data-password-close>Закрыть</button>
</div>
<form id="rustdeskPasswordForm" novalidate>
<div class="client-test-password-fields" id="rustdeskInitialFields" hidden>
<div class="client-test-password-field">
<label for="rustdeskInitialPassword">Введите пароль</label>
<input class="form-control" id="rustdeskInitialPassword" type="password" autocomplete="new-password">
</div>
</div>
<div class="client-test-password-fields" id="rustdeskChangeFields" hidden>
<div class="client-test-password-field">
<label for="rustdeskOldPassword">Старый пароль</label>
<input class="form-control" id="rustdeskOldPassword" type="password" autocomplete="current-password">
</div>
<div class="client-test-password-field">
<label for="rustdeskNewPassword">Новый пароль</label>
<input class="form-control" id="rustdeskNewPassword" type="password" autocomplete="new-password">
</div>
<div class="client-test-password-field">
<label for="rustdeskNewPasswordConfirm">Повторите новый пароль</label>
<input class="form-control" id="rustdeskNewPasswordConfirm" type="password" autocomplete="new-password">
</div>
</div>
<div class="client-test-password-fields" id="rustdeskDeleteFields" hidden>
<div class="client-test-password-field">
<label for="rustdeskAdminPassword">Пароль администратора</label>
<input class="form-control" id="rustdeskAdminPassword" type="password" autocomplete="current-password">
</div>
</div>
<div class="client-test-password-status" id="rustdeskPasswordStatus" role="status" aria-live="polite"></div>
<div class="client-test-password-actions">
<button class="btn btn-outline-danger client-test-password-delete" id="rustdeskDeleteButton" type="button">Удалить пароль</button>
<button class="btn btn-outline-success" id="rustdeskInstallButton" type="button" hidden>Установить пароль</button>
<button class="btn btn-outline-primary" id="rustdeskChangeButton" type="button">Изменить пароль</button>
</div>
</form>
</div>
</div>
{% endif %}
<div class="edit-modal" id="clientTestSpecsModal" aria-hidden="true">
<div class="edit-modal__dialog">
<div class="edit-modal__header">
<h4 class="edit-modal__title">Характеристики</h4>
<button type="button" class="btn btn-sm btn-outline-secondary" data-specs-close>Закрыть</button>
</div>
<div id="clientTestSpecsName" class="specs-modal__name"></div>
<ul id="clientTestSpecsList" class="specs-modal__list"></ul>
</div>
</div>
<script>
(function() {
const passwordToggle = document.getElementById('toggleRustdeskPassword');
const passwordModal = document.getElementById('rustdeskPasswordModal');
const passwordForm = document.getElementById('rustdeskPasswordForm');
const passwordStatus = document.getElementById('rustdeskPasswordStatus');
const initialFields = document.getElementById('rustdeskInitialFields');
const changeFields = document.getElementById('rustdeskChangeFields');
const deleteFields = document.getElementById('rustdeskDeleteFields');
const initialPassword = document.getElementById('rustdeskInitialPassword');
const oldPassword = document.getElementById('rustdeskOldPassword');
const newPassword = document.getElementById('rustdeskNewPassword');
const newPasswordConfirm = document.getElementById('rustdeskNewPasswordConfirm');
const adminPassword = document.getElementById('rustdeskAdminPassword');
const deletePasswordButton = document.getElementById('rustdeskDeleteButton');
const installPasswordButton = document.getElementById('rustdeskInstallButton');
const changePasswordButton = document.getElementById('rustdeskChangeButton');
const rows = document.querySelectorAll('.js-client-test-row');
const specsModal = document.getElementById('clientTestSpecsModal');
const specsName = document.getElementById('clientTestSpecsName');
const specsList = document.getElementById('clientTestSpecsList');
let hasPassword = passwordModal ? passwordModal.dataset.hasPassword === '1' : false;
let passwordMode = 'initial';
function setPasswordStatus(text, type = '') {
if (!passwordStatus) return;
passwordStatus.textContent = text || '';
passwordStatus.classList.toggle('is-error', type === 'error');
passwordStatus.classList.toggle('is-success', type === 'success');
}
function clearPasswordInputs() {
[initialPassword, oldPassword, newPassword, newPasswordConfirm, adminPassword]
.filter(Boolean)
.forEach((input) => { input.value = ''; });
}
function showInitialPasswordState() {
passwordMode = 'initial';
if (initialFields) initialFields.hidden = hasPassword;
if (changeFields) changeFields.hidden = true;
if (deleteFields) deleteFields.hidden = true;
if (deletePasswordButton) deletePasswordButton.textContent = 'Удалить пароль';
if (installPasswordButton) installPasswordButton.hidden = hasPassword;
if (changePasswordButton) changePasswordButton.hidden = !hasPassword;
if (changePasswordButton) changePasswordButton.textContent = 'Изменить пароль';
clearPasswordInputs();
if (!hasPassword && initialPassword) initialPassword.focus();
}
function showChangePasswordState() {
passwordMode = 'change';
if (initialFields) initialFields.hidden = true;
if (changeFields) changeFields.hidden = false;
if (deleteFields) deleteFields.hidden = true;
if (deletePasswordButton) deletePasswordButton.textContent = 'Удалить пароль';
if (installPasswordButton) installPasswordButton.hidden = true;
if (changePasswordButton) changePasswordButton.hidden = false;
if (changePasswordButton) changePasswordButton.textContent = 'Сохранить пароль';
clearPasswordInputs();
if (oldPassword) oldPassword.focus();
}
function showDeletePasswordState() {
passwordMode = 'delete';
if (initialFields) initialFields.hidden = true;
if (changeFields) changeFields.hidden = true;
if (deleteFields) deleteFields.hidden = false;
if (deletePasswordButton) deletePasswordButton.textContent = 'Подтвердить удаление';
if (installPasswordButton) installPasswordButton.hidden = true;
if (changePasswordButton) changePasswordButton.hidden = false;
if (changePasswordButton) changePasswordButton.textContent = 'Отмена';
clearPasswordInputs();
if (adminPassword) adminPassword.focus();
}
function setPasswordButtonsDisabled(disabled) {
if (deletePasswordButton) deletePasswordButton.disabled = disabled;
if (installPasswordButton) installPasswordButton.disabled = disabled;
if (changePasswordButton) changePasswordButton.disabled = disabled;
}
function passwordErrorMessage(code) {
const messages = {
invalid_old_password: 'Старый пароль указан неверно',
invalid_admin_password: 'Неверный пароль администратора',
password_already_set: 'Пароль уже установлен. Обновите страницу',
password_not_set: 'Пароль не установлен. Обновите страницу',
new_password_required: 'Введите новый пароль',
};
return messages[code] || 'Не удалось сохранить изменения';
}
async function sendPasswordRequest(payload) {
setPasswordButtonsDisabled(true);
setPasswordStatus('Сохранение...');
try {
const response = await fetch('/client-test/rustdesk-password-all', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !data.ok) {
setPasswordStatus(passwordErrorMessage(data.error), 'error');
return;
}
hasPassword = Boolean(data.has_password);
if (passwordModal) passwordModal.dataset.hasPassword = hasPassword ? '1' : '0';
showInitialPasswordState();
const message = payload.action === 'delete'
? `Пароль удалён со всех устройств: ${data.updated || 0}`
: `Пароль сохранён для всех устройств: ${data.updated || 0}`;
setPasswordStatus(message, 'success');
} catch (_error) {
setPasswordStatus('Ошибка соединения с сервером', 'error');
} finally {
setPasswordButtonsDisabled(false);
}
}
if (passwordToggle && passwordModal && passwordForm) {
passwordToggle.addEventListener('click', () => {
setPasswordStatus('');
showInitialPasswordState();
passwordModal.classList.add('show');
passwordModal.setAttribute('aria-hidden', 'false');
});
passwordModal.addEventListener('click', (event) => {
if (event.target === passwordModal || event.target.hasAttribute('data-password-close')) {
passwordModal.classList.remove('show');
passwordModal.setAttribute('aria-hidden', 'true');
}
});
deletePasswordButton.addEventListener('click', () => {
setPasswordStatus('');
if (passwordMode !== 'delete') {
showDeletePasswordState();
return;
}
const password = adminPassword ? adminPassword.value.trim() : '';
if (!password) {
setPasswordStatus('Введите пароль администратора', 'error');
if (adminPassword) adminPassword.focus();
return;
}
sendPasswordRequest({action: 'delete', admin_password: password});
});
installPasswordButton.addEventListener('click', () => {
setPasswordStatus('');
const password = initialPassword ? initialPassword.value.trim() : '';
if (!password) {
setPasswordStatus('Введите пароль', 'error');
if (initialPassword) initialPassword.focus();
return;
}
sendPasswordRequest({action: 'initialize', new_password: password});
});
changePasswordButton.addEventListener('click', () => {
setPasswordStatus('');
if (passwordMode === 'delete') {
showInitialPasswordState();
return;
}
if (passwordMode === 'initial' && hasPassword) {
showChangePasswordState();
return;
}
const oldValue = oldPassword ? oldPassword.value.trim() : '';
const newValue = newPassword ? newPassword.value.trim() : '';
const confirmValue = newPasswordConfirm ? newPasswordConfirm.value.trim() : '';
if (!oldValue || !newValue || !confirmValue) {
setPasswordStatus('Заполните все три поля', 'error');
return;
}
if (newValue !== confirmValue) {
setPasswordStatus('Новые пароли не совпадают', 'error');
if (newPasswordConfirm) newPasswordConfirm.focus();
return;
}
sendPasswordRequest({action: 'change', old_password: oldValue, new_password: newValue});
});
passwordForm.addEventListener('submit', (event) => {
event.preventDefault();
if (passwordMode === 'delete') deletePasswordButton.click();
else if (passwordMode === 'initial' && !hasPassword) installPasswordButton.click();
else changePasswordButton.click();
});
}
async function refreshClientStatuses() {
if (!rows.length || document.hidden) return;
try {
const response = await fetch('/client-test/statuses', {headers: {'Accept': 'application/json'}});
if (!response.ok) return;
const data = await response.json();
if (!data || !data.ok || !Array.isArray(data.devices)) return;
const statuses = new Map(data.devices.map((device) => [String(device.id), Boolean(device.is_online)]));
rows.forEach((row) => {
const isOnline = statuses.get(row.dataset.id) === true;
const status = isOnline ? 'Online' : 'Offline';
const statusElement = row.querySelector('[data-client-test-status]');
row.dataset.status = status;
if (!statusElement) return;
statusElement.textContent = status;
statusElement.classList.toggle('is-online', isOnline);
statusElement.classList.toggle('is-offline', !isOnline);
});
} catch (_error) {
// Keep the last displayed state until the server is reachable again.
}
}
if (rows.length) {
window.setInterval(refreshClientStatuses, 10000);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) refreshClientStatuses();
});
}
if (!rows.length || !specsModal || !specsName || !specsList) return;
function closeSpecsModal() {
specsModal.classList.remove('show');
specsModal.setAttribute('aria-hidden', 'true');
}
function openSpecsModal() {
specsModal.classList.add('show');
specsModal.setAttribute('aria-hidden', 'false');
}
specsModal.addEventListener('click', (e) => {
if (e.target === specsModal || e.target.hasAttribute('data-specs-close')) {
closeSpecsModal();
}
});
function appendSpecItem(label, value, fallback = '—') {
const cleaned = (value || '').trim();
const li = document.createElement('li');
li.textContent = `${label}: ${cleaned || fallback}`;
specsList.appendChild(li);
}
function appendStatusItem(label, value) {
const cleaned = (value || '').trim();
const li = document.createElement('li');
if (!cleaned) {
li.textContent = `${label}: —`;
specsList.appendChild(li);
return;
}
const statusClass = cleaned.toLowerCase() === 'online' ? 'is-online' : 'is-offline';
li.innerHTML = `${label}: <span class="specs-modal__status ${statusClass}">${cleaned}</span>`;
specsList.appendChild(li);
}
function openSpecsFromRow(row) {
const type = (row.getAttribute('data-device-type') || '').trim();
const model = (row.getAttribute('data-model') || '').trim();
specsName.textContent = [type || 'Устройство', model].filter(Boolean).join(' ');
specsList.innerHTML = '';
appendSpecItem('CPU', row.getAttribute('data-cpu') || '');
appendSpecItem('GPU', row.getAttribute('data-gpu') || '');
appendSpecItem('Материнская плата', row.getAttribute('data-motherboard') || '');
appendSpecItem('Память', row.getAttribute('data-memory') || '');
appendSpecItem('Накопители', row.getAttribute('data-storage') || '');
appendSpecItem('ОС', row.getAttribute('data-os-info') || '');
appendSpecItem('IP', row.getAttribute('data-ip-address') || '');
appendSpecItem('MAC', row.getAttribute('data-mac-address') || '');
const rustdeskId = (row.getAttribute('data-rustdesk-id') || '').trim();
appendSpecItem('RustDesk', rustdeskId);
appendSpecItem('Имя ПК', row.getAttribute('data-hostname') || '');
appendStatusItem('Статус', row.getAttribute('data-status') || '');
appendSpecItem('Последний контакт', row.getAttribute('data-last-seen') || '');
openSpecsModal();
}
function toggleActionRow(row) {
const nextRow = row.nextElementSibling;
if (nextRow && nextRow.classList.contains('row-actions')) {
nextRow.remove();
return;
}
document.querySelectorAll('.row-actions').forEach((actionRow) => actionRow.remove());
const rowId = row.dataset.id || '';
const actionRow = document.createElement('tr');
actionRow.className = 'row-actions';
actionRow.innerHTML = `<td colspan="11">
<div class="row-actions__inner">
<button type="button" class="btn btn-sm btn-outline-secondary js-open-client-test-specs">Характеристики</button>
{% if session.get('role') in ('admin','storekeeper') %}
<form method="post" action="/client-test/delete" class="d-inline ms-2" onsubmit="return confirm('Удалить запись клиента?');">
<input type="hidden" name="id" value="${rowId}">
<button class="btn btn-sm btn-outline-danger" type="submit">Удалить</button>
</form>
{% endif %}
</div>
</td>`;
row.insertAdjacentElement('afterend', actionRow);
actionRow.querySelector('.js-open-client-test-specs').addEventListener('click', () => openSpecsFromRow(row));
requestAnimationFrame(() => actionRow.classList.add('show'));
}
rows.forEach((row) => {
row.addEventListener('click', (e) => {
if (e.target.closest('button, input, select, textarea, a')) return;
toggleActionRow(row);
});
row.addEventListener('dblclick', (e) => {
if (e.target.closest('button, input, select, textarea, a')) return;
openSpecsFromRow(row);
});
});
})();
</script>
{% endblock %}