working on backend

This commit is contained in:
HotSwapp
2025-08-15 22:04:43 -05:00
parent abc7f289d1
commit 0347284556
16 changed files with 3929 additions and 33 deletions

136
static/js/batch-progress.js Normal file
View File

@@ -0,0 +1,136 @@
/**
* Batch Progress Realtime client.
* - Tries WebSocket first
* - Falls back to HTTP polling on failure
* - Auto heartbeats and reconnection with backoff
*/
(function(){
window.progress = window.progress || {};
function getAuthToken() {
try {
return (window.app && window.app.token) || localStorage.getItem('auth_token') || null;
} catch (_) {
return null;
}
}
function buildWsUrl(path) {
const loc = window.location;
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
const token = encodeURIComponent(getAuthToken() || '');
const sep = path.includes('?') ? '&' : '?';
return `${proto}//${loc.host}${path}${sep}token=${token}`;
}
function defaultOnUpdate(){/* no-op */}
function defaultOnError(){/* no-op */}
/**
* Subscribe to a batch progress stream.
* @param {string} batchId
* @param {(data: object|null) => void} onUpdate
* @param {(error: Error|string) => void} onError
* @param {number} pollIntervalMs
* @returns {() => void} unsubscribe function
*/
function subscribe(batchId, onUpdate = defaultOnUpdate, onError = defaultOnError, pollIntervalMs = 2000) {
let ws = null;
let closed = false;
let pollTimer = null;
let backoffMs = 1000;
async function pollOnce() {
try {
const resp = await window.http.wrappedFetch(`/api/billing/statements/batch-progress/${encodeURIComponent(batchId)}`);
if (!resp.ok) {
const err = await window.http.toError(resp, 'Failed to fetch batch progress');
throw err;
}
const json = await resp.json();
onUpdate(json);
} catch (e) {
onError(e);
}
}
function startPolling() {
if (closed) return;
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollOnce, pollIntervalMs);
// immediate first fetch
pollOnce();
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function tryWebSocket() {
const url = buildWsUrl(`/api/billing/statements/batch-progress/ws/${encodeURIComponent(batchId)}`);
try {
ws = new WebSocket(url);
} catch (e) {
onError(e);
startPolling();
return;
}
let pingTimer = null;
ws.onopen = function() {
stopPolling();
backoffMs = 1000;
// send heartbeat pings at 30s
pingTimer = setInterval(function(){
try { ws.send(JSON.stringify({type: 'ping'})); } catch (_) {}
}, 30000);
};
ws.onmessage = function(ev) {
try {
const msg = JSON.parse(ev.data);
if (msg && msg.type === 'progress') {
onUpdate(msg.data);
}
} catch (_) {
// ignore
}
};
ws.onerror = function(ev) {
onError(new Error('WebSocket error'));
};
ws.onclose = function() {
if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
if (closed) return;
// graceful fallback to polling and schedule reconnect with backoff
startPolling();
setTimeout(function(){
if (!closed) {
backoffMs = Math.min(backoffMs * 2, 30000);
tryWebSocket();
}
}, backoffMs);
};
}
// Kick off
tryWebSocket();
return function unsubscribe() {
closed = true;
stopPolling();
try { if (ws && ws.readyState <= 1) ws.close(); } catch(_) {}
ws = null;
};
}
window.progress.subscribe = subscribe;
})();

View File

