Dataforth UI push + dedup + refactor, GuruRMM roadmap evolution, Azure signing setup

Dataforth (projects/dataforth-dos/):
- UI feature: row coloring + PUSH/RE-PUSH buttons + Website Status filter
- Database dedup to one row per SN (2.89M -> 469K rows, UNIQUE constraint added)
- Import logic handles FAIL -> PASS retest transition
- Refactored upload-to-api.js to render datasheets in-memory (dropped For_Web filesystem dep)
- Bulk pushed 170,984 records to Hoffman API
- Statistical sanity check: 100/100 stamped SNs verified on Hoffman

GuruRMM (projects/msp-tools/guru-rmm/):
- ROADMAP.md: added Terminology (5-tier hierarchy), Tunnel Channels Phase 2,
  Logging/Audit/Observability, Multi-tenancy, Modular Architecture,
  Protocol Versioning, Certificates sections + Decisions Log
- CONTEXT.md: hierarchy table, new anti-patterns (bootstrap sacred,
  no cross-module imports), revised next-steps priorities

Session logs for both projects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 17:39:32 -07:00
parent eae9d7f644
commit 733d87f20e
42 changed files with 9153 additions and 7 deletions

View File

@@ -0,0 +1,79 @@
const fs = require('fs');
const https = require('https');
const { URL } = require('url');
const db = require('./db');
const CREDS = JSON.parse(fs.readFileSync('C:/ProgramData/dataforth-uploader/credentials.json', 'utf8'));
function req(method, uri, headers) {
return new Promise((res, rej) => {
const u = new URL(uri);
const r = https.request({
hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search,
method, headers, timeout: 20000,
}, rs => {
let d = '';
rs.on('data', c => d += c);
rs.on('end', () => res({ status: rs.statusCode, body: d }));
});
const t = setTimeout(() => { r.destroy(); rej(new Error('timeout')); }, 20000);
r.on('error', rej);
r.on('close', () => clearTimeout(t));
r.end();
});
}
(async () => {
const form = 'grant_type=client_credentials&client_id=' + encodeURIComponent(CREDS.CF_CLIENT_ID) +
'&client_secret=' + encodeURIComponent(CREDS.CF_CLIENT_SECRET) + '&scope=' + encodeURIComponent(CREDS.CF_SCOPE);
const tokR = await new Promise((r, j) => {
const u = new URL(CREDS.CF_TOKEN_URL);
const rq = https.request({
hostname: u.hostname, port: 443, path: u.pathname, method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(form) },
}, rs => {
let d = '';
rs.on('data', c => d += c);
rs.on('end', () => r({ status: rs.statusCode, body: d }));
});
rq.on('error', j);
rq.write(form);
rq.end();
});
const token = JSON.parse(tokR.body).access_token;
async function sample(label, sql, expect) {
console.log('=== ' + label + ' ===');
const rows = await db.query(sql);
let hit = 0, miss = 0, err = 0;
for (const r of rows) {
try {
const rr = await req('GET',
CREDS.CF_API_BASE + '/api/v1/TestReportDataFiles/' + encodeURIComponent(r.serial_number),
{ 'Authorization': 'Bearer ' + token });
if (rr.status === 200) hit++;
else if (rr.status === 404) miss++;
else { err++; console.log(' HTTP ' + rr.status + ' ' + r.serial_number); }
} catch (e) { err++; console.log(' ERR ' + r.serial_number + ' ' + e.message); }
}
console.log(' hit=' + hit + ' miss=' + miss + ' err=' + err + ' (' + expect + ')');
return { hit, miss, err };
}
await sample(
'Sample 1: 100 random stamped api_uploaded_at IS NOT NULL',
"SELECT serial_number FROM test_records WHERE api_uploaded_at IS NOT NULL ORDER BY random() LIMIT 100",
'expect hit=100',
);
await sample(
'Sample 2: 100 random unpushable PASS (NULL api_uploaded_at, PASS)',
"SELECT serial_number FROM test_records WHERE api_uploaded_at IS NULL AND overall_result='PASS' ORDER BY random() LIMIT 100",
'expect mostly miss (these are the 10K unpushables)',
);
await sample(
'Sample 3: 50 random FAIL',
"SELECT serial_number FROM test_records WHERE overall_result='FAIL' ORDER BY random() LIMIT 50",
'expect miss=50 (FAILs never reach Hoffman)',
);
await db.close();
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });

View File

@@ -0,0 +1,74 @@
/**
* One-time back-population of api_uploaded_at from server_inventory.txt.
*
* Reads SN list, UPDATEs test_records.api_uploaded_at = NOW() in batches
* for records whose serial_number appears in the inventory.
*
* Usage: node back-populate-api-uploaded.js [--inventory path] [--batch 1000] [--dry-run]
*/
const fs = require('fs');
const db = require('./db');
const args = process.argv.slice(2);
const arg = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i+1] : d; };
const flag = n => args.includes(n);
const INVENTORY = arg('--inventory', 'C:\\ProgramData\\dataforth-uploader\\server_inventory.txt');
const BATCH = parseInt(arg('--batch', '1000'), 10);
const DRY = flag('--dry-run');
async function main() {
if (!fs.existsSync(INVENTORY)) {
console.error(`[FAIL] inventory not found: ${INVENTORY}`);
process.exit(1);
}
const data = fs.readFileSync(INVENTORY, 'utf8');
const sns = data.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
console.log(`[INFO] inventory: ${sns.length} serial numbers`);
console.log(`[INFO] batch size: ${BATCH} dry-run: ${DRY}`);
const t0 = Date.now();
let totalMatched = 0;
for (let i = 0; i < sns.length; i += BATCH) {
const chunk = sns.slice(i, i + BATCH);
const placeholders = chunk.map((_, j) => `$${j + 1}`).join(',');
if (DRY) {
const row = await db.queryOne(
`SELECT COUNT(*) as c FROM test_records WHERE serial_number IN (${placeholders}) AND api_uploaded_at IS NULL`,
chunk,
);
totalMatched += parseInt(row.c, 10) || 0;
} else {
const result = await db.execute(
`UPDATE test_records SET api_uploaded_at = NOW() WHERE serial_number IN (${placeholders}) AND api_uploaded_at IS NULL`,
chunk,
);
totalMatched += result.rowCount || 0;
}
if ((i / BATCH) % 20 === 0) {
const rate = (i + chunk.length) / Math.max(1, (Date.now() - t0) / 1000);
const eta = Math.round((sns.length - i - chunk.length) / Math.max(1, rate));
console.log(` progress ${i + chunk.length}/${sns.length} matched-so-far=${totalMatched} rate=${rate.toFixed(0)}/s eta=${eta}s`);
}
}
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
console.log(`\n[DONE] ${elapsed}s`);
console.log(` inventory size: ${sns.length}`);
console.log(` ${DRY ? 'would update' : 'updated'}: ${totalMatched}`);
// Sanity: how many records have api_uploaded_at set vs null?
const tot = await db.queryOne(`SELECT COUNT(*) as c FROM test_records`);
const set = await db.queryOne(`SELECT COUNT(*) as c FROM test_records WHERE api_uploaded_at IS NOT NULL`);
const nul = await db.queryOne(`SELECT COUNT(*) as c FROM test_records WHERE api_uploaded_at IS NULL`);
console.log(`\n[DB STATE]`);
console.log(` total records: ${tot.c}`);
console.log(` api_uploaded_at SET: ${set.c}`);
console.log(` api_uploaded_at NULL: ${nul.c}`);
await db.close();
}
main().catch(e => { console.error('[FATAL]', e); process.exit(1); });

