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:
parent
a2c59d6528
commit
ea71eec194
147
apps/ops/src/composables/useDeviceStatus.js
Normal file
147
apps/ops/src/composables/useDeviceStatus.js
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -91,14 +91,49 @@
|
|||
<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 : ''))">
|
||||
<q-icon :name="deviceLucideIcon(eq.equipment_type)" size="20px" />
|
||||
<q-tooltip class="bg-grey-9 text-caption" :offset="[0, 6]">
|
||||
{{ eq.equipment_type }}{{ eq.brand ? ' — ' + eq.brand : '' }}{{ eq.model ? ' ' + eq.model : '' }}
|
||||
<br>SN: {{ eq.serial_number }}
|
||||
<template v-if="eq.mac_address"><br>MAC: {{ eq.mac_address }}</template>
|
||||
<template v-if="eq.olt_name"><br>OLT: {{ eq.olt_name }} — Slot {{ eq.olt_slot }}/Port {{ eq.olt_port }}/ONT {{ eq.olt_ontid }}</template>
|
||||
<template v-else-if="eq.ip_address"><br>IP: {{ eq.ip_address }}</template>
|
||||
<br>{{ eq.status }}
|
||||
<!-- Live status dot from GenieACS -->
|
||||
<span v-if="isOnline(eq.serial_number) === true" class="acs-dot acs-online" />
|
||||
<span v-else-if="isOnline(eq.serial_number) === false" class="acs-dot acs-offline" />
|
||||
<q-tooltip class="bg-grey-9 text-caption" :offset="[0, 6]" style="max-width:320px">
|
||||
<div><strong>{{ eq.equipment_type }}{{ eq.brand ? ' — ' + eq.brand : '' }}{{ eq.model ? ' ' + eq.model : '' }}</strong></div>
|
||||
<div>SN: {{ eq.serial_number }}</div>
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
|
@ -498,6 +533,7 @@ import CustomerInfoCard from 'src/components/customer/CustomerInfoCard.vue'
|
|||
// BillingKPIs removed — data shown in invoices section
|
||||
import InlineField from 'src/components/shared/InlineField.vue'
|
||||
import ChatterPanel from 'src/components/customer/ChatterPanel.vue'
|
||||
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
|
||||
|
||||
const props = defineProps({ id: String })
|
||||
|
||||
|
|
@ -528,6 +564,7 @@ const {
|
|||
} = useSubscriptionActions(subscriptions, customer, comments, invalidateCache)
|
||||
|
||||
const { onNoteAdded } = useCustomerNotes(comments, customer)
|
||||
const { fetchStatus, getDevice, isOnline, signalQuality, rebootDevice, refreshDeviceParams, loading: deviceStatusLoading } = useDeviceStatus()
|
||||
|
||||
const {
|
||||
modalOpen, modalLoading, modalDoctype, modalDocName, modalTitle,
|
||||
|
|
@ -540,6 +577,44 @@ function onDispatchCreated (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 ═══
|
||||
const locCollapsed = reactive({})
|
||||
function locHasSubs (locName) { return subscriptions.value.some(s => s.service_location === locName) }
|
||||
|
|
@ -676,6 +751,8 @@ async function loadCustomer (id) {
|
|||
subscriptions.value = subs
|
||||
invalidateAll()
|
||||
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 || ''))
|
||||
invoices.value = invs
|
||||
payments.value = pays
|
||||
|
|
@ -1146,8 +1223,9 @@ code {
|
|||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
|
|
@ -1175,4 +1253,22 @@ code {
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@
|
|||
* 3CX Call Log Poller:
|
||||
* → Polls 3CX xAPI every 30s for new completed calls
|
||||
* → 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')
|
||||
|
|
@ -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 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 ──
|
||||
// Map<topic, Set<{ res, email }>>
|
||||
const subscribers = new Map()
|
||||
|
|
@ -669,6 +677,12 @@ const server = http.createServer(async (req, res) => {
|
|||
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 ───
|
||||
json(res, 404, { error: 'Not found' })
|
||||
|
||||
|
|
@ -1019,6 +1033,420 @@ function start3cxPoller () {
|
|||
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', () => {
|
||||
log(`targo-hub listening on :${PORT}`)
|
||||
log(` SSE: GET /sse?topics=customer:C-LPB4`)
|
||||
|
|
@ -1032,6 +1460,9 @@ server.listen(PORT, '0.0.0.0', () => {
|
|||
log(` Health: GET /health`)
|
||||
log(` Telephony: GET|POST|PUT|DELETE /telephony/{trunks,agents,credentials,numbers,domains,acls,peers,workspaces,users}/{ref?}`)
|
||||
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(/:[^:@]+@/, ':***@')}`)
|
||||
// Start 3CX poller
|
||||
start3cxPoller()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user