feat(sync): add SSE live feed, unified logs page, fix Oracle connection
- Add SSE event bus in sync_service (subscribe/unsubscribe/_emit) - Add GET /api/sync/stream SSE endpoint for real-time sync progress - Rewrite logs.html: unified runs table + live feed + summary + filters - Rewrite logs.js: SSE EventSource client, run selection, pagination - Dashboard: clickable runs navigate to /logs?run=, sync started banner - Remove "Import Comenzi" nav item, delete sync_detail.html - Add error_message column to sync_runs table with migration - Fix: export TNS_ADMIN as OS env var so oracledb finds tnsnames.ora - Fix: use get_oracle_connection() instead of direct pool.acquire() - Fix: CRM_POLITICI_PRET_ART INSERT to match actual table schema Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -114,7 +114,7 @@ async function loadSyncHistory() {
|
||||
}
|
||||
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
|
||||
|
||||
return `<tr style="cursor:pointer" onclick="window.location='/sync/run/${esc(r.run_id)}'">
|
||||
return `<tr style="cursor:pointer" onclick="window.location='/logs?run=${esc(r.run_id)}'">
|
||||
<td>${started}</td>
|
||||
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
|
||||
<td>${r.total_orders || 0}</td>
|
||||
@@ -192,6 +192,16 @@ async function startSync() {
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
return;
|
||||
}
|
||||
// Show banner with link to live logs
|
||||
if (data.run_id) {
|
||||
const banner = document.getElementById('syncStartedBanner');
|
||||
const link = document.getElementById('syncRunLink');
|
||||
if (banner && link) {
|
||||
link.href = '/logs?run=' + encodeURIComponent(data.run_id);
|
||||
banner.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
loadDashboard();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
// logs.js - Jurnale Import page logic
|
||||
// logs.js - Unified Logs page with SSE live feed
|
||||
|
||||
let currentRunId = null;
|
||||
let eventSource = null;
|
||||
let runsPage = 1;
|
||||
let liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 };
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function fmtDatetime(iso) {
|
||||
if (!iso) return '-';
|
||||
try {
|
||||
@@ -17,9 +27,7 @@ function fmtDatetime(iso) {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
return iso;
|
||||
}
|
||||
} catch (e) { return iso; }
|
||||
}
|
||||
|
||||
function fmtDuration(startedAt, finishedAt) {
|
||||
@@ -41,50 +49,252 @@ function statusBadge(status) {
|
||||
}
|
||||
|
||||
function runStatusBadge(status) {
|
||||
switch ((status || '').toUpperCase()) {
|
||||
case 'SUCCESS': return '<span class="badge bg-success ms-1">SUCCESS</span>';
|
||||
case 'ERROR': return '<span class="badge bg-danger ms-1">ERROR</span>';
|
||||
case 'RUNNING': return '<span class="badge bg-primary ms-1">RUNNING</span>';
|
||||
case 'PARTIAL': return '<span class="badge bg-warning text-dark ms-1">PARTIAL</span>';
|
||||
default: return `<span class="badge bg-secondary ms-1">${esc(status || '')}</span>`;
|
||||
switch ((status || '').toLowerCase()) {
|
||||
case 'completed': return '<span class="badge bg-success">completed</span>';
|
||||
case 'running': return '<span class="badge bg-primary">running</span>';
|
||||
case 'failed': return '<span class="badge bg-danger">failed</span>';
|
||||
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRuns() {
|
||||
const sel = document.getElementById('runSelector');
|
||||
sel.innerHTML = '<option value="">Se incarca...</option>';
|
||||
// ── Runs Table ──────────────────────────────────
|
||||
|
||||
async function loadRuns(page) {
|
||||
if (page != null) runsPage = page;
|
||||
const perPage = 20;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sync/history?per_page=20');
|
||||
const res = await fetch(`/api/sync/history?page=${runsPage}&per_page=${perPage}`);
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
|
||||
const runs = data.runs || [];
|
||||
if (runs.length === 0) {
|
||||
sel.innerHTML = '<option value="">Nu exista sync runs</option>';
|
||||
return;
|
||||
}
|
||||
const total = data.total || runs.length;
|
||||
|
||||
// Populate dropdown
|
||||
const sel = document.getElementById('runSelector');
|
||||
sel.innerHTML = '<option value="">-- Selecteaza un sync run --</option>' +
|
||||
runs.map(r => {
|
||||
const date = fmtDatetime(r.started_at);
|
||||
const stats = `${r.total_orders || 0} total / ${r.imported || 0} ok / ${r.errors || 0} err`;
|
||||
const statusText = (r.status || '').toUpperCase();
|
||||
return `<option value="${esc(r.run_id)}">[${statusText}] ${date} — ${stats}</option>`;
|
||||
return `<option value="${esc(r.run_id)}"${r.run_id === currentRunId ? ' selected' : ''}>[${(r.status||'').toUpperCase()}] ${date} — ${stats}</option>`;
|
||||
}).join('');
|
||||
|
||||
// Populate table
|
||||
const tbody = document.getElementById('runsTableBody');
|
||||
if (runs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Niciun sync run</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = runs.map(r => {
|
||||
const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-';
|
||||
const duration = fmtDuration(r.started_at, r.finished_at);
|
||||
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
|
||||
const activeClass = r.run_id === currentRunId ? 'table-active' : '';
|
||||
return `<tr class="${activeClass}" data-href="/logs?run=${esc(r.run_id)}" onclick="selectRun('${esc(r.run_id)}')">
|
||||
<td>${started}</td>
|
||||
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
|
||||
<td>${r.total_orders || 0}</td>
|
||||
<td class="text-success">${r.imported || 0}</td>
|
||||
<td class="text-warning">${r.skipped || 0}</td>
|
||||
<td class="text-danger">${r.errors || 0}</td>
|
||||
<td>${duration}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const pagDiv = document.getElementById('runsTablePagination');
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
if (totalPages > 1) {
|
||||
pagDiv.innerHTML = `
|
||||
<button class="btn btn-sm btn-outline-secondary" ${runsPage <= 1 ? 'disabled' : ''} onclick="loadRuns(${runsPage - 1})"><i class="bi bi-chevron-left"></i></button>
|
||||
<small class="text-muted">${runsPage} / ${totalPages}</small>
|
||||
<button class="btn btn-sm btn-outline-secondary" ${runsPage >= totalPages ? 'disabled' : ''} onclick="loadRuns(${runsPage + 1})"><i class="bi bi-chevron-right"></i></button>
|
||||
`;
|
||||
} else {
|
||||
pagDiv.innerHTML = '';
|
||||
}
|
||||
} catch (err) {
|
||||
sel.innerHTML = '<option value="">Eroare la incarcare: ' + esc(err.message) + '</option>';
|
||||
document.getElementById('runsTableBody').innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${esc(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Run Selection ───────────────────────────────
|
||||
|
||||
async function selectRun(runId) {
|
||||
if (eventSource) { eventSource.close(); eventSource = null; }
|
||||
currentRunId = runId;
|
||||
|
||||
// Update URL without reload
|
||||
const url = new URL(window.location);
|
||||
if (runId) { url.searchParams.set('run', runId); } else { url.searchParams.delete('run'); }
|
||||
history.replaceState(null, '', url);
|
||||
|
||||
// Highlight active row in table
|
||||
document.querySelectorAll('#runsTableBody tr').forEach(tr => {
|
||||
tr.classList.toggle('table-active', tr.getAttribute('data-href') === `/logs?run=${runId}`);
|
||||
});
|
||||
|
||||
// Update dropdown
|
||||
document.getElementById('runSelector').value = runId || '';
|
||||
|
||||
if (!runId) {
|
||||
document.getElementById('runDetailSection').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('runDetailSection').style.display = '';
|
||||
|
||||
// Check if this run is currently active
|
||||
try {
|
||||
const statusRes = await fetch('/api/sync/status');
|
||||
const statusData = await statusRes.json();
|
||||
if (statusData.status === 'running' && statusData.run_id === runId) {
|
||||
startLiveFeed(runId);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* fall through to historical load */ }
|
||||
|
||||
// Load historical data
|
||||
document.getElementById('liveFeedCard').style.display = 'none';
|
||||
await loadRunLog(runId);
|
||||
}
|
||||
|
||||
// ── Live SSE Feed ───────────────────────────────
|
||||
|
||||
function startLiveFeed(runId) {
|
||||
liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 };
|
||||
|
||||
// Show live feed card, clear it
|
||||
const feedCard = document.getElementById('liveFeedCard');
|
||||
feedCard.style.display = '';
|
||||
document.getElementById('liveFeed').innerHTML = '';
|
||||
document.getElementById('logsBody').innerHTML = '';
|
||||
|
||||
// Reset summary
|
||||
document.getElementById('sum-total').textContent = '-';
|
||||
document.getElementById('sum-imported').textContent = '0';
|
||||
document.getElementById('sum-skipped').textContent = '0';
|
||||
document.getElementById('sum-errors').textContent = '0';
|
||||
document.getElementById('sum-duration').textContent = 'live...';
|
||||
|
||||
connectSSE();
|
||||
}
|
||||
|
||||
function connectSSE() {
|
||||
if (eventSource) eventSource.close();
|
||||
eventSource = new EventSource('/api/sync/stream');
|
||||
|
||||
eventSource.onmessage = function(e) {
|
||||
let event;
|
||||
try { event = JSON.parse(e.data); } catch (err) { return; }
|
||||
|
||||
if (event.type === 'keepalive') return;
|
||||
|
||||
if (event.type === 'phase') {
|
||||
appendFeedEntry('phase', event.message);
|
||||
}
|
||||
else if (event.type === 'order_result') {
|
||||
const icon = event.status === 'IMPORTED' ? '✅' : event.status === 'SKIPPED' ? '⏭️' : '❌';
|
||||
const progressText = event.progress ? `[${event.progress}]` : '';
|
||||
appendFeedEntry(
|
||||
event.status === 'ERROR' ? 'error' : event.status === 'IMPORTED' ? 'success' : '',
|
||||
`${progressText} #${event.order_number} ${event.customer_name || ''} → ${icon} ${event.status}${event.error_message ? ' — ' + event.error_message : ''}`
|
||||
);
|
||||
addOrderRow(event);
|
||||
updateLiveSummary(event);
|
||||
}
|
||||
else if (event.type === 'completed') {
|
||||
appendFeedEntry('phase', '🏁 Sync completed');
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
document.querySelector('.live-pulse')?.remove();
|
||||
// Reload full data from REST after short delay
|
||||
setTimeout(() => {
|
||||
loadRunLog(currentRunId);
|
||||
loadRuns();
|
||||
}, 500);
|
||||
}
|
||||
else if (event.type === 'failed') {
|
||||
appendFeedEntry('error', '💥 Sync failed: ' + (event.error || 'Unknown error'));
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
document.querySelector('.live-pulse')?.remove();
|
||||
setTimeout(() => {
|
||||
loadRunLog(currentRunId);
|
||||
loadRuns();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = function() {
|
||||
// SSE disconnected — try to load historical data
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
setTimeout(() => loadRunLog(currentRunId), 1000);
|
||||
};
|
||||
}
|
||||
|
||||
function appendFeedEntry(type, message) {
|
||||
const feed = document.getElementById('liveFeed');
|
||||
const now = new Date().toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const typeClass = type ? ` ${type}` : '';
|
||||
const iconMap = { phase: 'ℹ️', error: '❌', success: '✅' };
|
||||
const icon = iconMap[type] || '▶';
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `feed-entry${typeClass}`;
|
||||
entry.innerHTML = `<span class="feed-time">${now}</span><span class="feed-icon">${icon}</span><span class="feed-msg">${esc(message)}</span>`;
|
||||
feed.appendChild(entry);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
}
|
||||
|
||||
function addOrderRow(event) {
|
||||
const tbody = document.getElementById('logsBody');
|
||||
const status = (event.status || '').toUpperCase();
|
||||
|
||||
let details = '';
|
||||
if (event.error_message) {
|
||||
details = `<span class="text-danger">${esc(event.error_message)}</span>`;
|
||||
}
|
||||
if (event.missing_skus && Array.isArray(event.missing_skus) && event.missing_skus.length > 0) {
|
||||
details += `<div class="mt-1">${event.missing_skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('')}</div>`;
|
||||
}
|
||||
if (event.id_comanda) {
|
||||
details += `<small class="text-success">ID: ${event.id_comanda}</small>`;
|
||||
}
|
||||
if (!details) details = '<span class="text-muted">-</span>';
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.setAttribute('data-status', status);
|
||||
tr.innerHTML = `
|
||||
<td><code>${esc(event.order_number || '-')}</code></td>
|
||||
<td>${esc(event.customer_name || '-')}</td>
|
||||
<td class="text-center">${event.items_count ?? '-'}</td>
|
||||
<td>${statusBadge(status)}</td>
|
||||
<td>${details}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
function updateLiveSummary(event) {
|
||||
liveCounts.total++;
|
||||
if (event.status === 'IMPORTED') liveCounts.imported++;
|
||||
else if (event.status === 'SKIPPED') liveCounts.skipped++;
|
||||
else if (event.status === 'ERROR') liveCounts.errors++;
|
||||
|
||||
document.getElementById('sum-total').textContent = liveCounts.total;
|
||||
document.getElementById('sum-imported').textContent = liveCounts.imported;
|
||||
document.getElementById('sum-skipped').textContent = liveCounts.skipped;
|
||||
document.getElementById('sum-errors').textContent = liveCounts.errors;
|
||||
}
|
||||
|
||||
// ── Historical Run Log ──────────────────────────
|
||||
|
||||
async function loadRunLog(runId) {
|
||||
const tbody = document.getElementById('logsBody');
|
||||
const filterRow = document.getElementById('filterRow');
|
||||
const runSummary = document.getElementById('runSummary');
|
||||
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2"></div>Se incarca...</td></tr>';
|
||||
filterRow.style.display = 'none';
|
||||
runSummary.style.display = 'none';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/log`);
|
||||
@@ -95,40 +305,33 @@ async function loadRunLog(runId) {
|
||||
const orders = data.orders || [];
|
||||
|
||||
// Populate summary bar
|
||||
document.getElementById('sum-total').textContent = run.total_orders ?? '-';
|
||||
document.getElementById('sum-total').textContent = run.total_orders ?? '-';
|
||||
document.getElementById('sum-imported').textContent = run.imported ?? '-';
|
||||
document.getElementById('sum-skipped').textContent = run.skipped ?? '-';
|
||||
document.getElementById('sum-errors').textContent = run.errors ?? '-';
|
||||
document.getElementById('sum-skipped').textContent = run.skipped ?? '-';
|
||||
document.getElementById('sum-errors').textContent = run.errors ?? '-';
|
||||
document.getElementById('sum-duration').textContent = fmtDuration(run.started_at, run.finished_at);
|
||||
runSummary.style.display = '';
|
||||
|
||||
if (orders.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Nicio comanda in acest sync run</td></tr>';
|
||||
filterRow.style.display = 'none';
|
||||
const runError = run.error_message
|
||||
? `<tr><td colspan="5" class="text-center py-4"><span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${esc(run.error_message)}</span></td></tr>`
|
||||
: '<tr><td colspan="5" class="text-center text-muted py-4">Nicio comanda in acest sync run</td></tr>';
|
||||
tbody.innerHTML = runError;
|
||||
updateFilterCount();
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = orders.map(order => {
|
||||
const status = (order.status || '').toUpperCase();
|
||||
|
||||
// Parse missing_skus — API returns JSON string or null
|
||||
let missingSkuTags = '';
|
||||
if (order.missing_skus) {
|
||||
try {
|
||||
const skus = typeof order.missing_skus === 'string'
|
||||
? JSON.parse(order.missing_skus)
|
||||
: order.missing_skus;
|
||||
const skus = typeof order.missing_skus === 'string' ? JSON.parse(order.missing_skus) : order.missing_skus;
|
||||
if (Array.isArray(skus) && skus.length > 0) {
|
||||
missingSkuTags = '<div class="mt-1">' +
|
||||
skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('') +
|
||||
'</div>';
|
||||
skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('') + '</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
// malformed JSON — skip
|
||||
}
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
|
||||
const details = order.error_message
|
||||
? `<span class="text-danger">${esc(order.error_message)}</span>${missingSkuTags}`
|
||||
: missingSkuTags || '<span class="text-muted">-</span>';
|
||||
@@ -142,75 +345,61 @@ async function loadRunLog(runId) {
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
filterRow.style.display = '';
|
||||
// Reset filter to "Toate"
|
||||
// Reset filter
|
||||
document.querySelectorAll('[data-filter]').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.filter === 'all');
|
||||
});
|
||||
applyFilter('all');
|
||||
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>${esc(err.message)}
|
||||
</td></tr>`;
|
||||
filterRow.style.display = 'none';
|
||||
runSummary.style.display = 'none';
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-3"><i class="bi bi-exclamation-triangle me-1"></i>${esc(err.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filters ─────────────────────────────────────
|
||||
|
||||
function applyFilter(filter) {
|
||||
const rows = document.querySelectorAll('#logsBody tr[data-status]');
|
||||
let visible = 0;
|
||||
|
||||
rows.forEach(row => {
|
||||
const show = filter === 'all' || row.dataset.status === filter;
|
||||
row.style.display = show ? '' : 'none';
|
||||
if (show) visible++;
|
||||
});
|
||||
|
||||
updateFilterCount(visible, rows.length, filter);
|
||||
}
|
||||
|
||||
function updateFilterCount(visible, total, filter) {
|
||||
const el = document.getElementById('filterCount');
|
||||
if (!el) return;
|
||||
if (visible == null) {
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (filter === 'all') {
|
||||
el.textContent = `${total} comenzi`;
|
||||
} else {
|
||||
el.textContent = `${visible} din ${total} comenzi`;
|
||||
}
|
||||
if (visible == null) { el.textContent = ''; return; }
|
||||
el.textContent = filter === 'all' ? `${total} comenzi` : `${visible} din ${total} comenzi`;
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadRuns();
|
||||
|
||||
// Dropdown change
|
||||
document.getElementById('runSelector').addEventListener('change', function () {
|
||||
const runId = this.value;
|
||||
if (!runId) {
|
||||
document.getElementById('logsBody').innerHTML = `<tr id="emptyState">
|
||||
<td colspan="5" class="text-center text-muted py-5">
|
||||
<i class="bi bi-journal-text fs-2 d-block mb-2 text-muted opacity-50"></i>
|
||||
Selecteaza un sync run din lista de sus
|
||||
</td>
|
||||
</tr>`;
|
||||
document.getElementById('filterRow').style.display = 'none';
|
||||
document.getElementById('runSummary').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
loadRunLog(runId);
|
||||
document.getElementById('runSelector').addEventListener('change', function() {
|
||||
selectRun(this.value);
|
||||
});
|
||||
|
||||
// Filter buttons
|
||||
document.querySelectorAll('[data-filter]').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
applyFilter(this.dataset.filter);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-select run from URL or server
|
||||
const preselected = document.getElementById('preselectedRun');
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : '');
|
||||
if (runFromUrl) {
|
||||
selectRun(runFromUrl);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user