View File

@@ -0,0 +1,257 @@
/**
* Export Datasheets
*
* Generates TXT datasheets for unexported PASS records and writes them to X:\For_Web\.
* Updates forweb_exported_at after successful export.
*
* Usage:
* node export-datasheets.js Export all pending (batch mode)
* node export-datasheets.js --limit 100 Export up to 100 records
* node export-datasheets.js --file <paths> Export records matching specific source files
* node export-datasheets.js --serial 178439-1 Export a specific serial number
* node export-datasheets.js --dry-run Show what would be exported without writing
*/
const fs = require('fs');
const path = require('path');
const db = require('./db');
const { loadAllSpecs, getSpecs } = require('../parsers/spec-reader');
const { generateExactDatasheet } = require('../templates/datasheet-exact');
// Configuration
const OUTPUT_DIR = 'X:\\For_Web';
const BATCH_SIZE = 500;
async function run() {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const limitIdx = args.indexOf('--limit');
const limit = limitIdx >= 0 ? parseInt(args[limitIdx + 1]) : 0;
const serialIdx = args.indexOf('--serial');
const serial = serialIdx >= 0 ? args[serialIdx + 1] : null;
const fileIdx = args.indexOf('--file');
const files = fileIdx >= 0 ? args.slice(fileIdx + 1).filter(f => !f.startsWith('--')) : null;
console.log('========================================');
console.log('Datasheet Export');
console.log('========================================');
console.log(`Output: ${OUTPUT_DIR}`);
console.log(`Dry run: ${dryRun}`);
if (limit) console.log(`Limit: ${limit}`);
if (serial) console.log(`Serial: ${serial}`);
console.log(`Start: ${new Date().toISOString()}`);
if (!dryRun && !fs.existsSync(OUTPUT_DIR)) {
console.error(`ERROR: Output directory does not exist: ${OUTPUT_DIR}`);
process.exit(1);
}
console.log('\nLoading model specs...');
const specMap = loadAllSpecs();
// Build query
const conditions = [`overall_result = 'PASS'`, `forweb_exported_at IS NULL`];
const params = [];
let paramIdx = 0;
if (serial) {
paramIdx++;
conditions.push(`serial_number = $${paramIdx}`);
params.push(serial);
}
if (files && files.length > 0) {
const placeholders = files.map(() => { paramIdx++; return `$${paramIdx}`; }).join(',');
conditions.push(`source_file IN (${placeholders})`);
params.push(...files);
}
let sql = `SELECT * FROM test_records WHERE ${conditions.join(' AND ')} ORDER BY test_date DESC`;
if (limit) {
paramIdx++;
sql += ` LIMIT $${paramIdx}`;
params.push(limit);
}
const records = await db.query(sql, params);
console.log(`\nFound ${records.length} records to export`);
if (records.length === 0) {
console.log('Nothing to export.');
await db.close();
return { exported: 0, skipped: 0, errors: 0 };
}
let exported = 0;
let skipped = 0;
let errors = 0;
let noSpecs = 0;
let pendingUpdates = [];
for (const record of records) {
try {
const filename = record.serial_number + '.TXT';
const outputPath = path.join(OUTPUT_DIR, filename);
// VASLOG_ENG: verbatim byte-for-byte copy of the original file.
// Using fs.copyFileSync avoids any utf-8 round-trip that would
// corrupt non-ASCII bytes (CP1252 etc.) in customer datasheets.
// Fall back to writing raw_data if the source file is gone.
if (record.log_type === 'VASLOG_ENG') {
if (dryRun) {
console.log(` [DRY RUN] Would copy: ${record.source_file} -> ${filename}`);
exported++;
continue;
}
if (record.source_file && fs.existsSync(record.source_file)) {
fs.copyFileSync(record.source_file, outputPath);
} else {
console.warn(`[WARN] source file missing, writing decoded raw_data for ${record.serial_number}`);
if (!record.raw_data) {
skipped++;
continue;
}
fs.writeFileSync(outputPath, record.raw_data, 'utf8');
}
pendingUpdates.push(record.id);
exported++;
if (pendingUpdates.length >= BATCH_SIZE) {
await flushUpdates(pendingUpdates);
pendingUpdates = [];
process.stdout.write(`\r Exported: ${exported} / ${records.length}`);
}
continue;
}
// Template-generated datasheet path.
const specs = getSpecs(specMap, record.model_number);
if (!specs) {
noSpecs++;
skipped++;
continue;
}
const txt = generateExactDatasheet(record, specs);
if (!txt) {
skipped++;
continue;
}
if (dryRun) {
console.log(` [DRY RUN] Would write: ${filename}`);
exported++;
} else {
fs.writeFileSync(outputPath, txt, 'utf8');
pendingUpdates.push(record.id);
exported++;
// Batch commit
if (pendingUpdates.length >= BATCH_SIZE) {
await flushUpdates(pendingUpdates);
pendingUpdates = [];
process.stdout.write(`\r Exported: ${exported} / ${records.length}`);
}
}
} catch (err) {
console.error(`\n ERROR exporting ${record.serial_number}: ${err.message}`);
errors++;
}
}
// Flush remaining updates
if (pendingUpdates.length > 0) {
await flushUpdates(pendingUpdates);
}
console.log(`\n\n========================================`);
console.log(`Export Complete`);
console.log(`========================================`);
console.log(`Exported: ${exported}`);
console.log(`Skipped: ${skipped} (${noSpecs} missing specs)`);
console.log(`Errors: ${errors}`);
console.log(`End: ${new Date().toISOString()}`);
await db.close();
return { exported, skipped, errors };
}
async function flushUpdates(ids) {
const now = new Date().toISOString();
await db.transaction(async (txClient) => {
for (const id of ids) {
await txClient.execute(
'UPDATE test_records SET forweb_exported_at = $1 WHERE id = $2',
[now, id]
);
}
});
}
// Export function for use by import.js (no db argument -- uses shared pool)
async function exportNewRecords(specMap, filePaths) {
if (!fs.existsSync(OUTPUT_DIR)) {
console.log(`[EXPORT] Output directory not found: ${OUTPUT_DIR}`);
return 0;
}
const conditions = [`overall_result = 'PASS'`, `forweb_exported_at IS NULL`];
const params = [];
let paramIdx = 0;
if (filePaths && filePaths.length > 0) {
const placeholders = filePaths.map(() => { paramIdx++; return `$${paramIdx}`; }).join(',');
conditions.push(`source_file IN (${placeholders})`);
params.push(...filePaths);
}
const sql = `SELECT * FROM test_records WHERE ${conditions.join(' AND ')}`;
const records = await db.query(sql, params);
if (records.length === 0) return 0;
let exported = 0;
await db.transaction(async (txClient) => {
for (const record of records) {
const filename = record.serial_number + '.TXT';
const outputPath = path.join(OUTPUT_DIR, filename);
try {
// VASLOG_ENG: verbatim copy, preserving original bytes.
if (record.log_type === 'VASLOG_ENG') {
if (record.source_file && fs.existsSync(record.source_file)) {
fs.copyFileSync(record.source_file, outputPath);
} else {
console.warn(`[WARN] source file missing, writing decoded raw_data for ${record.serial_number}`);
if (!record.raw_data) continue;
fs.writeFileSync(outputPath, record.raw_data, 'utf8');
}
} else {
const specs = getSpecs(specMap, record.model_number);
if (!specs) continue;
const txt = generateExactDatasheet(record, specs);
if (!txt) continue;
fs.writeFileSync(outputPath, txt, 'utf8');
}
await txClient.execute(
'UPDATE test_records SET forweb_exported_at = $1 WHERE id = $2',
[new Date().toISOString(), record.id]
);
exported++;
} catch (err) {
console.error(`[EXPORT] Error writing ${filename}: ${err.message}`);
}
}
});
console.log(`[EXPORT] Generated ${exported} datasheet(s)`);
return exported;
}
if (require.main === module) {
run().catch(console.error);
}
module.exports = { exportNewRecords };

