1024 lines
42 KiB
HTML
1024 lines
42 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<script>
|
|
(function () {
|
|
try {
|
|
var t = (localStorage.getItem("theme") || "").trim().toLowerCase();
|
|
document.documentElement.setAttribute("data-theme", t === "light" ? "light" : "dark");
|
|
} catch (e) {}
|
|
})();
|
|
</script>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Проверка баз данных</title>
|
|
<link rel="stylesheet" href="/static/css/inter-fonts.css">
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:root {
|
|
--primary: #2563eb;
|
|
--primary-hover: #1d4ed8;
|
|
--success: #10b981;
|
|
--warning: #f59e0b;
|
|
--danger: #ef4444;
|
|
--info: #0ea5e9;
|
|
--gray-50: #f9fafb;
|
|
--gray-100: #f3f4f6;
|
|
--gray-200: #e5e7eb;
|
|
--gray-600: #4b5563;
|
|
--gray-900: #111827;
|
|
--white: #ffffff;
|
|
--border: var(--gray-200);
|
|
}
|
|
|
|
body {
|
|
font-family: 'Inter', system-ui, sans-serif;
|
|
background: var(--gray-50);
|
|
min-height: 100vh;
|
|
color: var(--gray-900);
|
|
padding: 20px 20px 48px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.nav-button {
|
|
position: fixed;
|
|
top: 16px;
|
|
right: 16px;
|
|
background: transparent;
|
|
border: none;
|
|
padding: 0;
|
|
width: 56px;
|
|
height: 56px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
z-index: 1000;
|
|
transition: transform 0.2s ease;
|
|
}
|
|
|
|
.nav-button:hover {
|
|
transform: scale(1.06);
|
|
}
|
|
|
|
.nav-button img {
|
|
width: 44px;
|
|
height: 44px;
|
|
}
|
|
|
|
.page-header {
|
|
text-align: center;
|
|
margin-bottom: 28px;
|
|
padding-top: 8px;
|
|
}
|
|
|
|
.main-title {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
color: var(--gray-900);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.subtitle {
|
|
color: var(--gray-600);
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.container {
|
|
max-width: 960px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.card {
|
|
background: var(--white);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 16px;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
border: 1px solid var(--gray-200);
|
|
}
|
|
|
|
.card-header {
|
|
margin-bottom: 16px;
|
|
padding-bottom: 12px;
|
|
border-bottom: 1px solid var(--gray-200);
|
|
}
|
|
|
|
.card-title {
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
color: var(--gray-900);
|
|
margin: 0;
|
|
}
|
|
|
|
.card-header-with-action {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.card-header-with-action .card-title {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
}
|
|
|
|
.card-header-with-action #check-db-btn {
|
|
min-width: 0;
|
|
flex-shrink: 0;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.btn {
|
|
padding: 12px 22px;
|
|
border-radius: 6px;
|
|
border: none;
|
|
font-size: 0.95rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background 0.2s, box-shadow 0.2s, transform 0.2s;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
min-width: 200px;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--primary);
|
|
color: var(--white);
|
|
}
|
|
|
|
.btn-primary:hover:not(:disabled) {
|
|
background: var(--primary-hover);
|
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
|
|
}
|
|
|
|
.btn-success {
|
|
background: var(--success);
|
|
color: var(--white);
|
|
}
|
|
|
|
.btn-success:hover:not(:disabled) {
|
|
filter: brightness(0.95);
|
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: var(--danger);
|
|
color: var(--white);
|
|
}
|
|
|
|
.btn-danger:hover:not(:disabled) {
|
|
filter: brightness(0.95);
|
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.25);
|
|
}
|
|
|
|
.btn-info {
|
|
background: var(--info);
|
|
color: var(--white);
|
|
}
|
|
|
|
.btn-info:hover:not(:disabled) {
|
|
filter: brightness(0.95);
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
transform: none !important;
|
|
box-shadow: none !important;
|
|
}
|
|
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-top: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.alert {
|
|
padding: 16px 18px;
|
|
border-radius: 8px;
|
|
margin: 16px 0;
|
|
border-left: 4px solid;
|
|
background: var(--white);
|
|
border: 1px solid var(--gray-200);
|
|
}
|
|
|
|
.alert-success {
|
|
border-left-color: var(--success);
|
|
background: #ecfdf5;
|
|
color: #065f46;
|
|
}
|
|
|
|
.alert-warning {
|
|
border-left-color: var(--warning);
|
|
background: #fffbeb;
|
|
color: #92400e;
|
|
}
|
|
|
|
.alert-danger {
|
|
border-left-color: var(--danger);
|
|
background: #fef2f2;
|
|
color: #991b1b;
|
|
}
|
|
|
|
.alert-info {
|
|
border-left-color: var(--info);
|
|
background: #f0f9ff;
|
|
color: #0c4a6e;
|
|
}
|
|
|
|
.alert h6 {
|
|
font-size: 1rem;
|
|
margin-bottom: 10px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.alert p {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.alert p:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
gap: 12px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--gray-50);
|
|
padding: 16px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
border: 1px solid var(--gray-200);
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 1.75rem;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
line-height: 1.2;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.stat-label {
|
|
color: var(--gray-600);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.loading-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.55);
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 2000;
|
|
}
|
|
|
|
.loading-content {
|
|
background: var(--white);
|
|
padding: 28px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
max-width: 340px;
|
|
width: 90%;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
|
border: 1px solid var(--gray-200);
|
|
}
|
|
|
|
.loader {
|
|
width: 44px;
|
|
height: 44px;
|
|
border: 3px solid var(--gray-200);
|
|
border-top-color: var(--primary);
|
|
border-radius: 50%;
|
|
animation: spin 0.9s linear infinite;
|
|
margin: 0 auto 16px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.db-details {
|
|
margin: 12px 0;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
padding-right: 8px;
|
|
}
|
|
|
|
.table-section {
|
|
background: var(--gray-50);
|
|
border-radius: 8px;
|
|
padding: 14px 16px;
|
|
margin-bottom: 12px;
|
|
border: 1px solid var(--gray-200);
|
|
border-left: 3px solid var(--primary);
|
|
}
|
|
|
|
.table-name {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
margin-bottom: 10px;
|
|
color: var(--gray-900);
|
|
}
|
|
|
|
.record-list {
|
|
list-style: none;
|
|
margin: 8px 0 0 0;
|
|
}
|
|
|
|
.record-list li {
|
|
padding: 10px 12px;
|
|
background: var(--white);
|
|
border-radius: 6px;
|
|
margin-bottom: 8px;
|
|
font-size: 0.95rem;
|
|
border: 1px solid var(--gray-200);
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.label-add {
|
|
color: #047857;
|
|
font-weight: 500;
|
|
margin: 10px 0 6px 0;
|
|
}
|
|
|
|
.label-remove {
|
|
color: #b91c1c;
|
|
font-weight: 500;
|
|
margin: 10px 0 6px 0;
|
|
}
|
|
|
|
.section-heading {
|
|
margin: 14px 0 8px 0;
|
|
font-weight: 600;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.mt-2 { margin-top: 16px; }
|
|
.text-muted { color: var(--gray-600); }
|
|
.text-center { text-align: center; }
|
|
|
|
html[data-theme="dark"] body {
|
|
background: #1a1a1a !important;
|
|
color: #e6e6e6 !important;
|
|
}
|
|
|
|
html[data-theme="dark"] .main-title,
|
|
html[data-theme="dark"] .card-title,
|
|
html[data-theme="dark"] .table-name {
|
|
color: #f3f4f6 !important;
|
|
}
|
|
|
|
html[data-theme="dark"] .subtitle {
|
|
color: #9ca3af !important;
|
|
}
|
|
|
|
html[data-theme="dark"] .card,
|
|
html[data-theme="dark"] .loading-content,
|
|
html[data-theme="dark"] .stat-card {
|
|
background: #242424 !important;
|
|
border-color: #3a3a3a !important;
|
|
color: #e6e6e6 !important;
|
|
}
|
|
|
|
html[data-theme="dark"] .card-header {
|
|
border-bottom-color: #3a3a3a !important;
|
|
}
|
|
|
|
html[data-theme="dark"] .alert {
|
|
background: #2d2d2d !important;
|
|
border-color: #3a3a3a !important;
|
|
}
|
|
|
|
html[data-theme="dark"] .alert-success {
|
|
color: #a7f3d0 !important;
|
|
border-left-color: var(--success) !important;
|
|
}
|
|
|
|
html[data-theme="dark"] .alert-warning {
|
|
color: #fde68a !important;
|
|
border-left-color: var(--warning) !important;
|
|
}
|
|
|
|
html[data-theme="dark"] .alert-danger {
|
|
color: #fecaca !important;
|
|
border-left-color: var(--danger) !important;
|
|
}
|
|
|
|
html[data-theme="dark"] .alert-info {
|
|
color: #bae6fd !important;
|
|
border-left-color: var(--info) !important;
|
|
}
|
|
|
|
html[data-theme="dark"] .table-section {
|
|
background: #1f1f1f !important;
|
|
border-color: #3a3a3a !important;
|
|
}
|
|
|
|
html[data-theme="dark"] .record-list li {
|
|
background: #2d2d2d !important;
|
|
border-color: #3a3a3a !important;
|
|
}
|
|
|
|
html[data-theme="dark"] .stat-value {
|
|
color: #60a5fa !important;
|
|
}
|
|
|
|
html[data-theme="dark"] .stat-label,
|
|
html[data-theme="dark"] .text-muted {
|
|
color: #9ca3af !important;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
body { padding: 16px 14px 40px; }
|
|
.main-title { font-size: 1.25rem; }
|
|
.btn { width: 100%; min-width: unset; }
|
|
.action-buttons { flex-direction: column; }
|
|
.db-details { max-height: 280px; }
|
|
}
|
|
|
|
body.db-check-embed {
|
|
padding: 12px 14px 20px;
|
|
min-height: auto;
|
|
}
|
|
body.db-check-embed .page-header {
|
|
display: none;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body{% if embed %} class="db-check-embed"{% endif %}>
|
|
{% if not embed %}
|
|
<button type="button" class="nav-button" id="kioskMenuButton" title="Меню" aria-label="Меню">
|
|
<img src="/static/menu.png" alt="">
|
|
</button>
|
|
{% endif %}
|
|
|
|
<div class="page-header">
|
|
<h1 class="main-title">Проверка баз данных</h1>
|
|
<p class="subtitle">Сравнение состояния клиента с сервером и восстановление</p>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<div class="card">
|
|
<div class="card-header card-header-with-action">
|
|
<h2 class="card-title">Проверка состояния</h2>
|
|
<button type="button" class="btn btn-primary" onclick="checkDatabase()" id="check-db-btn">
|
|
Запустить проверку
|
|
</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="db-check-result"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" id="stats-card" style="display: none;">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Статистика</h2>
|
|
</div>
|
|
<div class="stats-grid" id="db-stats"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="loading-overlay" id="loadingOverlay" aria-hidden="true">
|
|
<div class="loading-content">
|
|
<div class="loader" role="status" aria-label="Загрузка"></div>
|
|
<p style="font-weight: 500;">Выполняется проверка…</p>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/static/js/kiosk-dialog.js"></script>
|
|
<script>
|
|
let kioskPaired = false;
|
|
const DEVICE_TOKEN_KEY = 'wespDeviceToken';
|
|
function getDeviceToken() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const tokenFromQuery = (params.get('device_token') || '').trim();
|
|
if (tokenFromQuery) {
|
|
localStorage.setItem(DEVICE_TOKEN_KEY, tokenFromQuery);
|
|
return tokenFromQuery;
|
|
}
|
|
return (localStorage.getItem(DEVICE_TOKEN_KEY) || '').trim();
|
|
}
|
|
function buildHeaders(extra = {}) {
|
|
const headers = { ...extra };
|
|
const token = getDeviceToken();
|
|
if (token) headers['X-Device-Token'] = token;
|
|
return headers;
|
|
}
|
|
|
|
/** Текст ошибки из JSON ответа API (и legacy-полей). */
|
|
function apiErrorPayload(data) {
|
|
if (!data || typeof data !== 'object') return '';
|
|
if (typeof data.message === 'string' && data.message.trim()) return data.message.trim();
|
|
if (typeof data.error === 'string' && data.error.trim()) return data.error.trim();
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Понятное объяснение для оператора на ферме: без жаргона, без интернета,
|
|
* синхронизация только по локальной Wi‑Fi сети.
|
|
*/
|
|
function humanizeFarmDbError(context, rawText, httpStatus) {
|
|
const t = (rawText || '').trim();
|
|
const low = t.toLowerCase();
|
|
let st = httpStatus || 0;
|
|
if (!st) {
|
|
const m = /^HTTP\s+(\d{3})\b/i.exec(t);
|
|
if (m) st = parseInt(m[1], 10);
|
|
}
|
|
|
|
if (st === 401 || st === 403 || low.includes('не привязан') || low.includes('привязк')) {
|
|
return 'Терминал не подключён к серверу фермы или связь устарела.\n\n'
|
|
+ 'Откройте меню (кнопка в углу экрана) и выполните привязку терминала по инструкции. '
|
|
+ 'Интернет не нужен — достаточно, чтобы планшет был в той же Wi‑Fi сети, что и сервер.';
|
|
}
|
|
|
|
if (
|
|
(st === 404 && (low.includes('клиент') || low.includes('client')))
|
|
|| (low.includes('не найден') && (low.includes('клиент') || low.includes('client')))
|
|
) {
|
|
return 'Сервер не находит этот терминал в своём списке.\n\n'
|
|
+ 'Проверьте, что планшет в зоне Wi‑Fi фермы и привязка в меню выполнена. '
|
|
+ 'Если недавно меняли сервер или оборудование — сообщите администратору.';
|
|
}
|
|
|
|
if (st === 404) {
|
|
return 'Нужная служба на сервере не найдена.\n\nОбратитесь к администратору.';
|
|
}
|
|
|
|
if (st === 400) {
|
|
if (low.includes('client_id') || low.includes('обязателен')) {
|
|
return 'Запрос до сервера дошёл не полностью (не хватает данных терминала).\n\n'
|
|
+ 'Обновите страницу и попробуйте снова.';
|
|
}
|
|
return 'Сервер не принял запрос.\n\nОбновите страницу и повторите. Если снова ошибка — сообщите администратору.';
|
|
}
|
|
|
|
if (st === 429 || low.includes('слишком много') || low.includes('concurrent') || low.includes('слот')) {
|
|
return 'Сервер сейчас занят другими запросами синхронизации.\n\nПодождите минуту и нажмите проверку снова.';
|
|
}
|
|
|
|
if (st === 501 || (low.includes('отключен') && low.includes('операц')) || low.includes('restore-db')) {
|
|
return 'Это действие в текущей версии программы недоступно с этого экрана.\n\n'
|
|
+ 'Обратитесь к администратору — он подскажет другой способ восстановления данных.';
|
|
}
|
|
|
|
if (st >= 500 || low.includes('internal server')) {
|
|
return 'На сервере произошла внутренняя ошибка.\n\n'
|
|
+ 'Подождите немного и повторите. Если не поможет — перезагрузите терминал или компьютер сервера. '
|
|
+ 'Доступ в интернет не требуется.';
|
|
}
|
|
|
|
if (low.includes('failed to fetch') || low.includes('load failed') || low.includes('networkerror')
|
|
|| t === 'Failed to fetch') {
|
|
return 'Не удаётся связаться с сервером фермы.\n\n'
|
|
+ 'Проверьте: планшет в зоне Wi‑Fi роутера, роутер и сервер включены. '
|
|
+ 'Интернет не нужен — данные ходят только внутри вашей сети по Wi‑Fi.';
|
|
}
|
|
|
|
if (low.includes('aborted') || low.includes('timeout') || low.includes('таймаут')) {
|
|
return 'Сервер не ответил вовремя.\n\nПодойдите ближе к роутеру или подождите и попробуйте снова.';
|
|
}
|
|
|
|
if (low.includes('json') && (low.includes('parse') || low.includes('unexpected') || low.includes('invalid'))) {
|
|
return 'Ответ сервера не удалось разобрать.\n\nПроверьте стабильность Wi‑Fi и повторите проверку.';
|
|
}
|
|
|
|
if (context === 'check') {
|
|
return 'Проверку базы выполнить не удалось.\n\n'
|
|
+ 'Проверьте Wi‑Fi фермы и привязку терминала в меню, затем попробуйте снова. '
|
|
+ 'Если не поможет — сообщите администратору.';
|
|
}
|
|
return 'Восстановление выполнить не удалось.\n\n'
|
|
+ 'Проверьте Wi‑Fi фермы и привязку терминала в меню, затем попробуйте снова. '
|
|
+ 'Если не поможет — сообщите администратору.';
|
|
}
|
|
|
|
async function showFarmDbErrorModal(context, rawText, httpStatus) {
|
|
const title = context === 'check' ? 'Проверка базы' : 'Восстановление базы';
|
|
const body = humanizeFarmDbError(context, rawText, httpStatus);
|
|
await WespKioskDialog.alert(body, { title: title, variant: 'danger' });
|
|
}
|
|
|
|
function dbErrorInlineHint(context) {
|
|
const p = context === 'check' ? 'проверку' : 'восстановление';
|
|
return `
|
|
<div class="alert alert-danger">
|
|
<h6>Не удалось выполнить ${p}</h6>
|
|
<p>Только что было показано пояснение в отдельном окне. Если закрыли его: проверьте Wi‑Fi фермы и привязку терминала в меню (интернет не нужен).</p>
|
|
</div>`;
|
|
}
|
|
|
|
/** Понятные названия разделов данных вместо имён таблиц из базы. */
|
|
var DB_KIND_LABELS = {
|
|
recipe: 'рецепты',
|
|
recipes: 'рецепты',
|
|
component: 'компоненты кормов',
|
|
components: 'компоненты кормов',
|
|
ingredient: 'ингредиенты',
|
|
ingredients: 'ингредиенты',
|
|
feeding_period: 'рейсы кормления',
|
|
feeding_periods: 'рейсы кормления',
|
|
period_recipes: 'состав рецептов по рейсам',
|
|
feed_dispenser: 'кормораздатчики',
|
|
feed_mixers: 'кормосмесители',
|
|
feed_mixer: 'кормосмесители',
|
|
feeding_location: 'места кормления',
|
|
feeding_point: 'точки выдачи',
|
|
trip: 'рейсы',
|
|
unloading_group: 'группы выгрузки',
|
|
loading_report: 'отчёты о загрузке',
|
|
unloading_report: 'отчёты о выгрузке',
|
|
loading_report_component: 'детали загрузки',
|
|
component_loading_time: 'время загрузки компонентов',
|
|
unloading_report_group: 'группы в отчётах',
|
|
component_stock: 'остатки на складе',
|
|
sync_metadata: 'служебная информация синхронизации',
|
|
sync_clients: 'список устройств',
|
|
sync_queue: 'очередь обмена данными',
|
|
sync_conflicts: 'разбор расхождений',
|
|
sync_delivery: 'доставка данных',
|
|
sync_client_display_name: 'названия устройств',
|
|
hardware_settings: 'настройки оборудования',
|
|
kiosk_device: 'настройки терминала',
|
|
kiosk_pair_token: 'привязка терминала',
|
|
};
|
|
|
|
function looksLikeTechnicalId(s) {
|
|
var t = String(s || '').trim();
|
|
if (!t) return true;
|
|
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(t)) return true;
|
|
if (t.length >= 32 && /^[0-9a-f\-]+$/i.test(t)) return true;
|
|
if (t.length >= 24 && /^[0-9a-f]+$/i.test(t)) return true;
|
|
return false;
|
|
}
|
|
|
|
function humanKindLabel(tableKey) {
|
|
var k = String(tableKey || '').trim().toLowerCase();
|
|
if (DB_KIND_LABELS[k]) return DB_KIND_LABELS[k];
|
|
if (!k) return 'данные программы';
|
|
if (k.indexOf('sync_') === 0) return 'служебные данные синхронизации';
|
|
if (k.indexOf('kiosk') === 0) return 'настройки терминала';
|
|
return 'данные программы';
|
|
}
|
|
|
|
function humanRecordLine(details) {
|
|
if (!details || typeof details !== 'object') {
|
|
return 'позиция без названия';
|
|
}
|
|
var keys = ['name', 'recipe_name', 'component_name', 'title', 'farm', 'operator', 'label', 'display_name'];
|
|
for (var i = 0; i < keys.length; i++) {
|
|
var v = details[keys[i]];
|
|
if (v != null && String(v).trim() && !looksLikeTechnicalId(String(v))) {
|
|
return String(v).trim();
|
|
}
|
|
}
|
|
if (details.period_id != null && String(details.period_id).trim()) {
|
|
var p = String(details.period_id).trim();
|
|
if (!looksLikeTechnicalId(p)) {
|
|
return 'Рейс «' + p + '»';
|
|
}
|
|
}
|
|
return 'позиция без названия';
|
|
}
|
|
|
|
function humanRestoreSuccessText(raw) {
|
|
var t = (raw == null ? '' : String(raw)).trim();
|
|
if (!t) {
|
|
return 'Данные с сервера фермы применены. Можно снова запустить проверку.';
|
|
}
|
|
if (looksLikeTechnicalId(t) || t.length > 280) {
|
|
return 'Данные с сервера фермы применены. Можно снова запустить проверку.';
|
|
}
|
|
if (/error|exception|traceback|sql|errno|failed|undefined|null/i.test(t)) {
|
|
return 'Данные с сервера фермы применены. Можно снова запустить проверку.';
|
|
}
|
|
return t;
|
|
}
|
|
|
|
function showLoading() {
|
|
document.getElementById('loadingOverlay').style.display = 'flex';
|
|
}
|
|
|
|
function hideLoading() {
|
|
document.getElementById('loadingOverlay').style.display = 'none';
|
|
}
|
|
|
|
async function checkDatabase() {
|
|
if (!kioskPaired) return;
|
|
const btn = document.getElementById('check-db-btn');
|
|
const resultDiv = document.getElementById('db-check-result');
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = 'Проверка…';
|
|
showLoading();
|
|
resultDiv.innerHTML = '';
|
|
|
|
try {
|
|
const response = await fetch('/api/sync/check-db-client', {
|
|
method: 'POST',
|
|
headers: buildHeaders({ 'Content-Type': 'application/json' })
|
|
});
|
|
|
|
const responseText = await response.text();
|
|
let data;
|
|
try {
|
|
data = responseText ? JSON.parse(responseText) : {};
|
|
} catch {
|
|
data = {};
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const raw = apiErrorPayload(data) || responseText.slice(0, 300);
|
|
await showFarmDbErrorModal('check', raw, response.status);
|
|
resultDiv.innerHTML = dbErrorInlineHint('check');
|
|
return;
|
|
}
|
|
|
|
await displayResults(data);
|
|
} catch (error) {
|
|
await showFarmDbErrorModal('check', error && error.message ? error.message : String(error), 0);
|
|
resultDiv.innerHTML = dbErrorInlineHint('check');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Запустить проверку';
|
|
hideLoading();
|
|
}
|
|
}
|
|
|
|
async function displayResults(data) {
|
|
const resultDiv = document.getElementById('db-check-result');
|
|
const statsCard = document.getElementById('stats-card');
|
|
const statsDiv = document.getElementById('db-stats');
|
|
|
|
const apiErr = apiErrorPayload(data);
|
|
const legacyErrStr = typeof data.error === 'string' && data.error.trim() ? data.error.trim() : '';
|
|
|
|
if (data.error === true || data.success === false || legacyErrStr) {
|
|
const raw = apiErr || legacyErrStr;
|
|
await showFarmDbErrorModal('check', raw, 0);
|
|
resultDiv.innerHTML = dbErrorInlineHint('check');
|
|
return;
|
|
}
|
|
|
|
const comp = data.comparison || {};
|
|
const hasDifferences = (comp.total_differences > 0) ||
|
|
(comp.tables_only_on_server && comp.tables_only_on_server.length > 0) ||
|
|
(comp.tables_only_on_client && comp.tables_only_on_client.length > 0);
|
|
|
|
let html = `
|
|
<div class="alert ${hasDifferences ? 'alert-warning' : 'alert-success'}">
|
|
<h6>${hasDifferences ? 'Есть расхождения' : 'Всё совпадает'}</h6>`;
|
|
|
|
if (!hasDifferences) {
|
|
html += '<p>На сервере и на этом устройстве данные совпадают.</p>';
|
|
} else {
|
|
html += '<div class="db-details">';
|
|
html += '<p>Ниже — только понятные названия: что именно не совпало. Технические коды скрыты.</p>';
|
|
|
|
if (comp.total_differences > 0) {
|
|
html += `<p><strong>Сколько записей не совпало:</strong> ${comp.total_differences}</p>`;
|
|
}
|
|
|
|
if (comp.tables_only_on_server && comp.tables_only_on_server.length > 0) {
|
|
const labels = comp.tables_only_on_server.map(function (t) { return humanKindLabel(t); });
|
|
const uniq = [];
|
|
labels.forEach(function (x) {
|
|
if (uniq.indexOf(x) === -1) uniq.push(x);
|
|
});
|
|
html += `<p><strong>На сервере есть целый раздел, которого не видно на планшете:</strong> ${escapeHtml(uniq.join(', '))}.</p>`;
|
|
}
|
|
|
|
if (comp.tables_only_on_client && comp.tables_only_on_client.length > 0) {
|
|
const labels = comp.tables_only_on_client.map(function (t) { return humanKindLabel(t); });
|
|
const uniq = [];
|
|
labels.forEach(function (x) {
|
|
if (uniq.indexOf(x) === -1) uniq.push(x);
|
|
});
|
|
html += `<p><strong>На планшете есть данные, которых нет на сервере:</strong> ${escapeHtml(uniq.join(', '))}.</p>`;
|
|
}
|
|
|
|
if (comp.common_tables && Object.keys(comp.common_tables).length > 0) {
|
|
html += '<p class="section-heading">Подробнее по разделам</p>';
|
|
|
|
Object.entries(comp.common_tables).forEach(function (entry) {
|
|
const table = entry[0];
|
|
const info = entry[1];
|
|
const hasDetails = info.only_on_server_details || info.only_on_client_details || info.changed_records_details;
|
|
|
|
if (hasDetails) {
|
|
const kindTitle = humanKindLabel(table);
|
|
html += `<div class="table-section">
|
|
<div class="table-name">${escapeHtml(kindTitle)}</div>`;
|
|
|
|
if (info.only_on_server_details && Object.keys(info.only_on_server_details).length > 0) {
|
|
const n = Object.keys(info.only_on_server_details).length;
|
|
html += `<p class="label-add">Не хватает на этом устройстве (на сервере уже есть) — ${n} шт.</p>
|
|
<ul class="record-list">`;
|
|
|
|
Object.values(info.only_on_server_details).forEach(function (details) {
|
|
html += '<li>' + escapeHtml(humanRecordLine(details)) + '</li>';
|
|
});
|
|
|
|
html += '</ul>';
|
|
}
|
|
|
|
if (info.only_on_client_details && Object.keys(info.only_on_client_details).length > 0) {
|
|
const n = Object.keys(info.only_on_client_details).length;
|
|
html += `<p class="label-remove">Есть только на планшете (на сервере пока нет) — ${n} шт.</p>
|
|
<ul class="record-list">`;
|
|
|
|
Object.values(info.only_on_client_details).forEach(function (details) {
|
|
html += '<li>' + escapeHtml(humanRecordLine(details)) + '</li>';
|
|
});
|
|
|
|
html += '</ul>';
|
|
}
|
|
|
|
if (info.changed_records_details && Object.keys(info.changed_records_details).length > 0) {
|
|
html += '<p class="text-muted" style="margin-top:10px;">Часть строк в этом разделе и там и там есть, но содержимое отличается. Синхронизация подтянет актуальное с сервера.</p>';
|
|
}
|
|
|
|
html += '</div>';
|
|
}
|
|
});
|
|
}
|
|
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
|
|
if (hasDifferences) {
|
|
html += `
|
|
<div class="alert alert-info">
|
|
<h6>Что можно сделать</h6>
|
|
<p>Выберите действие — данные передаются только по Wi‑Fi вашей фермы.</p>
|
|
<div class="action-buttons">
|
|
<button type="button" class="btn btn-success" onclick="restoreDatabase('sync')">
|
|
Подтянуть недостающее с сервера
|
|
</button>
|
|
<button type="button" class="btn btn-danger" onclick="restoreDatabase('full')">
|
|
Полностью как на сервере
|
|
</button>
|
|
</div>
|
|
<p class="text-muted mt-2" style="font-size: 0.9rem;">
|
|
<strong>Подтянуть недостающее:</strong> добавит на планшет то, чего не хватает, не удаляя лишнее без необходимости.<br>
|
|
<strong>Полностью как на сервере:</strong> приведёт базу на планшете к виду на сервере (перед этим делается резервная копия).
|
|
</p>
|
|
</div>`;
|
|
}
|
|
|
|
resultDiv.innerHTML = html;
|
|
|
|
if (comp && (comp.total_differences !== undefined || comp.common_tables)) {
|
|
statsDiv.innerHTML = `
|
|
<div class="stat-card">
|
|
<div class="stat-value">${comp.total_differences || 0}</div>
|
|
<div class="stat-label">Записей не совпало</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">${comp.common_tables ? Object.keys(comp.common_tables).length : 0}</div>
|
|
<div class="stat-label">Разделов проверено</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">${comp.tables_only_on_server ? comp.tables_only_on_server.length : 0}</div>
|
|
<div class="stat-label">Раздел только на сервере</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">${comp.tables_only_on_client ? comp.tables_only_on_client.length : 0}</div>
|
|
<div class="stat-label">Раздел только на планшете</div>
|
|
</div>`;
|
|
|
|
statsCard.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
async function restoreDatabase(type) {
|
|
if (!kioskPaired) return;
|
|
const typeName = type === 'full' ? 'полное восстановление' : 'синхронизацию';
|
|
const warning = type === 'full'
|
|
? '\n\nВНИМАНИЕ: будет заменена вся локальная база снапшотом сервера. Перед этим создаётся резервная копия. Данные передаются только по локальной сети фермы (Wi‑Fi), интернет не нужен.'
|
|
: '\n\nБудут подтянуты недостающие данные с сервера. Работа идёт только по Wi‑Fi локальной сети, без интернета.';
|
|
|
|
if (!(await WespKioskDialog.confirm('Выполнить ' + typeName + '?' + warning, { danger: type === 'full' }))) return;
|
|
|
|
const resultDiv = document.getElementById('db-check-result');
|
|
resultDiv.innerHTML = '<div class="alert alert-info">Выполняется восстановление…</div>';
|
|
showLoading();
|
|
|
|
try {
|
|
const response = await fetch('/api/sync/restore-db', {
|
|
method: 'POST',
|
|
headers: buildHeaders({'Content-Type': 'application/json'}),
|
|
body: JSON.stringify({type: type})
|
|
});
|
|
|
|
const responseText = await response.text();
|
|
let data;
|
|
try {
|
|
data = responseText ? JSON.parse(responseText) : {};
|
|
} catch {
|
|
data = {};
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const raw = apiErrorPayload(data) || responseText.slice(0, 300);
|
|
await showFarmDbErrorModal('restore', raw, response.status);
|
|
resultDiv.innerHTML = dbErrorInlineHint('restore');
|
|
return;
|
|
}
|
|
|
|
if (data.success) {
|
|
let html = `
|
|
<div class="alert alert-success">
|
|
<h6>Готово</h6>
|
|
<p>${escapeHtml(humanRestoreSuccessText(data.message))}</p>`;
|
|
|
|
if (data.changes_count !== undefined) {
|
|
html += `<p class="text-muted mt-2">Обновлено записей: ${data.changes_count}</p>`;
|
|
}
|
|
|
|
html += `
|
|
<button type="button" class="btn btn-primary mt-2" onclick="checkDatabase()">
|
|
Проверить снова
|
|
</button>
|
|
</div>`;
|
|
|
|
resultDiv.innerHTML = html;
|
|
} else {
|
|
const raw = apiErrorPayload(data) || (typeof data.error === 'string' ? data.error : '');
|
|
await showFarmDbErrorModal('restore', raw, 0);
|
|
resultDiv.innerHTML = dbErrorInlineHint('restore');
|
|
}
|
|
} catch (error) {
|
|
await showFarmDbErrorModal('restore', error && error.message ? error.message : String(error), 0);
|
|
resultDiv.innerHTML = dbErrorInlineHint('restore');
|
|
} finally {
|
|
hideLoading();
|
|
}
|
|
}
|
|
</script>
|
|
<script src="/static/js/kiosk-theme.js"></script>
|
|
<script src="/static/js/kiosk-nav-menu.js"></script>
|
|
<script src="/static/js/kiosk-pairing.js"></script>
|
|
{% if not embed %}
|
|
<script src="/static/js/kiosk-db-check.js"></script>
|
|
<script src="/static/js/kiosk-calibration.js"></script>
|
|
{% endif %}
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
window.WespKioskTheme.setup({ withToggle: false });
|
|
window.WespKioskMenu.setup({ menuButtonSelector: '#kioskMenuButton' });
|
|
{% if not embed %}
|
|
window.WespKioskDbCheck.setup({ menuLinkSelector: '#dbCheckLink' });
|
|
window.WespKioskCalibration.setup({ menuLinkSelector: '#calibrationLink' });
|
|
{% endif %}
|
|
window.WespKioskPairing.setup({
|
|
menuLinkSelector: '#pairTerminalLink',
|
|
onPaired: function () {
|
|
kioskPaired = true;
|
|
},
|
|
onUnpaired: function () {
|
|
kioskPaired = false;
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
<link href="/static/css/wesp-update-banner.css" rel="stylesheet">
|
|
<script src="/static/js/wesp-update-notifier.js"></script>
|
|
<script>
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
if (window.WespUpdateNotifier) {
|
|
WespUpdateNotifier.init({ dialog: window.WespKioskDialog || window.WespDialog });
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|