gigafibre-fsm/services/targo-hub/lib/olt-snmp.js
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

561 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
const snmp = require('net-snmp')
const cfg = require('./config')
const { log } = require('./helpers')
const olts = new Map()
let pollTimer = null
const POLL_INTERVAL = cfg.OLT_POLL_INTERVAL || 300000
const SNMP_TIMEOUT = 4000
// --- TP-Link DeltaStream GPON OLT MIB (enterprise 11863) ---
// ONU table: .6.100.1.7.2.1.{col}.0.{port}.{onu}
const TPLINK = {
BASE: '1.3.6.1.4.1.11863.6.100.1.7.2.1',
COL_PORT_NAME: 3, COL_ONU_IDX: 4, COL_DESCRIPTION: 5, COL_SERIAL: 6,
COL_LINE_PROF: 7, COL_STATUS: 8, COL_VENDOR: 15, COL_MODEL: 16,
COL_UPTIME: 19, COL_HW_VER: 20, COL_RX_POWER: 26, COL_TX_POWER: 27,
COL_ONU_RX: 28, COL_DISTANCE: 30, COL_TEMPERATURE: 31, COL_LAST_DOWN: 32,
COL_FIRMWARE: 33, COL_OFFLINE_CAUSE: 42, COL_MAC: 43,
COL_MAP: {},
FETCH_COLS: [],
}
TPLINK.COL_MAP = {
[TPLINK.COL_PORT_NAME]: 'port', [TPLINK.COL_ONU_IDX]: 'onuIdx',
[TPLINK.COL_DESCRIPTION]: 'description', [TPLINK.COL_SERIAL]: 'serial',
[TPLINK.COL_VENDOR]: 'vendor', [TPLINK.COL_MODEL]: 'model',
[TPLINK.COL_UPTIME]: 'uptime', [TPLINK.COL_RX_POWER]: 'rxPowerOlt',
[TPLINK.COL_TX_POWER]: 'txPowerOlt', [TPLINK.COL_ONU_RX]: 'rxPowerOnu',
[TPLINK.COL_DISTANCE]: 'distance', [TPLINK.COL_TEMPERATURE]: 'temperature',
[TPLINK.COL_FIRMWARE]: 'firmware', [TPLINK.COL_OFFLINE_CAUSE]: 'lastOfflineCause',
[TPLINK.COL_MAC]: 'mac', [TPLINK.COL_LINE_PROF]: 'lineProfile',
}
TPLINK.FETCH_COLS = [
TPLINK.COL_PORT_NAME, TPLINK.COL_ONU_IDX, TPLINK.COL_DESCRIPTION, TPLINK.COL_SERIAL,
TPLINK.COL_STATUS, TPLINK.COL_VENDOR, TPLINK.COL_MODEL, TPLINK.COL_UPTIME,
TPLINK.COL_RX_POWER, TPLINK.COL_TX_POWER, TPLINK.COL_ONU_RX,
TPLINK.COL_DISTANCE, TPLINK.COL_TEMPERATURE, TPLINK.COL_FIRMWARE,
TPLINK.COL_OFFLINE_CAUSE, TPLINK.COL_MAC, TPLINK.COL_LINE_PROF,
]
// backward compat alias
const OID = TPLINK
// --- Raisecom ISCOM6800 GPON OLT MIB (enterprise 8886) ---
// ONU table: 1.3.6.1.4.1.8886.18.3.1.3.1.1.{col}.{onu_id}
// Signal table: 1.3.6.1.4.1.8886.18.3.1.3.3.1.1.{onu_id} (rx_power in dBm*10)
// onu_id encoding: (slot+32)*8388608 + port*65536 + ontid
const RAISECOM = {
BASE_TABLE: '1.3.6.1.4.1.8886.18.3.1.3.1.1',
BASE_SIGNAL: '1.3.6.1.4.1.8886.18.3.1.3.3.1.1',
COL_SERIAL: 2,
COL_DISTANCE: 6,
COL_FIRMWARE: 7,
COL_STATUS: 8,
COL_LINE_PROF: 9,
COL_DEREG_COUNT: 15,
COL_ONU_RX: 16,
FETCH_COLS: [2, 6, 7, 8, 9, 16],
COL_MAP: { 2: 'serial', 6: 'distance', 7: 'firmware', 9: 'lineProfile', 16: 'rxPowerOnu' },
}
function decodeRaisecomOnuId (onuId) {
const slot = Math.floor(onuId / 8388608) - 32
const port = Math.floor((onuId % 8388608) / 65536)
const ontid = onuId % 65536
return { slot, port, ontid }
}
function registerOlt ({ host, community, name, type }) {
if (!host || !community) return
if (olts.has(host)) return olts.get(host)
const vendor = (type || 'tplink').toLowerCase()
if (vendor !== 'tplink' && vendor !== 'raisecom') {
log(`OLT ${name || host}: unknown type "${type}", defaulting to tplink`)
}
const olt = {
host, community, name: name || host,
type: (vendor === 'raisecom') ? 'raisecom' : 'tplink',
lastPoll: null, lastError: null, lastDuration: null,
onuCount: 0, onlineCount: 0, onus: new Map(),
}
olts.set(host, olt)
log(`OLT registered: ${name || host} (${host}) [${olt.type}]`)
return olt
}
const createSession = (host, community) =>
snmp.createSession(host, community, { timeout: SNMP_TIMEOUT, retries: 1, version: snmp.Version2c })
function snmpWalk (session, oid) {
return new Promise((resolve) => {
const results = []
const timer = setTimeout(() => {
try { session.close() } catch {}
resolve(results)
}, SNMP_TIMEOUT + 2000)
session.subtree(oid, 50, (varbinds) => {
for (const vb of varbinds) {
if (!snmp.isVarbindError(vb)) results.push(vb)
}
}, (error) => {
clearTimeout(timer)
resolve(results)
})
})
}
function snmpGet (session, oid) {
return new Promise((resolve) => {
const timer = setTimeout(() => { try { session.close() } catch {} resolve(null) }, SNMP_TIMEOUT + 1000)
session.get([oid], (error, varbinds) => {
clearTimeout(timer)
if (error || !varbinds?.length || snmp.isVarbindError(varbinds[0])) return resolve(null)
resolve(varbinds[0])
})
})
}
// Raisecom OLT WAN IP OIDs: 1.3.6.1.4.1.8886.18.3.6.6.1.1.13.{olt_id}.{wan_idx}
// olt_id = slot * 10000000 + port * 100000 + ontid
const RAISECOM_WAN_IP_OID = '1.3.6.1.4.1.8886.18.3.6.6.1.1.13'
// Get management IP from Raisecom OLT via SNMP
// Can use either cached ONU data (serial lookup) or explicit slot/port/ontid params
async function getManageIp (serial, opts = {}) {
let oltHost, community, oltName, slot, port, ontId
if (serial) {
const onu = getOnuBySerial(serial)
if (onu) {
const olt = olts.get(onu.oltHost)
if (!olt || olt.type !== 'raisecom') return null
oltHost = olt.host; community = olt.community; oltName = olt.name
// Raisecom cache stores port as "0/slot/port" and onuIdx as ontid
const parts = (onu.port || '').split('/')
if (parts.length === 3) { slot = parseInt(parts[1]); port = parseInt(parts[2]) }
ontId = onu.onuIdx
}
}
// Allow explicit params (from ERPNext data when ONU not in cache yet)
if (opts.oltIp) oltHost = opts.oltIp
if (opts.community) community = opts.community
if (opts.slot != null) slot = opts.slot
if (opts.port != null) port = opts.port
if (opts.ontId != null) ontId = opts.ontId
if (!community) community = 'targosnmp'
if (!oltName) oltName = oltHost
if (!oltHost || slot == null || port == null || ontId == null) return null
const oltId = slot * 10000000 + port * 100000 + ontId
const session = createSession(oltHost, community)
const ips = []
try {
for (let wan = 1; wan <= 4; wan++) {
const vb = await snmpGet(session, `${RAISECOM_WAN_IP_OID}.${oltId}.${wan}`)
if (vb) {
const ip = extractVal(vb)
if (ip && ip !== '0.0.0.0' && ip !== '') ips.push({ wan, ip })
}
}
} finally { try { session.close() } catch {} }
if (!ips.length) return null
const mgmt = ips.find(i => i.ip.startsWith('172.17.') || i.ip.startsWith('10.'))
return { serial, oltName, oltHost, slot, port, ontId, ips, manageIp: mgmt?.ip || ips[0].ip }
}
// Raisecom per-ONU WAN octet counters (VLAN 40 = service internet) — mirrors F's
// device_ajax/raisecom_rcmg_bandwith.php. Cumulative octets (32-bit, may wrap):
// rx (download) = 1.3.6.1.4.1.8886.18.3.7.2.4.1.5.{oltId}.40
// tx (upload) = 1.3.6.1.4.1.8886.18.3.7.2.4.1.3.{oltId}.40
// oltId = slot*10000000 + port*100000 + ontid (same encoding as the WAN-IP table)
// Raisecom (tech-2) ONLY — TP-Link/tech-3 has no live WAN-rate counter in F.
// The CALLER samples twice and computes rate = ((val - last) / sec) * 8 bits/s
// (with counter-wrap handling), exactly like F's chart.
const RAISECOM_WAN_OCTETS_BASE = '1.3.6.1.4.1.8886.18.3.7.2.4.1'
const RC_WAN_VLAN = 40
async function getOnuBandwidth (serial, opts = {}) {
let oltHost, community, oltName, slot, port, ontId, type
if (serial) {
const onu = getOnuBySerial(serial)
if (onu) {
const olt = olts.get(onu.oltHost)
if (olt) {
type = olt.type; oltHost = olt.host; community = olt.community; oltName = olt.name
const parts = (onu.port || '').split('/') // Raisecom cache stores port as "0/slot/port"
if (parts.length === 3) { slot = parseInt(parts[1]); port = parseInt(parts[2]) }
ontId = onu.onuIdx
}
}
}
// Explicit params (from ERPNext Service Equipment when ONU not in cache)
if (opts.oltIp) oltHost = opts.oltIp
if (opts.community) community = opts.community
if (opts.slot != null) slot = parseInt(opts.slot)
if (opts.port != null) port = parseInt(opts.port)
if (opts.ontId != null) ontId = parseInt(opts.ontId)
if (opts.type) type = (opts.type + '').toLowerCase()
if (!community) community = 'targosnmp'
if (!oltName) oltName = oltHost
// tech-3 (TP-Link) has no live WAN octet counter in F → report unavailable, don't poll
if (type && type !== 'raisecom') return { available: false, reason: 'unsupported_olt_type', type }
if (!oltHost || slot == null || port == null || ontId == null || isNaN(slot) || isNaN(port) || isNaN(ontId)) {
return { available: false, reason: 'onu_not_resolved' }
}
const oltId = slot * 10000000 + port * 100000 + ontId
const session = createSession(oltHost, community)
let rxOctets = null, txOctets = null
try {
// Les 2 gets en parallèle (même session) → ~2× plus rapide à répondre
const [rxVb, txVb] = await Promise.all([
snmpGet(session, `${RAISECOM_WAN_OCTETS_BASE}.5.${oltId}.${RC_WAN_VLAN}`),
snmpGet(session, `${RAISECOM_WAN_OCTETS_BASE}.3.${oltId}.${RC_WAN_VLAN}`),
])
// These counters are SNMP Counter64 → net-snmp hands them back as an 8-byte
// big-endian Buffer (NOT an Integer). Parse to a JS number (octet totals stay
// well under 2^53 between resets, so precision is fine).
const toNum = (vb) => {
if (!vb) return null
const v = vb.value
if (typeof v === 'number') return v // Counter32/Gauge/Integer
if (typeof v === 'bigint') return Number(v)
if (Buffer.isBuffer(v)) {
try { return Number(v.readBigUInt64BE(0)) }
catch (e) { const n = Number('0x' + v.toString('hex')); return isNaN(n) ? null : n }
}
const n = parseInt(v, 10); return isNaN(n) ? null : n
}
rxOctets = toNum(rxVb); txOctets = toNum(txVb)
} finally { try { session.close() } catch {} }
if (rxOctets == null && txOctets == null) return { available: false, reason: 'no_counter', oltName, oltId }
return { available: true, serial: serial || null, oltName, slot, port, ontId, oltId, rxOctets, txOctets, ts: Date.now() }
}
function extractVal (vb) {
if (vb.type === snmp.ObjectType.OctetString) return vb.value.toString()
if (vb.type === snmp.ObjectType.Integer) return vb.value
return vb.value?.toString?.() || ''
}
function parseTplinkTable (varbinds) {
const byKey = new Map()
const baseLen = TPLINK.BASE.split('.').length
for (const vb of varbinds) {
const suffix = vb.oid.toString().split('.').slice(baseLen)
if (suffix.length < 3) continue
const col = parseInt(suffix[0])
const key = suffix.slice(1).join('.')
if (!byKey.has(key)) byKey.set(key, {})
const entry = byKey.get(key)
const val = extractVal(vb)
if (col === TPLINK.COL_STATUS) {
entry.status = (val === 1 || val === '1') ? 'online' : 'offline'
} else if (TPLINK.COL_MAP[col]) {
entry[TPLINK.COL_MAP[col]] = val
}
}
const onus = []
for (const [key, entry] of byKey) {
if (!entry.serial) continue
entry._key = key
onus.push(entry)
}
return onus
}
function parseRaisecomTable (tableVarbinds, signalVarbinds) {
const byId = new Map()
const tableBaseLen = RAISECOM.BASE_TABLE.split('.').length
for (const vb of tableVarbinds) {
const suffix = vb.oid.toString().split('.').slice(tableBaseLen)
if (suffix.length < 2) continue
const col = parseInt(suffix[0])
const onuId = parseInt(suffix[1])
if (isNaN(onuId)) continue
if (!byId.has(onuId)) byId.set(onuId, { _onuId: onuId })
const entry = byId.get(onuId)
const val = extractVal(vb)
if (col === RAISECOM.COL_STATUS) {
entry.status = (val === 1 || val === '1') ? 'online' : 'offline'
} else if (RAISECOM.COL_MAP[col]) {
entry[RAISECOM.COL_MAP[col]] = val
}
}
// Parse signal table for OLT-side rx power
const sigBaseLen = RAISECOM.BASE_SIGNAL.split('.').length
for (const vb of signalVarbinds) {
const suffix = vb.oid.toString().split('.').slice(sigBaseLen)
if (suffix.length < 1) continue
const onuId = parseInt(suffix[0])
if (isNaN(onuId) || !byId.has(onuId)) continue
const val = extractVal(vb)
// Raisecom reports rx_power in dBm * 10
byId.get(onuId).rxPowerOlt = typeof val === 'number' ? val / 10 : val
}
const onus = []
for (const [onuId, entry] of byId) {
if (!entry.serial) continue
const { slot, port, ontid } = decodeRaisecomOnuId(onuId)
entry.port = `0/${slot}/${port}`
entry.onuIdx = ontid
entry._key = `${slot}.${port}.${ontid}`
onus.push(entry)
}
return onus
}
// backward compat alias
const parseOnuTable = parseTplinkTable
async function pollTplink (session) {
const allVarbinds = []
for (const col of TPLINK.FETCH_COLS) {
const vbs = await snmpWalk(session, `${TPLINK.BASE}.${col}`)
allVarbinds.push(...vbs)
}
return parseTplinkTable(allVarbinds)
}
async function pollRaisecom (olt) {
// Raisecom needs a fresh session per walk (session gets closed after subtree timeout)
const tableVarbinds = []
for (const col of RAISECOM.FETCH_COLS) {
const s = createSession(olt.host, olt.community)
const vbs = await snmpWalk(s, `${RAISECOM.BASE_TABLE}.${col}`)
try { s.close() } catch {}
tableVarbinds.push(...vbs)
}
const sigSession = createSession(olt.host, olt.community)
const signalVarbinds = await snmpWalk(sigSession, RAISECOM.BASE_SIGNAL)
try { sigSession.close() } catch {}
return parseRaisecomTable(tableVarbinds, signalVarbinds)
}
async function pollOlt (olt) {
const startMs = Date.now()
let session
try {
session = createSession(olt.host, olt.community)
let onus
if (olt.type === 'raisecom') {
session.close(); session = null
onus = await pollRaisecom(olt)
} else {
onus = await pollTplink(session)
session.close(); session = null
}
const prevOnus = new Map(olt.onus)
olt.onus.clear()
let onlineCount = 0
for (const onu of onus) {
olt.onus.set(onu.serial, onu)
if (onu.status === 'online') onlineCount++
const prev = prevOnus.get(onu.serial)
if (prev && prev.status !== onu.status) {
const event = onu.status === 'offline' ? 'olt_offline' : 'olt_online'
logOnuEvent(olt, onu, event, onu.status === 'offline' ? (onu.lastOfflineCause || null) : null)
// Feed outage monitor for proactive detection
try {
const monitor = require('./outage-monitor')
if (onu.status === 'offline') monitor.onOnuOffline(olt, onu)
else monitor.onOnuOnline(olt, onu)
} catch (_) {}
}
}
const wasDown = !!olt.lastError // was previously in error state
olt.onuCount = onus.length
olt.onlineCount = onlineCount
olt.lastPoll = new Date().toISOString()
olt.lastError = null
olt.lastDuration = Date.now() - startMs
log(`OLT ${olt.name}: ${onus.length} ONUs (${onlineCount} online) in ${olt.lastDuration}ms`)
// If OLT was down and is now back, resolve the OLT-level incident
if (wasDown) {
try {
const monitor = require('./outage-monitor')
monitor.onOltUp(olt.name, 'snmp')
} catch (_) {}
}
} catch (e) {
const wasOk = !olt.lastError // first failure after success
olt.lastError = { message: e.message, ts: new Date().toISOString() }
olt.lastDuration = Date.now() - startMs
log(`OLT ${olt.name} SNMP FAILED (${olt.lastDuration}ms): ${e.message} — resuming next`)
// Trigger OLT-level outage synthesis (only on first failure, not repeated)
if (wasOk) {
try {
const monitor = require('./outage-monitor')
monitor.onOltDown(olt.name, 'snmp', { customerCount: olt.onuCount, error: e.message })
} catch (_) {}
}
} finally {
if (session) try { session.close() } catch {}
}
}
async function logOnuEvent (olt, onu, event, reason) {
log(`ONU ${onu.serial} on ${olt.name}: ${event}${reason ? ' (' + reason + ')' : ''}`)
try {
const { logDeviceEvent } = require('./devices')
if (logDeviceEvent) {
await logDeviceEvent(onu.serial, event, reason, {
olt: olt.name, port: onu.port, mac: onu.mac,
rxPowerOlt: onu.rxPowerOlt, rxPowerOnu: onu.rxPowerOnu,
distance: onu.distance, lastOfflineCause: onu.lastOfflineCause,
})
}
} catch (e) {
log(`Failed to log ONU event: ${e.message}`)
}
}
let _oltSkipCount = 0
async function pollAllOlts () {
if (!olts.size) return
// Pause gate — admin toggle via /admin/pollers. Skip silently-ish when
// paused (log every 12th tick = ~1h) so the log doesn't fill with "skipped".
if (require('./poller-control').isPaused('olt')) {
if ((_oltSkipCount++ % 12) === 0) log('OLT poll: skipped (paused via /admin/pollers)')
return
}
const startMs = Date.now()
let ok = 0, failed = 0
for (const [, olt] of olts) {
await pollOlt(olt)
olt.lastError ? failed++ : ok++
}
if (failed > 0) log(`OLT poll complete: ${ok} OK, ${failed} FAILED in ${Date.now() - startMs}ms`)
}
function getOnuBySerial (serial) {
if (!serial) return null
const normalized = serial.replace(/^(TPLG)([A-F0-9])/i, '$1-$2').toUpperCase()
for (const [, olt] of olts) {
const enrich = (o) => ({ ...o, oltName: olt.name, oltHost: olt.host })
let onu = olt.onus.get(serial) || olt.onus.get(normalized)
if (onu) return enrich(onu)
for (const [s, o] of olt.onus) {
if (s.includes(serial) || serial.includes(s.replace(/-/g, ''))) return enrich(o)
}
}
return null
}
// Get all ONUs on the same OLT port as a given serial
function getPortNeighbors (serial) {
const target = getOnuBySerial(serial)
if (!target || !target.port) return null
const neighbors = []
for (const [, olt] of olts) {
if (olt.name !== target.oltName) continue
for (const [, onu] of olt.onus) {
if (onu.port === target.port && onu.serial !== target.serial) {
neighbors.push({ ...onu, oltName: olt.name })
}
}
break
}
return { target, port: target.port, oltName: target.oltName, neighbors }
}
// Get aggregated port health stats
function getPortHealth (oltName, port) {
for (const [, olt] of olts) {
if (olt.name !== oltName) continue
const portOnus = []
for (const [, onu] of olt.onus) {
if (onu.port === port) portOnus.push({ ...onu, oltName: olt.name })
}
const total = portOnus.length
const online = portOnus.filter(o => o.status === 'online').length
const offline = portOnus.filter(o => o.status === 'offline')
// Classify offline causes
const causes = {}
for (const o of offline) {
const cause = classifyOfflineCause(o.lastOfflineCause)
causes[cause] = (causes[cause] || 0) + 1
}
return { oltName, port, total, online, offline: total - online, causes, onus: portOnus }
}
return null
}
function classifyOfflineCause (raw) {
if (!raw) return 'unknown'
const lc = (raw + '').toLowerCase()
if (lc.includes('dying') || lc.includes('gasp')) return 'dying_gasp'
if (lc.includes('branch') || lc.includes('fiber cut') || lc.includes('fibre')) return 'branch_fiber_cut'
if (lc.includes('losi') || lc.includes('los')) return 'loss_of_signal'
if (lc.includes('lobi') || lc.includes('lob')) return 'loss_of_burst'
return 'other'
}
const getOltStats = () => [...olts.entries()].map(([host, olt]) => ({
host, name: olt.name, type: olt.type, onuCount: olt.onuCount, onlineCount: olt.onlineCount,
lastPoll: olt.lastPoll, lastDuration: olt.lastDuration, lastError: olt.lastError,
}))
const getAllOnus = () => {
const all = []
for (const [, olt] of olts) {
for (const [, onu] of olt.onus) all.push({ ...onu, oltName: olt.name })
}
return all
}
function startOltPoller () {
for (const o of (cfg.OLT_LIST || [])) registerOlt(o)
if (cfg.OLT_HOST && !olts.has(cfg.OLT_HOST)) {
registerOlt({ host: cfg.OLT_HOST, community: cfg.OLT_COMMUNITY || 'public', name: cfg.OLT_NAME || cfg.OLT_HOST, type: cfg.OLT_TYPE })
}
if (!olts.size) return log('OLT SNMP poller: no OLTs configured')
log(`OLT SNMP poller: ${olts.size} OLT(s), polling every ${POLL_INTERVAL / 1000}s`)
setTimeout(() => {
pollAllOlts().catch(e => log('OLT poll error:', e.message))
pollTimer = setInterval(() => {
pollAllOlts().catch(e => log('OLT poll error:', e.message))
}, POLL_INTERVAL)
}, 75000) // 1er poll OLT DIFFÉRÉ à 75s (était 10s) + DÉCALÉ après le poll appareils (45s) → pas de rafale coïncidente au boot
}
function stopOltPoller () {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
module.exports = {
registerOlt, startOltPoller, stopOltPoller,
getOnuBySerial, getPortNeighbors, getPortHealth, classifyOfflineCause,
getOltStats, getAllOnus, pollAllOlts, getManageIp, getOnuBandwidth,
}