View File

@@ -0,0 +1,416 @@
/**
* Data Import Script
* Imports test data from DAT and SHT files into PostgreSQL database
*/
const fs = require('fs');
const path = require('path');
const db = require('./db');
const { parseMultilineFile, extractTestStation } = require('../parsers/multiline');
const { parseCsvFile } = require('../parsers/csvline');
const { parseShtFile } = require('../parsers/shtfile');
const { parseVaslogEngTxt } = require('../parsers/vaslog-engtxt');
// Data source paths
const TEST_PATH = 'C:/Shares/test';
const RECOVERY_PATH = 'C:/Shares/Recovery-TEST';
const HISTLOGS_PATH = path.join(TEST_PATH, 'Ate/HISTLOGS');
// Log types and their parsers.
// NOTE: `recursive` defaults to TRUE when absent (walk subfolders by default,
// preserving pre-existing production behavior for DSCLOG/5BLOG/8BLOG/PWRLOG/
// SCTLOG/7BLOG). Set it to FALSE explicitly on VASLOG so the .DAT walk does
// NOT descend into the "VASLOG - Engineering Tested" subfolder (belt-and-
// suspenders: the .DAT glob wouldn't match .txt, but be explicit anyway).
// VASLOG_ENG also sets recursive:false -- the eng-tested dir is flat.
const LOG_TYPES = {
'DSCLOG': { parser: 'multiline', ext: '.DAT' },
'5BLOG': { parser: 'multiline', ext: '.DAT' },
'8BLOG': { parser: 'multiline', ext: '.DAT' },
'PWRLOG': { parser: 'multiline', ext: '.DAT' },
'SCTLOG': { parser: 'multiline', ext: '.DAT' },
'VASLOG': { parser: 'multiline', ext: '.DAT', recursive: false },
'7BLOG': { parser: 'csvline', ext: '.DAT' },
// Engineering-tested SCMHVAS pre-rendered datasheets live under VASLOG/"VASLOG - Engineering Tested"/
'VASLOG_ENG': { parser: 'vaslog-engtxt', ext: '.txt', dir: 'VASLOG/VASLOG - Engineering Tested', recursive: false }
};
// Find all files of a specific type in a directory
function findFiles(dir, pattern, recursive = true) {
const results = [];
try {
if (!fs.existsSync(dir)) return results;
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory() && recursive) {
results.push(...findFiles(fullPath, pattern, recursive));
} else if (item.isFile()) {
if (pattern.test(item.name)) {
results.push(fullPath);
}
}
}
} catch (err) {
// Ignore permission errors
}
return results;
}
// Parse records from a file (sync -- file I/O only)
function parseFile(filePath, logType, parser) {
const testStation = extractTestStation(filePath);
switch (parser) {
case 'multiline':
return parseMultilineFile(filePath, logType, testStation);
case 'csvline':
return parseCsvFile(filePath, testStation);
case 'shtfile':
return parseShtFile(filePath, testStation);
case 'vaslog-engtxt':
return parseVaslogEngTxt(filePath, testStation);
default:
return [];
}
}
// Batch insert records into PostgreSQL
async function insertBatch(txClient, records) {
let imported = 0;
for (const record of records) {
try {
const result = await txClient.execute(
`INSERT INTO test_records
(log_type, model_number, serial_number, test_date, test_station, overall_result, raw_data, source_file)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (serial_number) DO UPDATE SET
log_type = EXCLUDED.log_type,
model_number = EXCLUDED.model_number,
test_date = EXCLUDED.test_date,
test_station = EXCLUDED.test_station,
overall_result = EXCLUDED.overall_result,
raw_data = EXCLUDED.raw_data,
source_file = EXCLUDED.source_file,
api_uploaded_at = NULL,
forweb_exported_at = NULL
WHERE test_records.overall_result = 'FAIL'
OR (EXCLUDED.overall_result = 'PASS' AND EXCLUDED.test_date > test_records.test_date)`,
[
record.log_type,
record.model_number,
record.serial_number,
record.test_date,
record.test_station,
record.overall_result,
record.raw_data,
record.source_file
]
);
if (result.rowCount > 0) imported++;
} catch (err) {
// Constraint error - skip
}
}
return imported;
}
// Import records from a file
async function importFile(txClient, filePath, logType, parser) {
let records = [];
try {
records = parseFile(filePath, logType, parser);
const imported = await insertBatch(txClient, records);
return { total: records.length, imported };
} catch (err) {
console.error(`Error importing ${filePath}: ${err.message}`);
return { total: 0, imported: 0 };
}
}
// Import from HISTLOGS (master consolidated logs)
async function importHistlogs(txClient) {
console.log('\n=== Importing from HISTLOGS ===');
let totalImported = 0;
let totalRecords = 0;
for (const [logType, config] of Object.entries(LOG_TYPES)) {
const subdir = config.dir || logType;
const logDir = path.join(HISTLOGS_PATH, subdir);
if (!fs.existsSync(logDir)) {
console.log(` ${logType}: directory not found`);
continue;
}
const files = findFiles(logDir, new RegExp(`\\${config.ext}$`, 'i'), config.recursive !== false);
console.log(` ${logType}: found ${files.length} files`);
for (const file of files) {
const { total, imported } = await importFile(txClient, file, logType, config.parser);
totalRecords += total;
totalImported += imported;
}
}
console.log(` HISTLOGS total: ${totalImported} records imported (${totalRecords} parsed)`);
return totalImported;
}
// Import from test station logs
async function importStationLogs(txClient, basePath, label) {
console.log(`\n=== Importing from ${label} ===`);
let totalImported = 0;
let totalRecords = 0;
const stationPattern = /^TS-\d+[LR]?$/i;
let stations = [];
try {
const items = fs.readdirSync(basePath, { withFileTypes: true });
stations = items
.filter(i => i.isDirectory() && stationPattern.test(i.name))
.map(i => i.name);
} catch (err) {
console.log(` Error reading ${basePath}: ${err.message}`);
return 0;
}
console.log(` Found stations: ${stations.join(', ')}`);
for (const station of stations) {
const logsDir = path.join(basePath, station, 'LOGS');
if (!fs.existsSync(logsDir)) continue;
for (const [logType, config] of Object.entries(LOG_TYPES)) {
const subdir = config.dir || logType;
const logDir = path.join(logsDir, subdir);
if (!fs.existsSync(logDir)) continue;
const files = findFiles(logDir, new RegExp(`\\${config.ext}$`, 'i'), config.recursive !== false);
for (const file of files) {
const { total, imported } = await importFile(txClient, file, logType, config.parser);
totalRecords += total;
totalImported += imported;
}
}
}
// Also import SHT files
const shtFiles = findFiles(basePath, /\.SHT$/i, true);
console.log(` Found ${shtFiles.length} SHT files`);
for (const file of shtFiles) {
const { total, imported } = await importFile(txClient, file, 'SHT', 'shtfile');
totalRecords += total;
totalImported += imported;
}
console.log(` ${label} total: ${totalImported} records imported (${totalRecords} parsed)`);
return totalImported;
}
// Import from Recovery-TEST backups (newest first)
async function importRecoveryBackups(txClient) {
console.log('\n=== Importing from Recovery-TEST backups ===');
if (!fs.existsSync(RECOVERY_PATH)) {
console.log(' Recovery-TEST directory not found');
return 0;
}
const backups = fs.readdirSync(RECOVERY_PATH, { withFileTypes: true })
.filter(i => i.isDirectory() && /^\d{2}-\d{2}-\d{2}$/.test(i.name))
.map(i => i.name)
.sort()
.reverse();
console.log(` Found backup dates: ${backups.join(', ')}`);
let totalImported = 0;
for (const backup of backups) {
const backupPath = path.join(RECOVERY_PATH, backup);
const imported = await importStationLogs(txClient, backupPath, `Recovery-TEST/${backup}`);
totalImported += imported;
}
return totalImported;
}
// Main import function
async function runImport() {
console.log('========================================');
console.log('Test Data Import');
console.log('========================================');
console.log(`Start time: ${new Date().toISOString()}`);
let grandTotal = 0;
await db.transaction(async (txClient) => {
grandTotal += await importHistlogs(txClient);
grandTotal += await importRecoveryBackups(txClient);
grandTotal += await importStationLogs(txClient, TEST_PATH, 'test');
});
const stats = await db.queryOne('SELECT COUNT(*) as count FROM test_records');
console.log('\n========================================');
console.log('Import Complete');
console.log('========================================');
console.log(`Total records in database: ${stats.count}`);
console.log(`End time: ${new Date().toISOString()}`);
await db.close();
}
// Import a single file (for incremental imports from sync)
async function importSingleFile(filePath) {
console.log(`Importing: ${filePath}`);
let logType = null;
let parser = null;
// VASLOG_ENG subpath must be checked before VASLOG (substring overlap).
if (filePath.includes('VASLOG - Engineering Tested')) {
logType = 'VASLOG_ENG';
parser = LOG_TYPES['VASLOG_ENG'].parser;
} else {
for (const [type, config] of Object.entries(LOG_TYPES)) {
if (type === 'VASLOG_ENG') continue;
if (filePath.includes(type)) {
logType = type;
parser = config.parser;
break;
}
}
}
if (!logType) {
if (/\.SHT$/i.test(filePath)) {
logType = 'SHT';
parser = 'shtfile';
} else {
console.log(` Unknown log type for: ${filePath}`);
return { total: 0, imported: 0 };
}
}
let result;
await db.transaction(async (txClient) => {
result = await importFile(txClient, filePath, logType, parser);
});
console.log(` Imported ${result.imported} of ${result.total} records`);
return result;
}
// Import multiple files (for batch incremental imports)
async function importFiles(filePaths) {
console.log(`\n========================================`);
console.log(`Incremental Import: ${filePaths.length} files`);
console.log(`========================================`);
let totalImported = 0;
let totalRecords = 0;
await db.transaction(async (txClient) => {
for (const filePath of filePaths) {
let logType = null;
let parser = null;
// VASLOG_ENG subpath must be checked before the generic loop --
// otherwise `includes('VASLOG')` hits first and the eng .txt gets
// dispatched to the multiline parser. Mirror importSingleFile().
if (filePath.includes('VASLOG - Engineering Tested')) {
logType = 'VASLOG_ENG';
parser = LOG_TYPES['VASLOG_ENG'].parser;
} else {
for (const [type, config] of Object.entries(LOG_TYPES)) {
if (type === 'VASLOG_ENG') continue;
if (filePath.includes(type)) {
logType = type;
parser = config.parser;
break;
}
}
}
if (!logType) {
if (/\.SHT$/i.test(filePath)) {
logType = 'SHT';
parser = 'shtfile';
} else {
console.log(` Skipping unknown type: ${filePath}`);
continue;
}
}
const { total, imported } = await importFile(txClient, filePath, logType, parser);
totalRecords += total;
totalImported += imported;
console.log(` ${path.basename(filePath)}: ${imported}/${total} records`);
}
});
console.log(`\nTotal: ${totalImported} records imported (${totalRecords} parsed)`);
// Export datasheets for newly imported records
if (totalImported > 0) {
try {
const { loadAllSpecs } = require('../parsers/spec-reader');
const { exportNewRecords } = require('./export-datasheets');
const specMap = loadAllSpecs();
await exportNewRecords(specMap, filePaths);
} catch (err) {
console.error(`[EXPORT] Datasheet export failed: ${err.message}`);
}
// Push newly-exported datasheets to Dataforth's Hoffman API.
// Best-effort; a failure here must not wedge the import flow. The
// daily fallback scheduled task catches anything this missed.
try {
const { uploadNewRecords } = require('./upload-to-api');
await uploadNewRecords(filePaths);
} catch (err) {
console.error(`[API-UPLOAD] upload after import failed: ${err.message}`);
}
}
return { total: totalRecords, imported: totalImported };
}
// Run if called directly
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length > 0 && args[0] === '--file') {
const files = args.slice(1);
if (files.length === 0) {
console.log('Usage: node import.js --file <file1> [file2] ...');
process.exit(1);
}
importFiles(files).then(() => db.close()).catch(console.error);
} else if (args.length > 0 && args[0] === '--help') {
console.log('Usage:');
console.log(' node import.js Full import from all sources');
console.log(' node import.js --file <f> Import specific file(s)');
process.exit(0);
} else {
runImport().catch(console.error);
}
}
module.exports = { runImport, importSingleFile, importFiles };

