665 lines
26 KiB
HTML
665 lines
26 KiB
HTML
{% 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 %}
|