Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor, admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS. Kredit & lisensi (ISC): - Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak atas base kode dan repo yang bermanfaat. - Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder. - Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
2817 lines
97 KiB
JavaScript
2817 lines
97 KiB
JavaScript
const snmp = require('net-snmp');
|
||
const net = require('net');
|
||
const Database = require('better-sqlite3');
|
||
const path = require('path');
|
||
const winston = require('winston');
|
||
const axios = require('axios');
|
||
const genieacs = require('../config/genieacs');
|
||
|
||
// Logger configuration
|
||
const logger = winston.createLogger({
|
||
level: 'info',
|
||
format: winston.format.combine(
|
||
winston.format.timestamp(),
|
||
winston.format.json()
|
||
),
|
||
transports: [
|
||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
||
new winston.transports.File({ filename: 'logs/combined.log' }),
|
||
new winston.transports.Console({
|
||
format: winston.format.combine(
|
||
winston.format.colorize(),
|
||
winston.format.simple()
|
||
)
|
||
})
|
||
]
|
||
});
|
||
|
||
const dbPath = path.join(__dirname, '../database/billing.db');
|
||
const db = new Database(dbPath);
|
||
|
||
/**
|
||
* Profil SNMP per brand OLT
|
||
* Setiap profil mendefinisikan OID tabel untuk status, nama, dan cara deteksi.
|
||
*/
|
||
const BRAND_PROFILES = {
|
||
hioso: [
|
||
{
|
||
name: 'HIOSO_EPON_C',
|
||
status_table: '1.3.6.1.4.1.25355.3.2.6.3.2.1.39',
|
||
name_table: '1.3.6.1.4.1.25355.3.2.6.3.2.1.37',
|
||
sn_table: '1.3.6.1.4.1.25355.3.2.6.3.2.1.11',
|
||
tx_power_table: '1.3.6.1.4.1.25355.3.2.6.14.2.1.4',
|
||
rx_power_table: '1.3.6.1.4.1.25355.3.2.6.14.2.1.8',
|
||
probe_oid: '1.3.6.1.4.1.25355.3.2.6.3.2.1.39',
|
||
/** OLT_OID_REFERENCE: integer meter (bukan per 0,1 m seperti ZTE/Huawei) */
|
||
distance_table: '1.3.6.1.4.1.25355.3.2.6.3.2.1.25',
|
||
distance_tenths_meter: false,
|
||
},
|
||
{
|
||
name: 'HIOSO_EPON_B',
|
||
status_table: '1.3.6.1.4.1.3320.101.10.1.1.26',
|
||
name_table: '1.3.6.1.4.1.3320.101.10.1.1.79',
|
||
sn_table: '1.3.6.1.4.1.3320.101.10.1.1.3',
|
||
tx_power_table: '1.3.6.1.4.1.3320.101.10.5.1.5',
|
||
rx_power_table: '1.3.6.1.4.1.3320.101.10.5.1.6',
|
||
offline_reason_table: '1.3.6.1.4.1.3320.101.11.1.1.11',
|
||
probe_oid: '1.3.6.1.4.1.3320.101.10.1.1.26',
|
||
},
|
||
{
|
||
name: 'HIOSO_GPON',
|
||
status_table: '1.3.6.1.4.1.25355.3.3.1.1.1.11',
|
||
name_table: '1.3.6.1.4.1.25355.3.3.1.1.1.2',
|
||
sn_table: '1.3.6.1.4.1.25355.3.3.1.1.1.5',
|
||
tx_power_table: '1.3.6.1.4.1.25355.3.3.1.1.4.1.2',
|
||
rx_power_table: '1.3.6.1.4.1.25355.3.3.1.1.4.1.1',
|
||
probe_oid: '1.3.6.1.4.1.25355.3.3.1.1.1.11',
|
||
},
|
||
],
|
||
hsgq: [
|
||
{
|
||
name: 'HSGQ_EPON',
|
||
status_table: '1.3.6.1.4.1.3320.101.10.1.1.26',
|
||
name_table: '1.3.6.1.4.1.3320.101.10.1.1.79',
|
||
sn_table: '1.3.6.1.4.1.3320.101.10.1.1.3',
|
||
tx_power_table: '1.3.6.1.4.1.3320.101.10.5.1.5',
|
||
rx_power_table: '1.3.6.1.4.1.3320.101.10.5.1.6',
|
||
offline_reason_table: '1.3.6.1.4.1.3320.101.11.1.1.11',
|
||
probe_oid: '1.3.6.1.4.1.3320.101.10.1.1.26',
|
||
},
|
||
],
|
||
zte: [
|
||
{
|
||
name: 'ZTE_GPON_C300',
|
||
status_table: '1.3.6.1.4.1.3902.1082.500.10.2.3.3.1.9',
|
||
name_table: '1.3.6.1.4.1.3902.1082.500.10.2.3.3.1.2',
|
||
sn_table: '1.3.6.1.4.1.3902.1082.500.10.2.3.3.1.6',
|
||
tx_power_table: '1.3.6.1.4.1.3902.1015.1010.11.2.1.1', // TX Optical power (0.01 dBm)
|
||
rx_power_table: '1.3.6.1.4.1.3902.1015.1010.11.2.1.2', // RX Optical power (0.01 dBm)
|
||
offline_reason_table: '1.3.6.1.4.1.3902.1082.500.10.2.3.8.1.7',
|
||
probe_oid: '1.3.6.1.4.1.3902.1082.500.10.2.3.3.1.9',
|
||
unauth_sn_table: '1.3.6.1.4.1.3902.1012.3.13.3.1.2',
|
||
unauth_type_table: '1.3.6.1.4.1.3902.1012.3.13.3.1.10',
|
||
distance_table: '1.3.6.1.4.1.3902.1015.1010.11.2.1.4',
|
||
distance_tenths_meter: true,
|
||
firmware_table: '1.3.6.1.4.1.3902.1015.1010.11.2.1.5',
|
||
uptime_table: '1.3.6.1.4.1.3902.1015.1010.11.2.1.6',
|
||
},
|
||
{
|
||
name: 'ZTE_GPON_C600',
|
||
status_table: '1.3.6.1.4.1.3902.1082.500.12.2.3.3.1.10',
|
||
name_table: '1.3.6.1.4.1.3902.1082.500.12.2.3.3.1.2',
|
||
sn_table: '1.3.6.1.4.1.3902.1082.500.12.2.3.3.1.3',
|
||
tx_power_table: '1.3.6.1.4.1.3902.1082.500.12.2.3.7.1.2',
|
||
rx_power_table: '1.3.6.1.4.1.3902.1082.500.12.2.3.7.1.3',
|
||
offline_reason_table: '1.3.6.1.4.1.3902.1082.500.12.2.3.8.1.7',
|
||
probe_oid: '1.3.6.1.4.1.3902.1082.500.12.2.3.3.1.10',
|
||
unauth_sn_table: '1.3.6.1.4.1.3902.1082.500.12.2.3.11.1.2',
|
||
unauth_type_table: '1.3.6.1.4.1.3902.1082.500.12.2.3.11.1.10',
|
||
},
|
||
{
|
||
name: 'ZTE_GPON_OLD',
|
||
status_table: '1.3.6.1.4.1.3902.1012.3.28.2.1.4',
|
||
name_table: '1.3.6.1.4.1.3902.1082.500.10.2.3.3.1.2',
|
||
sn_table: '1.3.6.1.4.1.3902.1082.500.10.2.3.3.1.6',
|
||
tx_power_table: '1.3.6.1.4.1.3902.1015.1010.11.2.1.1',
|
||
rx_power_table: '1.3.6.1.4.1.3902.1015.1010.11.2.1.2',
|
||
probe_oid: '1.3.6.1.4.1.3902.1012.3.28.2.1.4',
|
||
unauth_sn_table: '1.3.6.1.4.1.3902.1012.3.13.3.1.2',
|
||
}
|
||
],
|
||
vsol: [
|
||
{
|
||
name: 'VSOL_EPON',
|
||
status_table: '1.3.6.1.4.1.37950.1.1.5.13.1.1.4',
|
||
name_table: '1.3.6.1.4.1.37950.1.1.5.13.1.1.10',
|
||
sn_table: '1.3.6.1.4.1.37950.1.1.5.13.1.1.2',
|
||
tx_power_table: '1.3.6.1.4.1.37950.1.1.5.13.1.1.20', // 0.1 dBm
|
||
rx_power_table: '1.3.6.1.4.1.37950.1.1.5.13.1.1.21', // 0.1 dBm
|
||
probe_oid: '1.3.6.1.4.1.37950.1.1.5.13.1.1.4',
|
||
},
|
||
],
|
||
huawei: [
|
||
{
|
||
name: 'HUAWEI_GPON',
|
||
status_table: '1.3.6.1.4.1.2011.6.128.1.1.2.43.1.11',
|
||
name_table: '1.3.6.1.4.1.2011.6.128.1.1.2.43.1.3',
|
||
sn_table: '1.3.6.1.4.1.2011.6.128.1.1.2.43.1.9',
|
||
tx_power_table: '1.3.6.1.4.1.2011.6.128.1.1.2.46.1.3', // 0.01 dBm
|
||
rx_power_table: '1.3.6.1.4.1.2011.6.128.1.1.2.46.1.4', // 0.01 dBm
|
||
offline_reason_table: '1.3.6.1.4.1.2011.6.128.1.1.2.46.1.24',
|
||
probe_oid: '1.3.6.1.4.1.2011.6.128.1.1.2.43.1.11',
|
||
unauth_sn_table: '1.3.6.1.4.1.2011.6.128.1.1.2.45.1.4',
|
||
unauth_type_table: '1.3.6.1.4.1.2011.6.128.1.1.2.45.1.5',
|
||
distance_table: '1.3.6.1.4.1.2011.6.128.1.1.2.46.1.20',
|
||
distance_tenths_meter: true,
|
||
firmware_table: '1.3.6.1.4.1.2011.6.128.1.1.2.43.1.10',
|
||
uptime_table: '1.3.6.1.4.1.2011.6.128.1.1.2.46.1.15',
|
||
}
|
||
],
|
||
fiberhome: [
|
||
{
|
||
name: 'FIBERHOME_GPON',
|
||
status_table: '1.3.6.1.4.1.27332.1.1.1.8.1.7',
|
||
name_table: '1.3.6.1.4.1.27332.1.1.1.8.1.3',
|
||
sn_table: '1.3.6.1.4.1.27332.1.1.1.8.1.4',
|
||
tx_power_table: '1.3.6.1.4.1.27332.1.1.1.11.1.3',
|
||
rx_power_table: '1.3.6.1.4.1.27332.1.1.1.11.1.4',
|
||
probe_oid: '1.3.6.1.4.1.27332.1.1.1.8.1.7',
|
||
unauth_sn_table: '1.3.6.1.4.1.27332.1.1.1.1.1.1.10',
|
||
}
|
||
],
|
||
bdcom: [
|
||
{
|
||
name: 'BDCOM_GPON',
|
||
status_table: '1.3.6.1.4.1.3320.101.10.1.1.7',
|
||
name_table: '1.3.6.1.4.1.3320.101.10.1.1.3',
|
||
sn_table: '1.3.6.1.4.1.3320.101.10.1.1.4',
|
||
tx_power_table: '1.3.6.1.4.1.3320.101.10.3.1.3',
|
||
rx_power_table: '1.3.6.1.4.1.3320.101.10.3.1.4',
|
||
probe_oid: '1.3.6.1.4.1.3320.101.10.1.1.7',
|
||
}
|
||
],
|
||
cdata: [
|
||
{
|
||
name: 'CDATA_EPON',
|
||
status_table: '1.3.6.1.4.1.34592.1.3.100.12.1.1.1.15',
|
||
name_table: '1.3.6.1.4.1.34592.1.3.100.12.1.1.1.10',
|
||
sn_table: '1.3.6.1.4.1.34592.1.3.100.12.1.1.1.2', // LLID / MAC table (bukan .10)
|
||
tx_power_table: '1.3.6.1.4.1.34592.1.3.100.12.1.1.1.20',
|
||
rx_power_table: '1.3.6.1.4.1.34592.1.3.100.12.1.1.1.21', // 0.1 dBm
|
||
probe_oid: '1.3.6.1.4.1.34592.1.3.100.12.1.1.1.15',
|
||
}
|
||
],
|
||
generic: [
|
||
{
|
||
name: 'GENERIC_EPON_CTC',
|
||
status_table: '1.3.6.1.4.1.3320.101.10.1.1.26',
|
||
name_table: '1.3.6.1.4.1.3320.101.10.1.1.79',
|
||
sn_table: '1.3.6.1.4.1.3320.101.10.1.1.3',
|
||
tx_power_table: '1.3.6.1.4.1.3320.101.10.5.1.5',
|
||
rx_power_table: '1.3.6.1.4.1.3320.101.10.5.1.6',
|
||
probe_oid: '1.3.6.1.4.1.3320.101.10.1.1.26',
|
||
},
|
||
{
|
||
name: 'GENERIC_GPON_STD',
|
||
status_table: '1.3.6.1.4.1.2011.6.128.1.1.2.43.1.11',
|
||
name_table: '1.3.6.1.4.1.2011.6.128.1.1.2.43.1.3',
|
||
sn_table: '1.3.6.1.4.1.2011.6.128.1.1.2.43.1.9',
|
||
tx_power_table: '1.3.6.1.4.1.2011.6.128.1.1.2.46.1.3',
|
||
rx_power_table: '1.3.6.1.4.1.2011.6.128.1.1.2.46.1.4',
|
||
probe_oid: '1.3.6.1.4.1.2011.6.128.1.1.2.43.1.11',
|
||
}
|
||
]
|
||
};
|
||
|
||
// Nilai status yang dianggap "online" per brand
|
||
const ONLINE_VALUES = {
|
||
hioso: [1, 3, 4],
|
||
hsgq: [1, 3, 4],
|
||
zte: [1, 3, 'working', 'online'],
|
||
vsol: [1, 3, 4],
|
||
huawei: [5, 1, 'active', 'online'], // 5: operation
|
||
fiberhome: [1, 2, 3],
|
||
bdcom: [1, 2, 3],
|
||
cdata: [1, 3],
|
||
generic: [1, 2, 3, 4, 5, 'online', 'active', 'working'],
|
||
};
|
||
|
||
const getOnlineValues = (brandKey, profile) => {
|
||
const base = ONLINE_VALUES[brandKey] || [1, 3];
|
||
const profileName = String(profile?.name || '').toLowerCase();
|
||
const nameOid = String(profile?.name_table || '');
|
||
const isHiosoGpon = (brandKey === 'hioso' || brandKey === 'hsgq')
|
||
&& (profileName.includes('gpon') || nameOid.includes('.25355.3.3.'));
|
||
if (isHiosoGpon) return [2, 3, 4];
|
||
return base;
|
||
};
|
||
|
||
/**
|
||
* OID sistem per brand untuk mengambil metrics hardware.
|
||
* Semua diambil dengan snmp.get (bukan walk).
|
||
*/
|
||
const SYSTEM_OIDS = {
|
||
hioso: {
|
||
temp: '1.3.6.1.4.1.25355.3.2.1.1.1.0', // Suhu (°C)
|
||
cpu: '1.3.6.1.4.1.25355.3.2.1.1.2.0', // CPU Usage (%)
|
||
ram: '1.3.6.1.4.1.25355.3.2.1.1.3.0', // RAM Usage (%)
|
||
uplink_rx: '1.3.6.1.2.1.31.1.1.1.6.1', // ifHCInOctets (uplink port 1)
|
||
uplink_tx: '1.3.6.1.2.1.31.1.1.1.10.1', // ifHCOutOctets (uplink port 1)
|
||
},
|
||
hsgq: {
|
||
temp: '1.3.6.1.4.1.3320.101.11.1.12.1', // Temp sensor
|
||
cpu: '1.3.6.1.4.1.3320.101.11.1.13.1', // CPU Usage
|
||
ram: '1.3.6.1.4.1.3320.101.11.1.14.1', // RAM Usage
|
||
uplink_rx: '1.3.6.1.2.1.31.1.1.1.6.1',
|
||
uplink_tx: '1.3.6.1.2.1.31.1.1.1.10.1',
|
||
},
|
||
zte: {
|
||
temp: '1.3.6.1.4.1.3902.1082.500.10.2.2.4.1.19.1.1.1', // Temp sensor (zxAnCardCpuTemp)
|
||
cpu: '1.3.6.1.4.1.3902.1082.500.10.2.2.4.1.10.1.1.1', // CPU Usage
|
||
ram: '1.3.6.1.4.1.3902.1082.500.10.2.2.4.1.11.1.1.1', // RAM Usage
|
||
uplink_rx: '1.3.6.1.2.1.31.1.1.1.6.1',
|
||
uplink_tx: '1.3.6.1.2.1.31.1.1.1.10.1',
|
||
},
|
||
vsol: {
|
||
temp: '1.3.6.1.4.1.37950.1.1.5.1.1.11',
|
||
cpu: '1.3.6.1.4.1.37950.1.1.5.1.1.2',
|
||
ram: '1.3.6.1.4.1.37950.1.1.5.1.1.4',
|
||
uplink_rx: '1.3.6.1.2.1.31.1.1.1.6.1',
|
||
uplink_tx: '1.3.6.1.2.1.31.1.1.1.10.1',
|
||
},
|
||
huawei: {
|
||
temp: '1.3.6.1.4.1.2011.6.128.1.1.2.23.1.13.0.0', // Suhu (°C)
|
||
cpu: '1.3.6.1.4.1.2011.6.128.1.1.2.23.1.14.0.0', // CPU (%)
|
||
ram: '1.3.6.1.4.1.2011.6.128.1.1.2.23.1.15.0.0', // RAM (%)
|
||
uplink_rx: '1.3.6.1.2.1.31.1.1.1.6.1',
|
||
uplink_tx: '1.3.6.1.2.1.31.1.1.1.10.1',
|
||
},
|
||
fiberhome: {
|
||
temp: '1.3.6.1.4.1.27332.1.1.1.9.1.11.1.1', // Suhu (°C)
|
||
cpu: '1.3.6.1.4.1.27332.1.1.1.9.1.12.1.1', // CPU (%)
|
||
ram: '1.3.6.1.4.1.27332.1.1.1.9.1.14.1.1', // RAM (%)
|
||
uplink_rx: '1.3.6.1.2.1.31.1.1.1.6.1',
|
||
uplink_tx: '1.3.6.1.2.1.31.1.1.1.10.1',
|
||
},
|
||
bdcom: {
|
||
temp: '1.3.6.1.4.1.3320.101.11.1.12.1', // Suhu (°C)
|
||
cpu: '1.3.6.1.4.1.3320.101.11.1.13.1', // CPU (%)
|
||
ram: '1.3.6.1.4.1.3320.101.11.1.14.1', // RAM (%)
|
||
uplink_rx: '1.3.6.1.2.1.31.1.1.1.6.1',
|
||
uplink_tx: '1.3.6.1.2.1.31.1.1.1.10.1',
|
||
},
|
||
cdata: {
|
||
temp: '1.3.6.1.4.1.34592.1.3.100.1.1.1.2.0',
|
||
cpu: '1.3.6.1.4.1.34592.1.3.100.1.1.1.3.0',
|
||
ram: '1.3.6.1.4.1.34592.1.3.100.1.1.1.4.0',
|
||
uplink_rx: '1.3.6.1.2.1.31.1.1.1.6.1',
|
||
uplink_tx: '1.3.6.1.2.1.31.1.1.1.10.1',
|
||
},
|
||
};
|
||
|
||
const CARD_OIDS = {
|
||
zte: {
|
||
type: '1.3.6.1.4.1.3902.1082.500.10.2.2.4.1.4',
|
||
status: '1.3.6.1.4.1.3902.1082.500.10.2.2.4.1.6',
|
||
ports: '1.3.6.1.4.1.3902.1082.500.10.2.2.4.1.7',
|
||
cpu: '1.3.6.1.4.1.3902.1082.500.10.2.2.4.1.10',
|
||
ram: '1.3.6.1.4.1.3902.1082.500.10.2.2.4.1.11',
|
||
serial: '1.3.6.1.4.1.3902.1082.500.10.2.2.4.1.12',
|
||
},
|
||
huawei: {
|
||
type: '1.3.6.1.4.1.2011.6.128.1.1.2.23.1.4',
|
||
status: '1.3.6.1.4.1.2011.6.128.1.1.2.23.1.6',
|
||
ports: '1.3.6.1.4.1.2011.6.128.1.1.2.23.1.3',
|
||
cpu: '1.3.6.1.4.1.2011.6.128.1.1.2.23.1.14',
|
||
ram: '1.3.6.1.4.1.2011.6.128.1.1.2.23.1.15',
|
||
serial: '1.3.6.1.4.1.2011.6.128.1.1.2.23.1.9',
|
||
},
|
||
fiberhome: {
|
||
type: '1.3.6.1.4.1.27332.1.1.1.9.1.3',
|
||
status: '1.3.6.1.4.1.27332.1.1.1.9.1.5',
|
||
ports: '1.3.6.1.4.1.27332.1.1.1.9.1.6',
|
||
cpu: '1.3.6.1.4.1.27332.1.1.1.9.1.12',
|
||
ram: '1.3.6.1.4.1.27332.1.1.1.9.1.14',
|
||
serial: '1.3.6.1.4.1.27332.1.1.1.9.1.8',
|
||
},
|
||
bdcom: {
|
||
type: '1.3.6.1.4.1.3320.101.11.1.3',
|
||
status: '1.3.6.1.4.1.3320.101.11.1.5',
|
||
ports: '1.3.6.1.4.1.3320.101.11.1.6',
|
||
cpu: '1.3.6.1.4.1.3320.101.11.1.13',
|
||
ram: '1.3.6.1.4.1.3320.101.11.1.14',
|
||
serial: '1.3.6.1.4.1.3320.101.11.1.8',
|
||
}
|
||
};
|
||
|
||
const CARD_STATUS_MAP = {
|
||
1: 'INSERVICE',
|
||
2: 'STANDBY',
|
||
3: 'OFFLINE',
|
||
4: 'FAILED',
|
||
5: 'INIT',
|
||
};
|
||
|
||
// ─── DB CRUD ────────────────────────────────────────────────────────────────
|
||
|
||
function getAllOlts() {
|
||
return db.prepare('SELECT * FROM olts ORDER BY created_at DESC').all();
|
||
}
|
||
|
||
function getActiveOlts() {
|
||
return db.prepare('SELECT * FROM olts WHERE is_active = 1').all();
|
||
}
|
||
|
||
function getOltById(id) {
|
||
return db.prepare('SELECT * FROM olts WHERE id = ?').get(id);
|
||
}
|
||
|
||
function createOlt(data) {
|
||
const apiBase = String(data.api_base_url || '').trim();
|
||
const telnetPort = parseInt(data.telnet_port, 10);
|
||
const stmt = db.prepare(`
|
||
INSERT INTO olts (name, host, snmp_community, snmp_port, brand, description, is_active, web_user, web_password, api_base_url, telnet_port, enable_password)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`);
|
||
return stmt.run(
|
||
data.name,
|
||
data.host,
|
||
data.snmp_community || 'public',
|
||
data.snmp_port || 161,
|
||
data.brand || 'hioso',
|
||
data.description || '',
|
||
data.is_active !== undefined ? data.is_active : 1,
|
||
data.web_user || '',
|
||
data.web_password || '',
|
||
apiBase || null,
|
||
Number.isFinite(telnetPort) && telnetPort > 0 ? telnetPort : 23,
|
||
data.enable_password != null && String(data.enable_password).length ? String(data.enable_password) : null
|
||
);
|
||
}
|
||
|
||
function updateOlt(id, data) {
|
||
const prev = getOltById(id);
|
||
const apiBase = String(data.api_base_url || '').trim();
|
||
const telnetPort = parseInt(data.telnet_port, 10);
|
||
const enablePass = String(data.enable_password || '').trim().length > 0
|
||
? String(data.enable_password).trim()
|
||
: (prev && prev.enable_password) || null;
|
||
const stmt = db.prepare(`
|
||
UPDATE olts
|
||
SET name = ?, host = ?, snmp_community = ?, snmp_port = ?, brand = ?, description = ?, is_active = ?, web_user = ?, web_password = ?, api_base_url = ?, telnet_port = ?, enable_password = ?
|
||
WHERE id = ?
|
||
`);
|
||
return stmt.run(
|
||
data.name,
|
||
data.host,
|
||
data.snmp_community,
|
||
data.snmp_port,
|
||
data.brand,
|
||
data.description,
|
||
data.is_active ? 1 : 0,
|
||
data.web_user || '',
|
||
data.web_password || '',
|
||
apiBase || null,
|
||
Number.isFinite(telnetPort) && telnetPort > 0 ? telnetPort : 23,
|
||
enablePass,
|
||
id
|
||
);
|
||
}
|
||
|
||
function deleteOlt(id) {
|
||
return db.prepare('DELETE FROM olts WHERE id = ?').run(id);
|
||
}
|
||
|
||
// ─── SNMP HELPERS ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Normalisasi OID: hapus prefix 'iso.' atau awalan '1.' yang ganda
|
||
*/
|
||
const normalizeOid = (oid) => {
|
||
if (!oid) return '';
|
||
return oid.replace(/^iso\./, '1.').replace(/^\./, '');
|
||
};
|
||
|
||
/**
|
||
* Ekstrak suffix index dari OID yang di-walk
|
||
*/
|
||
const extractIdx = (rawOid, baseOid) => {
|
||
const normRaw = normalizeOid(rawOid);
|
||
const normBase = normalizeOid(baseOid);
|
||
if (normRaw.startsWith(normBase + '.')) {
|
||
return normRaw.substring(normBase.length + 1);
|
||
}
|
||
if (normRaw.startsWith(normBase)) {
|
||
return normRaw.substring(normBase.length).replace(/^\./, '');
|
||
}
|
||
// fallback
|
||
return rawOid.split('.').slice(-1)[0];
|
||
};
|
||
|
||
/**
|
||
* Cek apakah OID yang di-return masih di bawah baseOid
|
||
*/
|
||
const oidUnderBase = (rawOid, baseOid) => {
|
||
const normRaw = normalizeOid(rawOid);
|
||
const normBase = normalizeOid(baseOid);
|
||
return normRaw.startsWith(normBase + '.') || normRaw === normBase;
|
||
};
|
||
|
||
function formatSnWithColons(val) {
|
||
if (!val || typeof val !== 'string') return val;
|
||
const clean = val.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||
// Jika 12 karakter hex (MAC address format MikroTik, misal 2C334187A820 -> 2C:33:41:87:A8:20)
|
||
if (/^[0-9A-F]{12}$/.test(clean)) {
|
||
return clean.match(/.{1,2}/g).join(':');
|
||
}
|
||
// Jika 16 karakter hex (8 bytes GPON hex)
|
||
if (/^[0-9A-F]{16}$/.test(clean)) {
|
||
return clean.match(/.{1,2}/g).join(':');
|
||
}
|
||
return val;
|
||
}
|
||
|
||
const decodeSn = (val) => {
|
||
if (!val) return 'N/A';
|
||
let res = '';
|
||
if (Buffer.isBuffer(val)) {
|
||
const ascii = val.toString('utf8').replace(/\0/g, '').trim();
|
||
const looksAscii = ascii.length >= 4 && /^[\x20-\x7E]+$/.test(ascii);
|
||
res = looksAscii ? ascii.toUpperCase() : val.toString('hex').toUpperCase();
|
||
} else {
|
||
res = val.toString().toUpperCase();
|
||
}
|
||
return formatSnWithColons(res);
|
||
};
|
||
|
||
/**
|
||
* SN di SNMP sering jadi hex rapat (mis. 88D2742B900D); di CLI OLT bisa ditampilkan bertitik dua (88:D2:74:2B:90:0D).
|
||
* Untuk perintah otorisasi, keduanya harus jadi bentuk yang sama (hex huruf besar tanpa separator, atau SN GPON vendor).
|
||
*/
|
||
function normalizeSnForOltProvision(sn) {
|
||
const raw = String(sn == null ? '' : sn).trim();
|
||
if (!raw) return raw;
|
||
const alnum = raw.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||
if (/^[A-F0-9]+$/.test(alnum) && (alnum.length === 12 || alnum.length === 16)) return alnum;
|
||
if (/^[A-Z]{4}/.test(alnum) && alnum.length >= 8) return alnum;
|
||
return alnum;
|
||
}
|
||
|
||
const decodeUptime = (ticks) => {
|
||
if (!ticks) return 'N/A';
|
||
let seconds = Math.floor(ticks / 100);
|
||
const days = Math.floor(seconds / 86400);
|
||
seconds %= 86400;
|
||
const hours = Math.floor(seconds / 3600);
|
||
seconds %= 3600;
|
||
const minutes = Math.floor(seconds / 60);
|
||
return `${days}d ${hours}h ${minutes}m`;
|
||
};
|
||
|
||
const hiosoOnuIdFromIndex = (index) => {
|
||
if (index == null) return null;
|
||
const s = String(index).trim();
|
||
if (!s) return null;
|
||
|
||
if (s.includes('.')) {
|
||
const parts = s.split('.').filter(Boolean);
|
||
if (parts.length >= 2) {
|
||
const onu = parseInt(parts[parts.length - 1], 10);
|
||
const port = parseInt(parts[parts.length - 2], 10);
|
||
if (Number.isFinite(port) && Number.isFinite(onu)) return `0/${port}:${onu}`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const intIdx = parseInt(s, 10);
|
||
if (!Number.isFinite(intIdx)) return null;
|
||
let port = (intIdx >> 16) & 0xff;
|
||
if (port === 0 || port > 16) port = (intIdx >> 8) & 0xff;
|
||
const onu = intIdx & 0xff;
|
||
if (port === 0) return null;
|
||
return `0/${port}:${onu}`;
|
||
};
|
||
|
||
const telnetReadUntil = (socket, matcher, timeoutMs) => {
|
||
return new Promise((resolve, reject) => {
|
||
let buf = '';
|
||
const onData = (chunk) => {
|
||
const s = chunk.toString('utf8');
|
||
buf += s;
|
||
if (buf.includes('--More--') || buf.includes('--Press Enter--') || buf.includes('Enter Key To Continue')) {
|
||
socket.write(' ');
|
||
}
|
||
if (typeof matcher === 'function' ? matcher(buf) : matcher.test(buf)) {
|
||
cleanup();
|
||
resolve(buf);
|
||
}
|
||
};
|
||
const onErr = (e) => { cleanup(); reject(e); };
|
||
const onClose = () => { cleanup(); reject(new Error('Telnet connection closed')); };
|
||
const t = setTimeout(() => { cleanup(); reject(new Error('Telnet timeout')); }, timeoutMs);
|
||
const cleanup = () => {
|
||
clearTimeout(t);
|
||
socket.off('data', onData);
|
||
socket.off('error', onErr);
|
||
socket.off('close', onClose);
|
||
};
|
||
socket.on('data', onData);
|
||
socket.on('error', onErr);
|
||
socket.on('close', onClose);
|
||
});
|
||
};
|
||
|
||
const telnetLoginAndRun = async (host, user, pass, commands, opts = {}) => {
|
||
const telnetPort = Number(opts.port) > 0 ? Number(opts.port) : 23;
|
||
const enablePassword = opts.enablePassword != null && String(opts.enablePassword).length > 0
|
||
? String(opts.enablePassword)
|
||
: null;
|
||
|
||
const socket = new net.Socket();
|
||
socket.setTimeout(15000);
|
||
|
||
await new Promise((resolve, reject) => {
|
||
socket.connect(telnetPort, host, resolve);
|
||
socket.once('error', reject);
|
||
});
|
||
|
||
const promptRe = /[>#]\s*$/m;
|
||
const loginRe = /(login|username)\s*[:>]\s*$/im;
|
||
const passRe = /(password|passwd)\s*[:>]\s*$/im;
|
||
|
||
let banner = '';
|
||
try {
|
||
banner = await telnetReadUntil(socket, (b) => loginRe.test(b) || passRe.test(b) || promptRe.test(b), 8000);
|
||
} catch (e) {
|
||
socket.destroy();
|
||
throw e;
|
||
}
|
||
|
||
if (loginRe.test(banner)) {
|
||
socket.write(String(user || 'admin') + '\r\n');
|
||
await telnetReadUntil(socket, passRe, 8000);
|
||
socket.write(String(pass || 'admin') + '\r\n');
|
||
await telnetReadUntil(socket, promptRe, 8000);
|
||
} else if (passRe.test(banner)) {
|
||
socket.write(String(pass || 'admin') + '\r\n');
|
||
await telnetReadUntil(socket, promptRe, 8000);
|
||
} else if (promptRe.test(banner)) {
|
||
} else {
|
||
socket.destroy();
|
||
throw new Error('Telnet prompt not detected');
|
||
}
|
||
|
||
if (enablePassword) {
|
||
try {
|
||
socket.write('enable\r\n');
|
||
const enBuf = await telnetReadUntil(socket, (b) => /password\s*[:>]\s*$/im.test(b) || promptRe.test(b), 12000);
|
||
if (/password\s*[:>]\s*$/im.test(enBuf)) {
|
||
socket.write(enablePassword + '\r\n');
|
||
await telnetReadUntil(socket, promptRe, 12000);
|
||
}
|
||
} catch (e) {
|
||
socket.destroy();
|
||
throw new Error('Telnet enable gagal: ' + (e.message || String(e)));
|
||
}
|
||
}
|
||
|
||
let cmdList = Array.isArray(commands) ? [...commands] : [];
|
||
while (cmdList.length && /^enable\s*$/i.test(String(cmdList[0]).trim())) {
|
||
cmdList.shift();
|
||
}
|
||
|
||
const outputs = [];
|
||
for (const cmd of cmdList) {
|
||
socket.write(cmd + '\r\n');
|
||
const out = await telnetReadUntil(socket, promptRe, 15000);
|
||
outputs.push(out);
|
||
}
|
||
|
||
socket.end();
|
||
socket.destroy();
|
||
return outputs.join('\n');
|
||
};
|
||
|
||
const parseHiosoOnuTable = (text) => {
|
||
if (!text) return [];
|
||
const lines = String(text).split(/\r?\n/);
|
||
const rows = [];
|
||
|
||
for (const line of lines) {
|
||
const t = line.trim();
|
||
if (!/^0\/\d+:\d+/.test(t)) continue;
|
||
const parts = t.split(/\s+/);
|
||
if (parts.length >= 20) {
|
||
// 22-column legacy format
|
||
const id = parts[0];
|
||
const name = parts[1];
|
||
const mac = parts[2];
|
||
const status = parts[3];
|
||
const fwVersion = parts[4];
|
||
const chipId = parts[5];
|
||
const ports = parts[6];
|
||
const rtt = parts[7];
|
||
const distance = parts[8];
|
||
const ctcStatus = parts[9];
|
||
const ctcVer = parts[10];
|
||
const activate = parts[11];
|
||
const temperature = parts[12];
|
||
const txPower = parts[13];
|
||
const rxPower = parts[14];
|
||
const onlineTime = parts[15] + ' ' + parts[16];
|
||
const offlineTime = parts[17] + ' ' + parts[18];
|
||
const offlineReason = parts[19];
|
||
const deregisterCnt = parts[parts.length - 1];
|
||
const online = parts.slice(20, parts.length - 1).join(' ');
|
||
|
||
rows.push({
|
||
id,
|
||
name: name === 'NA' ? null : name,
|
||
mac: formatSnWithColons(mac),
|
||
status,
|
||
fwVersion,
|
||
chipId,
|
||
ports,
|
||
rtt,
|
||
distance,
|
||
ctcStatus,
|
||
ctcVer,
|
||
activate,
|
||
temperature,
|
||
txPower,
|
||
rxPower,
|
||
onlineTime,
|
||
offlineTime,
|
||
offlineReason,
|
||
online,
|
||
deregisterCnt,
|
||
});
|
||
} else if (parts.length >= 10) {
|
||
// 12-column modern format (HA7304 / HA7308)
|
||
// OnuId MacAddress Status Firmware ChipId Ge Fe Pots CtcStatus CtcVer Activate Uptime [Name]
|
||
const id = parts[0];
|
||
const mac = parts[1];
|
||
const status = parts[2];
|
||
const fwVersion = parts[3];
|
||
const chipId = parts[4];
|
||
const ports = (parts[5] || '0') + '/' + (parts[6] || '0');
|
||
const ctcStatus = parts[8];
|
||
const ctcVer = parts[9];
|
||
const activate = parts[10];
|
||
const uptime = parts[11];
|
||
const name = parts.length > 12 ? parts.slice(12).join(' ') : null;
|
||
|
||
rows.push({
|
||
id,
|
||
name: name === 'NA' || !name ? null : name,
|
||
mac,
|
||
status,
|
||
fwVersion,
|
||
chipId,
|
||
ports,
|
||
distance: '-',
|
||
ctcStatus,
|
||
ctcVer,
|
||
activate,
|
||
uptime,
|
||
txPower: 'N/A',
|
||
rxPower: 'N/A'
|
||
});
|
||
}
|
||
}
|
||
|
||
return rows;
|
||
};
|
||
|
||
const fetchHiosoOnuDetailViaTelnet = async (olt) => {
|
||
const user = olt.web_user || 'admin';
|
||
const pass = olt.web_password || 'admin';
|
||
const cmds = [
|
||
'show onu info epon 0/1 all',
|
||
'show onu info epon 0/2 all',
|
||
'show onu info epon 0/3 all',
|
||
'show onu info epon 0/4 all',
|
||
'show onu',
|
||
'show onu all',
|
||
'show onu info',
|
||
'show onu status',
|
||
];
|
||
|
||
try {
|
||
const out = await telnetLoginAndRun(olt.host, user, pass, cmds, {
|
||
port: olt.telnet_port || 23,
|
||
enablePassword: olt.enable_password || pass || 'admin'
|
||
});
|
||
const rows = parseHiosoOnuTable(out);
|
||
return rows.length > 0 ? rows : null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// ─── CORE SNMP FUNCTIONS ─────────────────────────────────────────────────────
|
||
|
||
const slowWalk = async (session, baseOid, maxEntries = 5000) => {
|
||
const results = {};
|
||
let walkCount = 0;
|
||
|
||
return new Promise((resolve) => {
|
||
session.subtree(
|
||
baseOid,
|
||
20, // maxRepetitions (GETBULK size)
|
||
(varbinds) => {
|
||
for (const vb of varbinds) {
|
||
if (!vb || vb.oid == null) continue;
|
||
if (snmp.isVarbindError(vb)) continue;
|
||
if (!oidUnderBase(vb.oid, baseOid)) continue;
|
||
|
||
const idx = extractIdx(vb.oid, baseOid);
|
||
results[idx] = vb.value;
|
||
walkCount++;
|
||
if (walkCount >= maxEntries) {
|
||
break;
|
||
}
|
||
}
|
||
},
|
||
(error) => {
|
||
// Resolve results when done, ignoring errors
|
||
resolve(results);
|
||
}
|
||
);
|
||
});
|
||
};
|
||
|
||
const walkSample = async (session, baseOid, maxItems = 3) => {
|
||
let currentOid = baseOid;
|
||
let count = 0;
|
||
const values = [];
|
||
|
||
while (count < maxItems) {
|
||
try {
|
||
const vb = await new Promise((rv, rj) => {
|
||
session.getNext([currentOid], (err, vbs) => {
|
||
if (err) rj(err);
|
||
else rv(vbs[0]);
|
||
});
|
||
});
|
||
|
||
if (!vb || vb.oid == null) break;
|
||
if (vb.type === snmp.ObjectType.EndOfMibView || vb.type === snmp.ObjectType.NoSuchObject || vb.type === snmp.ObjectType.NoSuchInstance) break;
|
||
if (!oidUnderBase(vb.oid, baseOid)) break;
|
||
|
||
values.push(vb.value);
|
||
currentOid = normalizeOid(vb.oid);
|
||
count++;
|
||
} catch (e) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return values;
|
||
};
|
||
|
||
/**
|
||
* Test apakah sebuah OID probe memberikan respons SNMP getNext yang valid
|
||
*/
|
||
const probeOid = async (session, oid) => {
|
||
try {
|
||
const vb = await new Promise((rv, rj) => {
|
||
session.getNext([oid], (err, vbs) => {
|
||
if (err) rj(err);
|
||
else rv(vbs[0]);
|
||
});
|
||
});
|
||
if (!vb || vb.type === snmp.ObjectType.EndOfMibView || vb.type === snmp.ObjectType.NoSuchObject) return false;
|
||
// Cek apakah hasil masih di bawah OID ini atau sub-treenya ada data
|
||
return oidUnderBase(vb.oid, oid);
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Ambil satu atau beberapa OID sekaligus menggunakan snmp.get.
|
||
* Kembalikan array nilai (atau null jika error/tidak ada).
|
||
*/
|
||
const snmpGet = async (session, oids) => {
|
||
try {
|
||
const vbs = await new Promise((rv, rj) => {
|
||
session.get(oids, (err, result) => {
|
||
if (err) rj(err);
|
||
else rv(result);
|
||
});
|
||
});
|
||
return vbs.map(vb => {
|
||
if (!vb || vb.type === snmp.ObjectType.NoSuchObject ||
|
||
vb.type === snmp.ObjectType.NoSuchInstance ||
|
||
vb.type === snmp.ObjectType.EndOfMibView) return null;
|
||
return vb.value;
|
||
});
|
||
} catch (e) {
|
||
return oids.map(() => null);
|
||
}
|
||
};
|
||
|
||
const bufferToInt = (val) => {
|
||
if (val == null) return null;
|
||
if (typeof val === 'number') return val;
|
||
if (typeof val === 'bigint') {
|
||
if (val <= BigInt(Number.MAX_SAFE_INTEGER) && val >= BigInt(Number.MIN_SAFE_INTEGER)) return Number(val);
|
||
return null;
|
||
}
|
||
if (typeof val === 'string') {
|
||
const n = Number(val);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
if (Buffer.isBuffer(val)) {
|
||
if (val.length === 1) return val.readUInt8(0);
|
||
if (val.length === 2) return val.readInt16BE(0);
|
||
if (val.length === 4) return val.readInt32BE(0);
|
||
if (val.length === 8) {
|
||
try {
|
||
const bi = val.readBigUInt64BE(0);
|
||
if (bi <= BigInt(Number.MAX_SAFE_INTEGER)) return Number(bi);
|
||
} catch (e) {}
|
||
return null;
|
||
}
|
||
const s = val.toString();
|
||
const n = Number(s);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const bufferToCounterBigInt = (val) => {
|
||
if (val == null) return null;
|
||
if (typeof val === 'bigint') return val;
|
||
if (typeof val === 'number') {
|
||
if (!Number.isFinite(val)) return null;
|
||
return BigInt(Math.max(0, Math.floor(val)));
|
||
}
|
||
if (typeof val === 'string') {
|
||
try {
|
||
if (val.trim() === '') return null;
|
||
return BigInt(val);
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
if (Buffer.isBuffer(val)) {
|
||
if (val.length === 8) {
|
||
try { return val.readBigUInt64BE(0); } catch (e) { return null; }
|
||
}
|
||
if (val.length === 4) {
|
||
try { return BigInt(val.readUInt32BE(0)); } catch (e) { return null; }
|
||
}
|
||
const s = val.toString();
|
||
try { return BigInt(s); } catch (e) { return null; }
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const getByIdx = (map, idx) => {
|
||
if (!map) return undefined;
|
||
if (map[idx] != null) return map[idx];
|
||
|
||
const keys = Object.keys(map);
|
||
if (keys.length === 0) return undefined;
|
||
|
||
const idxParts = String(idx).split('.').filter(Boolean);
|
||
const maxSegments = Math.min(6, idxParts.length);
|
||
|
||
for (let seg = 2; seg <= maxSegments; seg++) {
|
||
const suffix = '.' + idxParts.slice(-seg).join('.');
|
||
const matches = keys.filter(k => k.endsWith(suffix));
|
||
if (matches.length === 1) return map[matches[0]];
|
||
}
|
||
|
||
return undefined;
|
||
};
|
||
|
||
const toSigned16 = (n) => {
|
||
if (!Number.isFinite(n)) return null;
|
||
const u = n & 0xffff;
|
||
return u > 0x7fff ? u - 0x10000 : u;
|
||
};
|
||
|
||
const pickFirstPlausible = (candidates) => {
|
||
for (const v of candidates) {
|
||
if (!Number.isFinite(v)) continue;
|
||
if (v < -50 || v > 10) continue;
|
||
if (v > 0) continue;
|
||
return v;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const computeRxDbm = (brand, raw) => {
|
||
const signal = parseSignal(raw);
|
||
if (signal != null) return signal;
|
||
|
||
const n = bufferToInt(raw);
|
||
if (n == null) return null;
|
||
if (n === 0 || n === 65535) return null;
|
||
|
||
const signed = (n >= 0 && n <= 65535) ? toSigned16(n) : n;
|
||
if (signed == null) return null;
|
||
|
||
if (typeof signed === 'number' && signed < 0 && signed >= -60 && signed <= 10) return signed;
|
||
|
||
const ordered = brand === 'huawei'
|
||
? [signed / 100, signed / 10, signed / 1000, (signed / 100) - 100, (signed / 10) - 100]
|
||
: [signed / 10, signed / 100, signed / 1000, (signed / 10) - 100, (signed / 100) - 100];
|
||
|
||
const picked = pickFirstPlausible(ordered);
|
||
if (picked != null) return picked;
|
||
|
||
const fallback = pickFirstPlausible([signed / 10, signed / 100, signed / 1000]);
|
||
return fallback;
|
||
};
|
||
|
||
const safeToString = (val) => {
|
||
if (val == null) return null;
|
||
if (typeof val === 'string') return val;
|
||
if (typeof val === 'number') return String(val);
|
||
if (typeof val === 'bigint') return val.toString();
|
||
if (Buffer.isBuffer(val)) return val.toString();
|
||
return String(val);
|
||
};
|
||
|
||
const walkValues = async (session, baseOid) => Object.values(await slowWalk(session, baseOid));
|
||
|
||
const isLikelySn = (sample) => {
|
||
if (sample == null) return false;
|
||
let s = safeToString(sample);
|
||
if (!s) return false;
|
||
s = s.replace(/["']/g, '').replace(/\s+/g, '').trim();
|
||
if (s.length < 6) return false;
|
||
if (/(registered|offline|active|online|down|up|power|alarm)/i.test(s)) return false;
|
||
return true;
|
||
};
|
||
|
||
const pickSnTable = async (session, activeProfile) => {
|
||
if (activeProfile.sn_table) {
|
||
try {
|
||
const m = await slowWalk(session, activeProfile.sn_table);
|
||
if (m && Object.keys(m).length > 0) {
|
||
return { oid: activeProfile.sn_table, map: m };
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
const nameOid = activeProfile.name_table || '';
|
||
const lastDot = nameOid.lastIndexOf('.');
|
||
const parentBranch = lastDot > 0 ? nameOid.slice(0, lastDot) : nameOid;
|
||
|
||
const candidates = [
|
||
activeProfile.sn_table,
|
||
`${parentBranch}.11`,
|
||
'1.3.6.1.4.1.25355.3.2.10.1.1.2',
|
||
`${parentBranch}.12`,
|
||
`${parentBranch}.2`,
|
||
'1.3.6.1.4.1.25355.3.2.1.2.1.2',
|
||
'1.3.6.1.4.1.25355.3.2.6.1.1.18',
|
||
'1.3.6.1.4.1.25355.3.2.6.3.2.1.12',
|
||
'1.3.6.1.4.1.25355.3.2.6.1.1.2.1.6',
|
||
'1.3.6.1.4.1.25355.3.3.1.1.1.5',
|
||
'1.3.6.1.4.1.3320.101.10.1.1.3',
|
||
].filter(Boolean);
|
||
|
||
const seen = new Set();
|
||
const unique = [];
|
||
for (const oid of candidates) {
|
||
const n = normalizeOid(oid);
|
||
if (seen.has(n)) continue;
|
||
seen.add(n);
|
||
unique.push(oid);
|
||
}
|
||
|
||
for (const oid of unique) {
|
||
const samples = await walkSample(session, oid, 2);
|
||
if (samples.length === 0) continue;
|
||
if (!isLikelySn(samples[0])) continue;
|
||
const m = await slowWalk(session, oid);
|
||
return { oid, map: m };
|
||
}
|
||
|
||
return { oid: activeProfile.sn_table || null, map: {} };
|
||
};
|
||
|
||
const parseSignal = (val) => {
|
||
if (val == null) return null;
|
||
if (typeof val === 'number') return Number.isFinite(val) ? val : null;
|
||
if (typeof val === 'bigint') return null;
|
||
const s = safeToString(val);
|
||
if (!s) return null;
|
||
const m = s.match(/[-+]?\d*\.?\d+/);
|
||
if (!m) return null;
|
||
const num = Number(m[0]);
|
||
if (!Number.isFinite(num)) return null;
|
||
const abs = Math.abs(num);
|
||
if (abs > 500) return num / 100;
|
||
if (abs > 50) return num / 10;
|
||
return num;
|
||
};
|
||
|
||
const getHrCpuPercent = async (session) => {
|
||
const values = await walkValues(session, '1.3.6.1.2.1.25.3.3.1.2');
|
||
const nums = values.map(v => bufferToInt(v)).filter(v => Number.isFinite(v));
|
||
if (nums.length === 0) return null;
|
||
const avg = nums.reduce((s, v) => s + v, 0) / nums.length;
|
||
return Math.max(0, Math.min(100, Math.round(avg)));
|
||
};
|
||
|
||
const getHrRamPercent = async (session) => {
|
||
// Fast path: try direct GET on common hrStorage indices first (1, 3, 6, 7)
|
||
// hrStorageSize and hrStorageUsed with allocation unit 1024 bytes
|
||
const fastOids = [
|
||
'1.3.6.1.2.1.25.2.2.0', // hrMemorySize (total KB)
|
||
'1.3.6.1.2.1.25.2.3.1.4.1', // hrStorageAllocationUnits.1
|
||
'1.3.6.1.2.1.25.2.3.1.5.1', // hrStorageSize.1
|
||
'1.3.6.1.2.1.25.2.3.1.6.1', // hrStorageUsed.1
|
||
'1.3.6.1.2.1.25.2.3.1.2.1', // hrStorageType.1 (OID value)
|
||
];
|
||
try {
|
||
const vbs = await snmpGet(session, fastOids);
|
||
const totalKb = bufferToInt(vbs[0]);
|
||
const allocUnit = bufferToInt(vbs[1]);
|
||
const size = bufferToInt(vbs[2]);
|
||
const used = bufferToInt(vbs[3]);
|
||
|
||
if (Number.isFinite(totalKb) && totalKb > 0 &&
|
||
Number.isFinite(allocUnit) && allocUnit > 0 &&
|
||
Number.isFinite(size) && size > 0 &&
|
||
Number.isFinite(used)) {
|
||
// size/used are in allocation units
|
||
const usedKb = (used * allocUnit) / 1024;
|
||
const pct = Math.round((usedKb / totalKb) * 100);
|
||
return Math.max(0, Math.min(100, pct));
|
||
}
|
||
// Fallback: size as a percentage directly
|
||
if (Number.isFinite(size) && size > 0 && Number.isFinite(used)) {
|
||
return Math.max(0, Math.min(100, Math.round((used / size) * 100)));
|
||
}
|
||
} catch (e) {
|
||
// fall through to slow path
|
||
}
|
||
|
||
// Slow path: full walk (for other brands)
|
||
const descrMap = await slowWalk(session, '1.3.6.1.2.1.25.2.3.1.3');
|
||
const typeMap = await slowWalk(session, '1.3.6.1.2.1.25.2.3.1.2');
|
||
const sizeMap = await slowWalk(session, '1.3.6.1.2.1.25.2.3.1.5');
|
||
const usedMap = await slowWalk(session, '1.3.6.1.2.1.25.2.3.1.6');
|
||
|
||
const hrStorageRamType = '1.3.6.1.2.1.25.2.1.2';
|
||
|
||
const indices = new Set([
|
||
...Object.keys(descrMap),
|
||
...Object.keys(typeMap),
|
||
...Object.keys(sizeMap),
|
||
...Object.keys(usedMap),
|
||
]);
|
||
|
||
let best = null;
|
||
|
||
for (const idx of indices) {
|
||
const descr = (safeToString(descrMap[idx]) || '').toLowerCase();
|
||
const type = safeToString(typeMap[idx]) || '';
|
||
const size = bufferToInt(sizeMap[idx]);
|
||
const used = bufferToInt(usedMap[idx]);
|
||
if (!Number.isFinite(size) || !Number.isFinite(used) || size <= 0) continue;
|
||
|
||
const isRam = type.includes(hrStorageRamType) || descr.includes('ram') || descr.includes('memory') || descr.includes('mem');
|
||
if (!isRam) continue;
|
||
|
||
const pct = Math.round((used / size) * 100);
|
||
const entry = { pct, size, used };
|
||
if (!best) best = entry;
|
||
else if (entry.size > best.size) best = entry;
|
||
}
|
||
|
||
if (!best) return null;
|
||
return Math.max(0, Math.min(100, best.pct));
|
||
};
|
||
|
||
const getEntityTemperatureC = async (session) => {
|
||
const typeMap = await slowWalk(session, '1.3.6.1.2.1.99.1.1.1.1');
|
||
const valueMap = await slowWalk(session, '1.3.6.1.2.1.99.1.1.1.4');
|
||
const precisionMap = await slowWalk(session, '1.3.6.1.2.1.99.1.1.1.3');
|
||
|
||
const indices = new Set([
|
||
...Object.keys(typeMap),
|
||
...Object.keys(valueMap),
|
||
...Object.keys(precisionMap),
|
||
]);
|
||
|
||
const candidates = [];
|
||
|
||
for (const idx of indices) {
|
||
const t = bufferToInt(typeMap[idx]);
|
||
if (t !== 8) continue;
|
||
const v = bufferToInt(valueMap[idx]);
|
||
const p = bufferToInt(precisionMap[idx]);
|
||
if (!Number.isFinite(v) || !Number.isFinite(p)) continue;
|
||
const c = v / Math.pow(10, p);
|
||
if (Number.isFinite(c) && c >= -20 && c <= 120) candidates.push(c);
|
||
}
|
||
|
||
if (candidates.length === 0) return null;
|
||
candidates.sort((a, b) => b - a);
|
||
return candidates[0];
|
||
};
|
||
|
||
const autoPickUplinkCandidates = async (session) => {
|
||
const ifNameMap = await slowWalk(session, '1.3.6.1.2.1.31.1.1.1.1');
|
||
const ifDescrMap = await slowWalk(session, '1.3.6.1.2.1.2.2.1.2');
|
||
const ifOperMap = await slowWalk(session, '1.3.6.1.2.1.2.2.1.8');
|
||
const ifHighSpeedMap = await slowWalk(session, '1.3.6.1.2.1.31.1.1.1.15');
|
||
const ifSpeedMap = await slowWalk(session, '1.3.6.1.2.1.2.2.1.5');
|
||
|
||
const indices = new Set([
|
||
...Object.keys(ifNameMap),
|
||
...Object.keys(ifDescrMap),
|
||
...Object.keys(ifOperMap),
|
||
...Object.keys(ifHighSpeedMap),
|
||
...Object.keys(ifSpeedMap),
|
||
]);
|
||
|
||
const candidates = [];
|
||
|
||
for (const idx of indices) {
|
||
const name = (safeToString(ifNameMap[idx]) || safeToString(ifDescrMap[idx]) || '').toLowerCase();
|
||
const oper = bufferToInt(ifOperMap[idx]);
|
||
const hs = bufferToInt(ifHighSpeedMap[idx]);
|
||
const sp = bufferToInt(ifSpeedMap[idx]);
|
||
const speedScore = Number.isFinite(hs) && hs > 0 ? hs : (Number.isFinite(sp) ? (sp / 1_000_000) : 0);
|
||
|
||
let nameScore = 0;
|
||
if (name.includes('uplink')) nameScore += 50;
|
||
if (name.includes('trunk')) nameScore += 30;
|
||
if (name.includes('xge')) nameScore += 25;
|
||
if (name.includes('10g')) nameScore += 25;
|
||
if (name.includes('ge')) nameScore += 10;
|
||
if (name.includes('eth')) nameScore += 5;
|
||
if (name.includes('gpon') || name.includes('epon') || name.includes('pon') || name.includes('onu') || name.includes('llid')) nameScore -= 35;
|
||
if (name.includes('mgmt') || name.includes('loopback') || name.includes('null')) nameScore -= 50;
|
||
|
||
const operScore = oper === 1 ? 20 : 0;
|
||
const score = nameScore + operScore + Math.min(100, speedScore);
|
||
candidates.push({ idx, score, name });
|
||
}
|
||
|
||
candidates.sort((a, b) => b.score - a.score);
|
||
return candidates;
|
||
};
|
||
|
||
const autoPickUplinkIfIndex = async (session) => {
|
||
const candidates = await autoPickUplinkCandidates(session);
|
||
return candidates[0]?.idx || null;
|
||
};
|
||
|
||
const uplinkCache = new Map();
|
||
|
||
const pickUplinkIfIndexByTraffic = async (session, oltId) => {
|
||
if (oltId) {
|
||
const now = Date.now();
|
||
const cached = uplinkCache.get(oltId);
|
||
if (cached && (now - cached.timestamp < 3600000)) { // 1 hour cache
|
||
return cached.ifIndex;
|
||
}
|
||
}
|
||
|
||
const candidates = await autoPickUplinkCandidates(session);
|
||
if (candidates.length === 0) return null;
|
||
|
||
const top = candidates.slice(0, 6);
|
||
let best = null;
|
||
|
||
for (const c of top) {
|
||
const first = await readInterfaceOctets(session, c.idx);
|
||
await new Promise(rv => setTimeout(rv, 100)); // Jedah scan lebih cepat (100ms)
|
||
const second = await readInterfaceOctets(session, c.idx);
|
||
|
||
const wrap64 = BigInt(1) << BigInt(64);
|
||
let sum = BigInt(0);
|
||
let ok = false;
|
||
|
||
if (first.rx != null && second.rx != null) {
|
||
let d = second.rx - first.rx;
|
||
if (d < BigInt(0)) d = d + wrap64;
|
||
if (d > BigInt(0)) ok = true;
|
||
sum += d;
|
||
}
|
||
if (first.tx != null && second.tx != null) {
|
||
let d = second.tx - first.tx;
|
||
if (d < BigInt(0)) d = d + wrap64;
|
||
if (d > BigInt(0)) ok = true;
|
||
sum += d;
|
||
}
|
||
|
||
if (!ok) continue;
|
||
if (!best || sum > best.sum) best = { idx: c.idx, sum };
|
||
}
|
||
|
||
const ifIndex = best?.idx || candidates[0].idx;
|
||
if (oltId && ifIndex) {
|
||
uplinkCache.set(oltId, { ifIndex, timestamp: Date.now() });
|
||
}
|
||
return ifIndex;
|
||
};
|
||
|
||
const readInterfaceOctets = async (session, ifIndex) => {
|
||
const [hcIn, hcOut] = await snmpGet(session, [
|
||
`1.3.6.1.2.1.31.1.1.1.6.${ifIndex}`,
|
||
`1.3.6.1.2.1.31.1.1.1.10.${ifIndex}`,
|
||
]);
|
||
|
||
let rx = bufferToCounterBigInt(hcIn);
|
||
let tx = bufferToCounterBigInt(hcOut);
|
||
|
||
if (rx == null || tx == null) {
|
||
const [in32, out32] = await snmpGet(session, [
|
||
`1.3.6.1.2.1.2.2.1.10.${ifIndex}`,
|
||
`1.3.6.1.2.1.2.2.1.16.${ifIndex}`,
|
||
]);
|
||
if (rx == null) rx = bufferToCounterBigInt(in32);
|
||
if (tx == null) tx = bufferToCounterBigInt(out32);
|
||
}
|
||
|
||
return {
|
||
rx,
|
||
tx,
|
||
};
|
||
};
|
||
|
||
/**
|
||
* Ambil CPU, RAM, Suhu modul optik, dan Uptime via Telnet CLI untuk Hioso / HSGQ.
|
||
* Berfungsi sebagai fallback handal jika OLT tidak mendukung OID Suhu/CPU vendor via SNMP.
|
||
*/
|
||
const fetchHiosoSystemMetricsViaTelnet = async (olt, stats) => {
|
||
if (!olt || !olt.host) return;
|
||
const user = olt.web_user || 'admin';
|
||
const pass = olt.web_password || 'admin';
|
||
const cmds = [
|
||
'show cpu resource',
|
||
'show epon optical-ddm 0/1'
|
||
];
|
||
try {
|
||
const out = await telnetLoginAndRun(olt.host, user, pass, cmds, {
|
||
port: olt.telnet_port || 23,
|
||
enablePassword: olt.enable_password || pass || 'admin'
|
||
});
|
||
if (!out) return;
|
||
|
||
if (stats.cpu === 'N/A' || !stats.cpu || stats.cpu === '-') {
|
||
const cpuMatch = out.match(/CPU usage\s*:\s*(\d+)%/i);
|
||
if (cpuMatch) stats.cpu = `${cpuMatch[1]}%`;
|
||
}
|
||
|
||
if (stats.ram === 'N/A' || !stats.ram || stats.ram === '-') {
|
||
const ramMatch = out.match(/Memory usage\s*:\s*([\d\.]+)%/i);
|
||
if (ramMatch) stats.ram = `${Math.round(parseFloat(ramMatch[1]))}%`;
|
||
}
|
||
|
||
if (stats.temp === 'N/A' || !stats.temp || stats.temp === '-') {
|
||
const tempMatch = out.match(/Temperature\s*:\s*([\d\.]+)\s*C/i);
|
||
if (tempMatch) stats.temp = `${parseFloat(tempMatch[1]).toFixed(0)}°C`;
|
||
}
|
||
|
||
if (stats.uptime === 'N/A' || !stats.uptime || stats.uptime === '-') {
|
||
const uptimeMatch = out.match(/OS up time\s*:\s*([^\r\n]+)/i);
|
||
if (uptimeMatch) stats.uptime = uptimeMatch[1].trim();
|
||
}
|
||
} catch (e) {
|
||
logger.debug(`[oltService] fetchHiosoSystemMetricsViaTelnet: ${e.message}`);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Ambil system metrics (temp, cpu, ram, uplink) untuk brand tertentu.
|
||
* Mengisi field stats secara langsung.
|
||
*/
|
||
const fetchSystemMetrics = async (session, brandKey, stats, oltOrId) => {
|
||
const oids = SYSTEM_OIDS[brandKey] || SYSTEM_OIDS.hioso;
|
||
const oltId = (oltOrId && typeof oltOrId === 'object') ? oltOrId.id : oltOrId;
|
||
try {
|
||
const [temp, cpu, ram, rx, tx] = await snmpGet(session, [
|
||
oids.temp, oids.cpu, oids.ram, oids.uplink_rx, oids.uplink_tx
|
||
]);
|
||
const tempN = bufferToInt(temp);
|
||
const cpuN = bufferToInt(cpu);
|
||
const ramN = bufferToInt(ram);
|
||
if (Number.isFinite(tempN) && tempN >= -20 && tempN <= 120 && tempN !== 0) stats.temp = `${tempN}°C`;
|
||
if (cpuN != null) stats.cpu = `${cpuN}%`;
|
||
if (ramN != null) stats.ram = `${ramN}%`;
|
||
if (stats.temp === 'N/A') {
|
||
const c = await getEntityTemperatureC(session);
|
||
if (c != null) stats.temp = `${c.toFixed(1)}°C`;
|
||
}
|
||
|
||
const ifIndex = await pickUplinkIfIndexByTraffic(session, oltId);
|
||
if (ifIndex) {
|
||
const first = await readInterfaceOctets(session, ifIndex);
|
||
await new Promise(rv => setTimeout(rv, 800));
|
||
const second = await readInterfaceOctets(session, ifIndex);
|
||
|
||
const seconds = 0.8;
|
||
const wrap64 = BigInt(1) << BigInt(64);
|
||
|
||
if (first.rx != null && second.rx != null) {
|
||
let delta = second.rx - first.rx;
|
||
if (delta < BigInt(0)) delta = delta + wrap64;
|
||
const rate = Number(delta) / seconds;
|
||
stats.uplink_rx = Number.isFinite(rate) ? Math.max(0, Math.round(rate)) : 0;
|
||
}
|
||
if (first.tx != null && second.tx != null) {
|
||
let delta = second.tx - first.tx;
|
||
if (delta < BigInt(0)) delta = delta + wrap64;
|
||
const rate = Number(delta) / seconds;
|
||
stats.uplink_tx = Number.isFinite(rate) ? Math.max(0, Math.round(rate)) : 0;
|
||
}
|
||
} else {
|
||
const rxN = bufferToInt(rx);
|
||
const txN = bufferToInt(tx);
|
||
if (rxN != null) stats.uplink_rx = rxN;
|
||
if (txN != null) stats.uplink_tx = txN;
|
||
}
|
||
|
||
if (stats.cpu === 'N/A') {
|
||
const hrCpu = await getHrCpuPercent(session);
|
||
if (hrCpu != null) stats.cpu = `${hrCpu}%`;
|
||
}
|
||
if (stats.ram === 'N/A') {
|
||
const hrRam = await getHrRamPercent(session);
|
||
if (hrRam != null) stats.ram = `${hrRam}%`;
|
||
}
|
||
|
||
// Telnet Fallback untuk Suhu/CPU/RAM pada Hioso/HSGQ jika SNMP tidak menyediakan
|
||
if (stats.cpu === 'N/A' || stats.ram === 'N/A' || stats.temp === 'N/A') {
|
||
if (brandKey === 'hioso' || brandKey === 'hsgq') {
|
||
const oltObj = (oltOrId && typeof oltOrId === 'object') ? oltOrId : getOltById(oltOrId);
|
||
if (oltObj) {
|
||
await fetchHiosoSystemMetricsViaTelnet(oltObj, stats);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Ambil daftar card/board OLT.
|
||
*/
|
||
const fetchCardMetrics = async (session, brandKey, stats) => {
|
||
const oids = CARD_OIDS[brandKey];
|
||
if (!oids) return;
|
||
|
||
try {
|
||
const typeMap = await slowWalk(session, oids.type);
|
||
const statusMap = await slowWalk(session, oids.status);
|
||
const portMap = await slowWalk(session, oids.ports);
|
||
const serialMap = await slowWalk(session, oids.serial);
|
||
const cpuMap = await slowWalk(session, oids.cpu);
|
||
const ramMap = await slowWalk(session, oids.ram);
|
||
|
||
const cards = [];
|
||
const indices = Object.keys(typeMap);
|
||
|
||
for (const idx of indices) {
|
||
const type = safeToString(typeMap[idx]);
|
||
if (!type || type === '0') continue;
|
||
|
||
const statusNum = bufferToInt(statusMap[idx]);
|
||
const statusText = CARD_STATUS_MAP[statusNum] || (statusNum != null ? String(statusNum) : 'UNKNOWN');
|
||
|
||
const cpuVal = bufferToInt(cpuMap[idx]);
|
||
const ramVal = bufferToInt(ramMap[idx]);
|
||
|
||
cards.push({
|
||
index: idx,
|
||
type: type,
|
||
status: statusText,
|
||
ports: bufferToInt(portMap[idx]) || 0,
|
||
serial: decodeSn(serialMap[idx]),
|
||
cpu: cpuVal != null ? `${cpuVal}%` : 'N/A',
|
||
ram: ramVal != null ? `${ramVal}%` : 'N/A',
|
||
});
|
||
}
|
||
|
||
if (cards.length > 0) {
|
||
stats.cards = cards.sort((a, b) => String(a.index).localeCompare(String(b.index), undefined, { numeric: true }));
|
||
}
|
||
} catch (e) {
|
||
logger.error(`fetchCardMetrics error: ${e.message}`);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Ambil daftar ONU yang belum diotorisasi (Unregistered).
|
||
*/
|
||
const fetchUnauthOnus = async (session, profile, stats) => {
|
||
if (!profile.unauth_sn_table) return;
|
||
|
||
try {
|
||
const snMap = await slowWalk(session, profile.unauth_sn_table);
|
||
let typeMap = {};
|
||
if (profile.unauth_type_table) {
|
||
typeMap = await slowWalk(session, profile.unauth_type_table);
|
||
}
|
||
|
||
const indices = Object.keys(snMap);
|
||
for (const idx of indices) {
|
||
const sn = decodeSn(snMap[idx]);
|
||
if (!sn || sn === 'N/A') continue;
|
||
|
||
const type = safeToString(typeMap[idx]) || 'GENERIC';
|
||
const onuId = hiosoOnuIdFromIndex(idx);
|
||
|
||
stats.unauth_onus.push({
|
||
index: idx,
|
||
id: onuId || idx,
|
||
sn: sn,
|
||
type: type
|
||
});
|
||
}
|
||
} catch (e) {
|
||
logger.error(`fetchUnauthOnus error: ${e.message}`);
|
||
}
|
||
};
|
||
|
||
const decodeRxPower = (brand, val) => {
|
||
const rx = computeRxDbm(brand, val);
|
||
if (rx == null) return 'N/A';
|
||
return rx.toFixed(2);
|
||
};
|
||
|
||
function translateOfflineReason(brand, rawValue) {
|
||
if (rawValue == null) return null;
|
||
const valNum = bufferToInt(rawValue);
|
||
if (valNum == null) {
|
||
const valStr = String(rawValue).trim().toLowerCase();
|
||
if (valStr.includes('dying_gasp') || valStr.includes('power_off') || valStr.includes('poweroff')) {
|
||
return 'Mati Listrik / Adaptor Dicabut';
|
||
}
|
||
if (valStr.includes('los') || valStr.includes('wire_down') || valStr.includes('link_down')) {
|
||
return 'Kabel Fiber Optik Putus (LOS)';
|
||
}
|
||
if (valStr.includes('mpcp') || valStr.includes('timeout') || valStr.includes('unregister')) {
|
||
return 'Koneksi Terputus / Redaman Drop';
|
||
}
|
||
if (valStr.includes('manual') || valStr.includes('deactive')) {
|
||
return 'Dimatikan dari OLT (Deactive)';
|
||
}
|
||
return valStr;
|
||
}
|
||
|
||
const bKey = String(brand || '').toLowerCase();
|
||
|
||
if (bKey === 'zte') {
|
||
switch (valNum) {
|
||
case 2:
|
||
case 3:
|
||
return 'Kabel Fiber Optik Putus (LOS)';
|
||
case 4:
|
||
return 'Loss of Frame (Gangguan Sinyal)';
|
||
case 5:
|
||
return 'Signal Failure (Gangguan Sinyal)';
|
||
case 8:
|
||
return 'Gagal Otorisasi / Registrasi';
|
||
case 9:
|
||
return 'Mati Listrik / Adaptor Dicabut';
|
||
case 10:
|
||
return 'Dimatikan dari OLT (Deactive)';
|
||
case 12:
|
||
return 'ONU Reboot';
|
||
case 13:
|
||
return 'ONU Dimatikan';
|
||
default:
|
||
return `Penyebab Lain (${valNum})`;
|
||
}
|
||
}
|
||
|
||
if (bKey === 'huawei') {
|
||
switch (valNum) {
|
||
case 1:
|
||
case 2:
|
||
return 'Kabel Fiber Optik Putus (LOS)';
|
||
case 3:
|
||
return 'Loss of Frame (Gangguan Sinyal)';
|
||
case 4:
|
||
return 'Signal Failure (Gangguan Sinyal)';
|
||
case 7:
|
||
return 'Gagal Deaktivasi';
|
||
case 8:
|
||
return 'Dimatikan dari OLT (Deactive)';
|
||
case 9:
|
||
return 'ONU Reboot / Reset';
|
||
case 10:
|
||
return 'ONU Re-registrasi';
|
||
case 13:
|
||
return 'Mati Listrik / Adaptor Dicabut';
|
||
case 31:
|
||
case 32:
|
||
case 33:
|
||
return 'Reset oleh Pelanggan (Tombol/Sistem)';
|
||
case 30:
|
||
return 'Modul Optik Dimatikan';
|
||
default:
|
||
return `Penyebab Lain (${valNum})`;
|
||
}
|
||
}
|
||
|
||
if (bKey === 'hioso' || bKey === 'hsgq') {
|
||
switch (valNum) {
|
||
case 2:
|
||
return 'Normal / Berhasil Terkoneksi';
|
||
case 3:
|
||
return 'Koneksi Terputus (MPCP Down)';
|
||
case 4:
|
||
return 'Gangguan Link (OAM Down)';
|
||
case 7:
|
||
return 'Dimatikan dari OLT (Admin Down)';
|
||
case 8:
|
||
return 'Kabel Fiber Optik Putus (Wire Down)';
|
||
case 9:
|
||
return 'Mati Listrik / Adaptor Dicabut (Power Off)';
|
||
case 255:
|
||
return 'Penyebab tidak diketahui';
|
||
default:
|
||
return `Penyebab Lain (${valNum})`;
|
||
}
|
||
}
|
||
|
||
return `Kode (${valNum})`;
|
||
}
|
||
|
||
// ─── MAIN: getOltStats ────────────────────────────────────────────────────────
|
||
|
||
async function limitConcurrency(tasks, limit) {
|
||
const results = [];
|
||
const executing = new Set();
|
||
for (const task of tasks) {
|
||
const p = Promise.resolve().then(() => task());
|
||
results.push(p);
|
||
executing.add(p);
|
||
const clean = () => executing.delete(p);
|
||
p.then(clean, clean);
|
||
if (executing.size >= limit) {
|
||
await Promise.race(executing);
|
||
}
|
||
}
|
||
return Promise.all(results);
|
||
}
|
||
|
||
const statsCache = new Map();
|
||
const pendingPromises = new Map();
|
||
|
||
async function getOltStats(id, full = false) {
|
||
const cacheKey = `${id}:${full}`;
|
||
const now = Date.now();
|
||
const cached = statsCache.get(cacheKey);
|
||
const cacheDuration = full ? 120000 : 10000; // 120s cache for full table, 10s for summary
|
||
|
||
if (cached && (now - cached.timestamp < cacheDuration)) {
|
||
logger.info(`[oltService] Returning cached stats for OLT ${id} (full: ${full})`);
|
||
return cached.data;
|
||
}
|
||
|
||
if (pendingPromises.has(cacheKey)) {
|
||
logger.info(`[oltService] Coalescing concurrent stats request for OLT ${id} (full: ${full})`);
|
||
return pendingPromises.get(cacheKey);
|
||
}
|
||
|
||
const promise = (async () => {
|
||
try {
|
||
const result = await getOltStatsInternal(id, full);
|
||
if (result && !result.error) {
|
||
statsCache.set(cacheKey, {
|
||
data: result,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
return result;
|
||
} finally {
|
||
pendingPromises.delete(cacheKey);
|
||
}
|
||
})();
|
||
|
||
pendingPromises.set(cacheKey, promise);
|
||
return promise;
|
||
}
|
||
|
||
async function getOltStatsInternal(id, full = false) {
|
||
const olt = getOltById(id);
|
||
if (!olt) return null;
|
||
|
||
const stats = {
|
||
id: olt.id,
|
||
name: olt.name,
|
||
host: olt.host,
|
||
brand: olt.brand,
|
||
status: 'Offline',
|
||
error: null,
|
||
uptime: 'N/A',
|
||
temp: 'N/A',
|
||
cpu: 'N/A',
|
||
ram: 'N/A',
|
||
onus_total: 0,
|
||
onus_online: 0,
|
||
onus_offline: 0,
|
||
onus_weak: 0,
|
||
onus: [],
|
||
unauth_onus: [],
|
||
cards: [],
|
||
voltage: 'N/A',
|
||
uplink_rx: 0,
|
||
uplink_tx: 0,
|
||
};
|
||
|
||
const community = olt.snmp_community || 'public';
|
||
const brandKey = (olt.brand || 'hioso').toLowerCase();
|
||
|
||
// Gabungkan semua profil untuk deteksi otomatis jika brand yang dipilih tidak cocok
|
||
const selectedProfiles = (BRAND_PROFILES[brandKey] || []).map(p => ({ ...p, __brandKey: brandKey }));
|
||
const otherProfiles = Object.keys(BRAND_PROFILES)
|
||
.filter(k => k !== brandKey)
|
||
.reduce((acc, k) => acc.concat((BRAND_PROFILES[k] || []).map(p => ({ ...p, __brandKey: k }))), []);
|
||
const allAvailableProfiles = [...selectedProfiles, ...otherProfiles];
|
||
|
||
const session = snmp.createSession(olt.host, community, {
|
||
port: olt.snmp_port || 161,
|
||
timeout: full ? 10000 : 5000,
|
||
retries: full ? 2 : 1,
|
||
version: snmp.Version2c,
|
||
});
|
||
|
||
session.on('error', (err) => {
|
||
logger.warn(`[SNMP Session Error] OLT ${olt.host}: ${err.message}`);
|
||
});
|
||
|
||
let isResolved = false;
|
||
|
||
return new Promise((resolve) => {
|
||
const safeResolve = (data) => {
|
||
if (isResolved) return;
|
||
isResolved = true;
|
||
clearTimeout(globalTimeout);
|
||
try { session.close(); } catch (e) {}
|
||
resolve(data);
|
||
};
|
||
|
||
const timeoutMs = full ? 90000 : 20000;
|
||
const globalTimeout = setTimeout(() => {
|
||
stats.error = `Koneksi Timeout (${Math.round(timeoutMs / 1000)}s) - OLT ${olt.host} tidak merespons SNMP/Telnet`;
|
||
safeResolve(stats);
|
||
}, timeoutMs);
|
||
|
||
(async () => {
|
||
try {
|
||
// 1. Cek koneksi dasar (Uptime)
|
||
const uptimeVbs = await new Promise(rv => {
|
||
session.get(['1.3.6.1.2.1.1.3.0'], (err, vbs) => {
|
||
if (err) {
|
||
stats.error = err.message;
|
||
rv([]);
|
||
} else rv(vbs);
|
||
});
|
||
});
|
||
|
||
if (!uptimeVbs[0] || uptimeVbs[0].type === snmp.ObjectType.NoSuchObject || uptimeVbs[0].type === snmp.ObjectType.EndOfMibView) {
|
||
// Telnet Fallback for HIOSO/HSGQ if SNMP is not enabled
|
||
if (brandKey === 'hioso' || brandKey === 'hsgq') {
|
||
const telnetData = await fetchHiosoOnuDetailViaTelnet(olt);
|
||
if (telnetData && telnetData.length > 0) {
|
||
stats.status = 'Online';
|
||
stats.error = null;
|
||
stats.onus_total = telnetData.length;
|
||
stats.onus_online = telnetData.filter(r => String(r.status).toLowerCase().includes('on')).length;
|
||
stats.onus_offline = stats.onus_total - stats.onus_online;
|
||
if (full) {
|
||
stats.onus = telnetData.map((r, idx) => ({
|
||
index: r.id || idx,
|
||
id: r.id || '-',
|
||
name: r.name || r.mac || `ONU-${idx+1}`,
|
||
sn: formatSnWithColons(r.mac) || '-',
|
||
status: String(r.status).toLowerCase().includes('on') ? 'Online' : 'Offline',
|
||
offline_reason: translateOfflineReason(brandKey, r.offlineReason),
|
||
tx: r.txPower || 'N/A',
|
||
rx: r.rxPower || 'N/A',
|
||
distance: r.distance || '-',
|
||
firmware: r.fwVersion || '-',
|
||
uptime: r.onlineTime || '-'
|
||
}));
|
||
}
|
||
await fetchHiosoSystemMetricsViaTelnet(olt, stats);
|
||
safeResolve(stats);
|
||
return;
|
||
}
|
||
}
|
||
|
||
stats.error = `Tidak dapat terhubung ke OLT ${olt.host}:${olt.snmp_port || 161} (${stats.error || 'Offline / Socket Closed'}). Pastikan IP OLT aktif & Community string ('${community}') benar.`;
|
||
safeResolve(stats);
|
||
return;
|
||
}
|
||
|
||
stats.uptime = decodeUptime(uptimeVbs[0].value);
|
||
stats.status = 'Online';
|
||
|
||
// 3. Deteksi profil yang cocok
|
||
let activeProfile = null;
|
||
for (const profile of allAvailableProfiles) {
|
||
const ok = await probeOid(session, profile.probe_oid);
|
||
if (ok) {
|
||
activeProfile = profile;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!activeProfile) {
|
||
// Telnet Fallback if profile not matched
|
||
if (brandKey === 'hioso' || brandKey === 'hsgq') {
|
||
const telnetData = await fetchHiosoOnuDetailViaTelnet(olt);
|
||
if (telnetData && telnetData.length > 0) {
|
||
stats.status = 'Online';
|
||
stats.error = null;
|
||
stats.onus_total = telnetData.length;
|
||
stats.onus_online = telnetData.filter(r => String(r.status).toLowerCase().includes('on')).length;
|
||
stats.onus_offline = stats.onus_total - stats.onus_online;
|
||
if (full) {
|
||
stats.onus = telnetData.map((r, idx) => ({
|
||
index: r.id || idx,
|
||
id: r.id || '-',
|
||
name: r.name || r.mac || `ONU-${idx+1}`,
|
||
sn: formatSnWithColons(r.mac) || '-',
|
||
status: String(r.status).toLowerCase().includes('on') ? 'Online' : 'Offline',
|
||
offline_reason: translateOfflineReason(brandKey, r.offlineReason),
|
||
tx: r.txPower || 'N/A',
|
||
rx: r.rxPower || 'N/A',
|
||
distance: r.distance || '-',
|
||
firmware: r.fwVersion || '-',
|
||
uptime: r.onlineTime || '-'
|
||
}));
|
||
}
|
||
await fetchHiosoSystemMetricsViaTelnet(olt, stats);
|
||
safeResolve(stats);
|
||
return;
|
||
}
|
||
}
|
||
|
||
stats.error = `Brand/Profile OID tidak cocok dengan perangkat OLT ${olt.host}. Silakan ganti Brand di menu 'Edit OLT'.`;
|
||
safeResolve(stats);
|
||
return;
|
||
}
|
||
|
||
const detectedBrandKey = activeProfile.__brandKey || brandKey;
|
||
stats.activeProfile = activeProfile;
|
||
stats.systemOids = SYSTEM_OIDS[detectedBrandKey] || null;
|
||
stats.cardOids = CARD_OIDS[detectedBrandKey] || null;
|
||
stats.snmpConfig = {
|
||
host: olt.host,
|
||
port: olt.snmp_port || 161,
|
||
community: olt.snmp_community || 'public',
|
||
brand: olt.brand
|
||
};
|
||
const onlineVals = getOnlineValues(detectedBrandKey, activeProfile);
|
||
|
||
// Start system metrics concurrently
|
||
const systemMetricsPromise = fetchSystemMetrics(session, detectedBrandKey, stats, olt);
|
||
|
||
// 4. Mode Counter (ZTE)
|
||
if (activeProfile.is_counter) {
|
||
const [onlineMap, totalMap] = await Promise.all([
|
||
slowWalk(session, activeProfile.status_table),
|
||
slowWalk(session, activeProfile.name_table)
|
||
]);
|
||
|
||
stats.onus_online = Object.values(onlineMap).reduce((s, v) => s + (bufferToInt(v) || 0), 0);
|
||
stats.onus_total = Object.values(totalMap).reduce((s, v) => s + (bufferToInt(v) || 0), 0);
|
||
stats.onus_offline = Math.max(0, stats.onus_total - stats.onus_online);
|
||
|
||
await systemMetricsPromise;
|
||
safeResolve(stats);
|
||
return;
|
||
}
|
||
|
||
// 5. Mode Table (Hioso, VSOL, HSGQ, etc)
|
||
const [statusMap, nameMap] = await Promise.all([
|
||
slowWalk(session, activeProfile.status_table),
|
||
slowWalk(session, activeProfile.name_table)
|
||
]);
|
||
|
||
let snMap = {};
|
||
let rxMap = {};
|
||
let txMap = {};
|
||
let distMap = {};
|
||
let fwMap = {};
|
||
let upMap = {};
|
||
let reasonMap = {};
|
||
|
||
// OPTIMIZATION: Only fetch detailed metrics if full=true
|
||
// Summary mode: skip rx/tx/distance/firmware/uptime/reason walks
|
||
// This makes summary 5-10x faster
|
||
if (full) {
|
||
// For Hioso/HSGQ, rx/tx DDM walks take ~33s (OLT measures all 256 slots on demand).
|
||
// Use a dedicated slow session (30s timeout, 0 retries) and walk them SEQUENTIALLY
|
||
// AFTER the fast walks complete. This prevents packet loss from concurrent walking.
|
||
const isSlowDdmBrand = (detectedBrandKey === 'hioso' || detectedBrandKey === 'hsgq');
|
||
|
||
if (isSlowDdmBrand && (activeProfile.rx_power_table || activeProfile.tx_power_table)) {
|
||
// --- Phase 1: Fast walks (status already done, now sn + distance + card + unauth) ---
|
||
const fastTasks = [];
|
||
fastTasks.push(() => fetchCardMetrics(session, detectedBrandKey, stats));
|
||
fastTasks.push(() => fetchUnauthOnus(session, activeProfile, stats));
|
||
fastTasks.push(() => pickSnTable(session, activeProfile).then(res => snMap = res.map || {}));
|
||
if (activeProfile.distance_table) {
|
||
fastTasks.push(() => slowWalk(session, activeProfile.distance_table).then(res => distMap = res));
|
||
}
|
||
if (activeProfile.firmware_table) {
|
||
fastTasks.push(() => slowWalk(session, activeProfile.firmware_table).then(res => fwMap = res));
|
||
}
|
||
if (activeProfile.uptime_table) {
|
||
fastTasks.push(() => slowWalk(session, activeProfile.uptime_table).then(res => upMap = res));
|
||
}
|
||
if (activeProfile.offline_reason_table) {
|
||
fastTasks.push(() => slowWalk(session, activeProfile.offline_reason_table).then(res => reasonMap = res));
|
||
}
|
||
await limitConcurrency(fastTasks, 2);
|
||
|
||
// --- Phase 2: Slow DDM walks on dedicated session (sequential, high timeout) ---
|
||
const slowSession = snmp.createSession(olt.host, community, {
|
||
port: olt.snmp_port || 161,
|
||
timeout: 30000, // 30s per GETBULK batch — DDM on Hioso needs long timeouts
|
||
retries: 0, // No retries; a retry on a 30s timeout would kill the budget
|
||
version: snmp.Version2c,
|
||
});
|
||
slowSession.on('error', (err) => {
|
||
logger.warn(`[SNMP SlowSession Error] OLT ${olt.host}: ${err.message}`);
|
||
});
|
||
try {
|
||
if (activeProfile.rx_power_table) {
|
||
logger.info(`[oltService] Walking rx_power_table for OLT ${olt.host} (slow DDM session)`);
|
||
rxMap = await slowWalk(slowSession, activeProfile.rx_power_table);
|
||
logger.info(`[oltService] rx_power_table done, ${Object.keys(rxMap).length} entries`);
|
||
}
|
||
if (activeProfile.tx_power_table) {
|
||
logger.info(`[oltService] Walking tx_power_table for OLT ${olt.host} (slow DDM session)`);
|
||
txMap = await slowWalk(slowSession, activeProfile.tx_power_table);
|
||
logger.info(`[oltService] tx_power_table done, ${Object.keys(txMap).length} entries`);
|
||
}
|
||
|
||
// Gap-fill: Hioso OLT truncates walk responses but individual GET works for all slots.
|
||
// Find indices present in statusMap but missing from rxMap/txMap, then GET them directly.
|
||
const statusIndices = Object.keys(statusMap);
|
||
const missingRx = activeProfile.rx_power_table
|
||
? statusIndices.filter(idx => rxMap[idx] == null)
|
||
: [];
|
||
const missingTx = activeProfile.tx_power_table
|
||
? statusIndices.filter(idx => txMap[idx] == null)
|
||
: [];
|
||
|
||
if (missingRx.length > 0 || missingTx.length > 0) {
|
||
logger.info(`[oltService] Gap-filling DDM: ${missingRx.length} rx + ${missingTx.length} tx missing, fetching via GET`);
|
||
|
||
// Build OID list for batch GET (up to 50 per request)
|
||
const batchGet = async (oidBase, indices, targetMap) => {
|
||
const BATCH = 50;
|
||
for (let i = 0; i < indices.length; i += BATCH) {
|
||
const batch = indices.slice(i, i + BATCH);
|
||
const oids = batch.map(idx => `${oidBase}.${idx}`);
|
||
await new Promise(rv => {
|
||
slowSession.get(oids, (err, vbs) => {
|
||
if (err) { rv(); return; }
|
||
for (const vb of vbs) {
|
||
if (!vb || snmp.isVarbindError(vb)) continue;
|
||
const suffix = vb.oid.split(oidBase + '.')[1];
|
||
if (!suffix) continue;
|
||
const val = Buffer.isBuffer(vb.value)
|
||
? vb.value.toString('utf8').replace(/\0/g,'').trim()
|
||
: String(vb.value).trim();
|
||
if (val && val !== 'null' && val !== '0') {
|
||
targetMap[suffix] = vb.value; // store raw for decoding
|
||
}
|
||
}
|
||
rv();
|
||
});
|
||
});
|
||
}
|
||
};
|
||
|
||
await batchGet(activeProfile.rx_power_table, missingRx, rxMap);
|
||
await batchGet(activeProfile.tx_power_table, missingTx, txMap);
|
||
logger.info(`[oltService] Gap-fill done, rx now ${Object.keys(rxMap).length}, tx now ${Object.keys(txMap).length}`);
|
||
}
|
||
} catch (ddmErr) {
|
||
logger.warn(`[oltService] DDM walk failed for OLT ${olt.host}: ${ddmErr.message}`);
|
||
} finally {
|
||
try { slowSession.close(); } catch (e) {}
|
||
}
|
||
|
||
} else {
|
||
// --- Standard path for all other brands ---
|
||
const tasks = [];
|
||
tasks.push(() => fetchCardMetrics(session, detectedBrandKey, stats));
|
||
tasks.push(() => fetchUnauthOnus(session, activeProfile, stats));
|
||
tasks.push(() => pickSnTable(session, activeProfile).then(res => snMap = res.map || {}));
|
||
if (activeProfile.rx_power_table) {
|
||
tasks.push(() => slowWalk(session, activeProfile.rx_power_table).then(res => rxMap = res));
|
||
}
|
||
if (activeProfile.tx_power_table) {
|
||
tasks.push(() => slowWalk(session, activeProfile.tx_power_table).then(res => txMap = res));
|
||
}
|
||
if (activeProfile.distance_table) {
|
||
tasks.push(() => slowWalk(session, activeProfile.distance_table).then(res => distMap = res));
|
||
}
|
||
if (activeProfile.firmware_table) {
|
||
tasks.push(() => slowWalk(session, activeProfile.firmware_table).then(res => fwMap = res));
|
||
}
|
||
if (activeProfile.uptime_table) {
|
||
tasks.push(() => slowWalk(session, activeProfile.uptime_table).then(res => upMap = res));
|
||
}
|
||
if (activeProfile.offline_reason_table) {
|
||
tasks.push(() => slowWalk(session, activeProfile.offline_reason_table).then(res => reasonMap = res));
|
||
}
|
||
|
||
// OPTIMIZATION: Tune concurrency per brand
|
||
// ZTE handles 5, others 3-4
|
||
const concurrencyMap = {
|
||
'zte': 5,
|
||
'huawei': 4,
|
||
'hioso': 2,
|
||
'vsol': 4,
|
||
'cdata': 3,
|
||
'hsgq': 2
|
||
};
|
||
const concurrency = concurrencyMap[detectedBrandKey] || 4;
|
||
await limitConcurrency(tasks, concurrency);
|
||
}
|
||
}
|
||
|
||
await systemMetricsPromise;
|
||
|
||
const allIndices = new Set([...Object.keys(statusMap), ...Object.keys(nameMap)]);
|
||
stats.onus_total = allIndices.size;
|
||
|
||
const onus = [];
|
||
let weakCount = 0;
|
||
|
||
for (const idx of allIndices) {
|
||
const stRaw = getByIdx(statusMap, idx);
|
||
const stInt = bufferToInt(stRaw);
|
||
const stStr = stRaw == null ? '' : String(stRaw).trim().toLowerCase();
|
||
const isUp = stInt != null
|
||
? onlineVals.includes(stInt)
|
||
: (stStr === 'online' || stStr === 'up' || stStr === 'on' || stStr === 'operation');
|
||
|
||
const nameRaw = getByIdx(nameMap, idx);
|
||
// SNMP returns ONU name as Buffer (OctetString) — must decode as UTF-8
|
||
const nameStr = nameRaw == null
|
||
? ''
|
||
: (Buffer.isBuffer(nameRaw)
|
||
? nameRaw.toString('utf8')
|
||
: String(nameRaw)
|
||
).replace(/\0/g, '').trim();
|
||
const name = nameStr || ('ONU-' + idx);
|
||
const snVal = getByIdx(snMap, idx);
|
||
const rxVal = getByIdx(rxMap, idx);
|
||
const txVal = getByIdx(txMap, idx);
|
||
const distVal = getByIdx(distMap, idx);
|
||
const fwVal = getByIdx(fwMap, idx);
|
||
const upVal = getByIdx(upMap, idx);
|
||
|
||
const sn = snVal ? decodeSn(snVal) : '-';
|
||
const rx = decodeRxPower(detectedBrandKey, rxVal);
|
||
const tx = decodeRxPower(detectedBrandKey, txVal);
|
||
const distInt = bufferToInt(distVal);
|
||
let distance = '-';
|
||
if (activeProfile.distance_table && distInt != null && distInt > 0) {
|
||
const tenths = activeProfile.distance_tenths_meter !== false;
|
||
distance = tenths ? (distInt / 10).toFixed(1) + ' m' : `${distInt} m`;
|
||
}
|
||
const firmware = safeToString(fwVal) || '-';
|
||
const onuUptime = upVal ? decodeUptime(bufferToInt(upVal)) : '-';
|
||
const onuId = hiosoOnuIdFromIndex(idx);
|
||
|
||
if (isUp) stats.onus_online++;
|
||
else stats.onus_offline++;
|
||
|
||
if (rx !== 'N/A') {
|
||
const n = parseFloat(rx);
|
||
if (Number.isFinite(n) && n < -27) weakCount++;
|
||
}
|
||
|
||
if (full) {
|
||
const rxShown = rx === 'N/A' ? rx : rx + ' dBm';
|
||
const txShown = tx === 'N/A' ? tx : tx + ' dBm';
|
||
|
||
const rawReason = getByIdx(reasonMap, idx);
|
||
const offlineReason = translateOfflineReason(detectedBrandKey, rawReason);
|
||
|
||
onus.push({
|
||
index: idx,
|
||
id: onuId || '-',
|
||
name,
|
||
sn,
|
||
status: isUp ? 'Online' : 'Offline',
|
||
offline_reason: isUp ? null : (offlineReason || null),
|
||
tx: txShown,
|
||
rx: rxShown,
|
||
distance,
|
||
firmware,
|
||
uptime: onuUptime
|
||
});
|
||
}
|
||
}
|
||
|
||
stats.onus_weak = weakCount;
|
||
stats.onus = enrichOnusWithCustomerData(onus.sort((a, b) => a.name.localeCompare(b.name)));
|
||
safeResolve(stats);
|
||
} catch (err) {
|
||
stats.error = err.message;
|
||
safeResolve(stats);
|
||
}
|
||
})();
|
||
});
|
||
}
|
||
|
||
function enrichOnusWithCustomerData(onus) {
|
||
if (!Array.isArray(onus) || onus.length === 0) return onus;
|
||
try {
|
||
const custs = db.prepare(`
|
||
SELECT id, name, pppoe_username, mac_address, genieacs_tag, phone
|
||
FROM customers
|
||
`).all() || [];
|
||
|
||
const pppoeMap = new Map();
|
||
const macMap = new Map();
|
||
const tagMap = new Map();
|
||
const nameMap = new Map();
|
||
|
||
for (const c of custs) {
|
||
if (c.pppoe_username) pppoeMap.set(String(c.pppoe_username).trim().toLowerCase(), c);
|
||
if (c.mac_address) {
|
||
const rawMac = String(c.mac_address).replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
||
if (rawMac) macMap.set(rawMac, c);
|
||
}
|
||
if (c.genieacs_tag) tagMap.set(String(c.genieacs_tag).trim().toLowerCase(), c);
|
||
if (c.name) nameMap.set(String(c.name).trim().toLowerCase(), c);
|
||
}
|
||
|
||
for (const onu of onus) {
|
||
const snClean = String(onu.sn || '').replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
||
const nameClean = String(onu.name || '').trim().toLowerCase();
|
||
|
||
let matched = null;
|
||
if (snClean && macMap.has(snClean)) matched = macMap.get(snClean);
|
||
else if (snClean && tagMap.has(snClean)) matched = tagMap.get(snClean);
|
||
else if (nameClean && pppoeMap.has(nameClean)) matched = pppoeMap.get(nameClean);
|
||
else if (nameClean && nameMap.has(nameClean)) matched = nameMap.get(nameClean);
|
||
else if (snClean.length >= 6) {
|
||
for (const [mac, c] of macMap.entries()) {
|
||
if (mac.length >= 8 && (snClean.includes(mac) || mac.includes(snClean))) {
|
||
matched = c;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (matched) {
|
||
onu.customer_id = matched.id;
|
||
onu.customer_name = matched.name;
|
||
onu.customer_phone = matched.phone || null;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
logger.warn(`[OLT] Error enriching ONUs with customer data: ${e.message}`);
|
||
}
|
||
return onus;
|
||
}
|
||
|
||
// ─── ONU ACTIONS ─────────────────────────────────────────────────────────────
|
||
|
||
function decodeOltSnmpIndex(brand, index) {
|
||
const idxNum = parseInt(index, 10);
|
||
if (!Number.isFinite(idxNum)) {
|
||
const s = String(index || '');
|
||
let frame = '0', board = '1', port = '1', onuId = '1';
|
||
const parts = s.split(/[/: ]+/).filter(Boolean);
|
||
if (parts.length >= 3) {
|
||
board = parts[parts.length - 3];
|
||
port = parts[parts.length - 2];
|
||
onuId = parts[parts.length - 1];
|
||
}
|
||
return { frame, board, port, onuId };
|
||
}
|
||
|
||
const bKey = String(brand || '').toLowerCase();
|
||
if (bKey === 'zte') {
|
||
const board = (idxNum >> 16) & 0xFF;
|
||
const port = (idxNum >> 8) & 0xFF;
|
||
const onuId = idxNum & 0xFF;
|
||
return { frame: '0', board: String(board), port: String(port), onuId: String(onuId) };
|
||
} else if (bKey === 'huawei') {
|
||
const frame = (idxNum >> 21) & 0x1F;
|
||
const slot = (idxNum >> 16) & 0x1F;
|
||
const port = (idxNum >> 8) & 0xFF;
|
||
const onuId = idxNum & 0xFF;
|
||
return { frame: String(frame), board: String(slot), port: String(port), onuId: String(onuId) };
|
||
} else {
|
||
let port = (idxNum >> 16) & 0xFF;
|
||
if (port === 0 || port > 16) port = (idxNum >> 8) & 0xFF;
|
||
const onuId = idxNum & 0xFF;
|
||
return { frame: '0', board: '0', port: String(port), onuId: String(onuId) };
|
||
}
|
||
}
|
||
|
||
async function rebootOnu(oltId, index) {
|
||
const olt = getOltById(oltId);
|
||
if (!olt) throw new Error('OLT tidak ditemukan');
|
||
|
||
const brand = (olt.brand || 'hioso').toLowerCase();
|
||
if (brand === 'zte' || brand === 'huawei') {
|
||
const onuProvisionSvc = require('./onuProvisionService');
|
||
const oltConfig = {
|
||
vendor: olt.brand,
|
||
host: olt.host,
|
||
username: olt.web_user,
|
||
password: olt.web_password
|
||
};
|
||
const portVal = parseInt(olt.telnet_port, 10);
|
||
oltConfig.port = (Number.isFinite(portVal) && portVal > 0) ? portVal : 22;
|
||
|
||
const parsed = decodeOltSnmpIndex(olt.brand, index);
|
||
return await onuProvisionSvc.rebootONU(oltConfig, olt.brand, parsed);
|
||
}
|
||
|
||
const community = olt.snmp_community || 'public';
|
||
const session = snmp.createSession(olt.host, community, { port: olt.snmp_port || 161, version: snmp.Version2c });
|
||
const oid = `1.3.6.1.4.1.25355.3.2.6.3.2.1.40.${index}`;
|
||
return new Promise((resolve, reject) => {
|
||
session.set([{ oid, type: snmp.ASN1.Integer, value: 1 }], (error) => {
|
||
session.close();
|
||
if (error) reject(error);
|
||
else resolve(true);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function renameOnu(oltId, index, newName) {
|
||
const olt = getOltById(oltId);
|
||
if (!olt) throw new Error('OLT tidak ditemukan');
|
||
|
||
const brand = (olt.brand || 'hioso').toLowerCase();
|
||
if (brand === 'zte' || brand === 'huawei') {
|
||
const onuProvisionSvc = require('./onuProvisionService');
|
||
const oltConfig = {
|
||
vendor: olt.brand,
|
||
host: olt.host,
|
||
username: olt.web_user,
|
||
password: olt.web_password
|
||
};
|
||
const portVal = parseInt(olt.telnet_port, 10);
|
||
oltConfig.port = (Number.isFinite(portVal) && portVal > 0) ? portVal : 22;
|
||
|
||
const parsed = decodeOltSnmpIndex(olt.brand, index);
|
||
parsed.newName = newName;
|
||
return await onuProvisionSvc.renameONU(oltConfig, olt.brand, parsed);
|
||
}
|
||
|
||
const community = olt.snmp_community || 'public';
|
||
const session = snmp.createSession(olt.host, community, { port: olt.snmp_port || 161, version: snmp.Version2c });
|
||
const oid = `1.3.6.1.4.1.25355.3.2.6.3.2.1.37.${index}`;
|
||
return new Promise((resolve, reject) => {
|
||
session.set([{ oid, type: snmp.ASN1.OctetString, value: newName }], (error) => {
|
||
session.close();
|
||
if (error) reject(error);
|
||
else resolve(true);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Otorisasi ONU (Provisioning) via Telnet CLI.
|
||
*/
|
||
async function authorizeOnu(oltId, data) {
|
||
const olt = getOltById(oltId);
|
||
if (!olt) throw new Error('OLT tidak ditemukan');
|
||
|
||
const brand = (olt.brand || 'hioso').toLowerCase();
|
||
const { index, sn, name, vlan } = data;
|
||
const snCli = normalizeSnForOltProvision(sn);
|
||
|
||
// Parsing index (format ZTE: 1/board/port:onuId)
|
||
let board = 1, port = 1, onuId = 1;
|
||
if (index.includes('/')) {
|
||
const parts = index.split(/[/: ]+/).filter(Boolean);
|
||
if (parts.length >= 3) {
|
||
board = parts[parts.length - 3];
|
||
port = parts[parts.length - 2];
|
||
onuId = parts[parts.length - 1];
|
||
}
|
||
}
|
||
|
||
const cmds = [];
|
||
if (brand === 'zte') {
|
||
cmds.push('enable');
|
||
cmds.push('configure terminal');
|
||
cmds.push(`interface gpon-olt_1/${board}/${port}`);
|
||
cmds.push(`onu ${onuId} type ALL sn ${snCli}`);
|
||
cmds.push('exit');
|
||
cmds.push(`interface gpon-onu_1/${board}/${port}:${onuId}`);
|
||
if (name) cmds.push(`name ${name}`);
|
||
cmds.push('tcont 1 profile UP-100M'); // Default profile
|
||
cmds.push('gemport 1 tcont 1');
|
||
if (vlan) cmds.push(`service-port 1 vport 1 user-vlan ${vlan} vlan ${vlan}`);
|
||
cmds.push('exit');
|
||
cmds.push('end');
|
||
cmds.push('write');
|
||
} else if (brand === 'huawei') {
|
||
cmds.push('enable');
|
||
cmds.push('config');
|
||
cmds.push(`interface gpon 0/${board}`);
|
||
cmds.push(`ont add ${port} ${onuId} sn-auth ${snCli} omci ont-lineprofile-id 1 ont-srvprofile-id 1`);
|
||
if (name) cmds.push(`ont name ${port} ${onuId} "${name}"`);
|
||
cmds.push('quit');
|
||
if (vlan) cmds.push(`service-port vlan ${vlan} gpon 0/${board}/${port} ont ${onuId} gemport 1 multi-service user-vlan ${vlan}`);
|
||
cmds.push('save');
|
||
} else {
|
||
throw new Error(`Fitur otorisasi otomatis belum didukung untuk brand ${brand}`);
|
||
}
|
||
|
||
return await telnetLoginAndRun(olt.host, olt.web_user, olt.web_password, cmds, telnetOptsFromOlt(olt));
|
||
}
|
||
|
||
/**
|
||
* Cari data tambahan ONU dari ACS (WiFi, Client Count, dll)
|
||
*/
|
||
async function enrichOnusWithAcsData(onus) {
|
||
try {
|
||
logger.info(`[ACS-Sync] Memulai sinkronisasi data untuk ${onus.length} ONU`);
|
||
const acsDevices = await genieacs.getDevices();
|
||
if (!acsDevices || !acsDevices.length) {
|
||
logger.warn('[ACS-Sync] Tidak ada perangkat ditemukan di GenieACS');
|
||
return onus;
|
||
}
|
||
|
||
const normalizeSN = (sn) => String(sn || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||
|
||
for (const onu of onus) {
|
||
const targetSn = normalizeSN(onu.sn);
|
||
if (!targetSn || targetSn === '-') continue;
|
||
|
||
logger.debug(`[ACS-Sync] Mencari SN: ${targetSn}`);
|
||
|
||
// Cari device di ACS berdasarkan SN (fuzzy match)
|
||
const acsDev = acsDevices.find(d => {
|
||
const sn1 = d.Device?.DeviceInfo?.SerialNumber?._value;
|
||
const sn2 = d.InternetGatewayDevice?.DeviceInfo?.SerialNumber?._value;
|
||
const sn3 = d._id;
|
||
|
||
const acsSn = normalizeSN(sn1 || sn2 || sn3);
|
||
const match = acsSn.includes(targetSn) || targetSn.includes(acsSn);
|
||
if (match) logger.info(`[ACS-Sync] Match Found: ${targetSn} matches ${acsSn} (ID: ${d._id})`);
|
||
return match;
|
||
});
|
||
|
||
if (acsDev) {
|
||
// Ambil SSID (Cek beberapa path umum)
|
||
const ssid =
|
||
acsDev.InternetGatewayDevice?.LANDevice?.['1']?.WLANConfiguration?.['1']?.SSID?._value ||
|
||
acsDev.Device?.WiFi?.SSID?.['1']?.SSID?._value ||
|
||
acsDev.Device?.WiFi?.SSID?.['1']?.SSID ||
|
||
acsDev.InternetGatewayDevice?.LANDevice?.['1']?.WLANConfiguration?.['5']?.SSID?._value || '-';
|
||
|
||
onu.wifi_ssid = ssid;
|
||
|
||
// Hitung Client Connected (Sum up all radios if possible)
|
||
let associations = 0;
|
||
const paths = [
|
||
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.TotalAssociations',
|
||
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.TotalAssociations',
|
||
'Device.WiFi.AccessPoint.1.AssociatedDeviceNumberOfEntries',
|
||
'Device.WiFi.AccessPoint.2.AssociatedDeviceNumberOfEntries',
|
||
'InternetGatewayDevice.LANDevice.1.Hosts.HostNumberOfEntries',
|
||
'Device.Hosts.HostNumberOfEntries'
|
||
];
|
||
|
||
for (const p of paths) {
|
||
const val = getNestedValue(acsDev, p);
|
||
if (val !== undefined && val !== null) {
|
||
associations += parseInt(val) || 0;
|
||
}
|
||
}
|
||
|
||
onu.client_count = associations;
|
||
onu.acs_id = acsDev._id;
|
||
logger.info(`[ACS-Sync] Updated ${onu.sn}: SSID=${onu.wifi_ssid}, Users=${onu.client_count}`);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
logger.error(`[ACS-Sync] Error: ${e.message}`);
|
||
}
|
||
return onus;
|
||
}
|
||
|
||
function getNestedValue(obj, path) {
|
||
return path.split('.').reduce((prev, curr) => {
|
||
if (prev && prev[curr] !== undefined) {
|
||
const val = prev[curr];
|
||
return (val && val._value !== undefined) ? val._value : val;
|
||
}
|
||
return undefined;
|
||
}, obj);
|
||
}
|
||
|
||
function parseZteOnuIndex(index) {
|
||
let board = 1;
|
||
let port = 1;
|
||
let onuId = 1;
|
||
const s = String(index || '');
|
||
if (s.includes('/')) {
|
||
const parts = s.split(/[/: ]+/).filter(Boolean);
|
||
if (parts.length >= 3) {
|
||
board = parts[parts.length - 3];
|
||
port = parts[parts.length - 2];
|
||
onuId = parts[parts.length - 1];
|
||
}
|
||
}
|
||
return { board: String(board), port: String(port), onuId: String(onuId) };
|
||
}
|
||
|
||
const telnetOptsFromOlt = (olt) => ({
|
||
port: Number(olt.telnet_port) > 0 ? Number(olt.telnet_port) : 23,
|
||
enablePassword: olt.enable_password != null && String(olt.enable_password).length > 0 ? String(olt.enable_password) : null
|
||
});
|
||
|
||
/**
|
||
* Delegasi VLAN / service-port ke [go-api-c320](https://github.com/s4lfanet/go-api-c320) — POST /api/v1/vlan/onu
|
||
* Format pon_port: rack/shelf/slot (contoh 1/2/7) dari indeks gpon-onu_1/2/7:5
|
||
*/
|
||
async function configureZteWanViaGoApi(oltId, data) {
|
||
const olt = getOltById(oltId);
|
||
if (!olt) throw new Error('OLT tidak ditemukan');
|
||
|
||
const base = String(olt.api_base_url || '').trim().replace(/\/+$/, '');
|
||
if (!base) {
|
||
throw new Error('Isi "API Base URL" pada data OLT (contoh http://10.0.0.5:8081) agar metode Go API dapat memanggil go-api-c320.');
|
||
}
|
||
|
||
const mode = String(data.mode || 'Bridge');
|
||
if (mode === 'PPPoE') {
|
||
throw new Error('Integrasi REST ini memetakan ke VLAN/service-port go-api-c320. Untuk PPPoE gunakan Telnet CLI (OMCI) atau TR069.');
|
||
}
|
||
|
||
const { board, port, onuId } = parseZteOnuIndex(data.index);
|
||
const ponPort = `1/${board}/${port}`;
|
||
if (!/^\d+\/\d+\/\d+$/.test(ponPort)) {
|
||
throw new Error('Indeks ONU tidak valid untuk Go API (butuh slot/port/onu, mis. gpon-onu_1/2/7:5 → pon_port 1/2/7).');
|
||
}
|
||
|
||
const onu_id = parseInt(String(onuId), 10);
|
||
if (!Number.isFinite(onu_id) || onu_id < 1 || onu_id > 128) {
|
||
throw new Error('ONU ID tidak valid (1–128).');
|
||
}
|
||
|
||
const svlan = parseInt(String(data.vlan != null ? data.vlan : data.svlan), 10);
|
||
const cvlanIn = data.cvlan != null && String(data.cvlan).trim() !== '' ? parseInt(String(data.cvlan), 10) : NaN;
|
||
const cvlan = Number.isFinite(cvlanIn) ? cvlanIn : svlan;
|
||
|
||
if (!Number.isFinite(svlan) || svlan < 1 || svlan > 4094) {
|
||
throw new Error('VLAN / SVLAN harus antara 1 dan 4094.');
|
||
}
|
||
|
||
const vlan_mode = String(data.vlan_mode || 'tag').toLowerCase();
|
||
const allowed = ['tag', 'translation', 'transparent'];
|
||
if (!allowed.includes(vlan_mode)) {
|
||
throw new Error('vlan_mode harus: tag, translation, atau transparent (sesuai go-api-c320).');
|
||
}
|
||
|
||
if (vlan_mode === 'translation' && (!Number.isFinite(cvlan) || cvlan < 1)) {
|
||
throw new Error('Mode translation membutuhkan CVLAN.');
|
||
}
|
||
|
||
const priority = Number.isFinite(parseInt(data.priority, 10)) ? parseInt(data.priority, 10) : 0;
|
||
if (priority < 0 || priority > 7) {
|
||
throw new Error('Priority harus 0–7.');
|
||
}
|
||
|
||
const url = `${base}/api/v1/vlan/onu`;
|
||
const body = {
|
||
pon_port: ponPort,
|
||
onu_id,
|
||
svlan,
|
||
cvlan: vlan_mode === 'translation' ? cvlan : (Number.isFinite(cvlan) ? cvlan : svlan),
|
||
vlan_mode,
|
||
priority
|
||
};
|
||
|
||
const res = await axios.post(url, body, {
|
||
timeout: 90000,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
validateStatus: () => true
|
||
});
|
||
|
||
const payload = res.data;
|
||
const httpOk = res.status >= 200 && res.status < 300;
|
||
const apiCode = payload != null && payload.code != null ? Number(payload.code) : null;
|
||
if (!httpOk || (apiCode != null && apiCode >= 400)) {
|
||
const msg = payload?.message || payload?.status || payload?.error || `HTTP ${res.status}`;
|
||
throw new Error(`go-api-c320: ${msg}`);
|
||
}
|
||
|
||
return typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
||
}
|
||
|
||
/**
|
||
* Konfigurasi WAN ONU via TR069 (GenieACS).
|
||
*/
|
||
async function configureWanViaAcs(sn, data) {
|
||
const acsDevices = await genieacs.getDevices();
|
||
const acsDev = acsDevices.find(d => d._id.includes(sn) || (d.Device?.DeviceInfo?.SerialNumber?._value && d.Device.DeviceInfo.SerialNumber._value.includes(sn)));
|
||
|
||
if (!acsDev) throw new Error(`Perangkat dengan SN ${sn} tidak ditemukan di ACS.`);
|
||
|
||
const { mode, vlan, username, password, lans, ssids } = data;
|
||
const params = {};
|
||
|
||
// Helper to build binding strings
|
||
// Typical formats: "LAN1,LAN2" or "WLAN1,WLAN2"
|
||
const lanBind = lans ? lans.split(',').map(l => `LAN${l}`).join(',') : '';
|
||
const ssidBind = ssids ? ssids.split(',').map(s => `WLAN${s}`).join(',') : '';
|
||
|
||
if (mode === 'PPPoE') {
|
||
const basePath = "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.";
|
||
params[`${basePath}Enable`] = true;
|
||
params[`${basePath}ConnectionType`] = "IP_Routed";
|
||
params[`${basePath}Name`] = "INTERNET";
|
||
params[`${basePath}Username`] = username;
|
||
params[`${basePath}Password`] = password;
|
||
if (vlan) {
|
||
params[`${basePath}VLANID`] = vlan;
|
||
}
|
||
// LAN & SSID Binding for Broadcom/Typical ONUs
|
||
if (lanBind) params[`${basePath}X_BROADCOM_COM_LANBind`] = lanBind;
|
||
if (ssidBind) params[`${basePath}X_BROADCOM_COM_WLANBind`] = ssidBind;
|
||
|
||
} else {
|
||
const basePath = "InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.";
|
||
params[`${basePath}Enable`] = true;
|
||
params[`${basePath}ConnectionType`] = "IP_Bridged";
|
||
if (vlan) {
|
||
params[`${basePath}VLANID`] = vlan;
|
||
}
|
||
// LAN & SSID Binding
|
||
if (lanBind) params[`${basePath}X_BROADCOM_COM_LANBind`] = lanBind;
|
||
if (ssidBind) params[`${basePath}X_BROADCOM_COM_WLANBind`] = ssidBind;
|
||
}
|
||
|
||
return await genieacs.setParameterValues(acsDev._id, params);
|
||
}
|
||
|
||
/**
|
||
* Konfigurasi WAN ONU (PPPoE / Bridge) via Telnet CLI.
|
||
*/
|
||
async function configureOnuWan(oltId, data) {
|
||
const olt = getOltById(oltId);
|
||
if (!olt) throw new Error('OLT tidak ditemukan');
|
||
|
||
const brand = (olt.brand || 'hioso').toLowerCase();
|
||
const method = String(data.method || 'telnet').toLowerCase();
|
||
|
||
if (method === 'go-api' && brand !== 'zte') {
|
||
throw new Error('Metode REST (go-api-c320) hanya untuk OLT brand ZTE.');
|
||
}
|
||
if (brand === 'zte' && method === 'go-api') {
|
||
return configureZteWanViaGoApi(oltId, data);
|
||
}
|
||
|
||
const { index, mode, vlan, username, password, lans, ssids } = data;
|
||
|
||
// Parsing index
|
||
let board = 1, port = 1, onuId = 1;
|
||
if (index.includes('/')) {
|
||
const parts = index.split(/[/: ]+/).filter(Boolean);
|
||
if (parts.length >= 3) {
|
||
board = parts[parts.length - 3];
|
||
port = parts[parts.length - 2];
|
||
onuId = parts[parts.length - 1];
|
||
}
|
||
}
|
||
|
||
const cmds = [];
|
||
if (brand === 'zte') {
|
||
cmds.push('enable');
|
||
cmds.push('configure terminal');
|
||
|
||
if (mode === 'PPPoE') {
|
||
// ZTE C300/C320 PPPoE Config via OMCI (pon-onu-mng)
|
||
cmds.push(`pon-onu-mng gpon-onu_1/${board}/${port}:${onuId}`);
|
||
|
||
// 1. Configure WAN IP with PPPoE
|
||
// Note: vlan here is used as vlan-profile name. Usually named 'VLAN100' or just '100'
|
||
cmds.push(`wan-ip 1 mode pppoe username ${username} password ${password} vlan-profile ${vlan} host 1`);
|
||
|
||
// 2. Binding LAN & SSID
|
||
if (lans || ssids) {
|
||
let bindCmd = `wan 1`;
|
||
if (lans) bindCmd += ` ethuni ${lans}`;
|
||
if (ssids) bindCmd += ` ssid ${ssids}`;
|
||
bindCmd += ` service internet host 1`;
|
||
cmds.push(bindCmd);
|
||
}
|
||
|
||
cmds.push('exit');
|
||
|
||
// OLT side service-port
|
||
cmds.push(`interface gpon-onu_1/${board}/${port}:${onuId}`);
|
||
cmds.push(`service-port 1 vport 1 user-vlan ${vlan} vlan ${vlan}`);
|
||
} else {
|
||
// Bridge Mode
|
||
cmds.push(`pon-onu-mng gpon-onu_1/${board}/${port}:${onuId}`);
|
||
|
||
// Tagging each selected port to the target VLAN
|
||
if (vlan) {
|
||
if (lans) {
|
||
lans.split(',').forEach(l => {
|
||
cmds.push(`vlan port eth_0/${l} mode tag vlan ${vlan}`);
|
||
});
|
||
}
|
||
if (ssids) {
|
||
ssids.split(',').forEach(s => {
|
||
cmds.push(`vlan port wifi_0/${s} mode tag vlan ${vlan}`);
|
||
});
|
||
}
|
||
}
|
||
|
||
cmds.push('exit');
|
||
|
||
// OLT side service-port
|
||
cmds.push(`interface gpon-onu_1/${board}/${port}:${onuId}`);
|
||
cmds.push(`service-port 1 vport 1 user-vlan ${vlan} vlan ${vlan}`);
|
||
}
|
||
|
||
cmds.push('exit');
|
||
cmds.push('end');
|
||
cmds.push('write');
|
||
return await telnetLoginAndRun(olt.host, olt.web_user, olt.web_password, cmds, telnetOptsFromOlt(olt));
|
||
} else if (brand === 'huawei') {
|
||
cmds.push('enable');
|
||
cmds.push('config');
|
||
if (mode === 'PPPoE') {
|
||
// Huawei usually configures WAN via OMCI/TR069, but basic service-port is needed
|
||
cmds.push(`service-port vlan ${vlan} gpon 0/${board}/${port} ont ${onuId} gemport 1 multi-service user-vlan ${vlan}`);
|
||
} else {
|
||
cmds.push(`service-port vlan ${vlan} gpon 0/${board}/${port} ont ${onuId} gemport 1 multi-service user-vlan ${vlan}`);
|
||
}
|
||
cmds.push('save');
|
||
} else {
|
||
throw new Error(`Fitur konfigurasi WAN belum didukung untuk brand ${brand}`);
|
||
}
|
||
|
||
return await telnetLoginAndRun(olt.host, olt.web_user, olt.web_password, cmds, telnetOptsFromOlt(olt));
|
||
}
|
||
|
||
async function getAllOltsStats(full = false) {
|
||
const olts = getActiveOlts();
|
||
if (!olts || olts.length === 0) {
|
||
return {
|
||
is_all: true,
|
||
onus_total: 0,
|
||
onus_online: 0,
|
||
onus_offline: 0,
|
||
onus_weak: 0,
|
||
onus: [],
|
||
unauth_onus: [],
|
||
olts_summary: [],
|
||
error: 'Tidak ada OLT aktif'
|
||
};
|
||
}
|
||
|
||
const results = await Promise.all(olts.map(o => getOltStats(o.id, full)));
|
||
|
||
let onus_total = 0;
|
||
let onus_online = 0;
|
||
let onus_offline = 0;
|
||
let onus_weak = 0;
|
||
let uplink_rx = 0;
|
||
let uplink_tx = 0;
|
||
const allOnus = [];
|
||
const allUnauth = [];
|
||
const olts_summary = [];
|
||
|
||
const tempValues = [];
|
||
const cpuValues = [];
|
||
const ramValues = [];
|
||
|
||
for (let i = 0; i < results.length; i++) {
|
||
const s = results[i];
|
||
const oltInfo = olts[i];
|
||
if (s) {
|
||
onus_total += (s.onus_total || 0);
|
||
onus_online += (s.onus_online || 0);
|
||
onus_offline += (s.onus_offline || 0);
|
||
onus_weak += (s.onus_weak || 0);
|
||
uplink_rx += (s.uplink_rx || 0);
|
||
uplink_tx += (s.uplink_tx || 0);
|
||
|
||
const tN = parseFloat(s.temp);
|
||
if (Number.isFinite(tN) && tN > 0) tempValues.push(tN);
|
||
const cN = parseFloat(s.cpu);
|
||
if (Number.isFinite(cN) && cN >= 0) cpuValues.push(cN);
|
||
const rN = parseFloat(s.ram);
|
||
if (Number.isFinite(rN) && rN > 0) ramValues.push(rN);
|
||
|
||
olts_summary.push({
|
||
id: oltInfo.id,
|
||
name: oltInfo.name,
|
||
host: oltInfo.host,
|
||
brand: oltInfo.brand,
|
||
status: s.status,
|
||
onus_total: s.onus_total,
|
||
onus_online: s.onus_online,
|
||
onus_offline: s.onus_offline,
|
||
onus_weak: s.onus_weak,
|
||
temp: s.temp,
|
||
cpu: s.cpu,
|
||
ram: s.ram,
|
||
uptime: s.uptime,
|
||
uplink_rx: s.uplink_rx || 0,
|
||
uplink_tx: s.uplink_tx || 0,
|
||
error: s.error,
|
||
activeProfile: s.activeProfile || null,
|
||
systemOids: s.systemOids || null,
|
||
snmpConfig: s.snmpConfig || null
|
||
});
|
||
|
||
if (Array.isArray(s.onus)) {
|
||
s.onus.forEach(onu => {
|
||
allOnus.push({ ...onu, olt_id: oltInfo.id, olt_name: oltInfo.name });
|
||
});
|
||
}
|
||
|
||
if (Array.isArray(s.unauth_onus)) {
|
||
s.unauth_onus.forEach(u => {
|
||
allUnauth.push({ ...u, olt_id: oltInfo.id, olt_name: oltInfo.name });
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
let temp = 'N/A';
|
||
let cpu = 'N/A';
|
||
let ram = 'N/A';
|
||
let uptime = 'Multi-OLT Active';
|
||
|
||
if (olts_summary.length === 1) {
|
||
const s0 = olts_summary[0];
|
||
temp = s0.temp || 'N/A';
|
||
cpu = s0.cpu || 'N/A';
|
||
ram = s0.ram || 'N/A';
|
||
uptime = s0.uptime || 'N/A';
|
||
} else if (olts_summary.length > 1) {
|
||
if (tempValues.length > 0) {
|
||
const avgT = Math.round(tempValues.reduce((a, b) => a + b, 0) / tempValues.length);
|
||
temp = `${avgT}°C (rata-rata)`;
|
||
}
|
||
if (cpuValues.length > 0) {
|
||
const avgC = Math.round(cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length);
|
||
cpu = `${avgC}% (rata-rata)`;
|
||
}
|
||
if (ramValues.length > 0) {
|
||
const avgR = Math.round(ramValues.reduce((a, b) => a + b, 0) / ramValues.length);
|
||
ram = `${avgR}% (rata-rata)`;
|
||
}
|
||
const onlineCount = olts_summary.filter(o => o.status === 'Online').length;
|
||
uptime = `${onlineCount}/${olts_summary.length} OLT Online`;
|
||
}
|
||
|
||
return {
|
||
is_all: true,
|
||
onus_total,
|
||
onus_online,
|
||
onus_offline,
|
||
onus_weak,
|
||
temp,
|
||
cpu,
|
||
ram,
|
||
uptime,
|
||
uplink_rx,
|
||
uplink_tx,
|
||
onus: allOnus,
|
||
unauth_onus: allUnauth,
|
||
olts_summary
|
||
};
|
||
}
|
||
|
||
async function testOltConnection(data) {
|
||
const host = String(data.host || '').trim();
|
||
const snmpPort = parseInt(data.snmp_port, 10) || 161;
|
||
const snmpCommunity = String(data.snmp_community || 'public').trim();
|
||
const brand = String(data.brand || 'generic').toLowerCase();
|
||
const telnetPort = parseInt(data.telnet_port, 10) || 23;
|
||
const webUser = String(data.web_user || 'admin').trim();
|
||
const webPassword = String(data.web_password || 'admin').trim();
|
||
const enablePassword = data.enable_password ? String(data.enable_password).trim() : null;
|
||
|
||
if (!host) {
|
||
return { success: false, message: 'Host/IP OLT harus diisi.' };
|
||
}
|
||
|
||
const result = {
|
||
success: false,
|
||
host,
|
||
snmp: { ok: false, error: null, uptime: null, sysDescr: null, profile: null, onus: 0 },
|
||
telnet: { ok: false, error: null },
|
||
messages: []
|
||
};
|
||
|
||
// 1. Test SNMP v2c
|
||
try {
|
||
const session = snmp.createSession(host, snmpCommunity, {
|
||
port: snmpPort,
|
||
timeout: 3000,
|
||
retries: 1,
|
||
version: snmp.Version2c
|
||
});
|
||
|
||
const vbs = await new Promise((resolve) => {
|
||
session.get(['1.3.6.1.2.1.1.3.0'], (err, res) => {
|
||
if (err) resolve(null);
|
||
else resolve(res);
|
||
});
|
||
});
|
||
|
||
if (vbs && vbs[0] && !snmp.isVarbindError(vbs[0])) {
|
||
result.snmp.ok = true;
|
||
result.snmp.uptime = decodeUptime(vbs[0].value);
|
||
|
||
// Probe profile
|
||
const selectedProfiles = (BRAND_PROFILES[brand] || []).map(p => ({ ...p, __brandKey: brand }));
|
||
const otherProfiles = Object.keys(BRAND_PROFILES)
|
||
.filter(k => k !== brand)
|
||
.reduce((acc, k) => acc.concat((BRAND_PROFILES[k] || []).map(p => ({ ...p, __brandKey: k }))), []);
|
||
const allProfiles = [...selectedProfiles, ...otherProfiles];
|
||
|
||
for (const p of allProfiles) {
|
||
const ok = await probeOid(session, p.probe_oid);
|
||
if (ok) {
|
||
result.snmp.profile = p.name;
|
||
try {
|
||
const statusMap = await slowWalk(session, p.status_table, 500);
|
||
result.snmp.onus = Object.keys(statusMap).length;
|
||
} catch (e) {}
|
||
break;
|
||
}
|
||
}
|
||
result.messages.push(`SNMP OK (Uptime: ${result.snmp.uptime}, Profil: ${result.snmp.profile || 'Standar'}, ${result.snmp.onus} ONU)`);
|
||
} else {
|
||
result.snmp.error = 'Tidak ada respon SNMP. Pastikan Service SNMP di OLT sudah Aktif dan IP/Community benar.';
|
||
}
|
||
try { session.close(); } catch (e) {}
|
||
} catch (e) {
|
||
result.snmp.error = e.message;
|
||
}
|
||
|
||
// 2. Test Telnet
|
||
try {
|
||
await telnetLoginAndRun(host, webUser, webPassword, ['show version'], {
|
||
port: telnetPort,
|
||
enablePassword
|
||
});
|
||
result.telnet.ok = true;
|
||
result.messages.push('Telnet CLI OK (Login Berhasil)');
|
||
} catch (e) {
|
||
result.telnet.error = e.message;
|
||
}
|
||
|
||
result.success = result.snmp.ok || result.telnet.ok;
|
||
result.message = result.messages.join(' | ') || (result.snmp.error || result.telnet.error || 'Gagal terhubung ke OLT');
|
||
return result;
|
||
}
|
||
|
||
module.exports = {
|
||
getAllOlts, getActiveOlts, getOltById, createOlt, updateOlt, deleteOlt, getOltStats, getAllOltsStats, testOltConnection, rebootOnu, renameOnu, authorizeOnu,
|
||
configureOnuWan, configureZteWanViaGoApi, configureWanViaAcs,
|
||
// Diekspor untuk kebutuhan tooling (scripts/olt-probe.js) — sumber profil yang sama
|
||
BRAND_PROFILES, SYSTEM_OIDS, telnetLoginAndRun, telnetOptsFromOlt
|
||
};
|