|
|
<!doctype html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="utf-8" /> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
|
|
<title>mtDNA Location • Output</title> |
|
|
|
|
|
<link rel="icon" href="{{ url_for('static', filename='images/favicon.png') }}" /> |
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='Output.css') }}" media="screen"> |
|
|
</head> |
|
|
|
|
|
<body |
|
|
data-is-vip="{{ 'true' if isvip else 'false' }}" |
|
|
data-ws-url="{{ ws_url|default('') }}" |
|
|
> |
|
|
|
|
|
<div class="container"> |
|
|
|
|
|
<header class="row"> |
|
|
<div> |
|
|
<h1>mtDNA Location • Output</h1> |
|
|
<div class="muted small"> |
|
|
Job ID: <span id="job-id">{{ job_id }} |
|
|
</span> • Started: <span id="job-started-at">{{ started_at or '' }} |
|
|
</span> • Permitted #run: <span id="job-total-queries">{{ total_queries or '' }}</span></div> |
|
|
</div> |
|
|
<div class="toolbar"> |
|
|
<span id="ws-status" class="badge connecting" aria-live="polite" role="status"> |
|
|
<span class="badge-dot" aria-hidden="true"></span> |
|
|
<span class="badge-text">Connecting…</span> |
|
|
</span> |
|
|
<a class="btn btn-ghost" href="{{ url_for('home') }}">← Back</a> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<div class="spacer"></div> |
|
|
|
|
|
<section class="row grid-2"> |
|
|
|
|
|
<div class="card"> |
|
|
<h2>Run status</h2> |
|
|
<div class="kvs" aria-live="polite"> |
|
|
<div class="kv"> |
|
|
<div class="label">Overall status</div> |
|
|
<div id="overall-status" class="value">{{ status or 'queued' }}</div> |
|
|
</div> |
|
|
<div class="kv"> |
|
|
<div class="label">Progress</div> |
|
|
<div class="value"><span id="progress-value">0</span>%</div> |
|
|
</div> |
|
|
<div class="kv"> |
|
|
<div class="label">Processed</div> |
|
|
<div class="value"><span id="processed-count">0</span>/<span id="total-count">0</span></div> |
|
|
</div> |
|
|
<div class="kv"> |
|
|
<div class="label">Tier</div> |
|
|
<div class="value" id="user-tier">{{ 'Premium' if isvip else 'Freemium' }}</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="spacer"></div> |
|
|
|
|
|
<div class="progress" aria-hidden="true"><span id="progress-bar" style="width:0%"></span></div> |
|
|
|
|
|
<div class="spacer"></div> |
|
|
|
|
|
<div class="toolbar"> |
|
|
|
|
|
<button id="btn-cancel" class="btn btn-danger" data-action="cancel">Cancel job</button> |
|
|
<button id="btn-pause" class="btn" data-action="pause">Pause</button> |
|
|
<button id="btn-resume" class="btn" data-action="resume">Resume</button> |
|
|
</div> |
|
|
|
|
|
<div class="spacer"></div> |
|
|
|
|
|
<h3>Live log</h3> |
|
|
<pre id="live-log" class="log mono" aria-live="polite" aria-atomic="false"> |
|
|
<span id="log-placeholder" class="logline muted">Waiting for messages…</span> |
|
|
</pre> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="card"> |
|
|
<h2>Summary</h2> |
|
|
<ul class="muted summary"> |
|
|
<li>Accession IDs received: <strong id="sum-accessions">0</strong></li> |
|
|
<li>Unique isolates resolved: <strong id="sum-isolates">0</strong></li> |
|
|
<li>Errors / warnings: <strong id="sum-errors">0</strong></li> |
|
|
</ul> |
|
|
<div id="alerts"></div> |
|
|
|
|
|
<div class="spacer"></div> |
|
|
|
|
|
<h3>Downloads</h3> |
|
|
<div class="row download"> |
|
|
<a id="dl-csv" class="btn" href="#" download>Results (.csv)</a> |
|
|
<a id="dl-json" class="btn" href="#" download>Results (.json)</a> |
|
|
</div> |
|
|
|
|
|
<div class="spacer"></div> |
|
|
<h3>Share with</h3> |
|
|
<div class="row share"> |
|
|
<input id="share-link" class="mono" value="" readonly /> |
|
|
<button id="copy-share" class="btn">Send</button> |
|
|
</div> |
|
|
</div> |
|
|
</section> |
|
|
|
|
|
<div class="spacer"></div> |
|
|
|
|
|
|
|
|
<section class="card"> |
|
|
<h2>Results</h2> |
|
|
<div class="muted small" id="results-help">Rows will stream in as they complete.</div> |
|
|
|
|
|
<div class="spacer"></div> |
|
|
|
|
|
<div style="overflow:auto;"> |
|
|
<table id="results-table" style="table-layout:fixed;width:100%" aria-describedby="results-help"> |
|
|
<colgroup> |
|
|
<col width="3%"> |
|
|
<col width="8%"> |
|
|
<col width="26%"> |
|
|
<col width="8%"> |
|
|
<col width="25%"> |
|
|
<col width="8%"> |
|
|
<col width="15%"> |
|
|
<col width="7%"> |
|
|
</colgroup> |
|
|
<thead> |
|
|
<tr> |
|
|
<th scope="col" class="col-idx">#</th> |
|
|
<th scope="col" class="col-id">Sample ID</th> |
|
|
<th scope="col" class="col-country">Sources</th> |
|
|
<th scope="col" class="col-sampletype">Predicted Country</th> |
|
|
<th scope="col" class="col-countryex">Country Explanation</th> |
|
|
<th scope="col" class="col-sampleex">Predicted Type</th> |
|
|
<th scope="col" class="col-sources">Type Explanation</th> |
|
|
<th scope="col" class="col-time">Time</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody id="results-body"> |
|
|
<tr id="results-placeholder"> |
|
|
<td colspan="8" class="muted">No results yet.</td> |
|
|
</tr> |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</section> |
|
|
|
|
|
<div class="spacer"></div> |
|
|
|
|
|
|
|
|
<section class="row visualisation"> |
|
|
<div class="card"> |
|
|
<h3>Geography preview</h3> |
|
|
<div id="map-mount">Map placeholder</div> |
|
|
</div> |
|
|
<div class="card"> |
|
|
<h3>Progress over time</h3> |
|
|
<div id="chart-mount">Chart placeholder</div> |
|
|
</div> |
|
|
</section> |
|
|
|
|
|
<div class="spacer"></div> |
|
|
|
|
|
<footer class="muted small"> |
|
|
<span id="tier-text">{{ 'We love you ♥ Premium' if isvip else 'Freemium' }}</span> • mtDNA Location |
|
|
</footer> |
|
|
</div> |
|
|
|
|
|
|
|
|
<template id="tpl-log-line"><span class="logline"></span>\n</template> |
|
|
<template id="tpl-result-row"> |
|
|
<tr> |
|
|
<td class="mono"></td> |
|
|
<td></td> |
|
|
<td class="mono small muted"></td> |
|
|
<td></td> |
|
|
<td class="small"></td> |
|
|
</tr> |
|
|
</template> |
|
|
|
|
|
|
|
|
<script src="https://cdn.socket.io/3.1.3/socket.io.min.js"></script> |
|
|
<script> |
|
|
(function () { |
|
|
|
|
|
const els = { |
|
|
wsStatus: document.getElementById('ws-status'), |
|
|
wsBadgeText: document.querySelector('#ws-status .badge-text'), |
|
|
progressBar: document.getElementById('progress-bar'), |
|
|
progressValue: document.getElementById('progress-value'), |
|
|
processed: document.getElementById('processed-count'), |
|
|
total: document.getElementById('total-count'), |
|
|
overallStatus: document.getElementById('overall-status'), |
|
|
log: document.getElementById('live-log'), |
|
|
tbody: document.getElementById('results-body'), |
|
|
placeholder: document.getElementById('results-placeholder'), |
|
|
dlCSV: document.getElementById('dl-csv'), |
|
|
dlJSON: document.getElementById('dl-json'), |
|
|
}; |
|
|
const jobId = "{{ job_id }}"; |
|
|
|
|
|
function setConnection(state, text) { |
|
|
els.wsStatus?.classList.remove('connecting','connected','disconnected'); |
|
|
els.wsStatus?.classList.add(state); |
|
|
if (els.wsBadgeText) els.wsBadgeText.textContent = text; |
|
|
} |
|
|
function appendLog(line) { |
|
|
if (!els.log) return; |
|
|
|
|
|
const ph = document.getElementById('log-placeholder'); |
|
|
if (ph) ph.remove(); |
|
|
|
|
|
const span = document.createElement('span'); |
|
|
span.className = 'logline'; |
|
|
span.textContent = line; |
|
|
els.log.appendChild(span); |
|
|
els.log.appendChild(document.createTextNode('\n')); |
|
|
els.log.scrollTop = els.log.scrollHeight; |
|
|
} |
|
|
function appendRow(r) { |
|
|
if (els.placeholder) { els.placeholder.remove(); els.placeholder = null; } |
|
|
const tr = document.createElement('tr'); |
|
|
tr.innerHTML = ` |
|
|
<td class="mono">${r.idx ?? ''}</td> |
|
|
<td>${r.sample_id ?? ''}</td> |
|
|
<td class="mono small muted">${r.sources ?? ''}</td> |
|
|
<td>${r.predicted_country ?? ''}</td> |
|
|
<td class="small">${r.country_explanation ?? ''}</td> |
|
|
<td>${r.predicted_sample_type ?? ''}</td> |
|
|
<td class="small">${r.sample_type_explanation ?? ''}</td> |
|
|
<td>${(String(r.time_cost ?? r['Time cost'] ?? '').match(/\d+(?:\.\d+)?/) || [''])[0]}</td>`; |
|
|
els.tbody?.appendChild(tr); |
|
|
|
|
|
|
|
|
if (els.processed && els.total) { |
|
|
const done = (parseInt(els.processed.textContent || '0', 10) || 0) + 1; |
|
|
els.processed.textContent = String(done); |
|
|
const total = parseInt(els.total.textContent || '0', 10) || 0; |
|
|
if (total > 0) { |
|
|
const pct = Math.min(100, Math.round((done / total) * 100)); |
|
|
els.progressValue.textContent = pct; |
|
|
els.progressBar.style.width = pct + '%'; |
|
|
} |
|
|
} |
|
|
const sumIso = document.getElementById('sum-isolates'); |
|
|
if (sumIso) sumIso.textContent = String((parseInt(sumIso.textContent || '0', 10) || 0) + 1); |
|
|
} |
|
|
|
|
|
setConnection('connecting', 'Connecting…'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const qs = window.location.search; |
|
|
const endpoint = qs ? ('/?' + qs.slice(1)) : '/'; |
|
|
const socket = io(endpoint, { |
|
|
path: '/socket.io', |
|
|
transports: ['websocket','polling'], |
|
|
withCredentials: true, |
|
|
reconnectionAttempts: 5, |
|
|
timeout: 10000 |
|
|
}); |
|
|
|
|
|
socket.on('connect', () => { |
|
|
setConnection('connected', 'Connected'); |
|
|
appendLog('[WS] connected'); |
|
|
socket.emit('join', { job_id: jobId }); |
|
|
}); |
|
|
socket.on('connect_error', (err) => appendLog('[WS] connect_error: ' + (err?.message || err))); |
|
|
socket.on('disconnect', (reason) => { |
|
|
setConnection('disconnected', 'Disconnected'); |
|
|
appendLog('[WS] disconnected: ' + reason); |
|
|
}); |
|
|
|
|
|
socket.on('status', (data) => { |
|
|
if (els.overallStatus) els.overallStatus.textContent = data.state || 'unknown'; |
|
|
if (data.total != null) { |
|
|
if (els.total) els.total.textContent = data.total; |
|
|
const sumAcc = document.getElementById('sum-accessions'); |
|
|
if (sumAcc) sumAcc.textContent = data.total; |
|
|
} |
|
|
appendLog(`[STATUS] ${data.state}${data.elapsed ? ' in ' + data.elapsed : ''}`); |
|
|
}); |
|
|
|
|
|
socket.on('log', (data) => appendLog(`[LOG] ${data.msg}`)); |
|
|
socket.on('row', appendRow); |
|
|
|
|
|
window.addEventListener('beforeunload', () => { |
|
|
socket.emit('leave', { job_id: jobId }); |
|
|
}); |
|
|
})(); |
|
|
|
|
|
document.addEventListener('click', function (e) { |
|
|
const a = e.target.closest('a[href]'); |
|
|
if (!a) return; |
|
|
|
|
|
const href = a.getAttribute('href'); |
|
|
|
|
|
if (!href || /^(?:[a-z]+:)?\/\//i.test(href) || href.startsWith('#')) return; |
|
|
|
|
|
|
|
|
const qs = window.location.search; |
|
|
|
|
|
const joined = href + (href.includes('?') ? '&' + qs.slice(1) : qs); |
|
|
|
|
|
e.preventDefault(); |
|
|
window.location.href = joined; |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |