feat: GenieACS NBI integration for live CPE/ONT status

targo-hub:
- Add /devices/* endpoints proxying GenieACS NBI API (port 7557)
- /devices/summary — fleet stats (online/offline by model)
- /devices/lookup?serial=X — find device by serial number
- /devices/:id — device detail with summarized parameters
- /devices/:id/tasks — send reboot, getParameterValues, refresh
- /devices/:id/faults — device fault history
- GENIEACS_NBI_URL configurable via env var

ops app:
- New useDeviceStatus composable for live ACS status
- Equipment chips show green/red online dot from GenieACS
- Enriched tooltips: firmware, WAN IP, Rx/Tx power, SSID, last inform
- Right-click context menu: Reboot device, Refresh parameters
- Signal quality color coding (Rx power dBm thresholds)
- 1-minute client-side cache to avoid hammering NBI API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-02 20:55:13 -04:00
parent a2c59d6528
commit ea71eec194
3 changed files with 682 additions and 8 deletions

View File

@ -0,0 +1,147 @@
/**
* GenieACS live device status composable.
* Looks up devices by serial number via targo-hub GenieACS NBI proxy.
*/
import { ref, readonly } from 'vue'
const HUB_URL = (window.location.hostname === 'localhost')
? 'http://localhost:3300'
: 'https://msg.gigafibre.ca'
// Cache: serial → { data, ts }
const cache = new Map()
const CACHE_TTL = 60_000 // 1 min
/**
* Fetch live device info from GenieACS for a list of equipment objects.
* Equipment must have serial_number (and optionally mac_address).
* Returns a reactive Map<serial, deviceInfo>.
*/
export function useDeviceStatus () {
const deviceMap = ref(new Map()) // serial → { ...summarizedDevice }
const loading = ref(false)
const error = ref(null)
async function fetchStatus (equipmentList) {
if (!equipmentList || !equipmentList.length) return
loading.value = true
error.value = null
const now = Date.now()
const toFetch = []
const map = new Map(deviceMap.value)
for (const eq of equipmentList) {
const serial = eq.serial_number
if (!serial) continue
const cached = cache.get(serial)
if (cached && (now - cached.ts) < CACHE_TTL) {
map.set(serial, cached.data)
} else {
toFetch.push(serial)
}
}
// Batch lookups (GenieACS NBI doesn't support batch, so parallel individual)
if (toFetch.length) {
const results = await Promise.allSettled(
toFetch.map(serial =>
fetch(`${HUB_URL}/devices/lookup?serial=${encodeURIComponent(serial)}`)
.then(r => r.ok ? r.json() : null)
.then(data => ({ serial, data: Array.isArray(data) && data.length ? data[0] : null }))
.catch(() => ({ serial, data: null }))
)
)
for (const r of results) {
if (r.status === 'fulfilled' && r.value.data) {
map.set(r.value.serial, r.value.data)
cache.set(r.value.serial, { data: r.value.data, ts: now })
}
}
}
deviceMap.value = map
loading.value = false
}
function getDevice (serial) {
return deviceMap.value.get(serial) || null
}
function isOnline (serial) {
const d = getDevice(serial)
if (!d || !d.lastInform) return null // unknown
const age = Date.now() - new Date(d.lastInform).getTime()
return age < 5 * 60 * 1000 // online if last inform < 5 min ago
}
function signalQuality (serial) {
const d = getDevice(serial)
if (!d || d.rxPower == null) return null
const rx = parseFloat(d.rxPower)
if (isNaN(rx)) return null
// GPON Rx power: > -8 excellent, -8 to -20 good, -20 to -25 fair, < -25 bad
if (rx > -8) return 'excellent'
if (rx > -20) return 'good'
if (rx > -25) return 'fair'
return 'bad'
}
async function rebootDevice (serial) {
const d = getDevice(serial)
if (!d) throw new Error('Device not found in ACS')
const res = await fetch(
`${HUB_URL}/devices/${encodeURIComponent(d._id)}/tasks?connection_request&timeout=5000`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'reboot' }),
}
)
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || 'Reboot failed')
}
return res.json()
}
async function refreshDeviceParams (serial) {
const d = getDevice(serial)
if (!d) throw new Error('Device not found in ACS')
const res = await fetch(
`${HUB_URL}/devices/${encodeURIComponent(d._id)}/tasks?connection_request&timeout=10000`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'getParameterValues',
parameterNames: [
'InternetGatewayDevice.DeviceInfo.',
'InternetGatewayDevice.WANDevice.1.',
'Device.DeviceInfo.',
'Device.Optical.Interface.1.',
],
}),
}
)
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || 'Refresh failed')
}
// Invalidate cache so next fetch gets fresh data
cache.delete(serial)
return res.json()
}
return {
deviceMap: readonly(deviceMap),
loading: readonly(loading),
error: readonly(error),
fetchStatus,
getDevice,
isOnline,
signalQuality,
rebootDevice,
refreshDeviceParams,
}
}

