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:
2026-06-18 10:08:38 -07:00
parent c2335e859d
commit 84c7579a3d
3 changed files with 408 additions and 171 deletions

View File

@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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='';

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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;

View File

@@ -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)