feat: implement background task system with notifications

- Added background task manager with goroutine execution and panic recovery
- Replaced SSE streaming with background task execution for:
  * Price recalculation (RecalculateAll)
  * Stock import (ImportStockLog)
  * Pricelist creation (CreateWithProgress)
- Implemented unified polling for task status and DB connection in frontend
- Added task indicator in top bar showing running tasks count
- Added toast notifications for task completion/error
- Tasks automatically cleaned up after 10 minutes
- Tasks show progress (0-100%) with descriptive messages
- Updated handler constructors to receive task manager
- Added API endpoints for task status (/api/tasks, /api/tasks/:id)

Fixes issue with SSE disconnection on slow connections during long-running operations
This commit is contained in:
2026-02-08 20:39:59 +03:00
parent 06aa7c7067
commit e97cd5048c
15 changed files with 1080 additions and 555 deletions

View File

@@ -25,6 +25,7 @@
</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
@@ -102,22 +103,79 @@
el.title = errorText || 'Database not connected';
}
async function refreshDBConnectionStatus() {
const knownTasks = new Map(); // taskID -> {status, notified}
async function refreshSystemStatus() {
try {
const resp = await fetch('/api/db-status');
const data = await resp.json();
renderDBStatus(data.connected === true, data.error || '');
// 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 (data.connected && data.db_user) {
userEl.textContent = '@' + String(data.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) {
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;
@@ -217,8 +275,8 @@
});
}
refreshDBConnectionStatus();
setInterval(refreshDBConnectionStatus, 10000);
refreshSystemStatus();
setInterval(refreshSystemStatus, 5000); // 5 second polling
});
</script>
</body>