View File

@@ -0,0 +1,11 @@
-- Adds api_uploaded_at tracking column + partial index for "not-yet-uploaded" queries.
-- Safe to re-run (IF NOT EXISTS).
ALTER TABLE test_records
ADD COLUMN IF NOT EXISTS api_uploaded_at TIMESTAMPTZ DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_unuploaded_pass
ON test_records(overall_result, forweb_exported_at)
WHERE overall_result = 'PASS'
AND forweb_exported_at IS NOT NULL
AND api_uploaded_at IS NULL;

View File

@@ -0,0 +1,180 @@
/**
* Pull full Hoffman API inventory, diff against local DB, write three files:
* - _hoffman_only_sns.txt (SNs on Hoffman not in local DB)
* - _local_only_sns.txt (SNs in local DB not on Hoffman)
* - _pull_inventory.log (progress and summary)
*
* Writes directly via fs.appendFileSync so progress survives SSH disconnects.
* Run detached; tail the log file for progress.
*/
const fs = require('fs');
const https = require('https');
const { URL } = require('url');
const db = require('./db');
const LOG = 'C:/Shares/testdatadb/database/_pull_inventory.log';
const OUT_HOFFMAN = 'C:/Shares/testdatadb/database/_hoffman_only_sns.txt';
const OUT_LOCAL = 'C:/Shares/testdatadb/database/_local_only_sns.txt';
const CREDS_PATH = 'C:/ProgramData/dataforth-uploader/credentials.json';
const PAGE_SIZE = 1000;
function log(msg) {
const line = `[${new Date().toISOString()}] ${msg}\n`;
fs.appendFileSync(LOG, line);
}
function req(method, uri, headers) {
return new Promise((resolve, reject) => {
const u = new URL(uri);
const r = https.request({
hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search,
method, headers, timeout: 45000,
}, res => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => { clearTimeout(hardTimer); resolve({ status: res.statusCode, body: data }); });
res.on('error', e => { clearTimeout(hardTimer); reject(e); });
});
// Hard deadline — some proxies keep TCP alive but never send data. If we
// don't hear back in 45s, destroy the request and reject.
const hardTimer = setTimeout(() => {
r.destroy(new Error('hard deadline 45s'));
reject(new Error('hard deadline 45s'));
}, 45000);
r.on('error', e => { clearTimeout(hardTimer); reject(e); });
r.on('timeout', () => r.destroy(new Error('socket timeout')));
r.end();
});
}
async function reqRetry(method, uri, headers, tries = 3) {
let lastErr;
for (let i = 0; i < tries; i++) {
try { return await req(method, uri, headers); }
catch (e) {
lastErr = e;
log(` retry ${i+1}/${tries} after ${e.message}`);
await new Promise(r => setTimeout(r, 2000 * (i + 1)));
}
}
throw lastErr;
}
async function getToken(creds) {
const form = 'grant_type=client_credentials' +
'&client_id=' + encodeURIComponent(creds.CF_CLIENT_ID) +
'&client_secret=' + encodeURIComponent(creds.CF_CLIENT_SECRET) +
'&scope=' + encodeURIComponent(creds.CF_SCOPE);
const r = await new Promise((res, rej) => {
const u = new URL(creds.CF_TOKEN_URL);
const rq = https.request({
hostname: u.hostname, port: u.port || 443, path: u.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(form),
},
timeout: 30000,
}, resp => {
let d = '';
resp.on('data', c => d += c);
resp.on('end', () => res({ status: resp.statusCode, body: d }));
});
rq.on('error', rej);
rq.write(form);
rq.end();
});
const parsed = JSON.parse(r.body);
if (!parsed.access_token) throw new Error('token fetch failed: ' + r.status + ' ' + r.body.slice(0, 200));
return parsed.access_token;
}
(async () => {
try {
fs.writeFileSync(LOG, '');
log('START inventory pull');
const creds = JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8'));
const token = await getToken(creds);
log('token len=' + token.length);
const allSns = new Set();
let page = 1;
let total = null;
const t0 = Date.now();
const skippedPages = [];
let consecutiveFailures = 0;
while (true) {
const url = creds.CF_API_BASE + '/api/v1/TestReportDataFiles?page=' + page + '&pageSize=' + PAGE_SIZE;
let r;
try {
r = await reqRetry('GET', url, { 'Authorization': 'Bearer ' + token });
consecutiveFailures = 0;
} catch (e) {
// Skip this page on sustained failure, don't abort.
log(`page ${page} SKIPPED after retries: ${e.message}`);
skippedPages.push(page);
consecutiveFailures++;
if (consecutiveFailures >= 10) {
log(`FATAL: ${consecutiveFailures} consecutive page failures — aborting`);
break;
}
page++;
continue;
}
if (r.status !== 200) {
log(`page ${page} HTTP ${r.status}: ${r.body.slice(0, 300)}`);
break;
}
const obj = JSON.parse(r.body);
total = obj.TotalCount;
for (const it of obj.Items) allSns.add(it.SerialNumber);
if (page === 1 || page % 50 === 0 || allSns.size >= total) {
const rate = allSns.size / Math.max(1, (Date.now() - t0) / 1000);
const eta = Math.round((total - allSns.size) / Math.max(rate, 1));
log(`page ${page} collected ${allSns.size}/${total} rate ${rate.toFixed(0)}/s eta ${eta}s skipped=${skippedPages.length}`);
}
if (obj.Items.length < PAGE_SIZE || allSns.size >= total) break;
page++;
}
if (skippedPages.length > 0) {
log(`retrying ${skippedPages.length} skipped pages with longer delay`);
for (const p of skippedPages) {
const url = creds.CF_API_BASE + '/api/v1/TestReportDataFiles?page=' + p + '&pageSize=' + PAGE_SIZE;
try {
await new Promise(r => setTimeout(r, 3000));
const r2 = await reqRetry('GET', url, { 'Authorization': 'Bearer ' + token });
if (r2.status === 200) {
const obj = JSON.parse(r2.body);
for (const it of obj.Items) allSns.add(it.SerialNumber);
log(` recovered page ${p} (+${obj.Items.length} SNs)`);
}
} catch (e) {
log(` page ${p} still failed: ${e.message}`);
}
}
}
log(`Hoffman inventory collected: ${allSns.size}`);
log('querying local DB...');
const localRows = await db.query('SELECT serial_number FROM test_records');
const localSns = new Set(localRows.map(r => r.serial_number));
log(`Local DB unique SNs: ${localSns.size}`);
const hoffmanOnly = [];
for (const s of allSns) if (!localSns.has(s)) hoffmanOnly.push(s);
const localOnly = [];
for (const s of localSns) if (!allSns.has(s)) localOnly.push(s);
fs.writeFileSync(OUT_HOFFMAN, hoffmanOnly.join('\n'));
fs.writeFileSync(OUT_LOCAL, localOnly.join('\n'));
log(`Hoffman-only (need pull): ${hoffmanOnly.length} -> ${OUT_HOFFMAN}`);
log(`Local-only (not on Hoffman): ${localOnly.length} -> ${OUT_LOCAL}`);
log('DONE');
await db.close();
} catch (e) {
log('FATAL: ' + e.message + '\n' + (e.stack || ''));
process.exit(1);
}
})();

