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>
310 lines
12 KiB
JavaScript
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 };
|