gigafibre-fsm/services/targo-hub/lib/modem-bridge.js
louispaulb 41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request.

Flow editor (new):
- Generic visual editor for step trees, usable by project wizard + agent flows
- PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain
- Drag-and-drop reorder via vuedraggable with scope isolation per peer group
- Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved)
- Variable picker with per-applies_to catalog (Customer / Quotation /
  Service Contract / Issue / Subscription), insert + copy-clipboard modes
- trigger_condition helper with domain-specific JSONLogic examples
- Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern
- Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js
- ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates
- depends_on chips resolve to step labels instead of opaque "s4" ids

QR/OCR scanner (field app):
- Camera capture → Gemini Vision via targo-hub with 8s timeout
- IndexedDB offline queue retries photos when signal returns
- Watcher merges late-arriving scan results into the live UI

Dispatch:
- Planning mode (draft → publish) with offer pool for unassigned jobs
- Shared presets, recurrence selector, suggested-slots dialog
- PublishScheduleModal, unassign confirmation

Ops app:
- ClientDetailPage composables extraction (useClientData, useDeviceStatus,
  useWifiDiagnostic, useModemDiagnostic)
- Project wizard: shared detail sections, wizard catalog/publish composables
- Address pricing composable + pricing-mock data
- Settings redesign hosting flow templates

Targo-hub:
- Contract acceptance (JWT residential + DocuSeal commercial tracks)
- Referral system
- Modem-bridge diagnostic normalizer
- Device extractors consolidated

Migration scripts:
- Invoice/quote print format setup, Jinja rendering
- Additional import + fix scripts (reversals, dates, customers, payments)

Docs:
- Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS,
  FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT,
  APP_DESIGN_GUIDELINES
- Archived legacy wizard PHP for reference
- STATUS snapshots for 2026-04-18/19

Cleanup:
- Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*)
- .gitignore now covers invoice preview output + nested .DS_Store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:44:17 -04:00

310 lines
12 KiB
JavaScript

