diff --git a/apps/ops/src/composables/useDeviceStatus.js b/apps/ops/src/composables/useDeviceStatus.js new file mode 100644 index 0000000..7a893b3 --- /dev/null +++ b/apps/ops/src/composables/useDeviceStatus.js @@ -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. + */ +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, + } +} diff --git a/apps/ops/src/pages/ClientDetailPage.vue b/apps/ops/src/pages/ClientDetailPage.vue index 73503df..8899a87 100644 --- a/apps/ops/src/pages/ClientDetailPage.vue +++ b/apps/ops/src/pages/ClientDetailPage.vue @@ -91,14 +91,49 @@
- - {{ eq.equipment_type }}{{ eq.brand ? ' — ' + eq.brand : '' }}{{ eq.model ? ' ' + eq.model : '' }} -
SN: {{ eq.serial_number }} - - - -
{{ eq.status }} + + + + +
{{ eq.equipment_type }}{{ eq.brand ? ' — ' + eq.brand : '' }}{{ eq.model ? ' ' + eq.model : '' }}
+
SN: {{ eq.serial_number }}
+ + + +
{{ eq.status }}
+ +
+ + + + + + Redémarrer + + + + Rafraîchir params + + +
@@ -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; +} diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js index 6b1de4d..82991c7 100644 --- a/services/targo-hub/server.js +++ b/services/targo-hub/server.js @@ -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> 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()