Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
276 lines
8.9 KiB
JavaScript
276 lines
8.9 KiB
JavaScript
// tplink-session.js — Playwright-based TP-Link ONU session manager
|
|
// Uses headless Chromium because TP-Link XX230v GDPR_ENCRYPT requires
|
|
// the modem's own JS to handle RSA/AES key exchange natively.
|
|
|
|
const { chromium } = require('playwright');
|
|
|
|
const sessions = new Map();
|
|
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
const LOGIN_TIMEOUT_MS = 15 * 1000;
|
|
const NAV_TIMEOUT_MS = 20 * 1000;
|
|
|
|
let browser = null;
|
|
|
|
async function getBrowser() {
|
|
if (!browser || !browser.isConnected()) {
|
|
browser = await chromium.launch({
|
|
headless: true,
|
|
args: [
|
|
'--no-sandbox', '--disable-setuid-sandbox',
|
|
'--disable-dev-shm-usage', '--disable-gpu',
|
|
'--ignore-certificate-errors', '--disable-web-security',
|
|
]
|
|
});
|
|
console.log('[modem-bridge] Chromium launched');
|
|
}
|
|
return browser;
|
|
}
|
|
|
|
function resetIdleTimer(ip) {
|
|
const s = sessions.get(ip); if (!s) return;
|
|
clearTimeout(s.idleTimer);
|
|
s.idleTimer = setTimeout(() => {
|
|
console.log(`[modem-bridge] Session expired: ${ip}`);
|
|
closeSession(ip);
|
|
}, IDLE_TIMEOUT_MS);
|
|
}
|
|
|
|
function getSession(ip) {
|
|
const s = sessions.get(ip);
|
|
if (!s || !s.loggedIn) throw new Error(`No active session for ${ip}`);
|
|
s.lastActivity = Date.now();
|
|
resetIdleTimer(ip);
|
|
return s;
|
|
}
|
|
|
|
async function login(ip, user, pass, path = '/superadmin') {
|
|
if (sessions.has(ip)) await closeSession(ip);
|
|
|
|
const b = await getBrowser();
|
|
const context = await b.newContext({
|
|
ignoreHTTPSErrors: true,
|
|
viewport: { width: 1280, height: 720 },
|
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
});
|
|
|
|
const page = await context.newPage();
|
|
const url = `https://${ip}${path}`;
|
|
|
|
await page.goto(url, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS });
|
|
await page.waitForSelector('#pc-login-password', { timeout: LOGIN_TIMEOUT_MS });
|
|
|
|
const hasUsername = await page.evaluate(() => {
|
|
const el = document.getElementById('pc-login-user-div');
|
|
return el && !el.classList.contains('nd');
|
|
});
|
|
|
|
if (hasUsername) await page.fill('#pc-login-user', user);
|
|
await page.fill('#pc-login-password', pass);
|
|
await page.waitForTimeout(300);
|
|
|
|
const navigationPromise = page.waitForNavigation({
|
|
waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS
|
|
}).catch(() => null);
|
|
|
|
await page.click('#pc-login-btn');
|
|
await navigationPromise;
|
|
await page.waitForTimeout(2000);
|
|
|
|
// Handle force-logout dialog if another session was active
|
|
const forceLogoutBtn = await page.$('#confirm-yes');
|
|
if (forceLogoutBtn && await forceLogoutBtn.isVisible()) {
|
|
console.log(`[modem-bridge] Force logout dialog for ${ip}, confirming...`);
|
|
const navP2 = page.waitForNavigation({ waitUntil: 'networkidle', timeout: LOGIN_TIMEOUT_MS }).catch(() => null);
|
|
await forceLogoutBtn.click();
|
|
await navP2;
|
|
await page.waitForTimeout(3000);
|
|
}
|
|
|
|
const loginResult = await page.evaluate(() => {
|
|
const loginDiv = document.getElementById('pc-login');
|
|
if (loginDiv && !loginDiv.classList.contains('nd')) {
|
|
const errEl = document.querySelector('.errDiv .errText, .errDivP .errTextP');
|
|
return { success: false, error: errEl ? errEl.textContent.trim() : 'Login failed - still on login page' };
|
|
}
|
|
return { success: true, hasDm: typeof $ !== 'undefined' && $ && $.dm && typeof $.dm.get === 'function' };
|
|
});
|
|
|
|
if (!loginResult.success) {
|
|
await context.close();
|
|
throw new Error(`Login failed for ${ip}: ${loginResult.error}`);
|
|
}
|
|
|
|
const session = {
|
|
ip, context, page,
|
|
loggedIn: true,
|
|
loginTime: Date.now(),
|
|
lastActivity: Date.now(),
|
|
hasDm: loginResult.hasDm
|
|
};
|
|
|
|
sessions.set(ip, session);
|
|
resetIdleTimer(ip);
|
|
|
|
console.log(`[modem-bridge] Logged in to ${ip} (hasDm: ${loginResult.hasDm})`);
|
|
return { ip, loggedIn: true, loginTime: session.loginTime, hasDm: loginResult.hasDm };
|
|
}
|
|
|
|
async function dmGet(ip, oid, opts = {}) {
|
|
const s = getSession(ip);
|
|
const stack = opts.stack || '0,0,0,0,0,0';
|
|
const attrs = opts.attrs || null;
|
|
|
|
return await s.page.evaluate(({ oid, stack, attrs }) => {
|
|
return new Promise((resolve, reject) => {
|
|
if (!$ || !$.dm || !$.dm.get) { reject(new Error('Data manager not available')); return; }
|
|
|
|
const req = {
|
|
oid,
|
|
data: { stack },
|
|
callback: {
|
|
success: (data, others) => resolve({ success: true, data, others }),
|
|
fail: (errorcode, others, data) => resolve({ success: false, errorcode, data }),
|
|
error: (status) => reject(new Error('Request error: ' + status))
|
|
}
|
|
};
|
|
if (attrs) req.data.attrs = attrs;
|
|
|
|
try { $.dm.get(req); } catch(e) { reject(e); }
|
|
setTimeout(() => reject(new Error('DM request timeout')), 8000);
|
|
});
|
|
}, { oid, stack, attrs });
|
|
}
|
|
|
|
async function cgiRequest(ip, oidPath) {
|
|
const s = getSession(ip);
|
|
|
|
return await s.page.evaluate((oidPath) => {
|
|
return new Promise((resolve, reject) => {
|
|
if (!$ || !$.dm || !$.dm.cgi) { reject(new Error('CGI not available')); return; }
|
|
|
|
$.dm.cgi({
|
|
oid: oidPath,
|
|
callback: {
|
|
success: (data) => resolve({ success: true, data }),
|
|
fail: (errorcode) => resolve({ success: false, errorcode })
|
|
}
|
|
});
|
|
setTimeout(() => reject(new Error('CGI request timeout')), 8000);
|
|
});
|
|
}, oidPath);
|
|
}
|
|
|
|
async function getStatus(ip) {
|
|
const s = getSession(ip);
|
|
|
|
return await s.page.evaluate(() => {
|
|
const status = {};
|
|
try { if (typeof deviceInfo !== 'undefined') status.deviceInfo = deviceInfo; } catch(e) {}
|
|
try { if ($ && $.status) status.cached = $.status; } catch(e) {}
|
|
try {
|
|
status.dom = {};
|
|
document.querySelectorAll('[id*="status"], [id*="info"], [class*="status"]').forEach(el => {
|
|
if (el.textContent.trim().length < 200) status.dom[el.id || el.className] = el.textContent.trim();
|
|
});
|
|
} catch(e) {}
|
|
return status;
|
|
});
|
|
}
|
|
|
|
async function screenshot(ip) {
|
|
const s = getSession(ip);
|
|
return await s.page.screenshot({ type: 'png', fullPage: false });
|
|
}
|
|
|
|
async function wifiDiagnostic(ip) {
|
|
const s = getSession(ip);
|
|
|
|
return await s.page.evaluate(() => {
|
|
const start = Date.now();
|
|
|
|
// Factory for $.dm query calls — handles both getSubList and get
|
|
function dmQuery(method, oid, extraData) {
|
|
return new Promise((resolve) => {
|
|
if (!$ || !$.dm || !$.dm[method]) { resolve({ oid, error: `${method} not available` }); return; }
|
|
const req = {
|
|
oid,
|
|
callback: {
|
|
success: (data) => resolve({ oid, data }),
|
|
fail: (code) => resolve({ oid, error: code }),
|
|
}
|
|
};
|
|
if (extraData) req.data = extraData;
|
|
$.dm[method](req);
|
|
setTimeout(() => resolve({ oid, error: 'timeout' }), 8000);
|
|
});
|
|
}
|
|
|
|
return Promise.allSettled([
|
|
dmQuery('getSubList', 'DEV2_WIFI_RADIO'),
|
|
dmQuery('getSubList', 'DEV2_WIFI_APDEV'),
|
|
dmQuery('getSubList', 'DEV2_WIFI_APDEV_RADIO'),
|
|
dmQuery('getSubList', 'DEV2_WIFI_APDEV_ASSOCDEV'),
|
|
dmQuery('getSubList', 'DEV2_WIFI_DE_STA'),
|
|
dmQuery('getSubList', 'DEV2_WIFI_APDEV_STATS'),
|
|
dmQuery('getSubList', 'DEV2_IP_INTF_V4ADDR'),
|
|
dmQuery('get', 'DEV2_ONLINESTATUS', { stack: '0,0,0,0,0,0' }),
|
|
]).then(results => {
|
|
const keys = ['radios', 'meshNodes', 'nodeRadios', 'clients', 'clientStats', 'packetStats', 'wanIPs', 'onlineStatus'];
|
|
const out = { fetchedAt: new Date().toISOString(), durationMs: Date.now() - start };
|
|
results.forEach((r, i) => {
|
|
const val = r.status === 'fulfilled' ? r.value : { error: r.reason };
|
|
out[keys[i]] = val.error ? { error: val.error } : val.data;
|
|
});
|
|
return out;
|
|
});
|
|
});
|
|
}
|
|
|
|
async function evaluate(ip, code) {
|
|
const s = getSession(ip);
|
|
return await s.page.evaluate(new Function('return (' + code + ')'));
|
|
}
|
|
|
|
async function getPageContent(ip) {
|
|
const s = getSession(ip);
|
|
return await s.page.content();
|
|
}
|
|
|
|
async function navigate(ip, path) {
|
|
const s = getSession(ip);
|
|
await s.page.goto(`https://${ip}${path}`, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT_MS });
|
|
}
|
|
|
|
async function closeSession(ip) {
|
|
const s = sessions.get(ip);
|
|
if (!s) return;
|
|
clearTimeout(s.idleTimer);
|
|
try { await s.context.close(); }
|
|
catch(e) { console.warn(`[modem-bridge] Error closing session for ${ip}:`, e.message); }
|
|
sessions.delete(ip);
|
|
console.log(`[modem-bridge] Session closed for ${ip}`);
|
|
}
|
|
|
|
async function shutdown() {
|
|
for (const [ip] of sessions) await closeSession(ip);
|
|
if (browser) { await browser.close(); browser = null; }
|
|
console.log('[modem-bridge] Shutdown complete');
|
|
}
|
|
|
|
function listSessions() {
|
|
const result = [];
|
|
for (const [ip, s] of sessions) {
|
|
result.push({
|
|
ip, loggedIn: s.loggedIn, loginTime: s.loginTime,
|
|
lastActivity: s.lastActivity, idleMs: Date.now() - s.lastActivity, hasDm: s.hasDm
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
module.exports = {
|
|
login, dmGet, cgiRequest, getStatus, wifiDiagnostic,
|
|
evaluate, screenshot, getPageContent, navigate,
|
|
closeSession, shutdown, listSessions
|
|
};
|