| <!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> |