/**
* modem-bridge.js — Client for the modem-bridge Playwright service
*
* DEPENDENCY: modem-bridge container (port 3301 on Docker proxy network)
*
* Provides targo-hub with access to TP-Link ONU web GUI data
* by proxying requests to the modem-bridge headless Chromium service.
*
* ENDPOINTS EXPOSED (on targo-hub, port 3300):
* GET /modem/status?ip=X&user=Y&pass=Z — Full status (auto-login)
* GET /modem/dm/:oid?ip=X&user=Y&pass=Z — Raw DM read
* GET /modem/diagnostic?ip=X&user=Y&pass=Z — Full WiFi/mesh/WAN diagnostic
* GET /modem/screenshot?ip=X&user=Y&pass=Z — PNG screenshot
* GET /modem/sessions — List active sessions
* POST /modem/close — Close a session
*/
const http = require('http');
const { log, json: jsonResp, parseBody, erpFetch } = require('./helpers');
const BRIDGE_HOST = process.env.BRIDGE_HOST || 'modem-bridge';
const BRIDGE_PORT = parseInt(process.env.BRIDGE_PORT || '3301');
const BRIDGE_TOKEN = process.env.BRIDGE_TOKEN || '';
const DEFAULT_MODEM_USER = 'superadmin';
const DEFAULT_MODEM_PASS = process.env.DEFAULT_MODEM_PASS || '';
// Concurrency limiter for Playwright sessions (512MB container limit)
const MAX_CONCURRENT = 3;
let activeCount = 0;
const waitQueue = [];
function withConcurrencyLimit(fn) {
return new Promise((resolve, reject) => {
function run() {
activeCount++;
fn().then(resolve).catch(reject).finally(() => {
activeCount--;
if (waitQueue.length > 0) waitQueue.shift()();
});
}
if (activeCount >= MAX_CONCURRENT) {
waitQueue.push(run);
} else {
run();
}
});
}
// Diagnostic result cache (in-memory, TTL-based)
const diagCache = new Map();
const DIAG_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Lookup equipment credentials from ERPNext by serial number
*/
async function getEquipmentCredentials(serial) {
// Find equipment by serial
const filters = encodeURIComponent(JSON.stringify([['serial_number', '=', serial]]));
const fields = encodeURIComponent(JSON.stringify(['name', 'ip_address', 'login_user', 'brand', 'model', 'serial_number']));
const res = await erpFetch(`/api/resource/Service Equipment?filters=${filters}&fields=${fields}&limit_page_length=1`);
if (res.status !== 200 || !res.data?.data?.length) return null;
const eq = res.data.data[0];
// Get password via Frappe's password API
let pass = DEFAULT_MODEM_PASS;
try {
const passRes = await erpFetch(`/api/method/frappe.client.get_password?doctype=Service%20Equipment&name=${encodeURIComponent(eq.name)}&fieldname=login_password`);
if (passRes.status === 200 && passRes.data?.message) {
pass = passRes.data.message;
}
} catch (e) {
log('getEquipmentCredentials: password fetch failed for', eq.name, e.message);
}
return {
ip: (typeof eq.ip_address === 'string' && eq.ip_address.trim()) ? eq.ip_address.trim() : null,
user: eq.login_user || DEFAULT_MODEM_USER,
pass,
brand: eq.brand,
model: eq.model,
serial: eq.serial_number,
equipmentName: eq.name,
};
}
/**
* Make HTTP request to modem-bridge service
*/
function bridgeRequest(method, path, body = null) {
return new Promise((resolve, reject) => {
const opts = {
hostname: BRIDGE_HOST,
port: BRIDGE_PORT,
path,
method,
headers: {
'Content-Type': 'application/json',
}
};
if (BRIDGE_TOKEN) {
opts.headers['Authorization'] = `Bearer ${BRIDGE_TOKEN}`;
}
const bodyStr = body ? JSON.stringify(body) : '';
if (body) opts.headers['Content-Length'] = Buffer.byteLength(bodyStr);
const req = http.request(opts, res => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
try {
// Screenshots return binary
if (res.headers['content-type']?.includes('image/png')) {
resolve({ status: res.statusCode, body: Buffer.from(data, 'binary'), isPng: true });
} else {
resolve({ status: res.statusCode, body: JSON.parse(data) });
}
} catch(e) {
resolve({ status: res.statusCode, body: data });
}
});
});
req.on('error', reject);
if (body) req.write(bodyStr);
req.end();
});
}
/**
* Ensure a session exists for the given modem, login if needed
*/
async function ensureSession(ip, user, pass, path) {
// Check if session already exists
const listRes = await bridgeRequest('GET', '/session/list');
if (listRes.status === 200 && Array.isArray(listRes.body)) {
const existing = listRes.body.find(s => s.ip === ip && s.loggedIn);
if (existing) return existing;
}
// Login
const loginRes = await bridgeRequest('POST', '/session/login', {
ip, user, pass, path: path || '/superadmin'
});
if (loginRes.status !== 200) {
throw new Error(`Modem login failed: ${loginRes.body?.error || 'Unknown error'}`);
}
return loginRes.body;
}
/**
* Route handler for targo-hub
*/
async function handleModemRequest(req, res, path) {
try {
// GET /modem/sessions — list active sessions
if (path === '/modem/sessions' && req.method === 'GET') {
const result = await bridgeRequest('GET', '/session/list');
return jsonResp(res, result.status, result.body);
}
// POST /modem/close — close a session
if (path === '/modem/close' && req.method === 'POST') {
const body = await parseBody(req);
if (!body.ip) return jsonResp(res, 400, { error: 'Missing ip' });
const result = await bridgeRequest('DELETE', `/session/${body.ip}`);
return jsonResp(res, result.status, result.body);
}
// GET /modem/health — bridge health (no auth needed)
if (path === '/modem/health' && req.method === 'GET') {
const result = await bridgeRequest('GET', '/health');
return jsonResp(res, result.status, result.body);
}
// GET /modem/identify?ip=X — auto-detect modem type (no login needed)
if (path === '/modem/identify' && req.method === 'GET') {
const url = new URL(req.url, `http://${req.headers.host}`);
const ip = url.searchParams.get('ip');
if (!ip) return jsonResp(res, 400, { error: 'Missing ip param' });
const result = await bridgeRequest('GET', `/identify/${ip}`);
return jsonResp(res, result.status, result.body);
}
// GET /modem/manage-ip?serial=X — get management IP from OLT via SNMP
// Optionally pass olt_ip, slot, port, ontid if not in SNMP cache
if (path === '/modem/manage-ip' && req.method === 'GET') {
const url = new URL(req.url, `http://${req.headers.host}`);
const serial = url.searchParams.get('serial');
const opts = {};
if (url.searchParams.get('olt_ip')) opts.oltIp = url.searchParams.get('olt_ip');
if (url.searchParams.get('slot')) opts.slot = parseInt(url.searchParams.get('slot'));
if (url.searchParams.get('port')) opts.port = parseInt(url.searchParams.get('port'));
if (url.searchParams.get('ontid')) opts.ontId = parseInt(url.searchParams.get('ontid'));
try {
const { getManageIp } = require('./olt-snmp');
const result = await getManageIp(serial, opts);
if (!result) return jsonResp(res, 404, { error: 'No management IP found' });
return jsonResp(res, 200, result);
} catch(e) {
return jsonResp(res, 502, { error: e.message });
}
}
// GET /modem/diagnostic/auto?serial=X — auto-fetch credentials from ERPNext, run diagnostic
if (path === '/modem/diagnostic/auto' && req.method === 'GET') {
const url = new URL(req.url, `http://${req.headers.host}`);
const serial = url.searchParams.get('serial');
if (!serial) return jsonResp(res, 400, { error: 'Missing serial param' });
// Check cache first
const cached = diagCache.get(serial);
if (cached && (Date.now() - cached.ts) < DIAG_CACHE_TTL) {
return jsonResp(res, 200, { ...cached.data, cached: true });
}
// Lookup credentials from ERPNext
const creds = await getEquipmentCredentials(serial);
if (!creds) return jsonResp(res, 404, { error: 'Equipment not found in ERPNext', serial });
// Resolve management IP from OLT SNMP if not stored on equipment
let ip = creds.ip;
if (!ip) {
try {
const { getManageIp } = require('./olt-snmp');
const mgmt = await getManageIp(serial);
if (mgmt) ip = mgmt.manageIp || (typeof mgmt === 'string' ? mgmt : null);
} catch (e) {
log('diagnostic/auto: OLT IP resolve failed for', serial, e.message);
}
}
if (!ip || typeof ip !== 'string') return jsonResp(res, 404, { error: 'No management IP found', serial });
if (!creds.pass) return jsonResp(res, 400, { error: 'No password configured for equipment', serial, equipment: creds.equipmentName });
log('diagnostic/auto: running for', serial, 'ip:', ip, 'user:', creds.user);
// Stateless oneshot: login → scrape → logout → close (no session reuse)
const result = await withConcurrencyLimit(async () => {
const diag = await bridgeRequest('POST', '/diagnostic/oneshot', {
ip: String(ip), user: creds.user, pass: creds.pass,
});
if (diag.status !== 200) throw new Error(diag.body?.error || `Bridge returned ${diag.status}`);
return diag.body;
});
// Cache result
diagCache.set(serial, { data: result, ts: Date.now() });
return jsonResp(res, 200, result);
}
// All other /modem/* routes need ip, user, pass
const url = new URL(req.url, `http://${req.headers.host}`);
const ip = url.searchParams.get('ip');
const user = url.searchParams.get('user') || 'superadmin';
const pass = url.searchParams.get('pass');
const loginPath = url.searchParams.get('path') || '/superadmin';
if (!ip || !pass) {
return jsonResp(res, 400, { error: 'Missing required params: ip, pass' });
}
// Ensure session exists
await ensureSession(ip, user, pass, loginPath);
// GET /modem/status — full status
if (path === '/modem/status' && req.method === 'GET') {
const result = await bridgeRequest('GET', `/modem/${ip}/status`);
return jsonResp(res, result.status, result.body);
}
// GET /modem/dm/:oid — raw data manager read
const dmMatch = path.match(/^\/modem\/dm\/(.+)$/);
if (dmMatch && req.method === 'GET') {
const oid = dmMatch[1];
let queryStr = `?`;
if (url.searchParams.get('stack')) queryStr += `stack=${url.searchParams.get('stack')}&`;
if (url.searchParams.get('attrs')) queryStr += `attrs=${url.searchParams.get('attrs')}&`;
const result = await bridgeRequest('GET', `/modem/${ip}/dm/${oid}${queryStr}`);
return jsonResp(res, result.status, result.body);
}
// GET /modem/diagnostic — unified diagnostic via stateless oneshot
if (path === '/modem/diagnostic' && req.method === 'GET') {
const result = await bridgeRequest('POST', '/diagnostic/oneshot', { ip, user, pass });
return jsonResp(res, result.status, result.body);
}
// GET /modem/screenshot — PNG
if (path === '/modem/screenshot' && req.method === 'GET') {
const result = await bridgeRequest('GET', `/modem/${ip}/screenshot`);
if (result.isPng) {
res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': result.body.length });
return res.end(result.body);
}
return jsonResp(res, result.status, result.body);
}
return jsonResp(res, 404, { error: 'Unknown modem endpoint' });
} catch(e) {
log('modem-bridge error:', e.message);
return jsonResp(res, 502, { error: e.message });
}
}
module.exports = { handleModemRequest };