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>
179 lines
9.0 KiB
JavaScript
179 lines
9.0 KiB
JavaScript
'use strict'
|
|
const { deepGetValue } = require('./helpers')
|
|
|
|
const findWanIp = d => {
|
|
const pub = extractAllInterfaces(d).find(i => i.role === 'internet')
|
|
return pub ? pub.ip : ''
|
|
}
|
|
|
|
const classifyIpRole = (ip, name = '') => {
|
|
if (ip.startsWith('192.168.') || (ip.startsWith('10.') && name === 'br0')) return 'lan'
|
|
if (ip.startsWith('172.17.') || ip.startsWith('172.16.') || name.includes('_10') || name.includes('Management')) return 'management'
|
|
if (ip.startsWith('10.')) return 'service'
|
|
if (!ip.startsWith('169.254.')) return 'internet'
|
|
return 'unknown'
|
|
}
|
|
|
|
const extractAllInterfaces = d => {
|
|
// Device:2 model (TP-Link)
|
|
const ipIfaces = d.Device?.IP?.Interface
|
|
if (ipIfaces) {
|
|
const results = []
|
|
for (const ifKey of Object.keys(ipIfaces)) {
|
|
if (ifKey.startsWith('_')) continue
|
|
const iface = ipIfaces[ifKey]
|
|
if (!iface?.IPv4Address) continue
|
|
const name = iface.Name?._value || ''
|
|
for (const addrKey of Object.keys(iface.IPv4Address)) {
|
|
if (addrKey.startsWith('_')) continue
|
|
const addr = iface.IPv4Address[addrKey]
|
|
const ip = addr?.IPAddress?._value
|
|
if (!ip || ip === '0.0.0.0') continue
|
|
const status = addr.Status?._value
|
|
if (status && status !== 'Enabled') continue
|
|
results.push({ iface: ifKey, name, ip, mask: addr.SubnetMask?._value || '', addrType: addr.AddressingType?._value || '', role: classifyIpRole(ip, name) })
|
|
}
|
|
}
|
|
if (results.length) return results
|
|
}
|
|
|
|
// IGD model (Raisecom) — WANDevice.1.WANConnectionDevice.*.WANIPConnection.*
|
|
const wanDev = d.InternetGatewayDevice?.WANDevice?.['1']?.WANConnectionDevice
|
|
if (wanDev) {
|
|
const results = []
|
|
for (const cdKey of Object.keys(wanDev)) {
|
|
if (cdKey.startsWith('_')) continue
|
|
const cd = wanDev[cdKey]
|
|
const wanIp = cd?.WANIPConnection?.['1']
|
|
if (!wanIp) continue
|
|
const ip = wanIp.ExternalIPAddress?._value
|
|
if (!ip || ip === '0.0.0.0') continue
|
|
const name = wanIp.Name?._value || `WAN${cdKey}`
|
|
const status = wanIp.ConnectionStatus?._value || ''
|
|
results.push({ iface: cdKey, name, ip, mask: wanIp.SubnetMask?._value || '', addrType: wanIp.AddressingType?._value || '', role: classifyIpRole(ip, name), status })
|
|
}
|
|
if (results.length) return results
|
|
}
|
|
return []
|
|
}
|
|
|
|
const countAllWifiClients = d => {
|
|
const aps = d.Device?.WiFi?.AccessPoint
|
|
if (!aps) return { direct: 0, mesh: 0, total: 0, perAp: [] }
|
|
let direct = 0, mesh = 0
|
|
const perAp = []
|
|
for (const k of Object.keys(aps)) {
|
|
if (k.startsWith('_') || !aps[k]) continue
|
|
const n = Number(aps[k].AssociatedDeviceNumberOfEntries?._value) || 0
|
|
const ssidRef = aps[k].SSIDReference?._value || ''
|
|
const ssidIdx = ssidRef.match(/SSID\.(\d+)/)
|
|
const idx = ssidIdx ? parseInt(ssidIdx[1]) : parseInt(k)
|
|
if (idx <= 8) direct += n; else mesh += n
|
|
if (n > 0) perAp.push({ ap: k, ssid: ssidRef, clients: n })
|
|
}
|
|
return { direct, mesh, total: direct + mesh, perAp }
|
|
}
|
|
|
|
const extractMeshTopology = d => {
|
|
const apDevices = d.Device?.WiFi?.MultiAP?.APDevice
|
|
if (!apDevices) return null
|
|
const nodes = []
|
|
let totalClients = 0
|
|
for (const dk of Object.keys(apDevices)) {
|
|
if (dk.startsWith('_')) continue
|
|
const dev = apDevices[dk]
|
|
if (!dev?._object) continue
|
|
const name = dev.X_TP_HostName?._value || ''
|
|
const mac = dev.MACAddress?._value || ''
|
|
let nodeClients = 0
|
|
for (const rk of Object.keys(dev.Radio || {})) {
|
|
if (rk.startsWith('_')) continue
|
|
for (const ak of Object.keys(dev.Radio[rk]?.AP || {})) {
|
|
if (ak.startsWith('_')) continue
|
|
nodeClients += Number(dev.Radio[rk].AP[ak]?.AssociatedDeviceNumberOfEntries?._value) || 0
|
|
}
|
|
}
|
|
totalClients += nodeClients
|
|
if (name || mac) nodes.push({ id: dk, name, active: dev.X_TP_Active?._value === true, mac, ip: dev.X_TP_IPAddress?._value || '', clients: nodeClients })
|
|
}
|
|
return nodes.length ? { nodes, totalClients } : null
|
|
}
|
|
|
|
const extractIgdWifiSSIDs = d => {
|
|
const wlanCfg = d.InternetGatewayDevice?.LANDevice?.['1']?.WLANConfiguration
|
|
if (!wlanCfg) return null
|
|
const ssids = []
|
|
for (const k of Object.keys(wlanCfg)) {
|
|
if (k.startsWith('_')) continue
|
|
const cfg = wlanCfg[k]
|
|
const ssid = cfg?.SSID?._value
|
|
if (!ssid) continue
|
|
ssids.push({
|
|
index: parseInt(k),
|
|
ssid,
|
|
band: ssid.endsWith('-5G') ? '5GHz' : '2.4GHz',
|
|
enabled: cfg.Enable?._value === true || cfg.Enable?._value === 'true',
|
|
})
|
|
}
|
|
return ssids.length ? ssids : null
|
|
}
|
|
|
|
const summarizeDevice = d => {
|
|
const g = path => deepGetValue(d, path)
|
|
const s = path => { const v = g(path); return v != null ? String(v) : '' }
|
|
const counts = countAllWifiClients(d)
|
|
const mesh = extractMeshTopology(d)
|
|
return {
|
|
_id: d._id,
|
|
serial: s('DeviceID.SerialNumber')
|
|
|| s('InternetGatewayDevice.DeviceInfo.SerialNumber')
|
|
|| s('Device.DeviceInfo.SerialNumber')
|
|
|| d.DeviceID?.SerialNumber?._value
|
|
|| (d._id ? decodeURIComponent(d._id.split('-').slice(2).join('-')) : '')
|
|
|| '',
|
|
manufacturer: s('DeviceID.Manufacturer') || d.DeviceID?.Manufacturer?._value || d._deviceId?._Manufacturer || '',
|
|
model: s('DeviceID.ProductClass') || d.DeviceID?.ProductClass?._value || d._deviceId?._ProductClass || s('InternetGatewayDevice.DeviceInfo.ModelName') || '',
|
|
oui: s('DeviceID.OUI') || d.DeviceID?.OUI?._value || d._deviceId?._OUI || '',
|
|
firmware: s('InternetGatewayDevice.DeviceInfo.SoftwareVersion') || s('Device.DeviceInfo.SoftwareVersion') || '',
|
|
uptime: g('InternetGatewayDevice.DeviceInfo.UpTime') || g('Device.DeviceInfo.UpTime') || null,
|
|
lastInform: d._lastInform || null, lastBoot: d._lastBootstrap || null, registered: d._registered || null,
|
|
interfaces: extractAllInterfaces(d),
|
|
ip: s('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress') || findWanIp(d) || '',
|
|
rxPower: g('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.RXPower')
|
|
|| g('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.RXPower')
|
|
|| g('Device.Optical.Interface.1.Stats.SignalRxPower')
|
|
|| g('Device.Optical.Interface.1.OpticalSignalLevel') || null,
|
|
txPower: g('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.TXPower')
|
|
|| g('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.TXPower')
|
|
|| g('Device.Optical.Interface.1.Stats.SignalTxPower')
|
|
|| g('Device.Optical.Interface.1.TransmitOpticalLevel') || null,
|
|
pppoeUser: s('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username') || s('Device.PPP.Interface.1.Username') || '',
|
|
ssid: s('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID') || s('Device.WiFi.SSID.1.SSID') || '',
|
|
macAddress: s('InternetGatewayDevice.WANDevice.1.WANEthernetInterfaceConfig.MACAddress') || s('Device.Ethernet.Interface.1.MACAddress') || '',
|
|
tags: d._tags || [],
|
|
opticalStatus: s('Device.Optical.Interface.1.Status') || null,
|
|
opticalErrors: { sent: g('Device.Optical.Interface.1.Stats.ErrorsSent') || 0, received: g('Device.Optical.Interface.1.Stats.ErrorsReceived') || 0 },
|
|
wifi: {
|
|
radio1: { status: s('Device.WiFi.Radio.1.Status') || null, channel: g('Device.WiFi.Radio.1.Channel') || null, bandwidth: s('Device.WiFi.Radio.1.CurrentOperatingChannelBandwidth') || null, noise: g('Device.WiFi.Radio.1.Stats.Noise') || null, clients: g('Device.WiFi.AccessPoint.1.AssociatedDeviceNumberOfEntries') || 0 },
|
|
radio2: { status: s('Device.WiFi.Radio.2.Status') || null, channel: g('Device.WiFi.Radio.2.Channel') || null, bandwidth: s('Device.WiFi.Radio.2.CurrentOperatingChannelBandwidth') || null, noise: g('Device.WiFi.Radio.2.Stats.Noise') || null, clients: g('Device.WiFi.AccessPoint.2.AssociatedDeviceNumberOfEntries') || 0 },
|
|
radio3: { status: s('Device.WiFi.Radio.3.Status') || null, channel: g('Device.WiFi.Radio.3.Channel') || null, clients: g('Device.WiFi.AccessPoint.3.AssociatedDeviceNumberOfEntries') || 0 },
|
|
totalClients: mesh ? mesh.totalClients : counts.total,
|
|
directClients: counts.direct,
|
|
meshClients: mesh ? (mesh.totalClients - counts.direct) : counts.mesh,
|
|
},
|
|
mesh: mesh ? mesh.nodes : null,
|
|
hostsCount: g('Device.Hosts.HostNumberOfEntries')
|
|
|| g('InternetGatewayDevice.LANDevice.1.Hosts.HostNumberOfEntries') || null,
|
|
ethernet: {
|
|
port1: { status: s('Device.Ethernet.Interface.1.Status') || s('InternetGatewayDevice.LANDevice.1.LANEthernetInterfaceConfig.1.Status') || null, speed: g('Device.Ethernet.Interface.1.MaxBitRate') || null },
|
|
port2: { status: s('Device.Ethernet.Interface.2.Status') || s('InternetGatewayDevice.LANDevice.1.LANEthernetInterfaceConfig.2.Status') || null, speed: g('Device.Ethernet.Interface.2.MaxBitRate') || null },
|
|
port3: { status: s('InternetGatewayDevice.LANDevice.1.LANEthernetInterfaceConfig.3.Status') || null },
|
|
port4: { status: s('InternetGatewayDevice.LANDevice.1.LANEthernetInterfaceConfig.4.Status') || null },
|
|
},
|
|
// IGD WiFi SSIDs (Raisecom — no per-radio stats, just SSIDs)
|
|
wifiSSIDs: extractIgdWifiSSIDs(d),
|
|
}
|
|
}
|
|
|
|
module.exports = { summarizeDevice }
|