@@ -16,6 +16,7 @@ const app = {
document.addEventListener('DOMContentLoaded', function() {
try { setupGlobalErrorHandlers(); } catch (_) {}
initializeApp();
try { initializeBatchProgressUI(); } catch (_) {}
});
// Theme Management (centralized)
@@ -123,6 +124,141 @@ async function initializeApp() {
console.log('Delphi Database System initialized');
}
// Live Batch Progress (Admin Overview)
function initializeBatchProgressUI() {
const listEl = document.getElementById('batchProgressList');
const emptyEl = document.getElementById('batchProgressEmpty');
const refreshBtn = document.getElementById('refreshBatchesBtn');
if (!listEl || !emptyEl) return;
const subscriptions = new Map();
function percent(progress) {
if (!progress || !progress.total_files) return 0;
const done = Number(progress.processed_files || 0);
const total = Number(progress.total_files || 0);
return Math.max(0, Math.min(100, Math.round((done / total) * 100)));
}
function renderRow(progress) {
const pid = progress.batch_id;
const pct = percent(progress);
const status = String(progress.status || '').toUpperCase();
const current = progress.current_file || '';
const success = progress.successful_files || 0;
const failed = progress.failed_files || 0;
const total = progress.total_files || 0;
return (
`<div class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-3" data-batch="${pid}">
<div class="flex items-center justify-between gap-3 mb-2">
<div class="flex items-center gap-2">
<span class="text-xs px-2 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">${pid}</span>
<span class="text-xs font-medium ${status === 'COMPLETED' ? 'text-green-600 dark:text-green-400' : status === 'FAILED' ? 'text-red-600 dark:text-red-400' : status === 'CANCELLED' ? 'text-neutral-500' : 'text-amber-600 dark:text-amber-400'}">${status}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-neutral-500 dark:text-neutral-400">${success}/${total} ✓ • ${failed} ✕</span>
<button class="text-xs px-2 py-1 rounded bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 text-neutral-700 dark:text-neutral-300" data-action="cancel" ${status==='RUNNING'||status==='PENDING' ? '' : 'disabled'}>Cancel</button>
</div>
</div>
<div class="w-full h-2 bg-neutral-100 dark:bg-neutral-800 rounded">
<div class="h-2 rounded ${status==='FAILED'? 'bg-red-500' : status==='CANCELLED' ? 'bg-neutral-500' : 'bg-primary-500'}" style="width:${pct}%"></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs text-neutral-600 dark:text-neutral-400">
<span>${pct}%</span>
<span>${current ? 'Current: '+current : ''}</span>
</div>
</div>`
);
}
async function fetchActiveBatches() {
const resp = await window.http.wrappedFetch('/api/billing/statements/batch-list');
if (!resp.ok) return [];
return await resp.json();
}
function updateEmptyState() {
const hasRows = listEl.children.length > 0;
emptyEl.style.display = hasRows ? 'none' : '';
}
function upsertRow(data) {
const pid = data && data.batch_id ? data.batch_id : null;
if (!pid) return;
let row = listEl.querySelector(`[data-batch="${pid}"]`);
const html = renderRow(data);
if (row) {
row.outerHTML = html;
} else {
const container = document.createElement('div');
container.innerHTML = html;
listEl.prepend(container.firstChild);
}
updateEmptyState();
}
async function cancelBatch(batchId) {
try {
const resp = await window.http.wrappedFetch(`/api/billing/statements/batch-progress/${encodeURIComponent(batchId)}`, { method: 'DELETE' });
if (!resp.ok) {
throw await window.http.toError(resp, 'Failed to cancel batch');
}
// Let stream update the row; no-op here
} catch (e) {
console.warn('Cancel failed', e);
try { alert(window.http.formatAlert(e, 'Cancel failed')); } catch (_) {}
}
}
function attachRowHandlers() {
listEl.addEventListener('click', function(ev){
const btn = ev.target.closest('[data-action="cancel"]');
if (!btn) return;
const row = ev.target.closest('[data-batch]');
if (!row) return;
const pid = row.getAttribute('data-batch');
cancelBatch(pid);
});
}
async function subscribeTo(pid) {
if (!window.progress || typeof window.progress.subscribe !== 'function') return;
if (subscriptions.has(pid)) return;
const unsub = window.progress.subscribe(pid, function(progress){
if (!progress) return;
upsertRow(progress);
const status = String(progress.status || '').toUpperCase();
if (status === 'COMPLETED' || status === 'FAILED' || status === 'CANCELLED') {
// Auto-unsubscribe once terminal
const fn = subscriptions.get(pid);
if (fn) { try { fn(); } catch (_) {} }
subscriptions.delete(pid);
}
}, function(err){
// Non-fatal; polling fallback is handled inside subscribe()
console.debug('progress stream issue', err && err.message ? err.message : err);
});
subscriptions.set(pid, unsub);
}
async function refresh() {
const batches = await fetchActiveBatches();
if (!Array.isArray(batches)) return;
if (batches.length === 0) updateEmptyState();
for (const pid of batches) {
subscribeTo(pid);
}
}
if (refreshBtn) {
refreshBtn.addEventListener('click', function(){ refresh(); });
}
attachRowHandlers();
refresh();
}
// Form validation
function initializeFormValidation() {
// Native validation handling