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:
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user