Optimize task retention from 5 minutes to 30 seconds to reduce polling overhead since toast notifications are shown only once. Add conditional warehouse pricelist creation via checkbox. Fix category storage in warehouse pricelists to properly load from lot table. Replace SSE with task polling for all long operations. Add comprehensive logging for debugging while minimizing noise from polling endpoints. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
305 lines
16 KiB
HTML
305 lines
16 KiB
HTML
{{define "base"}}
|
||
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{{template "title" .}}</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||
<style>
|
||
.htmx-request { opacity: 0.5; }
|
||
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||
</style>
|
||
</head>
|
||
<body class="bg-gray-100 min-h-screen">
|
||
<nav class="bg-white shadow-sm sticky top-0 z-40">
|
||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
<div class="flex justify-between h-14">
|
||
<div class="flex items-center space-x-8">
|
||
<a href="/" class="text-xl font-bold text-orange-600">PriceForge</a>
|
||
<div class="hidden md:flex space-x-4">
|
||
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Администратор цен</a>
|
||
<a href="/pricelists" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Прайслисты</a>
|
||
<button type="button" onclick="openConnectionSettingsModal()" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center space-x-4">
|
||
<div id="task-indicator" class="hidden items-center gap-1 text-sm"></div>
|
||
<span id="db-connection-status" class="inline-flex items-center gap-1 text-xs text-gray-600">
|
||
<span class="inline-block w-2 h-2 rounded-full bg-gray-400"></span>
|
||
DB: checking
|
||
</span>
|
||
<span id="db-user" class="text-sm text-gray-600"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||
{{template "content" .}}
|
||
</main>
|
||
|
||
<div id="connection-settings-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50 px-4">
|
||
<div class="bg-white rounded-lg shadow-lg w-full max-w-md">
|
||
<div class="flex items-center justify-between px-5 py-4 border-b">
|
||
<h2 class="text-lg font-semibold text-gray-900">Параметры подключения</h2>
|
||
<button type="button" onclick="closeConnectionSettingsModal()" class="text-gray-400 hover:text-gray-700 text-xl leading-none">×</button>
|
||
</div>
|
||
<form id="connection-settings-form" class="p-5 space-y-3">
|
||
<div>
|
||
<label class="block text-sm text-gray-700 mb-1">Хост</label>
|
||
<input id="connection-host" name="host" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm text-gray-700 mb-1">Порт</label>
|
||
<input id="connection-port" name="port" type="number" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm text-gray-700 mb-1">База данных</label>
|
||
<input id="connection-database" name="database" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm text-gray-700 mb-1">Пользователь</label>
|
||
<input id="connection-user" name="user" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm text-gray-700 mb-1">Пароль</label>
|
||
<input id="connection-password" name="password" type="password" autocomplete="new-password" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500">
|
||
<p class="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы сохранить текущий пароль.</p>
|
||
</div>
|
||
<div id="connection-settings-status" class="hidden text-sm rounded-md px-3 py-2"></div>
|
||
<div class="flex items-center justify-end gap-2 pt-2">
|
||
<button type="button" onclick="testConnectionSettings()" class="px-3 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">Проверить</button>
|
||
<button type="submit" class="px-3 py-2 bg-orange-600 text-white rounded-md text-sm hover:bg-orange-700">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
||
|
||
<script>
|
||
function showToast(msg, type) {
|
||
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-orange-500' };
|
||
const el = document.getElementById('toast');
|
||
el.replaceChildren();
|
||
const toast = document.createElement('div');
|
||
toast.className = (colors[type] || colors.info) + ' text-white px-4 py-2 rounded shadow';
|
||
toast.textContent = String(msg || '');
|
||
el.appendChild(toast);
|
||
setTimeout(() => el.replaceChildren(), 3000);
|
||
}
|
||
|
||
function renderDBStatus(connected, errorText) {
|
||
const el = document.getElementById('db-connection-status');
|
||
if (!el) return;
|
||
if (connected) {
|
||
el.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-green-500"></span>DB: online';
|
||
el.title = '';
|
||
return;
|
||
}
|
||
el.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-red-500"></span>DB: offline';
|
||
el.title = errorText || 'Database not connected';
|
||
}
|
||
|
||
const knownTasks = new Map(); // taskID -> {status, notified}
|
||
|
||
async function refreshSystemStatus() {
|
||
try {
|
||
// Poll DB status
|
||
const dbResp = await fetch('/api/db-status');
|
||
const dbData = await dbResp.json();
|
||
renderDBStatus(dbData.connected === true, dbData.error || '');
|
||
|
||
const userEl = document.getElementById('db-user');
|
||
if (dbData.connected && dbData.db_user) {
|
||
userEl.textContent = '@' + String(dbData.db_user);
|
||
} else if (userEl) {
|
||
userEl.textContent = '';
|
||
}
|
||
|
||
// Poll tasks
|
||
const tasksResp = await fetch('/api/tasks');
|
||
const tasksData = await tasksResp.json();
|
||
updateTaskIndicator(tasksData.tasks || []);
|
||
} catch (e) {
|
||
renderDBStatus(false, 'Status request failed');
|
||
}
|
||
}
|
||
|
||
function updateTaskIndicator(tasks) {
|
||
const indicator = document.getElementById('task-indicator');
|
||
if (!indicator) return;
|
||
|
||
const runningTasks = tasks.filter(t => t.status === 'running');
|
||
|
||
if (runningTasks.length > 0) {
|
||
indicator.classList.remove('hidden');
|
||
indicator.classList.add('flex');
|
||
indicator.innerHTML = `
|
||
<svg class="animate-spin h-4 w-4 text-orange-600" fill="none" viewBox="0 0 24 24">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
<span class="text-xs">${runningTasks.length}</span>
|
||
`;
|
||
indicator.title = runningTasks.map(t => t.message).join(', ');
|
||
} else {
|
||
indicator.classList.add('hidden');
|
||
indicator.classList.remove('flex');
|
||
indicator.innerHTML = '';
|
||
}
|
||
|
||
// Check for newly completed/errored tasks
|
||
tasks.forEach(task => {
|
||
const known = knownTasks.get(task.id);
|
||
|
||
if (!known) {
|
||
// New task - check if it's recently completed (within last 10 seconds)
|
||
const isRecentlyCompleted = task.status !== 'running' && task.done_at;
|
||
if (isRecentlyCompleted) {
|
||
const doneAt = new Date(task.done_at);
|
||
const now = new Date();
|
||
const ageSeconds = (now - doneAt) / 1000;
|
||
|
||
// Show notification for tasks completed within the last 10 seconds
|
||
if (ageSeconds < 10) {
|
||
if (task.status === 'completed') {
|
||
showToast(task.message || 'Задача завершена', 'success');
|
||
} else if (task.status === 'error') {
|
||
showToast(task.error || 'Ошибка выполнения задачи', 'error');
|
||
}
|
||
knownTasks.set(task.id, {status: task.status, notified: true});
|
||
} else {
|
||
knownTasks.set(task.id, {status: task.status, notified: false});
|
||
}
|
||
} else {
|
||
knownTasks.set(task.id, {status: task.status, notified: false});
|
||
}
|
||
} else if (known.status === 'running' && task.status !== 'running' && !known.notified) {
|
||
// Task transitioned from running to completed/error
|
||
if (task.status === 'completed') {
|
||
showToast(task.message || 'Задача завершена', 'success');
|
||
} else if (task.status === 'error') {
|
||
showToast(task.error || 'Ошибка выполнения задачи', 'error');
|
||
}
|
||
knownTasks.set(task.id, {status: task.status, notified: true});
|
||
}
|
||
});
|
||
|
||
// Cleanup old tasks from knownTasks
|
||
for (const [taskID, info] of knownTasks.entries()) {
|
||
if (!tasks.find(t => t.id === taskID)) {
|
||
knownTasks.delete(taskID);
|
||
}
|
||
}
|
||
}
|
||
|
||
function closeConnectionSettingsModal() {
|
||
const modal = document.getElementById('connection-settings-modal');
|
||
if (!modal) return;
|
||
modal.classList.add('hidden');
|
||
modal.classList.remove('flex');
|
||
}
|
||
|
||
function showConnectionSettingsStatus(message, type) {
|
||
const el = document.getElementById('connection-settings-status');
|
||
if (!el) return;
|
||
el.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-orange-100', 'text-orange-800');
|
||
if (type === 'success') {
|
||
el.classList.add('bg-green-100', 'text-green-800');
|
||
} else if (type === 'error') {
|
||
el.classList.add('bg-red-100', 'text-red-800');
|
||
} else {
|
||
el.classList.add('bg-orange-100', 'text-orange-800');
|
||
}
|
||
el.textContent = message;
|
||
}
|
||
|
||
async function openConnectionSettingsModal() {
|
||
const modal = document.getElementById('connection-settings-modal');
|
||
if (!modal) return;
|
||
modal.classList.remove('hidden');
|
||
modal.classList.add('flex');
|
||
showConnectionSettingsStatus('Загрузка текущих параметров...', 'info');
|
||
|
||
try {
|
||
const resp = await fetch('/api/connection-settings');
|
||
const data = await resp.json();
|
||
document.getElementById('connection-host').value = data.host || '';
|
||
document.getElementById('connection-port').value = data.port || 3306;
|
||
document.getElementById('connection-database').value = data.database || '';
|
||
document.getElementById('connection-user').value = data.user || '';
|
||
document.getElementById('connection-password').value = '';
|
||
document.getElementById('connection-settings-status').classList.add('hidden');
|
||
} catch (e) {
|
||
showConnectionSettingsStatus('Не удалось загрузить параметры: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function testConnectionSettings() {
|
||
const form = document.getElementById('connection-settings-form');
|
||
if (!form) return;
|
||
showConnectionSettingsStatus('Проверка подключения...', 'info');
|
||
try {
|
||
const resp = await fetch('/api/connection-settings/test', {
|
||
method: 'POST',
|
||
body: new FormData(form),
|
||
});
|
||
const data = await resp.json();
|
||
if (data.success) {
|
||
showConnectionSettingsStatus('Подключение успешно.', 'success');
|
||
} else {
|
||
showConnectionSettingsStatus(data.error || 'Ошибка проверки подключения.', 'error');
|
||
}
|
||
} catch (e) {
|
||
showConnectionSettingsStatus('Ошибка сети: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
const form = document.getElementById('connection-settings-form');
|
||
if (form) {
|
||
form.addEventListener('submit', async function (e) {
|
||
e.preventDefault();
|
||
showConnectionSettingsStatus('Сохранение параметров...', 'info');
|
||
try {
|
||
const resp = await fetch('/api/connection-settings', {
|
||
method: 'POST',
|
||
body: new FormData(form),
|
||
});
|
||
const data = await resp.json();
|
||
if (!resp.ok || !data.success) {
|
||
showConnectionSettingsStatus(data.error || 'Ошибка сохранения параметров.', 'error');
|
||
return;
|
||
}
|
||
|
||
showConnectionSettingsStatus('Параметры сохранены. Выполняется перезапуск...', 'success');
|
||
setTimeout(async function () {
|
||
try {
|
||
await fetch('/api/restart', { method: 'POST' });
|
||
} catch (_) {
|
||
}
|
||
}, 300);
|
||
} catch (e) {
|
||
showConnectionSettingsStatus('Ошибка сети: ' + e.message, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
const modal = document.getElementById('connection-settings-modal');
|
||
if (modal) {
|
||
modal.addEventListener('click', function (e) {
|
||
if (e.target === modal) closeConnectionSettingsModal();
|
||
});
|
||
}
|
||
|
||
refreshSystemStatus();
|
||
setInterval(refreshSystemStatus, 5000); // 5 second polling
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
{{end}}
|