View File

@ -91,14 +91,49 @@
<div v-for="eq in locEquip(loc.name)" :key="eq.name" class="device-icon-chip" :class="deviceColorClass(eq.status)" <div v-for="eq in locEquip(loc.name)" :key="eq.name" class="device-icon-chip" :class="deviceColorClass(eq.status)"
@click="openModal('Service Equipment', eq.name, eq.equipment_type + (eq.brand ? ' — ' + eq.brand : ''))"> @click="openModal('Service Equipment', eq.name, eq.equipment_type + (eq.brand ? ' — ' + eq.brand : ''))">
<q-icon :name="deviceLucideIcon(eq.equipment_type)" size="20px" /> <q-icon :name="deviceLucideIcon(eq.equipment_type)" size="20px" />
<q-tooltip class="bg-grey-9 text-caption" :offset="[0, 6]"> <!-- Live status dot from GenieACS -->
{{ eq.equipment_type }}{{ eq.brand ? ' — ' + eq.brand : '' }}{{ eq.model ? ' ' + eq.model : '' }} <span v-if="isOnline(eq.serial_number) === true" class="acs-dot acs-online" />
<br>SN: {{ eq.serial_number }} <span v-else-if="isOnline(eq.serial_number) === false" class="acs-dot acs-offline" />
<template v-if="eq.mac_address"><br>MAC: {{ eq.mac_address }}</template> <q-tooltip class="bg-grey-9 text-caption" :offset="[0, 6]" style="max-width:320px">
<template v-if="eq.olt_name"><br>OLT: {{ eq.olt_name }} Slot {{ eq.olt_slot }}/Port {{ eq.olt_port }}/ONT {{ eq.olt_ontid }}</template> <div><strong>{{ eq.equipment_type }}{{ eq.brand ? ' — ' + eq.brand : '' }}{{ eq.model ? ' ' + eq.model : '' }}</strong></div>
<template v-else-if="eq.ip_address"><br>IP: {{ eq.ip_address }}</template> <div>SN: {{ eq.serial_number }}</div>
<br>{{ eq.status }} <template v-if="eq.mac_address"><div>MAC: {{ eq.mac_address }}</div></template>
<template v-if="eq.olt_name"><div>OLT: {{ eq.olt_name }} Slot {{ eq.olt_slot }}/Port {{ eq.olt_port }}/ONT {{ eq.olt_ontid }}</div></template>
<template v-else-if="eq.ip_address"><div>IP: {{ eq.ip_address }}</div></template>
<div>{{ eq.status }}</div>
<!-- GenieACS live data -->
<template v-if="getDevice(eq.serial_number)">
<q-separator dark class="q-my-xs" />
<div :style="{ color: isOnline(eq.serial_number) ? '#4ade80' : '#f87171' }">
{{ isOnline(eq.serial_number) ? '● En ligne' : '● Hors ligne' }}
<template v-if="getDevice(eq.serial_number).lastInform">
{{ formatTimeAgo(getDevice(eq.serial_number).lastInform) }}
</template>
</div>
<template v-if="getDevice(eq.serial_number).firmware"><div>FW: {{ getDevice(eq.serial_number).firmware }}</div></template>
<template v-if="getDevice(eq.serial_number).ip"><div>WAN IP: {{ getDevice(eq.serial_number).ip }}</div></template>
<template v-if="getDevice(eq.serial_number).rxPower != null">
<div :style="{ color: signalColor(eq.serial_number) }">
Rx: {{ getDevice(eq.serial_number).rxPower }} dBm
<template v-if="getDevice(eq.serial_number).txPower != null"> / Tx: {{ getDevice(eq.serial_number).txPower }} dBm</template>
</div>
</template>
<template v-if="getDevice(eq.serial_number).ssid"><div>SSID: {{ getDevice(eq.serial_number).ssid }}</div></template>
</template>
</q-tooltip> </q-tooltip>
<!-- Context menu for ACS actions -->
<q-menu context-menu v-if="getDevice(eq.serial_number)">
<q-list dense style="min-width:160px">
<q-item clickable v-close-popup @click="doReboot(eq)">
<q-item-section avatar><q-icon name="restart_alt" size="18px" /></q-item-section>
<q-item-section>Redémarrer</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="doRefreshParams(eq)">
<q-item-section avatar><q-icon name="sync" size="18px" /></q-item-section>
<q-item-section>Rafraîchir params</q-item-section>
</q-item>
</q-list>
</q-menu>
</div> </div>
</div> </div>
</div> </div>
@ -498,6 +533,7 @@ import CustomerInfoCard from 'src/components/customer/CustomerInfoCard.vue'
// BillingKPIs removed data shown in invoices section // BillingKPIs removed data shown in invoices section
import InlineField from 'src/components/shared/InlineField.vue' import InlineField from 'src/components/shared/InlineField.vue'
import ChatterPanel from 'src/components/customer/ChatterPanel.vue' import ChatterPanel from 'src/components/customer/ChatterPanel.vue'
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
const props = defineProps({ id: String }) const props = defineProps({ id: String })
@ -528,6 +564,7 @@ const {
} = useSubscriptionActions(subscriptions, customer, comments, invalidateCache) } = useSubscriptionActions(subscriptions, customer, comments, invalidateCache)
const { onNoteAdded } = useCustomerNotes(comments, customer) const { onNoteAdded } = useCustomerNotes(comments, customer)
const { fetchStatus, getDevice, isOnline, signalQuality, rebootDevice, refreshDeviceParams, loading: deviceStatusLoading } = useDeviceStatus()
const { const {
modalOpen, modalLoading, modalDoctype, modalDocName, modalTitle, modalOpen, modalLoading, modalDoctype, modalDocName, modalTitle,
@ -540,6 +577,44 @@ function onDispatchCreated (job) {
modalDispatchJobs.value.push(job) modalDispatchJobs.value.push(job)
} }
// GenieACS device actions
function formatTimeAgo (dateStr) {
if (!dateStr) return ''
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'à l\'instant'
if (mins < 60) return `il y a ${mins}m`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `il y a ${hrs}h`
const days = Math.floor(hrs / 24)
return `il y a ${days}j`
}
function signalColor (serial) {
const q = signalQuality(serial)
if (q === 'excellent') return '#4ade80'
if (q === 'good') return '#a3e635'
if (q === 'fair') return '#fbbf24'
return '#f87171'
}
async function doReboot (eq) {
try {
await rebootDevice(eq.serial_number)
Notify.create({ type: 'positive', message: `Redémarrage envoyé: ${eq.serial_number}`, position: 'top' })
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
}
}
async function doRefreshParams (eq) {
try {
await refreshDeviceParams(eq.serial_number)
Notify.create({ type: 'info', message: `Rafraîchissement lancé: ${eq.serial_number}`, position: 'top' })
// Re-fetch status after a short delay
setTimeout(() => fetchStatus([eq]), 3000)
} catch (e) {
Notify.create({ type: 'negative', message: `Erreur: ${e.message}`, position: 'top' })
}
}
// Location state // Location state
const locCollapsed = reactive({}) const locCollapsed = reactive({})
function locHasSubs (locName) { return subscriptions.value.some(s => s.service_location === locName) } function locHasSubs (locName) { return subscriptions.value.some(s => s.service_location === locName) }
@ -676,6 +751,8 @@ async function loadCustomer (id) {
subscriptions.value = subs subscriptions.value = subs
invalidateAll() invalidateAll()
equipment.value = equip equipment.value = equip
// Fetch live GenieACS status for ONTs (non-blocking)
if (equip.length) fetchStatus(equip).catch(() => {})
tickets.value = tix.sort((a, b) => (b.is_important || 0) - (a.is_important || 0) || (b.opening_date || '').localeCompare(a.opening_date || '')) tickets.value = tix.sort((a, b) => (b.is_important || 0) - (a.is_important || 0) || (b.opening_date || '').localeCompare(a.opening_date || ''))
invoices.value = invs invoices.value = invs
payments.value = pays payments.value = pays
@ -1146,8 +1223,9 @@ code {
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 8px; border-radius: 8px;
cursor: default; cursor: pointer;
transition: transform 0.15s; transition: transform 0.15s;
position: relative;
&:hover { &:hover {
transform: scale(1.1); transform: scale(1.1);
@ -1175,4 +1253,22 @@ code {
color: #94a3b8; color: #94a3b8;
} }
} }
.acs-dot {
position: absolute;
top: 2px;
right: 2px;
width: 8px;
height: 8px;
border-radius: 50%;
border: 1.5px solid #fff;
}
.acs-online {
background: #22c55e;
box-shadow: 0 0 4px #22c55e88;
}
.acs-offline {
background: #ef4444;
box-shadow: 0 0 4px #ef444488;
}
</style> </style>

View File

@ -22,6 +22,11 @@
* 3CX Call Log Poller: * 3CX Call Log Poller:
* Polls 3CX xAPI every 30s for new completed calls * Polls 3CX xAPI every 30s for new completed calls
* Logs to ERPNext Communication, broadcasts SSE * Logs to ERPNext Communication, broadcasts SSE
*
* GET|POST|DELETE /devices/*
* GenieACS NBI proxy for CPE/ONT device management
* /devices/summary fleet stats, /devices/lookup?serial=X find device
* /devices/:id/tasks send reboot, getParameterValues, etc.
*/ */
const http = require('http') const http = require('http')
@ -53,6 +58,9 @@ const TWILIO_TWIML_APP_SID = process.env.TWILIO_TWIML_APP_SID || ''
const ROUTR_DB_URL = process.env.ROUTR_DB_URL || '' const ROUTR_DB_URL = process.env.ROUTR_DB_URL || ''
const FNIDENTITY_DB_URL = process.env.FNIDENTITY_DB_URL || '' const FNIDENTITY_DB_URL = process.env.FNIDENTITY_DB_URL || ''
// GenieACS NBI Config
const GENIEACS_NBI_URL = process.env.GENIEACS_NBI_URL || 'http://10.5.2.115:7557'
// ── SSE Client Registry ── // ── SSE Client Registry ──
// Map<topic, Set<{ res, email }>> // Map<topic, Set<{ res, email }>>
const subscribers = new Map() const subscribers = new Map()
@ -669,6 +677,12 @@ const server = http.createServer(async (req, res) => {
return handleTelephony(req, res, method, path, url) return handleTelephony(req, res, method, path, url)
} }
// ─── GenieACS Device Management API ───
// Proxy to GenieACS NBI for CPE/ONT management
if (path.startsWith('/devices')) {
return handleGenieACS(req, res, method, path, url)
}
// ─── 404 ─── // ─── 404 ───
json(res, 404, { error: 'Not found' }) json(res, 404, { error: 'Not found' })
@ -1019,6 +1033,420 @@ function start3cxPoller () {
pbxPollTimer = setInterval(poll3cxCallLog, PBX_POLL_INTERVAL) pbxPollTimer = setInterval(poll3cxCallLog, PBX_POLL_INTERVAL)
} }
// ── GenieACS NBI API Proxy ──
// Provides /devices endpoints for CPE/ONT management via GenieACS NBI (port 7557)
function nbiRequest (nbiPath, method = 'GET', body = null, extraHeaders = {}) {
return new Promise((resolve, reject) => {
const u = new URL(nbiPath, GENIEACS_NBI_URL)
const opts = {
hostname: u.hostname,
port: u.port || 7557,
path: u.pathname + u.search,
method,
headers: { 'Content-Type': 'application/json', ...extraHeaders },
timeout: 10000,
}
const transport = u.protocol === 'https:' ? https : http
const req = transport.request(opts, (resp) => {
let data = ''
resp.on('data', c => { data += c })
resp.on('end', () => {
try {
resolve({ status: resp.statusCode, data: data ? JSON.parse(data) : null })
} catch {
resolve({ status: resp.statusCode, data: data })
}
})
})
req.on('error', reject)
req.on('timeout', () => { req.destroy(); reject(new Error('NBI timeout')) })
if (body) req.write(typeof body === 'string' ? body : JSON.stringify(body))
req.end()
})
}
async function handleGenieACS (req, res, method, path, url) {
try {
if (!GENIEACS_NBI_URL) {
return json(res, 503, { error: 'GenieACS NBI not configured' })
}
const parts = path.replace('/devices', '').split('/').filter(Boolean)
// parts: [] = list, [serial] = device detail, [serial, action] = action
// GET /devices — List all devices (with optional query filter)
if (parts.length === 0 && method === 'GET') {
const q = url.searchParams.get('q') || ''
const projection = url.searchParams.get('projection') || ''
const limit = url.searchParams.get('limit') || '50'
const skip = url.searchParams.get('skip') || '0'
let nbiPath = `/devices/?limit=${limit}&skip=${skip}`
if (q) nbiPath += '&query=' + encodeURIComponent(q)
if (projection) nbiPath += '&projection=' + encodeURIComponent(projection)
const r = await nbiRequest(nbiPath)
return json(res, r.status, r.data)
}
// GET /devices/summary — Aggregated device stats
if (parts[0] === 'summary' && method === 'GET') {
// Get all devices with minimal projection for counting
const r = await nbiRequest('/devices/?projection=_deviceId,_tags&limit=10000')
if (r.status !== 200 || !Array.isArray(r.data)) {
return json(res, r.status, r.data || { error: 'Failed to fetch devices' })
}
const devices = r.data
const stats = {
total: devices.length,
byManufacturer: {},
byProductClass: {},
byTag: {},
}
for (const d of devices) {
const did = d._deviceId || {}
const mfr = did._Manufacturer || 'Unknown'
const pc = did._ProductClass || 'Unknown'
stats.byManufacturer[mfr] = (stats.byManufacturer[mfr] || 0) + 1
stats.byProductClass[pc] = (stats.byProductClass[pc] || 0) + 1
if (d._tags && Array.isArray(d._tags)) {
for (const t of d._tags) {
stats.byTag[t] = (stats.byTag[t] || 0) + 1
}
}
}
return json(res, 200, stats)
}
// GET /devices/:id — Single device detail
if (parts.length === 1 && method === 'GET') {
const deviceId = decodeURIComponent(parts[0])
const q = JSON.stringify({ _id: deviceId })
const r = await nbiRequest('/devices/?query=' + encodeURIComponent(q))
if (r.status !== 200) return json(res, r.status, r.data)
const devices = Array.isArray(r.data) ? r.data : []
if (!devices.length) return json(res, 404, { error: 'Device not found' })
return json(res, 200, devices[0])
}
// POST /devices/:id/reboot — Reboot a CPE
if (parts.length === 2 && parts[1] === 'reboot' && method === 'POST') {
const deviceId = decodeURIComponent(parts[0])
const task = { name: 'reboot' }
const r = await nbiRequest(
'/devices/' + encodeURIComponent(deviceId) + '/tasks?connection_request',
'POST', task
)
return json(res, r.status, r.data)
}
// POST /devices/:id/refresh — Force parameter refresh
if (parts.length === 2 && parts[1] === 'refresh' && method === 'POST') {
const deviceId = decodeURIComponent(parts[0])
const task = {
name: 'getParameterValues',
parameterNames: [
'InternetGatewayDevice.',
'Device.',
],
}
const r = await nbiRequest(
'/devices/' + encodeURIComponent(deviceId) + '/tasks?connection_request',
'POST', task
)
return json(res, r.status, r.data)
}
// POST /devices/:id/set — Set parameter values
if (parts.length === 2 && parts[1] === 'set' && method === 'POST') {
const deviceId = decodeURIComponent(parts[0])
const body = await parseBody(req)
const parsed = JSON.parse(body)
// Expected: { parameterValues: [["path", "value", "type"], ...] }
if (!parsed.parameterValues) {
return json(res, 400, { error: 'Missing parameterValues array' })
}
const task = {
name: 'setParameterValues',
parameterValues: parsed.parameterValues,
}
const r = await nbiRequest(
'/devices/' + encodeURIComponent(deviceId) + '/tasks?connection_request',
'POST', task
)
return json(res, r.status, r.data)
}
// DELETE /devices/:id/tasks/:taskId — Cancel a pending task
if (parts.length === 3 && parts[1] === 'tasks' && method === 'DELETE') {
const taskId = parts[2]
const r = await nbiRequest('/tasks/' + taskId, 'DELETE')
return json(res, r.status, r.data)
}
// GET /devices/:id/tasks — List tasks for a device
if (parts.length === 2 && parts[1] === 'tasks' && method === 'GET') {
const deviceId = decodeURIComponent(parts[0])
const q = JSON.stringify({ device: deviceId })
const r = await nbiRequest('/tasks/?query=' + encodeURIComponent(q))
return json(res, r.status, r.data)
}
// GET /devices/:id/faults — List faults for a device
if (parts.length === 2 && parts[1] === 'faults' && method === 'GET') {
const deviceId = decodeURIComponent(parts[0])
const q = JSON.stringify({ device: deviceId })
const r = await nbiRequest('/faults/?query=' + encodeURIComponent(q))
return json(res, r.status, r.data)
}
// POST /devices/:id/tag — Add a tag
if (parts.length === 2 && parts[1] === 'tag' && method === 'POST') {
const deviceId = decodeURIComponent(parts[0])
const body = await parseBody(req)
const { tag } = JSON.parse(body)
if (!tag) return json(res, 400, { error: 'Missing tag' })
const r = await nbiRequest(
'/devices/' + encodeURIComponent(deviceId) + '/tags/' + encodeURIComponent(tag),
'POST'
)
return json(res, r.status, r.data)
}
// DELETE /devices/:id/tag/:tag — Remove a tag
if (parts.length === 3 && parts[1] === 'tag' && method === 'DELETE') {
const deviceId = decodeURIComponent(parts[0])
const tag = decodeURIComponent(parts[2])
const r = await nbiRequest(
'/devices/' + encodeURIComponent(deviceId) + '/tags/' + encodeURIComponent(tag),
'DELETE'
)
return json(res, r.status, r.data)
}
// GET /devices/faults — List all faults
if (parts[0] === 'faults' && parts.length === 1 && method === 'GET') {
const r = await nbiRequest('/faults/')
return json(res, r.status, r.data)
}
return json(res, 404, { error: 'Unknown device endpoint' })
} catch (e) {
log('GenieACS error:', e.message)
return json(res, 502, { error: 'GenieACS NBI error: ' + e.message })
}
}
// ── GenieACS NBI Proxy ──
// Proxies requests to the GenieACS NBI API for CPE/ONT device management
// Endpoints:
// GET /devices — List all devices (with optional query/projection)
// GET /devices/:id — Get single device by _id
// GET /devices/:id/tasks — Get pending tasks for device
// POST /devices/:id/tasks?connection_request — Push task (reboot, getParameterValues, etc.)
// DELETE /devices/:id — Delete device from ACS
// GET /devices/summary — Aggregate device stats (online/offline counts by model)
function genieRequest (method, path, body) {
return new Promise((resolve, reject) => {
const urlObj = new URL(path, GENIEACS_NBI_URL)
const opts = {
hostname: urlObj.hostname,
port: urlObj.port,
path: urlObj.pathname + urlObj.search,
method,
headers: { 'Content-Type': 'application/json' },
timeout: 15000,
}
const proto = urlObj.protocol === 'https:' ? https : http
const req = proto.request(opts, (resp) => {
let data = ''
resp.on('data', chunk => { data += chunk })
resp.on('end', () => {
try {
resolve({ status: resp.statusCode, data: data ? JSON.parse(data) : null })
} catch {
resolve({ status: resp.statusCode, data: data })
}
})
})
req.on('error', reject)
req.on('timeout', () => { req.destroy(); reject(new Error('GenieACS NBI timeout')) })
if (body) req.write(JSON.stringify(body))
req.end()
})
}
// Extract key parameters from a GenieACS device object into a flat summary
function summarizeDevice (d) {
const get = (path) => {
const node = path.split('.').reduce((obj, k) => obj && obj[k], d)
return node && node._value !== undefined ? node._value : (node && node._object !== undefined ? undefined : null)
}
const getStr = (path) => {
const v = get(path)
return v != null ? String(v) : ''
}
return {
_id: d._id || d['_id'],
serial: getStr('DeviceID.SerialNumber') || (d.DeviceID && d.DeviceID.SerialNumber && d.DeviceID.SerialNumber._value) || '',
manufacturer: getStr('DeviceID.Manufacturer') || (d.DeviceID && d.DeviceID.Manufacturer && d.DeviceID.Manufacturer._value) || '',
model: getStr('DeviceID.ProductClass') || (d.DeviceID && d.DeviceID.ProductClass && d.DeviceID.ProductClass._value) || '',
oui: getStr('DeviceID.OUI') || (d.DeviceID && d.DeviceID.OUI && d.DeviceID.OUI._value) || '',
firmware: getStr('InternetGatewayDevice.DeviceInfo.SoftwareVersion') || getStr('Device.DeviceInfo.SoftwareVersion') || '',
uptime: get('InternetGatewayDevice.DeviceInfo.UpTime') || get('Device.DeviceInfo.UpTime') || null,
lastInform: d['_lastInform'] || null,
lastBoot: d['_lastBootstrap'] || null,
registered: d['_registered'] || null,
ip: getStr('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress')
|| getStr('Device.IP.Interface.1.IPv4Address.1.IPAddress') || '',
rxPower: get('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.RXPower')
|| get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.RXPower')
|| get('Device.Optical.Interface.1.Stats.SignalRxPower') || null,
txPower: get('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.TXPower')
|| get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.TXPower')
|| get('Device.Optical.Interface.1.Stats.SignalTxPower') || null,
pppoeUser: getStr('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username')
|| getStr('Device.PPP.Interface.1.Username') || '',
ssid: getStr('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID')
|| getStr('Device.WiFi.SSID.1.SSID') || '',
macAddress: getStr('InternetGatewayDevice.WANDevice.1.WANEthernetInterfaceConfig.MACAddress')
|| getStr('Device.Ethernet.Interface.1.MACAddress') || '',
tags: d['_tags'] || [],
}
}
async function handleGenieACS (req, res, method, path, url) {
if (!GENIEACS_NBI_URL) {
return json(res, 503, { error: 'GenieACS NBI not configured' })
}
try {
const parts = path.replace('/devices', '').split('/').filter(Boolean)
// GET /devices/summary — aggregate stats
if (parts[0] === 'summary' && method === 'GET') {
const now = Date.now()
const fiveMinAgo = new Date(now - 5 * 60 * 1000).toISOString()
const result = await genieRequest('GET',
`/devices/?projection=DeviceID,_lastInform,_tags&limit=10000`)
const devices = Array.isArray(result.data) ? result.data : []
const stats = { total: devices.length, online: 0, offline: 0, models: {} }
for (const d of devices) {
const model = (d.DeviceID && d.DeviceID.ProductClass && d.DeviceID.ProductClass._value) || 'Unknown'
const lastInform = d._lastInform
const isOnline = lastInform && new Date(lastInform).toISOString() > fiveMinAgo
if (isOnline) stats.online++; else stats.offline++
if (!stats.models[model]) stats.models[model] = { total: 0, online: 0 }
stats.models[model].total++
if (isOnline) stats.models[model].online++
}
return json(res, 200, stats)
}
// GET /devices/lookup?serial=XXX — Find device by serial number
if (parts[0] === 'lookup' && method === 'GET') {
const serial = url.searchParams.get('serial')
const mac = url.searchParams.get('mac')
if (!serial && !mac) return json(res, 400, { error: 'Provide serial or mac parameter' })
let query = ''
if (serial) query = `DeviceID.SerialNumber = "${serial}"`
else if (mac) query = `InternetGatewayDevice.WANDevice.1.WANEthernetInterfaceConfig.MACAddress = "${mac}" OR Device.Ethernet.Interface.1.MACAddress = "${mac}"`
const result = await genieRequest('GET',
`/devices/?query=${encodeURIComponent(query)}&projection=DeviceID,InternetGatewayDevice,Device,_lastInform,_lastBootstrap,_registered,_tags`)
const devices = Array.isArray(result.data) ? result.data : []
return json(res, 200, devices.map(summarizeDevice))
}
// GET /devices — list devices (pass through query params)
if (!parts.length && method === 'GET') {
const limit = url.searchParams.get('limit') || '50'
const skip = url.searchParams.get('skip') || '0'
const query = url.searchParams.get('query') || ''
const projection = url.searchParams.get('projection') || 'DeviceID,_lastInform,_tags'
const sort = url.searchParams.get('sort') || '{"_lastInform":-1}'
let nbiPath = `/devices/?projection=${encodeURIComponent(projection)}&limit=${limit}&skip=${skip}&sort=${encodeURIComponent(sort)}`
if (query) nbiPath += `&query=${encodeURIComponent(query)}`
const result = await genieRequest('GET', nbiPath)
const devices = Array.isArray(result.data) ? result.data : []
// If projection is minimal, return raw; otherwise summarize
if (projection === 'DeviceID,_lastInform,_tags') {
return json(res, 200, devices.map(d => ({
_id: d._id,
serial: (d.DeviceID && d.DeviceID.SerialNumber && d.DeviceID.SerialNumber._value) || '',
manufacturer: (d.DeviceID && d.DeviceID.Manufacturer && d.DeviceID.Manufacturer._value) || '',
model: (d.DeviceID && d.DeviceID.ProductClass && d.DeviceID.ProductClass._value) || '',
oui: (d.DeviceID && d.DeviceID.OUI && d.DeviceID.OUI._value) || '',
lastInform: d._lastInform || null,
tags: d._tags || [],
})))
}
return json(res, 200, devices.map(summarizeDevice))
}
// Decode device ID (URL-encoded, e.g., "202BC1-BM632w-ZTEGC8B042%2F02211")
const deviceId = decodeURIComponent(parts[0])
const subResource = parts[1] || null
// GET /devices/:id — single device detail
if (!subResource && method === 'GET') {
const result = await genieRequest('GET',
`/devices/?query=${encodeURIComponent(`_id = "${deviceId}"`)}&projection=DeviceID,InternetGatewayDevice,Device,_lastInform,_lastBootstrap,_registered,_tags`)
const devices = Array.isArray(result.data) ? result.data : []
if (!devices.length) return json(res, 404, { error: 'Device not found' })
return json(res, 200, summarizeDevice(devices[0]))
}
// POST /devices/:id/tasks — send task to device
if (subResource === 'tasks' && method === 'POST') {
const body = await parseBody(req)
const connReq = url.searchParams.get('connection_request') !== null
let nbiPath = `/devices/${encodeURIComponent(deviceId)}/tasks`
if (connReq) nbiPath += '?connection_request'
const timeout = url.searchParams.get('timeout')
if (timeout) nbiPath += (connReq ? '&' : '?') + `timeout=${timeout}`
const result = await genieRequest('POST', nbiPath, body)
return json(res, result.status, result.data)
}
// GET /devices/:id/tasks — get pending tasks
if (subResource === 'tasks' && method === 'GET') {
const result = await genieRequest('GET',
`/tasks/?query=${encodeURIComponent(`device = "${deviceId}"`)}`)
return json(res, result.status, result.data)
}
// GET /devices/:id/faults — get device faults
if (subResource === 'faults' && method === 'GET') {
const result = await genieRequest('GET',
`/faults/?query=${encodeURIComponent(`device = "${deviceId}"`)}`)
return json(res, result.status, result.data)
}
// DELETE /devices/:id/tasks/:taskId — cancel a task
if (subResource === 'tasks' && parts[2] && method === 'DELETE') {
const result = await genieRequest('DELETE', `/tasks/${parts[2]}`)
return json(res, result.status, result.data)
}
// DELETE /devices/:id — remove device from ACS
if (!subResource && method === 'DELETE') {
const result = await genieRequest('DELETE', `/devices/${encodeURIComponent(deviceId)}`)
return json(res, result.status, result.data)
}
return json(res, 400, { error: 'Unknown device endpoint' })
} catch (e) {
log('GenieACS error:', e.message)
return json(res, 502, { error: 'GenieACS NBI error: ' + e.message })
}
}
server.listen(PORT, '0.0.0.0', () => { server.listen(PORT, '0.0.0.0', () => {
log(`targo-hub listening on :${PORT}`) log(`targo-hub listening on :${PORT}`)
log(` SSE: GET /sse?topics=customer:C-LPB4`) log(` SSE: GET /sse?topics=customer:C-LPB4`)
@ -1032,6 +1460,9 @@ server.listen(PORT, '0.0.0.0', () => {
log(` Health: GET /health`) log(` Health: GET /health`)
log(` Telephony: GET|POST|PUT|DELETE /telephony/{trunks,agents,credentials,numbers,domains,acls,peers,workspaces,users}/{ref?}`) log(` Telephony: GET|POST|PUT|DELETE /telephony/{trunks,agents,credentials,numbers,domains,acls,peers,workspaces,users}/{ref?}`)
log(` Telephony: GET /telephony/overview`) log(` Telephony: GET /telephony/overview`)
log(` Devices: GET /devices, /devices/summary, /devices/lookup?serial=X`)
log(` Devices: GET|POST|DELETE /devices/:id/tasks`)
log(` GenieACS: ${GENIEACS_NBI_URL}`)
log(` Routr DB: ${ROUTR_DB_URL.replace(/:[^:@]+@/, ':***@')}`) log(` Routr DB: ${ROUTR_DB_URL.replace(/:[^:@]+@/, ':***@')}`)
// Start 3CX poller // Start 3CX poller
start3cxPoller() start3cxPoller()