The push handlers set the button to 'skipped' then immediately ran search(), which re-rendered the inspector and wiped the text — so a skipped publish flashed and vanished (looked like nothing happened). Replace with persistent toasts that state the outcome explicitly: Published / already up-to-date / Push failed / and for a skip, '<model> isn't renderable yet, so nothing was sent.' Only refresh the row on an actual publish so the message isn't clobbered. Same for the multi-select Re-push summary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
542 lines
38 KiB
HTML
542 lines
38 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Dataforth · TestDataDB</title>
|
||
<style>
|
||
:root{
|
||
--bg:#f6f8fa; --surface:#ffffff; --border:#e3e8ee; --border-strong:#cbd5e1;
|
||
--ink:#0f172a; --ink-2:#475569; --ink-3:#94a3b8; --accent:#1e40af; --accent-soft:#eef4ff;
|
||
--pass-bg:#dcfce7; --pass-ink:#15803d; --fail-bg:#fee2e2; --fail-ink:#b91c1c;
|
||
--hover:#f1f5f9; --sel:#e0e7ff; --desk:#e7ecf1;
|
||
--mono:ui-monospace,"SFMono-Regular",Consolas,"Liberation Mono",monospace;
|
||
--sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||
--r:6px; --insp:500px;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html,body{height:100%;margin:0}
|
||
body{font-family:var(--sans);font-size:13.5px;color:var(--ink);background:var(--bg);
|
||
display:grid;grid-template-rows:auto 1fr;overflow:hidden;-webkit-font-smoothing:antialiased}
|
||
button,input,select{font-family:inherit}
|
||
:focus-visible{outline:none;box-shadow:0 0 0 2px var(--surface),0 0 0 4px var(--accent)}
|
||
/* ---------- header ---------- */
|
||
header{display:flex;align-items:center;gap:14px;padding:0 14px;height:50px;
|
||
background:var(--surface);border-bottom:1px solid var(--border);position:relative;z-index:30}
|
||
.brand{font-weight:700;letter-spacing:-.01em;white-space:nowrap;font-size:14.5px}
|
||
.brand b{color:var(--accent);font-weight:700}
|
||
.menubtn{display:none;border:1px solid var(--border);background:none;border-radius:var(--r);height:32px;width:34px;cursor:pointer;color:var(--ink-2)}
|
||
.omni{flex:1;position:relative;max-width:780px}
|
||
.omni .field{display:flex;align-items:center;height:38px;border:1px solid var(--border-strong);border-radius:8px;background:var(--bg);padding:0 8px 0 30px;position:relative}
|
||
.omni .field:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft);background:#fff}
|
||
.omni .ic{position:absolute;left:9px;color:var(--ink-3);font-size:15px;line-height:1}
|
||
.omni input{flex:1;height:100%;border:0;background:none;font-size:14px;color:var(--ink);outline:none}
|
||
.mode{font-size:10.5px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--accent);
|
||
background:var(--accent-soft);border:1px solid #dbeafe;border-radius:5px;padding:2px 7px;cursor:pointer;white-space:nowrap}
|
||
.mode:hover{background:#dbeafe}
|
||
.history{position:absolute;top:44px;left:0;right:0;background:#fff;border:1px solid var(--border);border-radius:8px;
|
||
box-shadow:0 8px 24px rgba(15,23,42,.12);padding:5px;display:none;z-index:40}
|
||
.history.show{display:block}
|
||
.history .hl{font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--ink-3);padding:5px 8px}
|
||
.history button{display:flex;align-items:center;gap:8px;width:100%;text-align:left;border:0;background:none;
|
||
border-radius:5px;padding:6px 8px;font-size:13px;cursor:pointer;color:var(--ink);font-family:var(--mono)}
|
||
.history button:hover{background:var(--hover)}
|
||
.badge{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ink-2);white-space:nowrap}
|
||
.dot{width:8px;height:8px;border-radius:50%;background:#22c55e;box-shadow:0 0 0 3px #22c55e22}
|
||
.hbtn{font-size:12.5px;color:var(--ink-2);background:none;border:1px solid var(--border);border-radius:var(--r);height:32px;padding:0 11px;cursor:pointer}
|
||
.hbtn:hover{background:var(--hover)}
|
||
/* ---------- stats popover ---------- */
|
||
.pop{position:absolute;top:46px;right:12px;width:340px;background:#fff;border:1px solid var(--border);
|
||
border-radius:10px;box-shadow:0 12px 34px rgba(15,23,42,.16);padding:16px;display:none;z-index:50}
|
||
.pop.show{display:block}
|
||
.pop h4{margin:0 0 2px;font-size:13px}
|
||
.pop .big{font-size:26px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
|
||
.pop .sub{font-size:11.5px;color:var(--ink-3)}
|
||
.pop .bar{display:flex;height:10px;border-radius:5px;overflow:hidden;margin:12px 0 6px;background:#eef2f6}
|
||
.pop .bar i{display:block}
|
||
.pop .legend{display:flex;gap:14px;font-size:11.5px;color:var(--ink-2)}
|
||
.pop .legend span::before{content:"";display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px;vertical-align:middle}
|
||
.pop .lt{display:flex;justify-content:space-between;font-size:12px;padding:3px 0;border-top:1px solid var(--border);font-family:var(--mono)}
|
||
.pop .lt:first-of-type{border-top:0;margin-top:8px}
|
||
/* ---------- layout ---------- */
|
||
main{display:grid;grid-template-columns:236px 1fr 0;min-height:0;height:100%}
|
||
body.insp-open main{grid-template-columns:236px 1fr var(--insp)}
|
||
body:not(.insp-open) .insp{display:none}
|
||
.pane{min-height:0;overflow:auto}
|
||
/* ---------- filter rail ---------- */
|
||
.rail{border-right:1px solid var(--border);background:var(--surface);padding:14px;overflow-y:auto}
|
||
.rail h3{font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3);margin:18px 0 8px;font-weight:600}
|
||
.rail h3:first-child{margin-top:0}
|
||
.presets{display:flex;flex-direction:column;gap:5px}
|
||
.preset{display:flex;align-items:center;gap:8px;width:100%;text-align:left;font-size:12.5px;height:31px;padding:0 9px;
|
||
border:1px solid var(--border);border-radius:var(--r);background:#fff;color:var(--ink);cursor:pointer;transition:background .08s}
|
||
.preset:hover:not(:disabled){background:var(--accent-soft);border-color:#bfdbfe}
|
||
.preset .pi{width:14px;text-align:center;color:var(--accent);font-size:12px}
|
||
.preset:disabled{opacity:.45;cursor:not-allowed}
|
||
.famrow{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
|
||
.fam{font-family:var(--mono);font-size:11.5px;height:26px;padding:0 9px;border:1px solid var(--border);border-radius:5px;background:#fff;color:var(--ink-2);cursor:pointer}
|
||
.fam:hover{background:var(--accent-soft);border-color:#bfdbfe;color:var(--accent)}
|
||
.rail label{display:block;font-size:11px;color:var(--ink-3);margin:8px 0 3px}
|
||
.rail input,.rail select{width:100%;height:31px;font-size:13px;padding:0 8px;border:1px solid var(--border-strong);border-radius:var(--r);background:#fff;color:var(--ink)}
|
||
.rail input:focus,.rail select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||
.chips{display:flex;gap:5px;margin-top:7px}
|
||
.chip{font-size:11px;height:24px;padding:0 8px;border:1px solid var(--border);border-radius:20px;background:#fff;color:var(--ink-2);cursor:pointer}
|
||
.chip:hover{background:var(--hover)}
|
||
.reset{margin-top:18px;width:100%;height:31px;font-size:12px;background:none;border:1px solid var(--border);border-radius:var(--r);color:var(--ink-2);cursor:pointer}
|
||
.reset:hover{background:var(--hover)}
|
||
/* ---------- results ---------- */
|
||
.results{display:grid;grid-template-rows:auto auto 1fr auto;min-height:0;background:var(--surface)}
|
||
.rtoolbar{display:flex;align-items:center;gap:12px;padding:8px 12px;border-bottom:1px solid var(--border);font-size:12px;color:var(--ink-2)}
|
||
.rtoolbar .count{font-weight:700;color:var(--ink);font-variant-numeric:tabular-nums}
|
||
.rtoolbar .sp{flex:1}
|
||
.rtoolbar a{font-size:12px;color:var(--accent);text-decoration:none}
|
||
.rtoolbar a:hover{text-decoration:underline}
|
||
.rtoolbar select{font-size:12px;color:var(--ink-2);border:1px solid var(--border);border-radius:5px;height:27px;background:#fff}
|
||
.selbar{display:none;align-items:center;gap:10px;padding:6px 12px;background:var(--accent-soft);border-bottom:1px solid #dbeafe;font-size:12px;color:var(--accent)}
|
||
.selbar.show{display:flex}
|
||
.selbar button{font-size:12px;height:26px;padding:0 9px;border:1px solid #bfdbfe;border-radius:var(--r);background:#fff;color:var(--accent);cursor:pointer}
|
||
.selbar button:disabled{opacity:.5;cursor:not-allowed}
|
||
.twrap{overflow:auto;min-height:0}
|
||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||
thead th{position:sticky;top:0;background:#f7f9fb;border-bottom:1px solid var(--border-strong);text-align:left;
|
||
font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);font-weight:600;padding:7px 10px;white-space:nowrap;z-index:1;user-select:none}
|
||
thead th.s{cursor:pointer}
|
||
thead th.s:hover{color:var(--ink-2)}
|
||
thead th .arr{color:var(--accent);font-size:9px;margin-left:3px}
|
||
thead th.ck{width:30px;padding-left:12px}
|
||
tbody td{padding:5px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
|
||
tbody td.ck{padding-left:12px}
|
||
tbody tr{cursor:pointer}
|
||
tbody tr:hover{background:var(--hover)}
|
||
tbody tr.sel{background:var(--sel)}
|
||
tbody tr.sel td:first-child{box-shadow:inset 3px 0 0 var(--accent)}
|
||
.mono{font-family:var(--mono);font-size:12.5px;font-variant-numeric:tabular-nums}
|
||
.pill{display:inline-block;font-size:10.5px;font-weight:700;padding:1px 8px;border-radius:4px;font-family:var(--mono)}
|
||
.pill.PASS{background:var(--pass-bg);color:var(--pass-ink)}
|
||
.pill.FAIL{background:var(--fail-bg);color:var(--fail-ink)}
|
||
.tag{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:2px 8px;border-radius:20px;font-family:var(--sans)}
|
||
.tag::before{content:"";width:7px;height:7px;border-radius:50%}
|
||
.tag.pub{background:var(--pass-bg);color:var(--pass-ink)} .tag.pub::before{background:var(--pass-ink)}
|
||
.tag.unpub{background:#fef3c7;color:#92400e} .tag.unpub::before{background:#d97706;background:none;box-shadow:inset 0 0 0 1.5px #d97706}
|
||
.web{font-size:13px}
|
||
.pager{display:flex;align-items:center;gap:10px;padding:7px 12px;border-top:1px solid var(--border);font-size:12px;color:var(--ink-2)}
|
||
.pager button{font-size:12px;height:28px;padding:0 11px;border:1px solid var(--border-strong);border-radius:var(--r);background:#fff;cursor:pointer;color:var(--ink)}
|
||
.pager button:disabled{opacity:.4;cursor:default}
|
||
.pager .hint{color:var(--ink-3)}
|
||
.state{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:40px 20px;color:var(--ink-3);text-align:center}
|
||
.state .em{font-size:26px;opacity:.6}
|
||
.state.err{color:var(--fail-ink)}
|
||
.state button{margin-top:4px;font-size:12px;height:28px;padding:0 12px;border:1px solid var(--border-strong);border-radius:var(--r);background:#fff;cursor:pointer;color:var(--ink)}
|
||
.skel{height:13px;margin:8px 10px;border-radius:4px;background:linear-gradient(90deg,#eef2f6,#e2e8f0,#eef2f6);background-size:200% 100%;animation:sh 1.1s infinite}
|
||
@keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}}
|
||
/* ---------- inspector ---------- */
|
||
.insp{position:relative;border-left:1px solid var(--border);background:var(--surface);display:grid;grid-template-rows:auto auto 1fr;min-height:0}
|
||
.resizer{position:absolute;left:-3px;top:0;bottom:0;width:7px;cursor:col-resize;z-index:5}
|
||
.resizer:hover,.resizer.drag{background:linear-gradient(90deg,transparent,var(--accent) 45%,var(--accent) 55%,transparent)}
|
||
body.resizing{cursor:col-resize;user-select:none}
|
||
.meta{padding:12px 14px;border-bottom:1px solid var(--border)}
|
||
.meta .sn{font-family:var(--mono);font-size:16px;font-weight:700;letter-spacing:-.01em}
|
||
.meta dl{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;margin:10px 0 0;font-size:12.5px}
|
||
.meta dt{color:var(--ink-3)}
|
||
.meta dd{margin:0;font-family:var(--mono)}
|
||
.acts{display:flex;gap:6px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--border)}
|
||
.acts a,.acts button{font-size:12px;height:29px;padding:0 11px;border:1px solid var(--border-strong);border-radius:var(--r);
|
||
background:#fff;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer}
|
||
.acts a:hover,.acts button:hover:not(:disabled){background:var(--hover)}
|
||
.acts .pri{background:var(--accent);border-color:var(--accent);color:#fff}
|
||
.acts .pri:hover{background:#1c3aa9}
|
||
.acts button:disabled{opacity:.5;cursor:not-allowed}
|
||
.viewer{background:var(--desk);overflow:auto;padding:14px;position:relative}
|
||
.viewer iframe{display:block;width:100%;min-height:100%;border:1px solid var(--border);border-radius:5px;background:#fff;
|
||
box-shadow:0 1px 5px rgba(15,23,42,.10)}
|
||
kbd{font-family:var(--mono);font-size:11px;background:#f1f5f9;border:1px solid var(--border-strong);border-bottom-width:2px;border-radius:4px;padding:0 5px;color:var(--ink-2)}
|
||
/* ---------- toast ---------- */
|
||
.toast{position:fixed;right:18px;bottom:18px;max-width:400px;z-index:60;display:flex;flex-direction:column;gap:8px}
|
||
.toast .t{background:#fff;border:1px solid var(--border);border-left:4px solid var(--accent);border-radius:8px;box-shadow:0 8px 28px rgba(15,23,42,.18);padding:11px 13px 12px;font-size:12.5px;color:var(--ink-2);cursor:pointer;animation:tin .16s}
|
||
.toast .t.ok{border-left-color:var(--pass-ink)} .toast .t.warn{border-left-color:#d97706} .toast .t.err{border-left-color:var(--fail-ink)}
|
||
.toast .t b{display:block;margin-bottom:2px;color:var(--ink);font-size:13px}
|
||
@keyframes tin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
||
/* ---------- responsive ---------- */
|
||
@media (max-width:1180px){
|
||
main{grid-template-columns:1fr 0}
|
||
body.insp-open main{grid-template-columns:1fr var(--insp)}
|
||
.rail{position:absolute;top:50px;bottom:0;left:0;width:250px;z-index:20;box-shadow:6px 0 20px rgba(15,23,42,.12);transform:translateX(-104%);transition:transform .18s}
|
||
body.rail-open .rail{transform:none}
|
||
.menubtn{display:inline-flex;align-items:center;justify-content:center}
|
||
}
|
||
@media (max-width:820px){ main{grid-template-columns:1fr} .insp{position:absolute;top:50px;bottom:0;right:0;width:min(94vw,520px);z-index:18;box-shadow:-8px 0 24px rgba(15,23,42,.14)} .insp:not(.open){display:none} .resizer{display:none} }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<button class="menubtn" id="menu" title="Filters">☰</button>
|
||
<div class="brand">Dataforth <b>·</b> TestDataDB</div>
|
||
<div class="omni">
|
||
<div class="field">
|
||
<span class="ic">🔍</span>
|
||
<input id="omni" autocomplete="off" spellcheck="false" placeholder="Search serial, model, or text… ( / to focus, s: m: t: to force )">
|
||
<button class="mode" id="mode" title="Click to force the search mode">auto</button>
|
||
</div>
|
||
<div class="history" id="history"></div>
|
||
</div>
|
||
<div class="badge"><span class="dot"></span><span id="ingest">loading…</span></div>
|
||
<button class="hbtn" id="statsBtn">Stats ▾</button>
|
||
<div class="pop" id="statsPop"></div>
|
||
</header>
|
||
|
||
<main>
|
||
<aside class="rail pane" id="rail">
|
||
<h3>Quick searches</h3>
|
||
<div class="presets" id="presets"></div>
|
||
<div class="famrow" id="families"></div>
|
||
<h3>Test date</h3>
|
||
<div class="chips" id="dateChips"></div>
|
||
<label>From</label><input type="date" id="fFrom">
|
||
<label>To</label><input type="date" id="fTo">
|
||
<h3>Model</h3>
|
||
<input id="fModel" list="modelList" placeholder="any model" autocomplete="off">
|
||
<datalist id="modelList"></datalist>
|
||
<h3>Station</h3>
|
||
<select id="fStation"><option value="">any station</option></select>
|
||
<h3>Log type</h3>
|
||
<select id="fLog"><option value="">any log</option></select>
|
||
<button class="reset" id="reset">Reset all</button>
|
||
</aside>
|
||
|
||
<section class="results">
|
||
<div class="rtoolbar">
|
||
<span class="count" id="count">—</span><span>results</span>
|
||
<span class="sp"></span>
|
||
<label>rows <select id="pageSize"><option>25</option><option selected>50</option><option>100</option></select></label>
|
||
<a id="exportCsv" href="#" title="Export current filtered results">Export CSV ↓</a>
|
||
</div>
|
||
<div class="selbar" id="selbar">
|
||
<span id="selcount">0 selected</span>
|
||
<button id="copySel">Copy serials</button>
|
||
<button id="repushSel" title="Publish / re-publish the selected serials to the public website">Re-push ▴</button>
|
||
<span class="sp" style="flex:1"></span>
|
||
<button id="selclear" style="border:0;background:none">clear</button>
|
||
</div>
|
||
<div class="twrap" id="twrap">
|
||
<table>
|
||
<thead><tr>
|
||
<th class="ck"><input type="checkbox" id="ckAll" title="select page"></th>
|
||
<th class="s" data-s="serial_number">Serial</th>
|
||
<th class="s" data-s="model_number">Model</th>
|
||
<th class="s" data-s="test_date">Test Date</th>
|
||
<th>Stn</th><th>Log</th>
|
||
<th class="s" data-s="overall_result">Result</th>
|
||
<th title="Published to public website">Web</th>
|
||
</tr></thead>
|
||
<tbody id="rows"></tbody>
|
||
</table>
|
||
<div id="state"></div>
|
||
</div>
|
||
<div class="pager">
|
||
<button id="prev">‹ Prev</button>
|
||
<span id="pginfo">Page 1</span>
|
||
<button id="next">Next ›</button>
|
||
<span class="sp" style="flex:1"></span>
|
||
<span class="hint"><kbd>/</kbd> search · <kbd>↑</kbd><kbd>↓</kbd> rows · <kbd>↵</kbd> open · <kbd>Esc</kbd> back</span>
|
||
</div>
|
||
</section>
|
||
|
||
<aside class="insp" id="insp">
|
||
<div class="resizer" id="resizer" title="Drag to resize"></div>
|
||
<div class="meta" id="meta"></div>
|
||
<div class="acts" id="acts" style="display:none"></div>
|
||
<div class="viewer" id="viewer"></div>
|
||
</aside>
|
||
</main>
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
const API='';
|
||
const $=id=>document.getElementById(id);
|
||
const state={q:'',serial:'',model:'',result:'',station:'',logtype:'',from:'',to:'',size:50,page:0,total:0,
|
||
selected:null,rows:[],sort:'',dir:'desc',checks:new Set(),force:'',webStatus:''};
|
||
let timer=null, certTimer=null;
|
||
const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||
const fmtDate=d=>d?String(d).slice(0,10):'';
|
||
const isoD=d=>d.toISOString().slice(0,10);
|
||
|
||
/* ---------- omni routing (with s:/m:/t: overrides + encoded serials) ---------- */
|
||
function routeOmni(raw){
|
||
state.q=state.serial=state.model=''; let v=(raw||'').trim(), mode=state.force;
|
||
const pm=v.match(/^([smt]):\s*(.*)$/i); if(pm){ mode={s:'serial',m:'model',t:'text'}[pm[1].toLowerCase()]; v=pm[2]; }
|
||
if(!v){ $('mode').textContent=state.force||'auto'; return; }
|
||
if(!mode){
|
||
if(/\s/.test(v)) mode='text';
|
||
else if(/^(scm|dsc|[57]b|8b|pwr|vas)/i.test(v)) mode='model';
|
||
else mode='serial'; // numeric AND encoded (A243-1) serials land here
|
||
}
|
||
if(mode==='model') state.model=v; else if(mode==='text') state.q=v; else state.serial=v;
|
||
$('mode').textContent=mode;
|
||
}
|
||
$('mode').onclick=()=>{ const seq=['auto','serial','model','text']; state.force=seq[(seq.indexOf(state.force||'auto')+1)%4]; if(state.force==='auto')state.force=''; routeOmni($('omni').value); state.page=0; search(); };
|
||
|
||
/* ---------- query / search ---------- */
|
||
function params(forExport){
|
||
const p=new URLSearchParams();
|
||
for(const k of ['q','serial','model','result','station','logtype','from','to']) if(state[k]) p.set(k,state[k]);
|
||
if(state.sort){ p.set('sort',state.sort); p.set('dir',state.dir); } // honored by /api/search (whitelisted)
|
||
if(state.webStatus) p.set('web_status',state.webStatus); // on=published, off=not yet published
|
||
if(!forExport){ p.set('limit',state.size); p.set('offset',state.page*state.size); }
|
||
return p;
|
||
}
|
||
function syncUrl(){ const p=params(); if(state.selected)p.set('selected',state.selected); history.replaceState(null,'','?'+p.toString()); }
|
||
function showState(html,cls){ $('state').className='state '+(cls||''); $('state').innerHTML=html; $('rows').innerHTML=''; }
|
||
async function search(){
|
||
$('state').innerHTML=''; $('rows').innerHTML=Array.from({length:9}).map(()=>'<tr><td colspan="8"><div class="skel"></div></td></tr>').join('');
|
||
try{
|
||
const r=await fetch(API+'/api/search?'+params().toString());
|
||
if(!r.ok) throw new Error('HTTP '+r.status);
|
||
const d=await r.json(); state.rows=d.records||[]; state.total=d.total||0; renderRows();
|
||
}catch(e){ showState('<div class="em">⚠</div>Search failed — '+esc(e.message)+'<button onclick="search()">Retry</button>','err'); $('count').textContent='—'; }
|
||
$('exportCsv').href=API+'/api/export?'+params(true).toString(); syncUrl();
|
||
}
|
||
function renderRows(){
|
||
$('count').textContent=state.total.toLocaleString();
|
||
const pages=Math.max(1,Math.ceil(state.total/state.size));
|
||
$('pginfo').textContent='Page '+(state.page+1)+' of '+pages.toLocaleString();
|
||
$('prev').disabled=state.page<=0; $('next').disabled=state.page>=pages-1;
|
||
if(!state.rows.length){ showState('<div class="em">∅</div>No records match.'+(activeFilters()?'<button onclick="document.getElementById(\'reset\').click()">Clear filters</button>':'')); return; }
|
||
$('state').innerHTML='';
|
||
$('rows').innerHTML=state.rows.map(r=>{
|
||
const ck=state.checks.has(String(r.id))?'checked':'';
|
||
return `<tr data-id="${r.id}">
|
||
<td class="ck"><input type="checkbox" data-ck="${r.id}" ${ck}></td>
|
||
<td class="mono" style="font-weight:600">${esc(r.serial_number)}</td>
|
||
<td class="mono">${esc(r.model_number)}</td>
|
||
<td class="mono">${fmtDate(r.test_date)}</td>
|
||
<td class="mono">${esc((r.test_station||'').replace(/^TS-/,''))}</td>
|
||
<td class="mono" style="color:var(--ink-3)">${esc((r.log_type||'').replace(/LOG$/,''))}</td>
|
||
<td><span class="pill ${r.overall_result}">${esc(r.overall_result)}</span></td>
|
||
<td class="web" style="text-align:center;color:${r.api_uploaded_at?'var(--pass-ink)':'var(--ink-3)'}" title="${r.api_uploaded_at?'published':'not published'}">${r.api_uploaded_at?'●':'○'}</td>
|
||
</tr>`;}).join('');
|
||
[...$('rows').children].forEach(tr=>{
|
||
tr.onclick=e=>{ if(e.target.dataset.ck!==undefined)return; select(tr.dataset.id); };
|
||
const cb=tr.querySelector('[data-ck]'); cb.onclick=e=>{e.stopPropagation(); const id=cb.dataset.ck; cb.checked?state.checks.add(id):state.checks.delete(id); updateSel();};
|
||
});
|
||
updateSortHeads();
|
||
if(state.serial&&state.rows[0]) select(state.rows[0].id,true);
|
||
else if(state.selected){ const tr=[...$('rows').children].find(t=>t.dataset.id==state.selected); if(tr)tr.classList.add('sel'); }
|
||
}
|
||
function activeFilters(){ return ['serial','model','q','result','station','logtype','from','to'].some(k=>state[k]); }
|
||
|
||
/* ---------- selection (instant) + cert (lazy) ---------- */
|
||
function select(id,auto){
|
||
state.selected=id;
|
||
[...$('rows').children].forEach(t=>t.classList.toggle('sel',t.dataset.id==id));
|
||
const r=state.rows.find(x=>x.id==id); if(!r) return;
|
||
$('meta').innerHTML=`<div class="sn">${esc(r.serial_number)}</div>
|
||
<dl><dt>Model</dt><dd>${esc(r.model_number)}</dd>
|
||
<dt>Date</dt><dd>${fmtDate(r.test_date)}</dd>
|
||
<dt>Station</dt><dd>${esc(r.test_station)}</dd>
|
||
<dt>Result</dt><dd><span class="pill ${r.overall_result}">${esc(r.overall_result)}</span></dd>
|
||
<dt>Log</dt><dd>${esc(r.log_type)}</dd>
|
||
${r.work_order?`<dt>WO</dt><dd>${esc(r.work_order)}</dd>`:''}
|
||
<dt>Web</dt><dd>${r.api_uploaded_at?`<span class="tag pub">Published</span> <span style="color:var(--ink-3)">${fmtDate(r.api_uploaded_at)}</span>`:'<span class="tag unpub">Not published</span>'}</dd></dl>`;
|
||
const ds=API+'/api/datasheet/'+id;
|
||
$('acts').style.display='flex';
|
||
$('acts').innerHTML=`<a class="pri" href="${ds}?format=html" target="_blank">Open ↗</a>
|
||
<button onclick="printCert()">Print</button>
|
||
<a href="${ds}?format=txt" download="${esc(r.serial_number)}.txt">TXT</a>
|
||
<a href="${ds}?format=html" download="${esc(r.serial_number)}.html">HTML</a>
|
||
<button onclick="pushWeb('${id}',this)" title="Publish / re-publish this cert to the public website">Push to Web ▴</button>`;
|
||
$('insp').classList.add('open'); document.body.classList.add('insp-open');
|
||
// lazy-load the certificate so fast arrow/typing stays snappy
|
||
$('viewer').innerHTML='<div class="state"><div class="skel" style="width:60%"></div></div>';
|
||
clearTimeout(certTimer);
|
||
certTimer=setTimeout(()=>loadCert(ds),auto?240:60);
|
||
syncUrl();
|
||
}
|
||
function loadCert(ds){
|
||
$('viewer').innerHTML='<iframe id="dsframe" title="datasheet"></iframe>';
|
||
const f=$('dsframe'); f.onload=()=>{styleCert();fitCert();setTimeout(fitCert,120);setTimeout(fitCert,350);}; f.src=ds+'?format=html';
|
||
}
|
||
function styleCert(){ const f=$('dsframe'); try{ const doc=f.contentDocument; if(!doc)return;
|
||
if(!doc.getElementById('_inj')){ const s=doc.createElement('style'); s.id='_inj';
|
||
s.textContent='html,body{background:#fff!important;margin:0!important}body{padding:16px 20px!important;color:#0f172a}pre{margin:0;font-family:'+getComputedStyle(document.body).getPropertyValue('--mono')+';font-size:12.5px;line-height:1.32}';
|
||
(doc.head||doc.documentElement).appendChild(s); }
|
||
}catch(e){} }
|
||
function fitCert(){ const f=$('dsframe'); if(!f)return; try{ const doc=f.contentDocument; if(!doc||!doc.body)return;
|
||
const b=doc.body, root=doc.documentElement;
|
||
// measure natural content width (transform doesn't reflow, so text never rewraps)
|
||
b.style.transform='none'; b.style.width='max-content'; b.style.transformOrigin='0 0';
|
||
root.style.overflow='hidden';
|
||
const nat=Math.max(b.scrollWidth,root.scrollWidth), av=f.clientWidth-12; // widest line + right margin
|
||
if(!nat){ return; } // not laid out yet — the retry will catch it
|
||
const k=nat>av ? Math.max(.4, av/nat) : 1;
|
||
b.style.transform='scale('+k+')';
|
||
f.style.height=Math.ceil(b.scrollHeight*k+2)+'px'; // size the frame to the scaled cert, no v-scroll-in-frame
|
||
}catch(e){} }
|
||
function printCert(){ const f=$('dsframe'); if(f&&f.contentWindow){f.contentWindow.focus();f.contentWindow.print();} }
|
||
window.addEventListener('resize',()=>{fitCert();});
|
||
|
||
/* ---------- multi-select ---------- */
|
||
function updateSel(){ const n=state.checks.size; $('selbar').classList.toggle('show',n>0); $('selcount').textContent=n+' selected';
|
||
$('ckAll').checked=n>0&&state.rows.every(r=>state.checks.has(String(r.id))); }
|
||
$('ckAll').onclick=e=>{ state.rows.forEach(r=>{ e.target.checked?state.checks.add(String(r.id)):state.checks.delete(String(r.id)); });
|
||
[...$('rows').querySelectorAll('[data-ck]')].forEach(c=>c.checked=e.target.checked); updateSel(); };
|
||
$('copySel').onclick=()=>{ const sns=state.rows.filter(r=>state.checks.has(String(r.id))).map(r=>r.serial_number).join('\n');
|
||
navigator.clipboard&&navigator.clipboard.writeText(sns); $('copySel').textContent='Copied ✓'; setTimeout(()=>$('copySel').textContent='Copy serials',1200); };
|
||
$('selclear').onclick=()=>{ state.checks.clear(); [...$('rows').querySelectorAll('[data-ck]')].forEach(c=>c.checked=false); $('ckAll').checked=false; updateSel(); };
|
||
|
||
/* ---------- publish to public website (POST /api/upload, idempotent) ---------- */
|
||
async function doUpload(payload){
|
||
const r=await fetch(API+'/api/upload',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||
const d=await r.json().catch(()=>({})); if(!r.ok) throw new Error(d.error||('HTTP '+r.status)); return d;
|
||
}
|
||
function showToast(title,msg,type){ const c=$('toast'); if(!c)return; const el=document.createElement('div');
|
||
el.className='t '+(type||''); el.innerHTML='<b>'+esc(title)+'</b>'+(msg?esc(msg):''); el.onclick=()=>el.remove();
|
||
c.appendChild(el); setTimeout(()=>{el.style.transition='opacity .3s';el.style.opacity='0';setTimeout(()=>el.remove(),320);}, type==='warn'||type==='err'?9000:5000); }
|
||
async function pushWeb(id,btn){
|
||
const r=state.rows.find(x=>x.id==id); const sn=r?r.serial_number:id; const mdl=r?r.model_number:'';
|
||
if(!confirm('Publish '+sn+' to the public Dataforth website now?')) return;
|
||
const t=btn.textContent; btn.disabled=true; btn.textContent='Publishing…';
|
||
try{ const d=await doUpload({ids:[+id]}); const pub=(d.created||0)+(d.updated||0);
|
||
if(pub>0){ showToast('Published — '+sn,'Sent to the public website.','ok'); setTimeout(search,400); }
|
||
else if(d.errors){ showToast('Push failed — '+sn, d.errors+' error(s) from the website API.','err'); }
|
||
else if(d.skipped){ showToast('Not published — '+sn, mdl+' isn’t renderable yet, so nothing was sent. This model still needs the render fix before it can publish.','warn'); }
|
||
else if(d.unchanged){ showToast(sn+' already up to date','Already on the website — nothing changed.','ok'); }
|
||
else { showToast('No change — '+sn, 'Nothing was published.','warn'); }
|
||
}catch(e){ showToast('Push failed — '+sn, e.message,'err'); }
|
||
btn.disabled=false; btn.textContent=t;
|
||
}
|
||
async function pushSelected(){
|
||
const ids=[...state.checks].map(Number); if(!ids.length) return;
|
||
if(!confirm('Publish '+ids.length+' selected serial(s) to the public Dataforth website now?')) return;
|
||
const b=$('repushSel'),t=b.textContent; b.disabled=true; b.textContent='Publishing…';
|
||
try{ const d=await doUpload({ids}); const pub=(d.created||0)+(d.updated||0);
|
||
showToast('Push complete', pub+' published · '+(d.unchanged||0)+' unchanged · '+(d.skipped||0)+' skipped (not renderable)'+(d.errors?(' · '+d.errors+' error'):'')+'.', d.errors?'err':(pub||d.unchanged?'ok':'warn'));
|
||
if(pub) search();
|
||
}catch(e){ showToast('Push failed', e.message,'err'); }
|
||
b.disabled=false; b.textContent=t;
|
||
}
|
||
$('repushSel').onclick=pushSelected;
|
||
|
||
/* ---------- sort ---------- */
|
||
function updateSortHeads(){ [...document.querySelectorAll('thead th.s')].forEach(th=>{
|
||
const on=th.dataset.s===state.sort; th.querySelector('.arr')?.remove();
|
||
if(on){ const a=document.createElement('span'); a.className='arr'; a.textContent=state.dir==='asc'?'▲':'▼'; th.appendChild(a);} }); }
|
||
document.querySelectorAll('thead th.s').forEach(th=>th.onclick=()=>{
|
||
const f=th.dataset.s; if(state.sort===f) state.dir=state.dir==='asc'?'desc':'asc'; else {state.sort=f;state.dir=f==='test_date'?'desc':'asc';}
|
||
state.page=0; search(); });
|
||
|
||
/* ---------- filters / paging ---------- */
|
||
function debouncedSearch(){ clearTimeout(timer); timer=setTimeout(()=>{state.page=0;state.checks.clear();updateSel();search();},280); }
|
||
$('omni').addEventListener('input',e=>{routeOmni(e.target.value);debouncedSearch();});
|
||
$('omni').addEventListener('focus',()=>{ if(!$('omni').value) showHistory(true); });
|
||
$('omni').addEventListener('blur',()=>setTimeout(()=>showHistory(false),150));
|
||
$('fFrom').onchange=e=>{state.from=e.target.value;state.page=0;search();};
|
||
$('fTo').onchange=e=>{state.to=e.target.value;state.page=0;search();};
|
||
$('fModel').onchange=e=>{state.model=e.target.value;state.page=0;search();};
|
||
$('fStation').onchange=e=>{state.station=e.target.value;state.page=0;search();};
|
||
$('fLog').onchange=e=>{state.logtype=e.target.value;state.page=0;search();};
|
||
$('pageSize').onchange=e=>{state.size=+e.target.value;state.page=0;search();};
|
||
$('prev').onclick=()=>{if(state.page>0){state.page--;search();$('twrap').scrollTop=0;}};
|
||
$('next').onclick=()=>{state.page++;search();$('twrap').scrollTop=0;};
|
||
$('reset').onclick=()=>{ ['serial','model','q','result','station','logtype','from','to'].forEach(k=>state[k]='');
|
||
state.sort='';state.webStatus='';state.checks.clear();updateSel(); $('omni').value='';$('mode').textContent=state.force||'auto';
|
||
$('fFrom').value=$('fTo').value=$('fStation').value=$('fLog').value=$('fModel').value=''; state.page=0; search(); };
|
||
$('menu').onclick=()=>document.body.classList.toggle('rail-open');
|
||
|
||
/* ---------- presets ---------- */
|
||
function clearAll(){ ['serial','model','q','result','station','logtype','from','to'].forEach(k=>state[k]=''); state.webStatus='';state.sort='';
|
||
$('omni').value='';$('mode').textContent=state.force||'auto';$('fFrom').value=$('fTo').value=$('fStation').value=$('fLog').value=$('fModel').value=''; }
|
||
function applyPreset(fn){ clearAll(); fn(); state.page=0; state.checks.clear(); updateSel(); document.body.classList.remove('rail-open'); search(); }
|
||
const PRESETS=[
|
||
{ic:'◷',label:'Recent',fn:()=>{}},
|
||
{ic:'✕',label:'Failures',fn:()=>{state.result='FAIL';}},
|
||
{ic:'•',label:'Today',fn:()=>{const t=isoD(new Date());state.from=t;state.to=t;}},
|
||
{ic:'7',label:'Last 7 days',fn:()=>{const d=new Date();d.setDate(d.getDate()-7);state.from=isoD(d);}},
|
||
{ic:'▴',label:'Latest uploads',fn:()=>{state.webStatus='on';state.sort='api_uploaded_at';state.dir='desc';}},
|
||
{ic:'○',label:'Not yet published',fn:()=>{state.webStatus='off';}},
|
||
];
|
||
const SOON=[
|
||
{label:'Retested units',why:'needs a retest flag in the pipeline (next)'},
|
||
];
|
||
function renderPresets(){
|
||
$('presets').innerHTML='';
|
||
PRESETS.forEach(p=>{const b=document.createElement('button');b.className='preset';b.innerHTML='<span class="pi">'+p.ic+'</span>'+p.label;b.onclick=()=>applyPreset(p.fn);$('presets').appendChild(b);});
|
||
SOON.forEach(s=>{const b=document.createElement('button');b.className='preset';b.disabled=true;b.title=s.why;b.innerHTML='<span class="pi">…</span>'+s.label;$('presets').appendChild(b);});
|
||
$('families').innerHTML='';
|
||
['DSCA','8B','5B','7B','SCM5B'].forEach(m=>{const b=document.createElement('button');b.className='fam';b.textContent=m;b.onclick=()=>applyPreset(()=>{state.model=m;$('fModel').value=m;});$('families').appendChild(b);});
|
||
$('dateChips').innerHTML='';
|
||
[['Today',0],['7d',7],['30d',30],['Year',-1]].forEach(([lbl,n])=>{const b=document.createElement('button');b.className='chip';b.textContent=lbl;
|
||
b.onclick=()=>{const d=new Date(); if(n===-1)state.from=d.getFullYear()+'-01-01'; else if(n===0){state.from=state.to=isoD(d);} else {d.setDate(d.getDate()-n);state.from=isoD(d);state.to='';}
|
||
$('fFrom').value=state.from;$('fTo').value=state.to||''; state.page=0;search();};
|
||
$('dateChips').appendChild(b);});
|
||
}
|
||
|
||
/* ---------- recent searches ---------- */
|
||
function recents(){ try{return JSON.parse(localStorage.getItem('tdb_recent')||'[]')}catch(e){return[]} }
|
||
function pushRecent(q){ if(!q)return; let r=recents().filter(x=>x!==q); r.unshift(q); r=r.slice(0,8); localStorage.setItem('tdb_recent',JSON.stringify(r)); }
|
||
function showHistory(on){ const r=recents(); if(!on||!r.length){$('history').classList.remove('show');return;}
|
||
$('history').innerHTML='<div class="hl">Recent</div>'+r.map(q=>`<button data-q="${esc(q)}">↩ ${esc(q)}</button>`).join('');
|
||
[...$('history').querySelectorAll('button')].forEach(b=>b.onmousedown=()=>{$('omni').value=b.dataset.q;routeOmni(b.dataset.q);state.page=0;search();showHistory(false);});
|
||
$('history').classList.add('show'); }
|
||
|
||
/* ---------- resizer ---------- */
|
||
(function(){const rz=$('resizer');let on=false;
|
||
rz.addEventListener('mousedown',e=>{on=true;rz.classList.add('drag');document.body.classList.add('resizing');e.preventDefault();});
|
||
window.addEventListener('mousemove',e=>{if(!on)return;let w=Math.max(360,Math.min(window.innerWidth-540,window.innerWidth-e.clientX));document.documentElement.style.setProperty('--insp',w+'px');});
|
||
window.addEventListener('mouseup',()=>{if(on){on=false;rz.classList.remove('drag');document.body.classList.remove('resizing');fitCert();}});
|
||
})();
|
||
|
||
/* ---------- stats popover ---------- */
|
||
let statsLoaded=false;
|
||
$('statsBtn').onclick=async()=>{ const p=$('statsPop'); if(p.classList.contains('show')){p.classList.remove('show');return;}
|
||
p.classList.add('show'); if(statsLoaded)return;
|
||
try{ const s=await (await fetch(API+'/api/stats')).json(); statsLoaded=true;
|
||
const res=s.by_result||[]; const pass=(res.find(r=>r.overall_result==='PASS')||{}).count||0; const fail=(res.find(r=>r.overall_result==='FAIL')||{}).count||0; const tot=pass+fail||1;
|
||
p.innerHTML=`<h4>Database</h4><div class="big">${(s.total_records||0).toLocaleString()}</div><div class="sub">records · ${fmtDate(s.date_range&&s.date_range.oldest)} → ${fmtDate(s.date_range&&s.date_range.newest)}</div>
|
||
<div class="bar"><i style="width:${pass/tot*100}%;background:var(--pass-ink)"></i><i style="width:${fail/tot*100}%;background:var(--fail-ink)"></i></div>
|
||
<div class="legend"><span style="--c:var(--pass-ink)">PASS ${pass.toLocaleString()}</span><span>FAIL ${fail.toLocaleString()}</span></div>
|
||
<div style="margin-top:6px">${(s.by_log_type||[]).slice(0,7).map(l=>`<div class="lt"><span>${esc(l.log_type)}</span><span>${l.count.toLocaleString()}</span></div>`).join('')}</div>`;
|
||
p.querySelectorAll('.legend span')[0].style.cssText='--c:var(--pass-ink)';
|
||
}catch(e){ p.innerHTML='<div class="sub">stats unavailable</div>'; } };
|
||
document.addEventListener('click',e=>{ if(!$('statsPop').contains(e.target)&&e.target!==$('statsBtn')) $('statsPop').classList.remove('show'); });
|
||
|
||
/* ---------- keyboard ---------- */
|
||
document.addEventListener('keydown',e=>{
|
||
if(e.key==='/'&&document.activeElement!==$('omni')){e.preventDefault();$('omni').focus();$('omni').select();return;}
|
||
if(e.key==='Escape'){ if($('statsPop').classList.contains('show')){$('statsPop').classList.remove('show');return;}
|
||
if($('insp').classList.contains('open')){$('insp').classList.remove('open');document.body.classList.remove('insp-open');state.selected=null;[...$('rows').children].forEach(t=>t.classList.remove('sel'));syncUrl();return;}
|
||
if(document.activeElement===$('omni')&&$('omni').value){$('omni').value='';routeOmni('');debouncedSearch();}else $('omni').focus(); return;}
|
||
if((e.key==='ArrowDown'||e.key==='ArrowUp')&&state.rows.length){
|
||
e.preventDefault(); let i=state.rows.findIndex(r=>r.id==state.selected);
|
||
i=e.key==='ArrowDown'?Math.min(state.rows.length-1,i+1):Math.max(0,i-1);
|
||
if(state.rows[i]){select(state.rows[i].id);const tr=[...$('rows').children][i];if(tr)tr.scrollIntoView({block:'nearest'});}}
|
||
if(e.key==='Enter'){ if(document.activeElement===$('omni')) pushRecent($('omni').value.trim());
|
||
if(state.selected) window.open(API+'/api/datasheet/'+state.selected+'?format=html','_blank'); }
|
||
});
|
||
|
||
/* ---------- bootstrap ---------- */
|
||
async function boot(){
|
||
const u=new URLSearchParams(location.search);
|
||
if(u.get('serial')){state.serial=u.get('serial');$('omni').value=state.serial;routeOmni(state.serial);}
|
||
else if(u.get('model')){state.model=u.get('model');$('omni').value=state.model;routeOmni(state.model);}
|
||
else if(u.get('q')){state.q=u.get('q');$('omni').value=state.q;routeOmni(state.q);}
|
||
for(const k of ['result','station','logtype','from','to']) if(u.get(k))state[k]=u.get(k);
|
||
if(u.get('sort')){state.sort=u.get('sort');state.dir=u.get('dir')||'desc';}
|
||
state.selected=u.get('selected');
|
||
renderPresets();
|
||
try{ const f=await (await fetch(API+'/api/filters')).json();
|
||
$('modelList').innerHTML=(f.models||[]).map(m=>`<option value="${esc(m.model_number)}">${esc(m.model_number)} (${m.count})</option>`).join('');
|
||
$('fStation').innerHTML='<option value="">any station</option>'+(f.stations||[]).map(s=>`<option>${esc(s)}</option>`).join('');
|
||
$('fLog').innerHTML='<option value="">any log</option>'+(f.log_types||[]).map(l=>`<option>${esc(l)}</option>`).join('');
|
||
}catch(e){}
|
||
try{ const s=await (await fetch(API+'/api/stats')).json();
|
||
$('ingest').textContent=(s.total_records||0).toLocaleString()+' records · newest '+fmtDate(s.date_range&&s.date_range.newest);
|
||
}catch(e){ $('ingest').textContent='stats unavailable'; }
|
||
if(state.from)$('fFrom').value=state.from; if(state.to)$('fTo').value=state.to;
|
||
if(state.station)$('fStation').value=state.station; if(state.logtype)$('fLog').value=state.logtype; if(state.model)$('fModel').value=state.model;
|
||
$('omni').focus(); search();
|
||
}
|
||
boot();
|
||
</script>
|
||
</body>
|
||
</html>
|