dataforth/testdatadb: wire UI presets + publish buttons; add /api/search sort/dir
Backend (deployed live on AD2, service restarted, + repo copy resynced — it was far behind the deployed server): - /api/search: add whitelisted sort/dir (NULLS LAST) so sortable headers and the "Latest uploads" preset work. web_status filter and POST /api/upload already existed on the server; the stale repo copy now matches live. Frontend (redesign prototype): - "Latest uploads" preset (web_status=on + sort=api_uploaded_at desc) and "Not yet published" (web_status=off) are now active presets. - Push to Web (inspector) + Re-push (multi-select) wired to POST /api/upload behind a confirm() gate; refresh WEB status after. Validated idempotently on a published record (unchanged:1, errors:0). - "Retested units" stays disabled — needs a retest flag in the pipeline (next). tools/preview-proxy.py: forward POST so the publish buttons work in same-origin preview. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -204,7 +204,7 @@
|
||||
<div class="selbar" id="selbar">
|
||||
<span id="selcount">0 selected</span>
|
||||
<button id="copySel">Copy serials</button>
|
||||
<button id="repushSel" disabled title="Re-publish to the website — needs an API endpoint">Re-push ▴</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>
|
||||
@@ -244,7 +244,7 @@
|
||||
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:''};
|
||||
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):'';
|
||||
@@ -269,7 +269,8 @@ $('mode').onclick=()=>{ const seq=['auto','serial','model','text']; state.force=
|
||||
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); } // needs API; harmless if ignored
|
||||
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;
|
||||
}
|
||||
@@ -332,7 +333,7 @@ function select(id,auto){
|
||||
<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 disabled title="Re-publish this cert — needs a POST /api/publish endpoint">Push to Web ▴</button>`;
|
||||
<button onclick="pushWeb('${id}',this)" title="Publish / re-publish this cert to the public website">Push to Web ▴</button>`;
|
||||
$('insp').classList.add('open');
|
||||
// lazy-load the certificate so fast arrow/typing stays snappy
|
||||
$('viewer').innerHTML='<div class="state"><div class="skel" style="width:60%"></div></div>';
|
||||
@@ -372,6 +373,31 @@ $('copySel').onclick=()=>{ const sns=state.rows.filter(r=>state.checks.has(Strin
|
||||
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;
|
||||
}
|
||||
async function pushWeb(id,btn){
|
||||
const r=state.rows.find(x=>x.id==id); const sn=r?r.serial_number:id;
|
||||
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]});
|
||||
btn.textContent=d.errors?('✕ '+d.errors+' err'):(d.skipped&&!(d.created+d.updated+d.unchanged)?'skipped':'Published ✓');
|
||||
setTimeout(search,500); // refresh WEB status from the DB
|
||||
}catch(e){ btn.textContent='✕ '+e.message.slice(0,16); btn.disabled=false; }
|
||||
}
|
||||
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});
|
||||
b.textContent='✓ '+((d.created||0)+(d.updated||0))+' pushed'+(d.skipped?(' · '+d.skipped+' skip'):'');
|
||||
setTimeout(()=>{b.textContent=t;b.disabled=false;search();},1400);
|
||||
}catch(e){ b.textContent='✕ failed'; b.disabled=false; alert('Push failed: '+e.message); }
|
||||
}
|
||||
$('repushSel').onclick=pushSelected;
|
||||
|
||||
/* ---------- sort ---------- */
|
||||
function updateSortHeads(){ [...document.querySelectorAll('thead th.s')].forEach(th=>{
|
||||
const on=th.dataset.s===state.sort; th.querySelector('.arr')?.remove();
|
||||
@@ -394,12 +420,12 @@ $('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.checks.clear();updateSel(); $('omni').value='';$('mode').textContent=state.force||'auto';
|
||||
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]='');
|
||||
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=[
|
||||
@@ -407,11 +433,11 @@ const PRESETS=[
|
||||
{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:'Latest upload batch',why:'needs sort=uploaded in /api/search'},
|
||||
{label:'Retested units',why:'needs a retest flag in the pipeline'},
|
||||
{label:'Not yet published',why:'needs a published filter in /api/search'},
|
||||
{label:'Retested units',why:'needs a retest flag in the pipeline (next)'},
|
||||
];
|
||||
function renderPresets(){
|
||||
$('presets').innerHTML='';
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
/**
|
||||
* API Routes for Test Data Database
|
||||
*
|
||||
* Fixed version - uses a single persistent database connection instead of
|
||||
* opening and closing on every request. WAL journal mode enabled for
|
||||
* concurrent read support. Limit parameter capped at 1000.
|
||||
* PostgreSQL version - uses pg.Pool via database/db.js.
|
||||
* All route handlers are async. FTS uses tsvector/plainto_tsquery.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
const db = require('../database/db');
|
||||
const { generateDatasheet } = require('../templates/datasheet');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton database connection - opened once at module load
|
||||
// ---------------------------------------------------------------------------
|
||||
const DB_PATH = path.join(__dirname, '..', 'database', 'testdata.db');
|
||||
|
||||
const db = new Database(DB_PATH, { readonly: false });
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -42,107 +32,88 @@ function clampOffset(value) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/search
|
||||
// Search test records
|
||||
// Query params: serial, model, from, to, result, q, station, logtype, limit, offset
|
||||
// Query params: serial, model, from, to, result, q, station, logtype, web_status, limit, offset
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/search', (req, res) => {
|
||||
router.get('/search', async (req, res) => {
|
||||
try {
|
||||
const { serial, model, from, to, result, q, station, logtype } = req.query;
|
||||
const { serial, model, from, to, result, q, station, logtype, workorder, web_status } = req.query;
|
||||
const limit = clampLimit(req.query.limit || 100);
|
||||
const offset = clampOffset(req.query.offset || 0);
|
||||
|
||||
let sql = 'SELECT * FROM test_records WHERE 1=1';
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
let paramIdx = 0;
|
||||
|
||||
const addParam = (val) => {
|
||||
paramIdx++;
|
||||
params.push(val);
|
||||
return '$' + paramIdx;
|
||||
};
|
||||
|
||||
if (q) {
|
||||
// Full-text search using tsvector
|
||||
conditions.push(`search_vector @@ plainto_tsquery('english', ${addParam(q)})`);
|
||||
}
|
||||
|
||||
if (serial) {
|
||||
sql += ' AND serial_number LIKE ?';
|
||||
params.push(serial.includes('%') ? serial : `%${serial}%`);
|
||||
const val = serial.includes('%') ? serial : `%${serial}%`;
|
||||
conditions.push(`serial_number LIKE ${addParam(val)}`);
|
||||
}
|
||||
|
||||
if (workorder) {
|
||||
conditions.push(`work_order = ${addParam(workorder)}`);
|
||||
}
|
||||
|
||||
if (model) {
|
||||
sql += ' AND model_number LIKE ?';
|
||||
params.push(model.includes('%') ? model : `%${model}%`);
|
||||
const val = model.includes('%') ? model : `%${model}%`;
|
||||
conditions.push(`model_number LIKE ${addParam(val)}`);
|
||||
}
|
||||
|
||||
if (from) {
|
||||
sql += ' AND test_date >= ?';
|
||||
params.push(from);
|
||||
conditions.push(`test_date >= ${addParam(from)}`);
|
||||
}
|
||||
|
||||
if (to) {
|
||||
sql += ' AND test_date <= ?';
|
||||
params.push(to);
|
||||
conditions.push(`test_date <= ${addParam(to)}`);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
sql += ' AND overall_result = ?';
|
||||
params.push(result.toUpperCase());
|
||||
conditions.push(`overall_result = ${addParam(result.toUpperCase())}`);
|
||||
}
|
||||
|
||||
if (station) {
|
||||
sql += ' AND test_station = ?';
|
||||
params.push(station);
|
||||
conditions.push(`test_station = ${addParam(station)}`);
|
||||
}
|
||||
|
||||
if (logtype) {
|
||||
sql += ' AND log_type = ?';
|
||||
params.push(logtype);
|
||||
conditions.push(`log_type = ${addParam(logtype)}`);
|
||||
}
|
||||
|
||||
if (q) {
|
||||
// Full-text search - rebuild query with FTS
|
||||
sql = `SELECT test_records.* FROM test_records
|
||||
JOIN test_records_fts ON test_records.id = test_records_fts.rowid
|
||||
WHERE test_records_fts MATCH ?`;
|
||||
params.length = 0;
|
||||
params.push(q);
|
||||
|
||||
if (serial) {
|
||||
sql += ' AND serial_number LIKE ?';
|
||||
params.push(serial.includes('%') ? serial : `%${serial}%`);
|
||||
}
|
||||
if (model) {
|
||||
sql += ' AND model_number LIKE ?';
|
||||
params.push(model.includes('%') ? model : `%${model}%`);
|
||||
}
|
||||
if (station) {
|
||||
sql += ' AND test_station = ?';
|
||||
params.push(station);
|
||||
}
|
||||
if (logtype) {
|
||||
sql += ' AND log_type = ?';
|
||||
params.push(logtype);
|
||||
}
|
||||
if (result) {
|
||||
sql += ' AND overall_result = ?';
|
||||
params.push(result.toUpperCase());
|
||||
}
|
||||
if (from) {
|
||||
sql += ' AND test_date >= ?';
|
||||
params.push(from);
|
||||
}
|
||||
if (to) {
|
||||
sql += ' AND test_date <= ?';
|
||||
params.push(to);
|
||||
}
|
||||
if (req.query.web_status === 'off') {
|
||||
conditions.push('api_uploaded_at IS NULL');
|
||||
} else if (req.query.web_status === 'on') {
|
||||
conditions.push('api_uploaded_at IS NOT NULL');
|
||||
}
|
||||
|
||||
sql += ' ORDER BY test_date DESC, serial_number';
|
||||
sql += ' LIMIT ? OFFSET ?';
|
||||
params.push(limit, offset);
|
||||
const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
|
||||
const records = db.prepare(sql).all(...params);
|
||||
// whitelisted sort (prevents injection); NULLS LAST so e.g. unpublished rows don't lead an api_uploaded_at sort
|
||||
const SORTABLE = { serial_number:'serial_number', model_number:'model_number', test_date:'test_date', overall_result:'overall_result', test_station:'test_station', log_type:'log_type', api_uploaded_at:'api_uploaded_at' };
|
||||
const sortCol = SORTABLE[req.query.sort];
|
||||
const sortDir = String(req.query.dir || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
const orderBy = sortCol ? `ORDER BY ${sortCol} ${sortDir} NULLS LAST, serial_number` : 'ORDER BY test_date DESC, serial_number';
|
||||
const dataSql = `SELECT * FROM test_records ${where} ${orderBy} LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}`;
|
||||
const countSql = `SELECT COUNT(*) as count FROM test_records ${where}`;
|
||||
const countParams = params.slice(0, paramIdx - 2); // exclude limit/offset
|
||||
|
||||
// Get total count
|
||||
let countSql = sql.replace(/SELECT .* FROM/, 'SELECT COUNT(*) as count FROM')
|
||||
.replace(/ORDER BY.*$/, '');
|
||||
countSql = countSql.replace(/LIMIT \? OFFSET \?/, '');
|
||||
|
||||
const countParams = params.slice(0, -2);
|
||||
const total = db.prepare(countSql).get(...countParams);
|
||||
const [records, countRow] = await Promise.all([
|
||||
db.query(dataSql, params),
|
||||
db.queryOne(countSql, countParams),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
records,
|
||||
total: total?.count || records.length,
|
||||
total: countRow?.count ? parseInt(countRow.count, 10) : records.length,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
@@ -156,9 +127,9 @@ router.get('/search', (req, res) => {
|
||||
// GET /api/record/:id
|
||||
// Get single record by ID
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/record/:id', (req, res) => {
|
||||
router.get('/record/:id', async (req, res) => {
|
||||
try {
|
||||
const record = db.prepare('SELECT * FROM test_records WHERE id = ?').get(req.params.id);
|
||||
const record = await db.queryOne('SELECT * FROM test_records WHERE id = $1', [req.params.id]);
|
||||
|
||||
if (!record) {
|
||||
return res.status(404).json({ error: 'Record not found' });
|
||||
@@ -176,21 +147,102 @@ router.get('/record/:id', (req, res) => {
|
||||
// Generate datasheet for a record
|
||||
// Query params: format (html, txt)
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/datasheet/:id', (req, res) => {
|
||||
router.get('/datasheet/:id', async (req, res) => {
|
||||
try {
|
||||
const record = db.prepare('SELECT * FROM test_records WHERE id = ?').get(req.params.id);
|
||||
const record = await db.queryOne('SELECT * FROM test_records WHERE id = $1', [req.params.id]);
|
||||
|
||||
if (!record) {
|
||||
return res.status(404).json({ error: 'Record not found' });
|
||||
}
|
||||
|
||||
const format = req.query.format || 'html';
|
||||
const datasheet = generateDatasheet(record, format);
|
||||
|
||||
if (format === 'html') {
|
||||
res.type('html').send(datasheet);
|
||||
// Try exact-match formatter first
|
||||
const { loadAllSpecs, getSpecs } = require('../parsers/spec-reader');
|
||||
const { generateExactDatasheet } = require('../templates/datasheet-exact');
|
||||
const specMap = loadAllSpecs();
|
||||
const specs = getSpecs(specMap, record.model_number);
|
||||
const exactTxt = generateExactDatasheet(record, specs);
|
||||
|
||||
if (exactTxt && format === 'html') {
|
||||
// Render exact-match TXT as styled HTML page
|
||||
const escaped = exactTxt
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Data Sheet - ${record.serial_number}</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.page {
|
||||
background: white;
|
||||
padding: 40px 30px;
|
||||
max-width: 720px;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
pre {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.toolbar {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #999;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.toolbar button:hover { background: #e0e0e0; }
|
||||
@media print {
|
||||
body { background: white; padding: 0; }
|
||||
.page { box-shadow: none; border: none; padding: 0; }
|
||||
.toolbar { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<button onclick="window.print()">Print</button>
|
||||
<button onclick="window.open('/api/datasheet/${record.id}/pdf')">Download PDF</button>
|
||||
<button onclick="window.close()">Close</button>
|
||||
</div>
|
||||
<div class="page">
|
||||
<pre>${escaped}</pre>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
res.type('html').send(html);
|
||||
} else if (exactTxt && format === 'txt') {
|
||||
res.type('text/plain').send(exactTxt);
|
||||
} else {
|
||||
res.type('text/plain').send(datasheet);
|
||||
// Fall back to generic template
|
||||
const datasheet = generateDatasheet(record, format);
|
||||
if (format === 'html') {
|
||||
res.type('html').send(datasheet);
|
||||
} else {
|
||||
res.type('text/plain').send(datasheet);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [DATASHEET ERROR] ${err.message}`);
|
||||
@@ -198,45 +250,83 @@ router.get('/datasheet/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/datasheet/:id/pdf
|
||||
// Generate PDF datasheet for a record (on-demand download)
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/datasheet/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const record = await db.queryOne('SELECT * FROM test_records WHERE id = $1', [req.params.id]);
|
||||
|
||||
if (!record) {
|
||||
return res.status(404).json({ error: 'Record not found' });
|
||||
}
|
||||
|
||||
const { loadAllSpecs, getSpecs } = require('../parsers/spec-reader');
|
||||
const { generateExactDatasheet } = require('../templates/datasheet-exact');
|
||||
const PDFDocument = require('pdfkit');
|
||||
|
||||
const specMap = loadAllSpecs();
|
||||
const specs = getSpecs(specMap, record.model_number);
|
||||
let txt = generateExactDatasheet(record, specs);
|
||||
|
||||
// Fall back to generic datasheet if exact-match formatter doesn't support this family
|
||||
if (!txt) {
|
||||
txt = generateDatasheet(record, 'txt');
|
||||
}
|
||||
|
||||
if (!txt) {
|
||||
return res.status(422).json({ error: 'Could not generate datasheet (missing specs or data)' });
|
||||
}
|
||||
|
||||
const doc = new PDFDocument({
|
||||
size: 'LETTER',
|
||||
margins: { top: 36, bottom: 36, left: 36, right: 36 }
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${record.serial_number}.pdf"`);
|
||||
doc.pipe(res);
|
||||
|
||||
doc.font('Courier').fontSize(9.5);
|
||||
const lines = txt.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
doc.text(line, { lineGap: 1 });
|
||||
}
|
||||
|
||||
doc.end();
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [PDF ERROR] ${err.message}`);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/stats
|
||||
// Get database statistics
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/stats', (req, res) => {
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
const stats = {
|
||||
total_records: db.prepare('SELECT COUNT(*) as count FROM test_records').get().count,
|
||||
by_log_type: db.prepare(`
|
||||
SELECT log_type, COUNT(*) as count
|
||||
FROM test_records
|
||||
GROUP BY log_type
|
||||
ORDER BY count DESC
|
||||
`).all(),
|
||||
by_result: db.prepare(`
|
||||
SELECT overall_result, COUNT(*) as count
|
||||
FROM test_records
|
||||
GROUP BY overall_result
|
||||
`).all(),
|
||||
by_station: db.prepare(`
|
||||
SELECT test_station, COUNT(*) as count
|
||||
FROM test_records
|
||||
WHERE test_station IS NOT NULL AND test_station != ''
|
||||
GROUP BY test_station
|
||||
ORDER BY test_station
|
||||
`).all(),
|
||||
date_range: db.prepare(`
|
||||
SELECT MIN(test_date) as oldest, MAX(test_date) as newest
|
||||
FROM test_records
|
||||
`).get(),
|
||||
recent_serials: db.prepare(`
|
||||
SELECT DISTINCT serial_number, model_number, test_date
|
||||
FROM test_records
|
||||
ORDER BY test_date DESC
|
||||
LIMIT 10
|
||||
`).all()
|
||||
};
|
||||
const [totalRow, byLogType, byResult, byStation, dateRange, recentSerials] = await Promise.all([
|
||||
db.queryOne('SELECT COUNT(*) as count FROM test_records'),
|
||||
db.query('SELECT log_type, COUNT(*) as count FROM test_records GROUP BY log_type ORDER BY count DESC'),
|
||||
db.query('SELECT overall_result, COUNT(*) as count FROM test_records GROUP BY overall_result'),
|
||||
db.query(`SELECT test_station, COUNT(*) as count FROM test_records
|
||||
WHERE test_station IS NOT NULL AND test_station != ''
|
||||
GROUP BY test_station ORDER BY test_station`),
|
||||
db.queryOne('SELECT MIN(test_date) as oldest, MAX(test_date) as newest FROM test_records'),
|
||||
db.query(`SELECT DISTINCT serial_number, model_number, test_date
|
||||
FROM test_records ORDER BY test_date DESC LIMIT 10`),
|
||||
]);
|
||||
|
||||
res.json(stats);
|
||||
res.json({
|
||||
total_records: parseInt(totalRow.count, 10),
|
||||
by_log_type: byLogType.map(r => ({ ...r, count: parseInt(r.count, 10) })),
|
||||
by_result: byResult.map(r => ({ ...r, count: parseInt(r.count, 10) })),
|
||||
by_station: byStation.map(r => ({ ...r, count: parseInt(r.count, 10) })),
|
||||
date_range: dateRange,
|
||||
recent_serials: recentSerials,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [STATS ERROR] ${err.message}`);
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -247,30 +337,22 @@ router.get('/stats', (req, res) => {
|
||||
// GET /api/filters
|
||||
// Get available filter options (test stations, log types, models)
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/filters', (req, res) => {
|
||||
router.get('/filters', async (req, res) => {
|
||||
try {
|
||||
const filters = {
|
||||
stations: db.prepare(`
|
||||
SELECT DISTINCT test_station
|
||||
FROM test_records
|
||||
WHERE test_station IS NOT NULL AND test_station != ''
|
||||
ORDER BY test_station
|
||||
`).all().map(r => r.test_station),
|
||||
log_types: db.prepare(`
|
||||
SELECT DISTINCT log_type
|
||||
FROM test_records
|
||||
ORDER BY log_type
|
||||
`).all().map(r => r.log_type),
|
||||
models: db.prepare(`
|
||||
SELECT DISTINCT model_number, COUNT(*) as count
|
||||
FROM test_records
|
||||
GROUP BY model_number
|
||||
ORDER BY count DESC
|
||||
LIMIT 500
|
||||
`).all()
|
||||
};
|
||||
const [stations, logTypes, models] = await Promise.all([
|
||||
db.query(`SELECT DISTINCT test_station FROM test_records
|
||||
WHERE test_station IS NOT NULL AND test_station != ''
|
||||
ORDER BY test_station`),
|
||||
db.query('SELECT DISTINCT log_type FROM test_records ORDER BY log_type'),
|
||||
db.query(`SELECT DISTINCT model_number, COUNT(*) as count FROM test_records
|
||||
GROUP BY model_number ORDER BY count DESC LIMIT 500`),
|
||||
]);
|
||||
|
||||
res.json(filters);
|
||||
res.json({
|
||||
stations: stations.map(r => r.test_station),
|
||||
log_types: logTypes.map(r => r.log_type),
|
||||
models: models.map(r => ({ ...r, count: parseInt(r.count, 10) })),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [FILTERS ERROR] ${err.message}`);
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -281,51 +363,60 @@ router.get('/filters', (req, res) => {
|
||||
// GET /api/export
|
||||
// Export search results as CSV
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/export', (req, res) => {
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const { serial, model, from, to, result, station, logtype } = req.query;
|
||||
|
||||
let sql = 'SELECT * FROM test_records WHERE 1=1';
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
let paramIdx = 0;
|
||||
|
||||
const addParam = (val) => {
|
||||
paramIdx++;
|
||||
params.push(val);
|
||||
return '$' + paramIdx;
|
||||
};
|
||||
|
||||
if (serial) {
|
||||
sql += ' AND serial_number LIKE ?';
|
||||
params.push(serial.includes('%') ? serial : `%${serial}%`);
|
||||
const val = serial.includes('%') ? serial : `%${serial}%`;
|
||||
conditions.push(`serial_number LIKE ${addParam(val)}`);
|
||||
}
|
||||
|
||||
if (model) {
|
||||
sql += ' AND model_number LIKE ?';
|
||||
params.push(model.includes('%') ? model : `%${model}%`);
|
||||
const val = model.includes('%') ? model : `%${model}%`;
|
||||
conditions.push(`model_number LIKE ${addParam(val)}`);
|
||||
}
|
||||
|
||||
if (from) {
|
||||
sql += ' AND test_date >= ?';
|
||||
params.push(from);
|
||||
conditions.push(`test_date >= ${addParam(from)}`);
|
||||
}
|
||||
|
||||
if (to) {
|
||||
sql += ' AND test_date <= ?';
|
||||
params.push(to);
|
||||
conditions.push(`test_date <= ${addParam(to)}`);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
sql += ' AND overall_result = ?';
|
||||
params.push(result.toUpperCase());
|
||||
conditions.push(`overall_result = ${addParam(result.toUpperCase())}`);
|
||||
}
|
||||
|
||||
if (station) {
|
||||
sql += ' AND test_station = ?';
|
||||
params.push(station);
|
||||
conditions.push(`test_station = ${addParam(station)}`);
|
||||
}
|
||||
|
||||
if (logtype) {
|
||||
sql += ' AND log_type = ?';
|
||||
params.push(logtype);
|
||||
conditions.push(`log_type = ${addParam(logtype)}`);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY test_date DESC, serial_number LIMIT 10000';
|
||||
if (req.query.web_status === 'off') {
|
||||
conditions.push('api_uploaded_at IS NULL');
|
||||
} else if (req.query.web_status === 'on') {
|
||||
conditions.push('api_uploaded_at IS NOT NULL');
|
||||
}
|
||||
|
||||
const records = db.prepare(sql).all(...params);
|
||||
const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
const sql = `SELECT * FROM test_records ${where} ORDER BY test_date DESC, serial_number LIMIT 10000`;
|
||||
|
||||
const records = await db.query(sql, params);
|
||||
|
||||
// Generate CSV
|
||||
const headers = ['id', 'log_type', 'model_number', 'serial_number', 'test_date', 'test_station', 'overall_result', 'source_file'];
|
||||
@@ -348,16 +439,119 @@ router.get('/export', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/workorder/:wo
|
||||
// Get work order details and all associated test lines
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/workorder/:wo', async (req, res) => {
|
||||
try {
|
||||
const wo = req.params.wo;
|
||||
|
||||
const [header, lines, testRecords] = await Promise.all([
|
||||
db.queryOne('SELECT * FROM work_orders WHERE wo_number = $1', [wo]),
|
||||
db.query('SELECT * FROM work_order_lines WHERE wo_number = $1 ORDER BY test_date, test_time', [wo]),
|
||||
db.query(
|
||||
'SELECT id, log_type, model_number, serial_number, test_date, test_station, overall_result, work_order FROM test_records WHERE work_order = $1 ORDER BY serial_number',
|
||||
[wo]
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
work_order: header || { wo_number: wo },
|
||||
lines,
|
||||
test_records: testRecords,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [WO ERROR] ${err.message}`);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/workorder-search?q=<query>
|
||||
// Search work orders by number (prefix match)
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get('/workorder-search', async (req, res) => {
|
||||
try {
|
||||
const q = req.query.q || '';
|
||||
if (q.length < 2) {
|
||||
return res.json({ results: [] });
|
||||
}
|
||||
|
||||
const results = await db.query(
|
||||
'SELECT wo_number, wo_date, program, test_station FROM work_orders WHERE wo_number LIKE $1 ORDER BY wo_date DESC LIMIT 50',
|
||||
[q + '%']
|
||||
);
|
||||
|
||||
res.json({ results });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup function for graceful shutdown
|
||||
// ---------------------------------------------------------------------------
|
||||
function cleanup() {
|
||||
async function cleanup() {
|
||||
try {
|
||||
db.close();
|
||||
await db.close();
|
||||
} catch (err) {
|
||||
console.error(`[${new Date().toISOString()}] [CLEANUP ERROR] ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/upload
|
||||
*
|
||||
* Body: { ids?: number[], serialNumbers?: string[], all_unuploaded?: boolean }
|
||||
*
|
||||
* Pushes selected records to the Dataforth website API. Accepts either a set
|
||||
* of record IDs (resolved to serial_number + checked for exported status), a
|
||||
* direct list of serial numbers, or all_unuploaded:true to push every PASS
|
||||
* record where api_uploaded_at IS NULL.
|
||||
*
|
||||
* Response: { created, updated, unchanged, errors, skipped, processed, sns }
|
||||
*/
|
||||
router.post('/upload', async (req, res) => {
|
||||
try {
|
||||
const { ids, serialNumbers, all_unuploaded } = req.body || {};
|
||||
const { uploadBySerialNumbers } = require('../database/upload-to-api');
|
||||
|
||||
let sns = [];
|
||||
if (all_unuploaded) {
|
||||
const rows = await db.query(
|
||||
`SELECT DISTINCT serial_number FROM test_records
|
||||
WHERE overall_result = 'PASS'
|
||||
AND api_uploaded_at IS NULL
|
||||
ORDER BY serial_number`
|
||||
);
|
||||
sns = rows.map(r => r.serial_number);
|
||||
} else if (Array.isArray(ids) && ids.length > 0) {
|
||||
const placeholders = ids.map((_, i) => `$${i + 1}`).join(',');
|
||||
const rows = await db.query(
|
||||
`SELECT DISTINCT serial_number FROM test_records
|
||||
WHERE id IN (${placeholders})
|
||||
AND overall_result = 'PASS'`,
|
||||
ids,
|
||||
);
|
||||
sns = rows.map(r => r.serial_number);
|
||||
} else if (Array.isArray(serialNumbers) && serialNumbers.length > 0) {
|
||||
sns = [...new Set(serialNumbers)];
|
||||
} else {
|
||||
return res.status(400).json({ error: 'provide ids[], serialNumbers[], or all_unuploaded=true' });
|
||||
}
|
||||
|
||||
if (sns.length === 0) {
|
||||
return res.json({ created:0, updated:0, unchanged:0, errors:0, skipped:0, processed:0, sns:[] });
|
||||
}
|
||||
|
||||
const result = await uploadBySerialNumbers(sns);
|
||||
res.json({ ...result, processed: sns.length, sns });
|
||||
} catch (err) {
|
||||
console.error(`[UPLOAD] ${err.message}`);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.cleanup = cleanup;
|
||||
|
||||
@@ -37,6 +37,23 @@ class H(http.server.BaseHTTPRequestHandler):
|
||||
else:
|
||||
self._send(404, "text/plain", b"not found")
|
||||
|
||||
def do_POST(self):
|
||||
if self.path.startswith("/api/"):
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(n) if n else b""
|
||||
req = urllib.request.Request(
|
||||
TARGET + self.path, data=body, method="POST",
|
||||
headers={"Content-Type": self.headers.get("Content-Type", "application/json")})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
self._send(200, r.headers.get("Content-Type", "application/json"), r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
self._send(e.code, "application/json", e.read())
|
||||
except Exception as e:
|
||||
self._send(502, "text/plain", str(e).encode())
|
||||
else:
|
||||
self._send(404, "text/plain", b"not found")
|
||||
|
||||
def _send(self, code, ct, body):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ct)
|
||||
|
||||
Reference in New Issue
Block a user