View File

@@ -0,0 +1,27 @@
/**
* In-memory equivalent of what export-datasheets.js writes to
* X:\For_Web\<SN>.TXT. Lets upload-to-api.js POST directly to Hoffman's API
* from DB state without a filesystem intermediate.
*
* Returns a string (datasheet text) or null if the record cannot be rendered
* (no specs for the model, no raw_data for VASLOG_ENG, etc.).
*/
const { loadAllSpecs, getSpecs } = require('../parsers/spec-reader');
const { generateExactDatasheet } = require('../templates/datasheet-exact');
let _specMap = null;
function specs() {
if (_specMap === null) _specMap = loadAllSpecs();
return _specMap;
}
function renderContent(record) {
if (record.log_type === 'VASLOG_ENG') {
return record.raw_data || null;
}
const modelSpecs = getSpecs(specs(), record.model_number);
if (!modelSpecs) return null;
return generateExactDatasheet(record, modelSpecs) || null;
}
module.exports = { renderContent };

View File

@@ -0,0 +1,215 @@
/**
* Post-import uploader — pushes just-imported records to Dataforth's Hoffman
* API. Called from import.js after insertBatch, and from the /api/upload
* endpoint for individual/bulk UI pushes.
*
* Datasheet content is rendered in memory from the DB row via
* render-datasheet.renderContent — no For_Web filesystem dependency.
*
* Credentials come from C:\ProgramData\dataforth-uploader\credentials.json
* (ACL'd to SYSTEM + Administrators + svc_testdatadb).
*
* The API is idempotent — already-present records return Unchanged.
*/
const fs = require('fs');
const https = require('https');
const { URL } = require('url');
const db = require('./db');
const { renderContent } = require('./render-datasheet');
const CREDS_PATH = 'C:\\ProgramData\\dataforth-uploader\\credentials.json';
const BATCH = 100;
const TOKEN_LEEWAY_MS = 60 * 1000;
const HTTP_TIMEOUT_MS = 120 * 1000;
const RECORD_COLUMNS = 'id, log_type, model_number, serial_number, test_date, test_station, overall_result, raw_data, source_file';
let _creds = null;
function loadCreds() {
if (_creds) return _creds;
if (!fs.existsSync(CREDS_PATH)) {
throw new Error(`creds file not found: ${CREDS_PATH}`);
}
_creds = JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8'));
for (const k of ['CF_TOKEN_URL','CF_API_BASE','CF_CLIENT_ID','CF_CLIENT_SECRET','CF_SCOPE']) {
if (!_creds[k]) throw new Error(`${CREDS_PATH} missing field ${k}`);
}
return _creds;
}
let _tok = { value: null, expiresAt: 0 };
function httpPost(uri, body, headers) {
return new Promise((resolve, reject) => {
const u = new URL(uri);
const req = https.request({
hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search,
method: 'POST',
headers: Object.assign({}, headers, {'Content-Length': Buffer.byteLength(body)}),
timeout: HTTP_TIMEOUT_MS,
}, res => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
try { resolve({status: res.statusCode, body: JSON.parse(data)}); }
catch (e) { resolve({status: res.statusCode, body: {_raw: data}}); }
});
});
req.on('error', reject);
req.on('timeout', () => req.destroy(new Error('http timeout')));
req.write(body);
req.end();
});
}
async function getToken(force = false) {
const c = loadCreds();
if (!force && _tok.value && Date.now() < _tok.expiresAt - TOKEN_LEEWAY_MS) {
return _tok.value;
}
const form = Object.entries({
grant_type: 'client_credentials',
client_id: c.CF_CLIENT_ID,
client_secret: c.CF_CLIENT_SECRET,
scope: c.CF_SCOPE,
}).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
const r = await httpPost(c.CF_TOKEN_URL, form, {'Content-Type': 'application/x-www-form-urlencoded'});
if (r.status !== 200 || !r.body.access_token) {
throw new Error(`token fetch failed: ${r.status} ${JSON.stringify(r.body).slice(0,200)}`);
}
_tok.value = r.body.access_token;
_tok.expiresAt = Date.now() + (r.body.expires_in || 3600) * 1000;
return _tok.value;
}
async function bulkPost(items) {
const c = loadCreds();
for (let attempt = 0; attempt < 2; attempt++) {
const tok = await getToken(attempt > 0);
try {
const r = await httpPost(
`${c.CF_API_BASE}/api/v1/TestReportDataFiles/bulk`,
JSON.stringify({Items: items}),
{'Authorization': `Bearer ${tok}`, 'Content-Type': 'application/json'},
);
if (r.status === 401 && attempt === 0) continue;
return r;
} catch (e) {
if (attempt === 0) { await new Promise(r => setTimeout(r, 5000)); continue; }
return {status: 0, body: {_error: e.message}};
}
}
}
async function stampConfirmed(items, errors) {
const badSns = new Set();
for (const e of (errors || [])) {
const matches = String(e).match(/\b\d+-\d+[A-Z]?\b/gi) || [];
for (const m of matches) badSns.add(m);
}
const confirmedSns = items.map(it => it.SerialNumber).filter(sn => !badSns.has(sn));
if (confirmedSns.length === 0) return;
try {
const placeholders = confirmedSns.map((_, j) => `$${j + 1}`).join(',');
await db.execute(
`UPDATE test_records SET api_uploaded_at = NOW() WHERE serial_number IN (${placeholders})`,
confirmedSns,
);
} catch (e) {
console.error(`[API-UPLOAD] stamp failed: ${e.message}`);
}
}
async function uploadRecords(records, result) {
const t0 = Date.now();
for (let i = 0; i < records.length; i += BATCH) {
const chunk = records.slice(i, i + BATCH);
const items = [];
for (const r of chunk) {
let content;
try {
content = renderContent(r);
} catch (e) {
console.error(`[API-UPLOAD] render fail ${r.serial_number}: ${e.message}`);
result.errors++;
continue;
}
if (!content) { result.skipped++; continue; }
items.push({ SerialNumber: r.serial_number, Content: content });
}
if (items.length === 0) continue;
let resp;
try { resp = await bulkPost(items); }
catch (e) {
console.error(`[API-UPLOAD] batch threw: ${e.message}`);
result.errors += items.length;
continue;
}
if (resp.status !== 200) {
console.error(`[API-UPLOAD] HTTP ${resp.status}: ${JSON.stringify(resp.body).slice(0,200)}`);
result.errors += items.length;
continue;
}
result.created += resp.body.Created || 0;
result.updated += resp.body.Updated || 0;
result.unchanged += resp.body.Unchanged || 0;
result.errors += (resp.body.Errors || []).length;
await stampConfirmed(items, resp.body.Errors);
}
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
console.log(`[API-UPLOAD] done in ${elapsed}s: created=${result.created} updated=${result.updated} unchanged=${result.unchanged} errors=${result.errors} skipped=${result.skipped}`);
}
/**
* Post-import upload. Non-throwing — upload failure must not wedge the import flow.
* @param {string[]} filePaths - source_file values from the just-completed import
*/
async function uploadNewRecords(filePaths) {
const result = {created: 0, updated: 0, unchanged: 0, errors: 0, skipped: 0};
try {
if (!filePaths || filePaths.length === 0) return result;
if (!fs.existsSync(CREDS_PATH)) {
console.log(`[API-UPLOAD] credentials not configured (${CREDS_PATH}); skipping`);
return result;
}
const placeholders = filePaths.map((_, i) => `$${i + 1}`).join(',');
const records = await db.query(
`SELECT ${RECORD_COLUMNS} FROM test_records WHERE overall_result = 'PASS' AND source_file IN (${placeholders})`,
filePaths,
);
if (records.length === 0) {
console.log('[API-UPLOAD] no records eligible for upload');
return result;
}
console.log(`[API-UPLOAD] ${records.length} records to upload`);
await uploadRecords(records, result);
} catch (e) {
console.error(`[API-UPLOAD] fatal: ${e.message}`);
}
return result;
}
/**
* Upload records identified by serial numbers. Called by /api/upload for
* per-record and bulk UI pushes. Throws on hard failures so the endpoint
* can return 500.
*/
async function uploadBySerialNumbers(sns) {
const result = {created: 0, updated: 0, unchanged: 0, errors: 0, skipped: 0};
if (!sns || sns.length === 0) return result;
if (!fs.existsSync(CREDS_PATH)) {
throw new Error(`credentials not configured (${CREDS_PATH})`);
}
const placeholders = sns.map((_, i) => `$${i + 1}`).join(',');
const records = await db.query(
`SELECT ${RECORD_COLUMNS} FROM test_records WHERE serial_number IN (${placeholders}) AND overall_result = 'PASS'`,
sns,
);
if (records.length === 0) return result;
await uploadRecords(records, result);
return result;
}
module.exports = { uploadNewRecords, uploadBySerialNumbers };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,552 @@
/**
* API Routes for Test Data Database
*
* 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 db = require('../database/db');
const { generateDatasheet } = require('../templates/datasheet');
const router = express.Router();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const MAX_LIMIT = 1000;
function clampLimit(value) {
const parsed = parseInt(value, 10);
if (isNaN(parsed) || parsed < 1) return 100;
return Math.min(parsed, MAX_LIMIT);
}
function clampOffset(value) {
const parsed = parseInt(value, 10);
if (isNaN(parsed) || parsed < 0) return 0;
return parsed;
}
// ---------------------------------------------------------------------------
// GET /api/search
// Search test records
// Query params: serial, model, from, to, result, q, station, logtype, web_status, limit, offset
// ---------------------------------------------------------------------------
router.get('/search', async (req, res) => {
try {
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);
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) {
const val = serial.includes('%') ? serial : `%${serial}%`;
conditions.push(`serial_number LIKE ${addParam(val)}`);
}
if (workorder) {
conditions.push(`work_order = ${addParam(workorder)}`);
}
if (model) {
const val = model.includes('%') ? model : `%${model}%`;
conditions.push(`model_number LIKE ${addParam(val)}`);
}
if (from) {
conditions.push(`test_date >= ${addParam(from)}`);
}
if (to) {
conditions.push(`test_date <= ${addParam(to)}`);
}
if (result) {
conditions.push(`overall_result = ${addParam(result.toUpperCase())}`);
}
if (station) {
conditions.push(`test_station = ${addParam(station)}`);
}
if (logtype) {
conditions.push(`log_type = ${addParam(logtype)}`);
}
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 where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
const dataSql = `SELECT * FROM test_records ${where} ORDER BY test_date DESC, serial_number 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
const [records, countRow] = await Promise.all([
db.query(dataSql, params),
db.queryOne(countSql, countParams),
]);
res.json({
records,
total: countRow?.count ? parseInt(countRow.count, 10) : records.length,
limit,
offset
});
} catch (err) {
console.error(`[${new Date().toISOString()}] [SEARCH ERROR] ${err.message}`);
res.status(500).json({ error: err.message });
}
});
// ---------------------------------------------------------------------------
// GET /api/record/:id
// Get single record by ID
// ---------------------------------------------------------------------------
router.get('/record/:id', 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' });
}
res.json(record);
} catch (err) {
console.error(`[${new Date().toISOString()}] [RECORD ERROR] ${err.message}`);
res.status(500).json({ error: err.message });
}
});
// ---------------------------------------------------------------------------
// GET /api/datasheet/:id
// Generate datasheet for a record
// Query params: format (html, txt)
// ---------------------------------------------------------------------------
router.get('/datasheet/:id', 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 format = req.query.format || 'html';
// 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 {
// 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}`);
res.status(500).json({ error: err.message });
}
});
// ---------------------------------------------------------------------------
// 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', async (req, res) => {
try {
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({
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 });
}
});
// ---------------------------------------------------------------------------
// GET /api/filters
// Get available filter options (test stations, log types, models)
// ---------------------------------------------------------------------------
router.get('/filters', async (req, res) => {
try {
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({
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 });
}
});
// ---------------------------------------------------------------------------
// GET /api/export
// Export search results as CSV
// ---------------------------------------------------------------------------
router.get('/export', async (req, res) => {
try {
const { serial, model, from, to, result, station, logtype } = req.query;
const conditions = [];
const params = [];
let paramIdx = 0;
const addParam = (val) => {
paramIdx++;
params.push(val);
return '$' + paramIdx;
};
if (serial) {
const val = serial.includes('%') ? serial : `%${serial}%`;
conditions.push(`serial_number LIKE ${addParam(val)}`);
}
if (model) {
const val = model.includes('%') ? model : `%${model}%`;
conditions.push(`model_number LIKE ${addParam(val)}`);
}
if (from) {
conditions.push(`test_date >= ${addParam(from)}`);
}
if (to) {
conditions.push(`test_date <= ${addParam(to)}`);
}
if (result) {
conditions.push(`overall_result = ${addParam(result.toUpperCase())}`);
}
if (station) {
conditions.push(`test_station = ${addParam(station)}`);
}
if (logtype) {
conditions.push(`log_type = ${addParam(logtype)}`);
}
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 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'];
let csv = headers.join(',') + '\n';
for (const record of records) {
const row = headers.map(h => {
const val = record[h] || '';
return `"${String(val).replace(/"/g, '""')}"`;
});
csv += row.join(',') + '\n';
}
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=test_records.csv');
res.send(csv);
} catch (err) {
console.error(`[${new Date().toISOString()}] [EXPORT ERROR] ${err.message}`);
res.status(500).json({ error: err.message });
}
});
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
async function cleanup() {
try {
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;