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>
638 lines
22 KiB
JavaScript
638 lines
22 KiB
JavaScript
// diagnostic-normalizer.js — Normalizes vendor-specific diagnostic data into a unified model
|
|
// Supports: TP-Link XX230v ($.dm), Raisecom BOA (ASP), Raisecom PHP
|
|
'use strict'
|
|
|
|
// TP-Link MAC OUI prefixes (first 3 octets, uppercase with colons)
|
|
const TPLINK_OUIS = new Set([
|
|
'30:DE:4B', '98:25:4A', 'E8:48:B8', '50:C7:BF', 'A8:42:A1', '60:32:B1',
|
|
'B0:BE:76', 'C0:06:C3', 'F4:F2:6D', 'D8:07:B6', '6C:5A:B0', '14:EB:B6',
|
|
'A8:29:48', '78:20:51', '3C:64:CF', '0C:EF:15', 'E4:FA:C4', 'E8:9C:25',
|
|
'5C:A6:E6', '54:AF:97', '1C:3B:F3', 'B4:B0:24', 'EC:41:18', '68:FF:7B',
|
|
'AC:15:A2', '88:C3:97', '34:60:F9', '98:DA:C4', '40:ED:00', 'CC:32:E5',
|
|
])
|
|
|
|
const MESH_HOSTNAME_PATTERNS = [
|
|
{ re: /deco\s*m4/i, model: 'Deco M4' },
|
|
{ re: /deco\s*m5/i, model: 'Deco M5' },
|
|
{ re: /deco\s*x20/i, model: 'Deco X20' },
|
|
{ re: /deco\s*x50/i, model: 'Deco X50' },
|
|
{ re: /deco/i, model: 'Deco' },
|
|
{ re: /hx220/i, model: 'HX220' },
|
|
{ re: /hx230/i, model: 'HX230' },
|
|
{ re: /tp-?link/i, model: 'TP-Link' },
|
|
{ re: /extender/i, model: 'Extender' },
|
|
{ re: /repeater/i, model: 'Repeater' },
|
|
]
|
|
|
|
function macOui(mac) {
|
|
if (!mac) return ''
|
|
return mac.replace(/[-]/g, ':').toUpperCase().substring(0, 8)
|
|
}
|
|
|
|
/**
|
|
* Detect wired mesh repeaters from DHCP leases + WiFi client list.
|
|
* A host is wired if it's NOT in the wifiClients list (by MAC).
|
|
*/
|
|
function detectWiredEquipment(dhcpLeases, wifiClientMacs) {
|
|
const wifiSet = new Set((wifiClientMacs || []).map(m => m.toUpperCase().replace(/[-]/g, ':')))
|
|
const equipment = []
|
|
|
|
for (const lease of dhcpLeases) {
|
|
const mac = (lease.mac || '').toUpperCase().replace(/[-]/g, ':')
|
|
const hostname = (lease.hostname || '').toLowerCase()
|
|
const oui = macOui(mac)
|
|
const isWired = !wifiSet.has(mac)
|
|
|
|
lease.isWired = isWired
|
|
|
|
let match = null
|
|
let matchReason = null
|
|
|
|
// Check hostname patterns (most reliable)
|
|
for (const p of MESH_HOSTNAME_PATTERNS) {
|
|
if (p.re.test(hostname) || p.re.test(lease.hostname || '')) {
|
|
match = { model: p.model, type: 'mesh_repeater' }
|
|
matchReason = `hostname:${p.re.source}`
|
|
break
|
|
}
|
|
}
|
|
|
|
// Fallback: TP-Link OUI on a wired connection (probable mesh node)
|
|
if (!match && isWired && TPLINK_OUIS.has(oui)) {
|
|
match = { model: 'TP-Link device', type: 'possible_mesh_repeater' }
|
|
matchReason = `oui:${oui}`
|
|
}
|
|
|
|
if (match) {
|
|
lease.isMeshRepeater = true
|
|
equipment.push({
|
|
hostname: lease.hostname || '', mac: lease.mac, ip: lease.ip,
|
|
port: lease.interface || null,
|
|
model: match.model, type: match.type, matchReason,
|
|
})
|
|
} else {
|
|
lease.isMeshRepeater = false
|
|
}
|
|
}
|
|
return equipment
|
|
}
|
|
|
|
// --- Raisecom BOA normalizer ---
|
|
|
|
/**
|
|
* Parse Raisecom BOA JavaScript data arrays from page HTML.
|
|
* Raisecom BOA embeds data as: var arr = []; arr.push(new it_nr("idx", new it("key", val), ...))
|
|
* Returns an array of objects with key-value pairs.
|
|
*/
|
|
function parseBoaArray(html, varName) {
|
|
const items = []
|
|
// Match full push line: varName.push(new it_nr("idx", new it("k1", v1), new it("k2", v2)));
|
|
// Use line-based matching to avoid nested parenthesis issues
|
|
const pushRe = new RegExp(varName + '\\.push\\(new it_nr\\((.+?)\\)\\);', 'g')
|
|
let m
|
|
while ((m = pushRe.exec(html)) !== null) {
|
|
const line = m[1]
|
|
const obj = {}
|
|
// Extract new it("key", value) pairs from the line
|
|
const itRe = /new it\("(\w+)",\s*("(?:[^"\\]|\\.)*"|[^,)]+)\)/g
|
|
let im
|
|
while ((im = itRe.exec(line)) !== null) {
|
|
let val = im[2].trim()
|
|
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1)
|
|
else if (!isNaN(val) && val !== '') val = Number(val)
|
|
obj[im[1]] = val
|
|
}
|
|
if (Object.keys(obj).length > 0) items.push(obj)
|
|
}
|
|
return items
|
|
}
|
|
|
|
/**
|
|
* Parse Raisecom BOA key-value tables from HTML.
|
|
* Structure: <tr><td>Label</td><td>Value</td></tr>
|
|
*/
|
|
function parseBoaKvTable(html) {
|
|
const kv = {}
|
|
// Collect KV pairs from ALL <tr><td>label</td><td>value</td></tr> patterns across the page
|
|
const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
|
|
let rm
|
|
while ((rm = rowRe.exec(html)) !== null) {
|
|
const cells = []
|
|
const cellRe = /<td[^>]*>([\s\S]*?)<\/td>/gi
|
|
let cm
|
|
while ((cm = cellRe.exec(rm[1])) !== null) {
|
|
cells.push(cm[1].replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim())
|
|
}
|
|
if (cells.length >= 2 && cells[0] && cells[1]) {
|
|
kv[cells[0]] = cells[1]
|
|
}
|
|
}
|
|
return kv
|
|
}
|
|
|
|
function normalizeRaisecomBoa(pages) {
|
|
const result = {
|
|
modemType: 'raisecom_boa',
|
|
online: null,
|
|
wanIPs: [],
|
|
ethernetPorts: [],
|
|
dhcpLeases: [],
|
|
wiredEquipment: [],
|
|
radios: [],
|
|
meshNodes: [], // Raisecom has no EasyMesh
|
|
wifiClients: [],
|
|
issues: [],
|
|
device: {},
|
|
gpon: {},
|
|
}
|
|
|
|
// --- Device basic info ---
|
|
if (pages.deviceInfo) {
|
|
const kv = parseBoaKvTable(pages.deviceInfo)
|
|
result.device = {
|
|
model: kv['Device Model'] || '',
|
|
serial: kv['Device Serial Number'] || '',
|
|
gponSn: kv['GPON SN'] || '',
|
|
hardware: kv['Hardware Version'] || '',
|
|
firmware: kv['Software Version'] || '',
|
|
versionDate: kv['Version Date'] || '',
|
|
cpu: kv['CPU Usage'] || '',
|
|
memory: kv['Memory Usage'] || '',
|
|
uptime: kv['UP Times'] || '',
|
|
}
|
|
}
|
|
|
|
// --- GPON optical info ---
|
|
if (pages.gpon) {
|
|
const kv = parseBoaKvTable(pages.gpon)
|
|
result.gpon = {
|
|
linkState: kv['Link State'] || '',
|
|
sn: kv['GPONSN'] || '',
|
|
txPower: kv['Tx Power'] || '',
|
|
rxPower: kv['Rx Power'] || '',
|
|
temperature: kv['Temperature'] || '',
|
|
voltage: kv['Voltage'] || '',
|
|
biasCurrent: kv['Bias Current'] || '',
|
|
}
|
|
// Check link state
|
|
const linkOk = result.gpon.linkState && result.gpon.linkState.includes('O5')
|
|
result.online = { ipv4: linkOk, ipv6: false, uptimeV4: 0 }
|
|
}
|
|
|
|
// --- WAN interfaces ---
|
|
if (pages.wan) {
|
|
const links = parseBoaArray(pages.wan, 'links')
|
|
for (const l of links) {
|
|
const ip = l.ipAddr || ''
|
|
const role = ip.startsWith('172.17.') || ip.startsWith('172.16.') ? 'management'
|
|
: ip.startsWith('10.') ? 'service'
|
|
: ip.startsWith('192.168.') ? 'lan'
|
|
: (!ip.startsWith('169.254.') && ip) ? 'internet'
|
|
: 'unknown'
|
|
result.wanIPs.push({
|
|
ip, mask: '', type: l.protocol || 'IPoE', role,
|
|
vlanId: l.vlanId, name: l.servName || '', status: l.strStatus || '',
|
|
gateway: l.gw || '', dns: l.DNS || '',
|
|
})
|
|
}
|
|
// Set online based on WAN status
|
|
const hasUpWan = links.some(l => l.strStatus === 'up' && l.ipAddr && !l.ipAddr.startsWith('10.') && !l.ipAddr.startsWith('172.'))
|
|
if (result.online) result.online.ipv4 = hasUpWan || result.online.ipv4
|
|
else result.online = { ipv4: hasUpWan, ipv6: false, uptimeV4: 0 }
|
|
}
|
|
|
|
// --- Ethernet ports + connected clients ---
|
|
if (pages.ethernet) {
|
|
const ethers = parseBoaArray(pages.ethernet, 'ethers')
|
|
const clients = parseBoaArray(pages.ethernet, 'clts')
|
|
|
|
for (const e of ethers) {
|
|
const portNum = parseInt(e.ifname?.replace(/\D/g, '') || '0')
|
|
const hasTraffic = (e.rx_packets || 0) > 0 || (e.tx_packets || 0) > 0
|
|
result.ethernetPorts.push({
|
|
port: portNum, label: e.ifname || `Port ${portNum}`,
|
|
status: hasTraffic ? 'Up' : 'Down',
|
|
speed: null, duplex: null,
|
|
stats: {
|
|
rxPackets: e.rx_packets || 0, rxBytes: e.rx_bytes || 0,
|
|
rxErrors: e.rx_errors || 0, rxDropped: e.rx_dropped || 0,
|
|
txPackets: e.tx_packets || 0, txBytes: e.tx_bytes || 0,
|
|
txErrors: e.tx_errors || 0, txDropped: e.tx_dropped || 0,
|
|
},
|
|
connectedDevice: null, // Will be cross-referenced with DHCP
|
|
})
|
|
}
|
|
|
|
// DHCP clients from Ethernet info page
|
|
for (const c of clients) {
|
|
result.dhcpLeases.push({
|
|
hostname: c.devname || '',
|
|
ip: c.ipAddr || '',
|
|
mac: c.macAddr || '',
|
|
expiry: c.liveTime ? parseInt(c.liveTime) : null,
|
|
interface: null, isWired: false, isMeshRepeater: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- WiFi clients ---
|
|
if (pages.wlan) {
|
|
const wlanClients = parseBoaArray(pages.wlan, 'clts') // Same var name
|
|
// Also check for other var names
|
|
const wlanClients2 = parseBoaArray(pages.wlan, 'clients')
|
|
const allWlan = [...wlanClients, ...wlanClients2]
|
|
|
|
for (const c of allWlan) {
|
|
result.wifiClients.push({
|
|
mac: c.macAddr || c.MACAddress || '',
|
|
hostname: '',
|
|
ip: '',
|
|
signal: c.rssi ? parseInt(c.rssi) : null,
|
|
band: c.bandWidth ? `${c.bandWidth}MHz` : '',
|
|
standard: '',
|
|
active: c.linkState === 'Assoc' || c.linkState === 'associated' || true,
|
|
linkDown: c.currRxRate ? parseInt(c.currRxRate) * 1000 : 0,
|
|
linkUp: c.currTxRate ? parseInt(c.currTxRate) * 1000 : 0,
|
|
lossPercent: 0,
|
|
meshNode: '',
|
|
})
|
|
}
|
|
|
|
// Extract radio info from the wlan page too
|
|
const radios = parseBoaArray(pages.wlan, 'ethers') // Sometimes reused var name
|
|
// Fallback: parse KV from the wlan basic config if available
|
|
if (pages.wlanConfig) {
|
|
const kv = parseBoaKvTable(pages.wlanConfig)
|
|
if (kv['Channel'] || kv['Band']) {
|
|
result.radios.push({
|
|
band: kv['Band'] || '2.4GHz',
|
|
channel: parseInt(kv['Channel']) || 0,
|
|
bandwidth: kv['Channel Width'] || kv['Bandwidth'] || '',
|
|
standard: kv['Mode'] || '',
|
|
txPower: 0, autoChannel: false, status: 'Up',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Detect wired equipment (mesh repeaters)
|
|
const wifiMacs = result.wifiClients.map(c => c.mac)
|
|
result.wiredEquipment = detectWiredEquipment(result.dhcpLeases, wifiMacs)
|
|
|
|
// Cross-reference Ethernet ports with DHCP clients
|
|
// (BOA doesn't tell us which port each client is on, but we flag wired devices)
|
|
|
|
return result
|
|
}
|
|
|
|
// --- Raisecom PHP (WS2) normalizer ---
|
|
// WS2 has a single dashboard page with HTML tables (not JS arrays like BOA)
|
|
|
|
function parseHtmlTables(html) {
|
|
const tables = []
|
|
const tableRe = /<table[^>]*>([\s\S]*?)<\/table>/gi
|
|
let m
|
|
while ((m = tableRe.exec(html)) !== null) {
|
|
const rows = []
|
|
const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
|
|
let rm
|
|
while ((rm = rowRe.exec(m[1])) !== null) {
|
|
const cells = []
|
|
const cellRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi
|
|
let cm
|
|
while ((cm = cellRe.exec(rm[1])) !== null) {
|
|
cells.push(cm[1].replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/\s+/g, ' ').trim())
|
|
}
|
|
if (cells.length >= 2) rows.push(cells)
|
|
}
|
|
if (rows.length > 0) tables.push(rows)
|
|
}
|
|
return tables
|
|
}
|
|
|
|
function normalizeRaisecomPhp(pages) {
|
|
const result = {
|
|
modemType: 'raisecom_php',
|
|
online: null,
|
|
wanIPs: [],
|
|
ethernetPorts: [],
|
|
dhcpLeases: [],
|
|
wiredEquipment: [],
|
|
radios: [],
|
|
meshNodes: [],
|
|
wifiClients: [],
|
|
issues: [],
|
|
device: {},
|
|
gpon: {},
|
|
}
|
|
|
|
// WS2 returns the same dashboard for all ?parts= params
|
|
// Use deviceInfo page (or any, they're the same)
|
|
const html = pages.deviceInfo || pages.wan || pages.ethernet || ''
|
|
if (!html) return result
|
|
|
|
const tables = parseHtmlTables(html)
|
|
|
|
for (const rows of tables) {
|
|
// Device info table: 2-column KV pairs (label | value)
|
|
if (rows.length >= 5 && rows.some(r => r[0] === 'Device Model')) {
|
|
for (const [k, v] of rows) {
|
|
if (k === 'Device Model') result.device.model = v
|
|
if (k === 'Device Serial Number') result.device.serial = v
|
|
if (k === 'Hardware Version') result.device.hardware = v
|
|
if (k === 'Software Version') result.device.firmware = v
|
|
if (k === 'Sub Version') result.device.subVersion = v
|
|
if (k === 'System Up Time') result.device.uptime = v
|
|
if (k === 'System Name') result.device.name = v
|
|
}
|
|
}
|
|
|
|
// CPU table: "CPU Usage:28% | Time:..."
|
|
if (rows.length === 1 && rows[0][0]?.includes('CPU Usage')) {
|
|
const cpuMatch = rows[0][0].match(/CPU Usage:\s*([\d.]+)%/)
|
|
if (cpuMatch) result.device.cpu = cpuMatch[1] + '%'
|
|
}
|
|
|
|
// Memory table
|
|
if (rows.length === 1 && rows[0][0]?.includes('Memory Usage')) {
|
|
const memMatch = rows[0][0].match(/Memory Usage:\s*([\d.]+)%/)
|
|
if (memMatch) result.device.memory = memMatch[1] + '%'
|
|
}
|
|
|
|
// WAN connections table: header row + data rows
|
|
if (rows.length >= 2 && rows[0].some(h => h === 'Connection Name')) {
|
|
const headers = rows[0]
|
|
for (let i = 1; i < rows.length; i++) {
|
|
const row = rows[i]
|
|
const obj = {}
|
|
headers.forEach((h, idx) => { if (row[idx]) obj[h] = row[idx] })
|
|
|
|
const ip = (obj['IP Address/Net Mask Length'] || '').split('/')[0] || ''
|
|
const mask = (obj['IP Address/Net Mask Length'] || '').split('/')[1] || ''
|
|
const status = obj['Link Status'] || ''
|
|
const name = obj['Connection Name'] || ''
|
|
|
|
if (ip && ip !== '---') {
|
|
const role = ip.startsWith('172.17.') || ip.startsWith('172.16.') ? 'management'
|
|
: ip.startsWith('10.') ? 'service'
|
|
: ip.startsWith('192.168.') ? 'lan'
|
|
: !ip.startsWith('169.254.') ? 'internet' : 'unknown'
|
|
result.wanIPs.push({
|
|
ip, mask, type: obj['Address Assign Mode'] || '', role,
|
|
name, status, gateway: obj['Gate Address'] || '',
|
|
dns: obj['DNS'] || '',
|
|
rxRate: obj['Receiving Rate(Kbits/s)'] || '',
|
|
txRate: obj['Sending Rate(Kbits/s)'] || '',
|
|
})
|
|
}
|
|
|
|
if (status === 'UP') {
|
|
result.online = result.online || { ipv4: false, ipv6: false, uptimeV4: 0 }
|
|
if (name.includes('Internet')) result.online.ipv4 = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// WiFi table: "SSID | Service Status | connected count (下挂PC数量)"
|
|
// Note: WS2 tables sometimes have header in <thead><tr> but data rows without <tr> opening tags
|
|
if (rows.length >= 1 && rows[0].some(h => h === 'SSID(Wireless Network Name)' || h === 'SSID')) {
|
|
for (let i = 1; i < rows.length; i++) {
|
|
if (rows[i][0] && rows[i][1]) {
|
|
const ssid = rows[i][0]
|
|
const statusRaw = rows[i][1]
|
|
const isOn = /^(Enable|UP|ON)$/i.test(statusRaw)
|
|
const band = ssid.match(/-5G$/i) ? '5GHz' : '2.4GHz'
|
|
result.radios.push({
|
|
band, channel: 0, bandwidth: '', standard: '',
|
|
txPower: 0, autoChannel: false,
|
|
status: isOn ? 'Up' : 'Down',
|
|
ssid,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ethernet port table: rows of [LANx, UP/DOWN] — either one row with all ports or multiple rows
|
|
if (rows.length >= 1 && rows.some(r => /^LAN\d$/i.test(r[0]))) {
|
|
for (const row of rows) {
|
|
// Each row is either [LANx, UP/DOWN] or a flat row [LAN1, UP, LAN2, DOWN, ...]
|
|
if (/^LAN\d$/i.test(row[0]) && row.length === 2) {
|
|
result.ethernetPorts.push({
|
|
port: parseInt(row[0].replace(/\D/g, '')),
|
|
label: row[0],
|
|
status: row[1] === 'UP' ? 'Up' : 'Down',
|
|
speed: null, duplex: null, stats: null, connectedDevice: null,
|
|
})
|
|
} else {
|
|
// Flat format: LAN1 UP LAN2 DOWN ...
|
|
for (let i = 0; i < row.length - 1; i += 2) {
|
|
if (/^LAN\d$/i.test(row[i])) {
|
|
result.ethernetPorts.push({
|
|
port: parseInt(row[i].replace(/\D/g, '')),
|
|
label: row[i],
|
|
status: row[i + 1] === 'UP' ? 'Up' : 'Down',
|
|
speed: null, duplex: null, stats: null, connectedDevice: null,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Fallback: direct HTML regex for WS2 malformed tables ---
|
|
// WS2 <tbody> rows often lack <tr> opening tags, so parseHtmlTables misses them
|
|
|
|
// WiFi SSIDs: extract from <span class="content_td">SSID</span> + <span class="content_td">ON/OFF</span> pairs
|
|
if (result.radios.length === 0 && html.includes('SSID(Wireless Network Name)')) {
|
|
const ssidRe = /<td[^>]*>\s*<span[^>]*>([^<]+)<\/span>\s*<\/td>\s*<td[^>]*>\s*<span[^>]*>(ON|OFF|UP|DOWN|Enable|Disable)<\/span>/gi
|
|
let sm
|
|
while ((sm = ssidRe.exec(html)) !== null) {
|
|
const ssid = sm[1].trim()
|
|
const isOn = /^(ON|UP|Enable)$/i.test(sm[2])
|
|
const band = ssid.match(/-5G$/i) ? '5GHz' : '2.4GHz'
|
|
result.radios.push({
|
|
band, channel: 0, bandwidth: '', standard: '',
|
|
txPower: 0, autoChannel: false,
|
|
status: isOn ? 'Up' : 'Down',
|
|
ssid,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Ethernet ports: extract LAN1-4 status from <span>LANx</span>...<span>UP/DOWN</span> pairs
|
|
if (result.ethernetPorts.length === 0 && html.includes('LAN1')) {
|
|
const lanRe = /<span[^>]*>(LAN\d)<\/span>[\s\S]*?<span[^>]*>(UP|DOWN)<\/span>/gi
|
|
let lm
|
|
while ((lm = lanRe.exec(html)) !== null) {
|
|
result.ethernetPorts.push({
|
|
port: parseInt(lm[1].replace(/\D/g, '')),
|
|
label: lm[1],
|
|
status: lm[2] === 'UP' ? 'Up' : 'Down',
|
|
speed: null, duplex: null, stats: null, connectedDevice: null,
|
|
})
|
|
}
|
|
}
|
|
|
|
// If no online status determined, check if any WAN is UP
|
|
if (!result.online && result.wanIPs.some(w => w.status === 'UP')) {
|
|
result.online = { ipv4: true, ipv6: false, uptimeV4: 0 }
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// --- TP-Link normalizer ---
|
|
|
|
function normalizeTplink(raw) {
|
|
// Raw data from fullDiagnostic() — keys match the Promise.allSettled order
|
|
const result = {
|
|
modemType: 'tplink_xx230v',
|
|
online: null,
|
|
wanIPs: [],
|
|
ethernetPorts: [],
|
|
dhcpLeases: [],
|
|
wiredEquipment: [],
|
|
radios: [],
|
|
meshNodes: [],
|
|
wifiClients: [],
|
|
issues: [],
|
|
}
|
|
|
|
// Online status
|
|
if (raw.onlineStatus && !raw.onlineStatus.error) {
|
|
result.online = {
|
|
ipv4: raw.onlineStatus.onlineStatusV4 === 'online',
|
|
ipv6: raw.onlineStatus.onlineStatusV6 === 'online',
|
|
uptimeV4: parseInt(raw.onlineStatus.onlineTimeV4) || 0,
|
|
}
|
|
}
|
|
|
|
// WAN IPs
|
|
if (Array.isArray(raw.wanIPs)) {
|
|
for (const w of raw.wanIPs) {
|
|
if (!w.IPAddress || w.IPAddress === '0.0.0.0') continue
|
|
if (w.status === 'Disabled') continue
|
|
const ip = w.IPAddress
|
|
const role = ip.startsWith('192.168.') ? 'lan'
|
|
: ip.startsWith('172.17.') || ip.startsWith('172.16.') ? 'management'
|
|
: ip.startsWith('10.') ? 'service'
|
|
: !ip.startsWith('169.254.') ? 'internet' : 'unknown'
|
|
result.wanIPs.push({
|
|
ip, mask: w.subnetMask || '', type: w.addressingType || '', role,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Radios
|
|
if (Array.isArray(raw.radios)) {
|
|
for (const r of raw.radios) {
|
|
result.radios.push({
|
|
band: r.operatingFrequencyBand || '',
|
|
channel: parseInt(r.channel) || 0,
|
|
bandwidth: r.currentOperatingChannelBandwidth || '',
|
|
standard: r.operatingStandards || '',
|
|
txPower: parseInt(r.transmitPower) || 0,
|
|
autoChannel: r.autoChannelEnable === '1',
|
|
status: r.status || '',
|
|
})
|
|
}
|
|
}
|
|
|
|
// Mesh nodes
|
|
if (Array.isArray(raw.meshNodes)) {
|
|
for (const n of raw.meshNodes) {
|
|
result.meshNodes.push({
|
|
hostname: n.X_TP_HostName || 'unknown',
|
|
model: n.X_TP_ModelName || '',
|
|
mac: n.MACAddress || '',
|
|
ip: n.X_TP_IPAddress || '',
|
|
active: n.X_TP_Active === '1',
|
|
isController: n.X_TP_IsController === '1',
|
|
cpu: parseInt(n.X_TP_CPUUsage) || 0,
|
|
uptime: parseInt(n.X_TP_UpTime) || 0,
|
|
firmware: n.softwareVersion || '',
|
|
backhaul: {
|
|
type: n.backhaulLinkType || 'Ethernet',
|
|
signal: parseInt(n.backhaulSignalStrength) || 0,
|
|
utilization: parseInt(n.backhaulLinkUtilization) || 0,
|
|
linkRate: parseInt(n.X_TP_LinkRate) || 0,
|
|
},
|
|
speedUp: parseInt(n.X_TP_UpSpeed) || 0,
|
|
speedDown: parseInt(n.X_TP_DownSpeed) || 0,
|
|
})
|
|
}
|
|
}
|
|
|
|
// WiFi clients
|
|
const clientMap = new Map()
|
|
if (Array.isArray(raw.clients)) {
|
|
for (const c of raw.clients) {
|
|
clientMap.set(c.MACAddress, {
|
|
mac: c.MACAddress || '', hostname: c.X_TP_HostName || '',
|
|
ip: c.X_TP_IPAddress || '',
|
|
signal: parseInt(c.signalStrength) || 0,
|
|
band: '', standard: c.operatingStandard || '',
|
|
active: c.active === '1',
|
|
linkDown: parseInt(c.lastDataDownlinkRate) || 0,
|
|
linkUp: parseInt(c.lastDataUplinkRate) || 0,
|
|
lossPercent: 0, meshNode: '',
|
|
apMac: c.X_TP_ApDeviceMac || '',
|
|
radioMac: c.X_TP_RadioMac || '',
|
|
})
|
|
}
|
|
}
|
|
if (Array.isArray(raw.clientStats)) {
|
|
for (const s of raw.clientStats) {
|
|
const existing = clientMap.get(s.MACAddress)
|
|
if (existing) {
|
|
existing.retrans = parseInt(s.retransCount) || 0
|
|
existing.packetsSent = parseInt(s.packetsSent) || 0
|
|
}
|
|
}
|
|
}
|
|
// Compute loss
|
|
for (const c of clientMap.values()) {
|
|
if (c.packetsSent > 100 && c.retrans > 0) {
|
|
c.lossPercent = Math.round((c.retrans / c.packetsSent) * 1000) / 10
|
|
}
|
|
}
|
|
result.wifiClients = [...clientMap.values()]
|
|
|
|
// Ethernet ports (from DEV2_ETHERNET_IF if available)
|
|
if (Array.isArray(raw.ethernetIfs)) {
|
|
for (let i = 0; i < raw.ethernetIfs.length; i++) {
|
|
const e = raw.ethernetIfs[i]
|
|
result.ethernetPorts.push({
|
|
port: i + 1, label: e.alias || e.name || `Port ${i + 1}`,
|
|
status: e.status || 'Down',
|
|
speed: parseInt(e.maxBitRate || e.currentBitRate) || null,
|
|
duplex: e.duplexMode || null,
|
|
stats: null, connectedDevice: null,
|
|
})
|
|
}
|
|
}
|
|
|
|
// DHCP hosts (from DEV2_HOSTS_HOST if available)
|
|
if (Array.isArray(raw.hosts)) {
|
|
const wifiMacs = new Set([...clientMap.keys()].map(m => m.toUpperCase()))
|
|
for (const h of raw.hosts) {
|
|
const mac = (h.PhysAddress || h.physAddress || '').toUpperCase()
|
|
result.dhcpLeases.push({
|
|
hostname: h.HostName || h.hostName || '',
|
|
ip: h.IPAddress || h.ipAddress || '',
|
|
mac: h.PhysAddress || h.physAddress || '',
|
|
expiry: h.LeaseTimeRemaining != null ? parseInt(h.LeaseTimeRemaining) : null,
|
|
interface: h.Layer1Interface || '',
|
|
isWired: !wifiMacs.has(mac),
|
|
isMeshRepeater: false,
|
|
})
|
|
}
|
|
result.wiredEquipment = detectWiredEquipment(result.dhcpLeases, [...clientMap.keys()])
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
module.exports = { normalizeRaisecomBoa, normalizeRaisecomPhp, normalizeTplink, detectWiredEquipment, parseBoaArray, parseBoaKvTable }
|