OPEN-ISP v0.5 (pickle-lime) — rilis open-source perdana
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
This commit is contained in:
commit
eaf05f2811
280 files changed
+123975
No files matched your search
@@ -0,0 +1,5 @@
|
||||
-- Add PPPoE manual input fields
|
||||
ALTER TABLE customers ADD COLUMN pppoe_password TEXT DEFAULT '';
|
||||
ALTER TABLE customers ADD COLUMN pppoe_remote_address TEXT DEFAULT '';
|
||||
|
||||
-- Made with Bob
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Script untuk menambahkan tabel promo_slides ke database existing
|
||||
*/
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '../database/billing.db');
|
||||
|
||||
try {
|
||||
const db = new Database(dbPath);
|
||||
|
||||
console.log('[Script] Opening database:', dbPath);
|
||||
|
||||
// Cek apakah table sudah ada
|
||||
const tableExists = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='promo_slides'
|
||||
`).get();
|
||||
|
||||
if (tableExists) {
|
||||
console.log('[Script] ✓ Table promo_slides sudah ada');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Buat table jika belum ada
|
||||
console.log('[Script] Creating table promo_slides...');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS promo_slides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
image_path TEXT NOT NULL,
|
||||
url TEXT DEFAULT '',
|
||||
open_in_new_tab INTEGER DEFAULT 0,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
created_at DATETIME DEFAULT (CURRENT_TIMESTAMP),
|
||||
updated_at DATETIME DEFAULT (CURRENT_TIMESTAMP)
|
||||
);
|
||||
`);
|
||||
|
||||
console.log('[Script] ✓ Table promo_slides berhasil dibuat');
|
||||
|
||||
// Verifikasi table
|
||||
const result = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='promo_slides'
|
||||
`).get();
|
||||
|
||||
if (result) {
|
||||
console.log('[Script] ✓ Verifikasi berhasil: table promo_slides ada di database');
|
||||
}
|
||||
|
||||
db.close();
|
||||
console.log('[Script] ✓ Selesai! Database updated successfully.');
|
||||
process.exit(0);
|
||||
|
||||
} catch (e) {
|
||||
console.error('[Script] ✗ Error:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Script untuk memeriksa data Mikhmon di database
|
||||
*/
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const dbPath = path.join(__dirname, '../database/billing.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
console.log('=== MEMERIKSA DATA MIKHMON DI DATABASE ===\n');
|
||||
|
||||
// Cari profiles dengan Mikhmon
|
||||
const profiles = db.prepare(`
|
||||
SELECT name, onLogin
|
||||
FROM hotspot_profiles
|
||||
WHERE onLogin LIKE '%rem%'
|
||||
LIMIT 10
|
||||
`).all();
|
||||
|
||||
console.log(`Ditemukan ${profiles.length} profile dengan Mikhmon:\n`);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
console.log('Tidak ada profile dengan Mikhmon di database.');
|
||||
} else {
|
||||
profiles.forEach((p, i) => {
|
||||
console.log(`--- Profile ${i + 1}: ${p.name} ---`);
|
||||
console.log('onLogin:', p.onLogin ? p.onLogin.substring(0, 150) + (p.onLogin.length > 150 ? '...' : '') : 'null');
|
||||
console.log('');
|
||||
});
|
||||
}
|
||||
|
||||
// Cek semua profiles
|
||||
const allProfiles = db.prepare('SELECT name, onLogin FROM hotspot_profiles LIMIT 20').all();
|
||||
console.log(`\nTotal profiles di database: ${allProfiles.length}`);
|
||||
|
||||
db.close();
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Script SQL untuk menambahkan kolom yang hilang di tabel customers
|
||||
-- Aman dijalankan berkali-kali (akan skip jika kolom sudah ada)
|
||||
|
||||
-- Kolom Hotspot
|
||||
ALTER TABLE customers ADD COLUMN hotspot_username TEXT DEFAULT '';
|
||||
ALTER TABLE customers ADD COLUMN hotspot_password TEXT DEFAULT '';
|
||||
ALTER TABLE customers ADD COLUMN hotspot_profile TEXT DEFAULT '';
|
||||
|
||||
-- Kolom Connection Type
|
||||
ALTER TABLE customers ADD COLUMN mac_address TEXT;
|
||||
ALTER TABLE customers ADD COLUMN connection_type TEXT DEFAULT 'pppoe';
|
||||
ALTER TABLE customers ADD COLUMN static_ip TEXT;
|
||||
|
||||
-- Kolom Auto Isolate
|
||||
ALTER TABLE customers ADD COLUMN auto_isolate INTEGER DEFAULT 1;
|
||||
ALTER TABLE customers ADD COLUMN isolate_day INTEGER DEFAULT 10;
|
||||
|
||||
-- Kolom Email
|
||||
ALTER TABLE customers ADD COLUMN email TEXT DEFAULT '';
|
||||
|
||||
-- Kolom Router & OLT
|
||||
ALTER TABLE customers ADD COLUMN router_id INTEGER REFERENCES routers(id) ON DELETE SET NULL;
|
||||
ALTER TABLE customers ADD COLUMN olt_id INTEGER REFERENCES olts(id) ON DELETE SET NULL;
|
||||
ALTER TABLE customers ADD COLUMN pon_port TEXT DEFAULT '';
|
||||
ALTER TABLE customers ADD COLUMN odp_id INTEGER REFERENCES odps(id) ON DELETE SET NULL;
|
||||
|
||||
-- Kolom Lokasi
|
||||
ALTER TABLE customers ADD COLUMN lat TEXT;
|
||||
ALTER TABLE customers ADD COLUMN lng TEXT;
|
||||
ALTER TABLE customers ADD COLUMN cable_path TEXT;
|
||||
|
||||
-- Kolom Promo
|
||||
ALTER TABLE customers ADD COLUMN promo_cycles_used INTEGER DEFAULT 0;
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script untuk memperbaiki struktur database di server produksi
|
||||
# Menambahkan kolom yang hilang di tabel customers
|
||||
|
||||
echo "=========================================="
|
||||
echo "Fix Database Columns - Billing RTRW"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Cek apakah database ada
|
||||
DB_PATH="database/billing.db"
|
||||
if [ ! -f "$DB_PATH" ]; then
|
||||
echo "❌ Error: Database tidak ditemukan di $DB_PATH"
|
||||
echo " Pastikan Anda menjalankan script ini dari root folder aplikasi"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Database ditemukan: $DB_PATH"
|
||||
echo ""
|
||||
|
||||
# Backup database
|
||||
BACKUP_PATH="database/billing.db.backup-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "📦 Membuat backup database..."
|
||||
cp "$DB_PATH" "$BACKUP_PATH"
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ Backup berhasil: $BACKUP_PATH"
|
||||
else
|
||||
echo "❌ Gagal membuat backup!"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Jalankan script verifikasi
|
||||
echo "🔍 Memeriksa struktur database..."
|
||||
echo ""
|
||||
node scripts/verify-database.js
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✓ Selesai!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Langkah selanjutnya:"
|
||||
echo "1. Restart aplikasi: pm2 restart all"
|
||||
echo "2. Test login pelanggan"
|
||||
echo "3. Cek log untuk memastikan tidak ada error"
|
||||
echo ""
|
||||
echo "Jika ada masalah, restore backup:"
|
||||
echo "cp $BACKUP_PATH $DB_PATH"
|
||||
echo ""
|
||||
else
|
||||
echo ""
|
||||
echo "❌ Terjadi error saat memperbaiki database"
|
||||
echo " Database tidak diubah, backup tersimpan di:"
|
||||
echo " $BACKUP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Migration Script: Add Photo Fields to Attendance Table
|
||||
* Run this once to update existing database
|
||||
*/
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '../database/billing.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
console.log('🔄 Starting migration: Add photo fields to attendance table...');
|
||||
|
||||
try {
|
||||
// Check if columns already exist
|
||||
const tableInfo = db.prepare("PRAGMA table_info(attendance)").all();
|
||||
const columnNames = tableInfo.map(col => col.name);
|
||||
|
||||
const hasCheckInPhoto = columnNames.includes('check_in_photo');
|
||||
const hasCheckOutPhoto = columnNames.includes('check_out_photo');
|
||||
|
||||
if (hasCheckInPhoto && hasCheckOutPhoto) {
|
||||
console.log('✅ Migration already applied. All columns exist.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Add columns if they don't exist
|
||||
if (!hasCheckInPhoto) {
|
||||
console.log('➕ Adding column: check_in_photo');
|
||||
db.exec("ALTER TABLE attendance ADD COLUMN check_in_photo TEXT DEFAULT ''");
|
||||
}
|
||||
|
||||
if (!hasCheckOutPhoto) {
|
||||
console.log('➕ Adding column: check_out_photo');
|
||||
db.exec("ALTER TABLE attendance ADD COLUMN check_out_photo TEXT DEFAULT ''");
|
||||
}
|
||||
|
||||
console.log('✅ Migration completed successfully!');
|
||||
console.log('');
|
||||
console.log('New columns added:');
|
||||
console.log(' - check_in_photo: Path foto saat check-in');
|
||||
console.log(' - check_out_photo: Path foto saat check-out');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Migration Script: Add Photo Fields to Tickets Table
|
||||
* Run this once to update existing database
|
||||
*/
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '../database/billing.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
console.log('🔄 Starting migration: Add photo fields to tickets table...');
|
||||
|
||||
try {
|
||||
// Check if columns already exist
|
||||
const tableInfo = db.prepare("PRAGMA table_info(tickets)").all();
|
||||
const columnNames = tableInfo.map(col => col.name);
|
||||
|
||||
const hasNotes = columnNames.includes('technician_notes');
|
||||
const hasPhotos = columnNames.includes('photos');
|
||||
const hasMetadata = columnNames.includes('photo_metadata');
|
||||
const hasCustomerPhotos = columnNames.includes('customer_photos');
|
||||
const hasCustomerMetadata = columnNames.includes('customer_photo_metadata');
|
||||
|
||||
if (hasNotes && hasPhotos && hasMetadata && hasCustomerPhotos && hasCustomerMetadata) {
|
||||
console.log('✅ Migration already applied. All columns exist.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Add columns if they don't exist
|
||||
if (!hasNotes) {
|
||||
console.log('➕ Adding column: technician_notes');
|
||||
db.exec("ALTER TABLE tickets ADD COLUMN technician_notes TEXT DEFAULT ''");
|
||||
}
|
||||
|
||||
if (!hasPhotos) {
|
||||
console.log('➕ Adding column: photos');
|
||||
db.exec("ALTER TABLE tickets ADD COLUMN photos TEXT DEFAULT ''");
|
||||
}
|
||||
|
||||
if (!hasMetadata) {
|
||||
console.log('➕ Adding column: photo_metadata');
|
||||
db.exec("ALTER TABLE tickets ADD COLUMN photo_metadata TEXT DEFAULT ''");
|
||||
}
|
||||
|
||||
if (!hasCustomerPhotos) {
|
||||
console.log('➕ Adding column: customer_photos');
|
||||
db.exec("ALTER TABLE tickets ADD COLUMN customer_photos TEXT DEFAULT ''");
|
||||
}
|
||||
|
||||
if (!hasCustomerMetadata) {
|
||||
console.log('➕ Adding column: customer_photo_metadata');
|
||||
db.exec("ALTER TABLE tickets ADD COLUMN customer_photo_metadata TEXT DEFAULT ''");
|
||||
}
|
||||
|
||||
console.log('✅ Migration completed successfully!');
|
||||
console.log('');
|
||||
console.log('New columns added:');
|
||||
console.log(' - technician_notes: Catatan teknisi saat menyelesaikan tugas');
|
||||
console.log(' - photos: JSON array path foto yang diupload teknisi');
|
||||
console.log(' - photo_metadata: JSON array metadata foto teknisi (timestamp, GPS, dll)');
|
||||
console.log(' - customer_photos: JSON array path foto yang diupload pelanggan');
|
||||
console.log(' - customer_photo_metadata: JSON array metadata foto pelanggan');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OLT Probe — deteksi cepat kompatibilitas OLT baru (multi-vendor).
|
||||
*
|
||||
* Tujuan: sebelum/saat membeli OLT merk baru, jalankan probe ini untuk tahu:
|
||||
* 1. SNMP hidup? vendor-nya siapa (dari sysObjectID / enterprise OID)?
|
||||
* 2. Profil brand bawaan mana yang cocok (hioso/hsgq/cdata/vsol/generic/...)?
|
||||
* 3. Telnet/SSH/Web UI terbuka? keluarga CLI-nya apa?
|
||||
* 4. Rekomendasi: brand yang dipilih di aplikasi + apakah rename perlu adapter.
|
||||
*
|
||||
* HANYA perintah BACA (read-only). Tidak mengubah konfigurasi apa pun.
|
||||
*
|
||||
* Contoh:
|
||||
* node scripts/olt-probe.js --from-db 1
|
||||
* node scripts/olt-probe.js --host 192.168.16.2 --user admin --pass admin
|
||||
* node scripts/olt-probe.js --host 10.0.0.9 --community public --cmds "show version,show onu"
|
||||
* node scripts/olt-probe.js --from-db 1 --json /tmp/probe-olt1.json
|
||||
*/
|
||||
const net = require('net');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const snmp = require('net-snmp');
|
||||
const oltSvc = require('../services/oltService');
|
||||
|
||||
/* ── Pemetaan enterprise OID → vendor (untuk tebakan cepat) ───────────── */
|
||||
const ENTERPRISE_VENDOR = {
|
||||
'25355': 'Hioso',
|
||||
'3320': 'CTC-style EPON (umum di OLT China)',
|
||||
'3902': 'ZTE',
|
||||
'2011': 'Huawei',
|
||||
'34592': 'C-Data',
|
||||
'37950': 'V-SOL',
|
||||
'27332': 'FiberHome',
|
||||
'35048': 'BDCOM?',
|
||||
'13158': 'Cisco (umum)',
|
||||
'8072': 'Net-SNMP generik — vendor tidak menyetel sysObjectID (banyak OLT embedded Linux); penentuan pakai hasil "Profil brand" di bawah',
|
||||
};
|
||||
|
||||
/* ── util ─────────────────────────────────────────────────────────────── */
|
||||
function arg(name, def = null) {
|
||||
const i = process.argv.indexOf('--' + name);
|
||||
if (i === -1) return def;
|
||||
const v = process.argv[i + 1];
|
||||
return (v && !v.startsWith('--')) ? v : true;
|
||||
}
|
||||
function tcpCheck(host, port, timeout = 3000) {
|
||||
return new Promise((resolve) => {
|
||||
const s = new net.Socket();
|
||||
const done = (ok) => { try { s.destroy(); } catch (e) {} resolve(ok); };
|
||||
s.setTimeout(timeout);
|
||||
s.once('connect', () => done(true));
|
||||
s.once('timeout', () => done(false));
|
||||
s.once('error', () => done(false));
|
||||
s.connect(port, host);
|
||||
});
|
||||
}
|
||||
function snmpValueToString(value) {
|
||||
try {
|
||||
let v = value;
|
||||
if (Buffer.isBuffer(v)) v = v.toString('utf8');
|
||||
v = String(v == null ? '' : v).replace(/\0/g, '').trim();
|
||||
// beberapa perangkat mengembalikan string ber-quote
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1).trim();
|
||||
return v;
|
||||
} catch (e) { return String(value); }
|
||||
}
|
||||
function looksLikeOidString(s) {
|
||||
return /^\d+(\.\d+){4,}$/.test(String(s || '').trim());
|
||||
}
|
||||
function decodeOid(buf) {
|
||||
try {
|
||||
const b = Buffer.from(buf);
|
||||
if (!b.length) return '';
|
||||
const out = [Math.floor(b[0] / 40), b[0] % 40];
|
||||
let val = 0;
|
||||
for (let i = 1; i < b.length; i++) {
|
||||
val = (val << 7) | (b[i] & 0x7f);
|
||||
if (!(b[i] & 0x80)) { out.push(val); val = 0; }
|
||||
}
|
||||
return out.join('.');
|
||||
} catch (e) { return String(buf); }
|
||||
}
|
||||
function snmpGet(session, oids, timeout = 4000) {
|
||||
return new Promise((resolve) => {
|
||||
let finished = false;
|
||||
const t = setTimeout(() => { if (!finished) { finished = true; resolve(null); } }, timeout);
|
||||
try {
|
||||
session.get(oids, (err, vbs) => {
|
||||
if (finished) return;
|
||||
finished = true; clearTimeout(t);
|
||||
resolve(err ? null : vbs);
|
||||
});
|
||||
} catch (e) { if (!finished) { finished = true; clearTimeout(t); resolve(null); } }
|
||||
});
|
||||
}
|
||||
function snmpSubtreeCount(session, oid, limit = 8, timeout = 5000) {
|
||||
return new Promise((resolve) => {
|
||||
const rows = [];
|
||||
let finished = false;
|
||||
const done = () => { if (!finished) { finished = true; resolve(rows); } };
|
||||
const t = setTimeout(done, timeout);
|
||||
try {
|
||||
session.subtree(oid, 20, (vbs) => {
|
||||
for (const vb of vbs) {
|
||||
if (rows.length >= limit) break;
|
||||
let val = vb.value;
|
||||
if (Buffer.isBuffer(val)) {
|
||||
const s = val.toString('utf8').replace(/\0/g, '').trim();
|
||||
val = /^[\x20-\x7E]+$/.test(s) && s.length >= 2 ? s : val.toString('hex').toUpperCase();
|
||||
}
|
||||
rows.push({ oid: vb.oid, value: String(val).slice(0, 60) });
|
||||
}
|
||||
if (rows.length >= limit) { try { session.close(); } catch (e) {} }
|
||||
}, () => { clearTimeout(t); done(); });
|
||||
} catch (e) { clearTimeout(t); done(); }
|
||||
});
|
||||
}
|
||||
function httpProbe(url, timeout = 5000, allowSelfSigned = false) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const lib = u.protocol === 'https:' ? https : http;
|
||||
const req = lib.request({
|
||||
host: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: u.pathname,
|
||||
method: 'GET', timeout,
|
||||
rejectUnauthorized: false,
|
||||
headers: { 'User-Agent': 'OPEN-ISP-OLT-Probe/1.0' },
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (d) => { if (body.length < 4000) body += d.toString('utf8'); });
|
||||
res.on('end', () => {
|
||||
const title = (body.match(/<title[^>]*>([^<]{0,80})</i) || [])[1] || '';
|
||||
resolve({ ok: true, status: res.statusCode, server: res.headers.server || '', title: title.trim() });
|
||||
});
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(); resolve({ ok: false, why: 'timeout' }); });
|
||||
req.on('error', (e) => resolve({ ok: false, why: e.code || e.message }));
|
||||
req.end();
|
||||
} catch (e) { resolve({ ok: false, why: e.message }); }
|
||||
});
|
||||
}
|
||||
/* Fallback telnet yang tahan terhadap prompt aneh (mis. '#' tanpa newline) */
|
||||
async function rawTelnetRun(host, port, user, pass, cmds, waitMs = 2200) {
|
||||
const sock = new net.Socket();
|
||||
let buf = '';
|
||||
sock.setTimeout(15000);
|
||||
sock.on('data', (d) => { buf += d.toString('utf8'); });
|
||||
await new Promise((resolve, reject) => {
|
||||
sock.connect(port, host, resolve);
|
||||
sock.once('error', reject);
|
||||
});
|
||||
const wait = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
const drain = () => { const x = buf; buf = ''; return x; };
|
||||
const clean = (t) => String(t).replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').replace(/\0/g, '').replace(/[^\x20-\x7E\r\n]/g, '');
|
||||
await wait(2500);
|
||||
let banner = clean(drain());
|
||||
if (/(login|username)\s*[:>]/i.test(banner)) { sock.write(String(user || 'admin') + '\r\n'); await wait(1600); banner += clean(drain()); }
|
||||
if (/(password|passwd)\s*[:>]/i.test(banner)) { sock.write(String(pass || 'admin') + '\r\n'); await wait(1800); banner += clean(drain()); }
|
||||
sock.write('\r\n'); await wait(800); banner += clean(drain());
|
||||
const outputs = [];
|
||||
for (const cmd of cmds) {
|
||||
sock.write(cmd + '\r\n');
|
||||
await wait(waitMs);
|
||||
outputs.push(clean(drain()));
|
||||
}
|
||||
try { sock.end(); sock.destroy(); } catch (e) {}
|
||||
return { banner: banner.trim(), outputs };
|
||||
}
|
||||
|
||||
function truncate(s, n) { s = String(s || ''); return s.length > n ? (s.slice(0, n) + '\n…[dipotong]') : s; }
|
||||
|
||||
/* ── main ─────────────────────────────────────────────────────────────── */
|
||||
(async () => {
|
||||
const fromDb = arg('from-db');
|
||||
let host = arg('host');
|
||||
let community = arg('community', 'public');
|
||||
let snmpPort = Number(arg('snmp-port', 161));
|
||||
let user = arg('user', 'admin');
|
||||
let pass = arg('pass', 'admin');
|
||||
let telnetPort = Number(arg('telnet-port', 23));
|
||||
let cmds = String(arg('cmds', 'show version')).split(',').map(s => s.trim()).filter(Boolean);
|
||||
const jsonOut = arg('json');
|
||||
const enablePassword = arg('enable-password');
|
||||
|
||||
if (fromDb) {
|
||||
const db = require('../config/database');
|
||||
const olt = db.prepare('SELECT * FROM olts WHERE id = ?').get(Number(fromDb));
|
||||
if (!olt) { console.error('OLT id=' + fromDb + ' tidak ditemukan di database'); process.exit(1); }
|
||||
host = host || olt.host;
|
||||
community = (community === 'public') ? (olt.snmp_community || community) : community;
|
||||
snmpPort = snmpPort || Number(olt.snmp_port || 161);
|
||||
user = (user === 'admin') ? (olt.web_user || user) : user;
|
||||
pass = (pass === 'admin') ? (olt.web_password || pass) : pass;
|
||||
telnetPort = telnetPort || Number(olt.telnet_port || 23);
|
||||
console.log(`[i] Memakai data OLT dari DB: id=${olt.id} "${olt.name}" brand=${olt.brand} host=${host}`);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
console.log('Penggunaan: node scripts/olt-probe.js --host <ip> [--community public] [--user admin --pass admin]\n' +
|
||||
' node scripts/olt-probe.js --from-db <id-olt> [--json hasil.json]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = { host, at: new Date().toISOString(), snmp: null, tcp: {}, profiles: [], telnet: null, web: null, verdict: [] };
|
||||
console.log(`\n=== OLT PROBE — ${host} ===\n`);
|
||||
|
||||
/* 1) cek port TCP */
|
||||
console.log('-- Konektivitas --');
|
||||
for (const p of [23, 22, 80, 443]) {
|
||||
const ok = await tcpCheck(host, p);
|
||||
result.tcp[p] = ok;
|
||||
console.log(` port ${String(p).padEnd(3)} : ${ok ? 'TERBUKA' : 'tertutup/tidak merespons'}`);
|
||||
}
|
||||
|
||||
/* 2) SNMP: identitas */
|
||||
console.log('\n-- SNMP --');
|
||||
const session = snmp.createSession(host, community, { port: snmpPort, version: snmp.Version2c, timeout: 4000, retries: 1 });
|
||||
const idVbs = await snmpGet(session, ['1.3.6.1.2.1.1.1.0', '1.3.6.1.2.1.1.2.0', '1.3.6.1.2.1.1.5.0', '1.3.6.1.2.1.1.3.0'], 5000);
|
||||
if (!idVbs) {
|
||||
console.log(' SNMP: TIDAK MERESPONS (community salah / SNMP mati / port beda)');
|
||||
result.snmp = { ok: false };
|
||||
} else {
|
||||
const sysDescr = idVbs[0] && idVbs[0].value != null ? snmpValueToString(idVbs[0].value) : '-';
|
||||
const sysObjRaw = idVbs[1] && idVbs[1].value != null ? snmpValueToString(idVbs[1].value) : '';
|
||||
const sysObjFromBuf = idVbs[1] && idVbs[1].value != null ? decodeOid(idVbs[1].value) : '-';
|
||||
const sysObj = looksLikeOidString(sysObjRaw) ? sysObjRaw : (looksLikeOidString(sysObjFromBuf) ? sysObjFromBuf : sysObjFromBuf);
|
||||
const sysName = idVbs[2] && idVbs[2].value != null ? snmpValueToString(idVbs[2].value) : '-';
|
||||
const uptime = idVbs[3] && idVbs[3].value != null ? String(idVbs[3].value) : '-';
|
||||
const ent = (sysObj.split('.')[6] || '');
|
||||
let vendor = ENTERPRISE_VENDOR[ent] || '';
|
||||
if (!vendor) {
|
||||
// fallback: cari enterprise di daftar berdasarkan kemunculan substring OID
|
||||
for (const [k, v] of Object.entries(ENTERPRISE_VENDOR)) {
|
||||
if (sysObj.includes('4.1.' + k + '.')) { vendor = v + ' (terdeteksi dari OID)'; break; }
|
||||
}
|
||||
}
|
||||
if (!vendor) vendor = ent ? `(enterprise ${ent} — belum terdaftar, laporkan ke developer)` : '-';
|
||||
result.snmp = { ok: true, sysDescr, sysObjectID: sysObj, sysName, uptime, enterprise: ent, vendorGuess: vendor };
|
||||
console.log(` sysDescr : ${truncate(sysDescr, 120)}`);
|
||||
console.log(` sysObjectID : ${sysObj}`);
|
||||
console.log(` enterprise : ${ent} → tebakan vendor: ${vendor}`);
|
||||
console.log(` sysName : ${sysName}`);
|
||||
}
|
||||
|
||||
/* 3) probe semua profil brand bawaan */
|
||||
if (result.snmp && result.snmp.ok) {
|
||||
console.log('\n-- Profil brand bawaan (uji OID probe) --');
|
||||
for (const brandKey of Object.keys(oltSvc.BRAND_PROFILES || {})) {
|
||||
for (const prof of (oltSvc.BRAND_PROFILES[brandKey] || [])) {
|
||||
const probe = prof.probe_oid;
|
||||
if (!probe) continue;
|
||||
const vbs = await snmpGet(session, [probe], 3500);
|
||||
if (vbs) {
|
||||
const sample = await snmpSubtreeCount(session, prof.status_table || probe, 5, 5000);
|
||||
result.profiles.push({ brandKey, profile: prof.name, ok: true, sample: sample.slice(0, 5) });
|
||||
console.log(` ✅ ${brandKey.toUpperCase()} / ${prof.name} — HIDUP (contoh ${sample.length} entri: ${sample[0] ? sample[0].value : '-'} ...)`);
|
||||
} else {
|
||||
result.profiles.push({ brandKey, profile: prof.name, ok: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!result.profiles.some(p => p.ok)) console.log(' (tidak ada profil yang cocok — kemungkinan MIB privat; kirim hasil JSON ini ke developer)');
|
||||
}
|
||||
try { session.close(); } catch (e) {}
|
||||
|
||||
/* 4) Telnet (read-only) */
|
||||
console.log('\n-- Telnet CLI --');
|
||||
try {
|
||||
const out = await oltSvc.telnetLoginAndRun(host, user, pass, cmds, {
|
||||
port: telnetPort,
|
||||
enablePassword: enablePassword !== null ? String(enablePassword) : null,
|
||||
});
|
||||
result.telnet = { ok: true, cmds, output: truncate(out, 2500) };
|
||||
const head = String(out).split('\n').map(l => l.trim()).filter(Boolean).slice(0, 12).join('\n ');
|
||||
console.log(` ✅ Login telnet BERHASIL (${cmds.join(' | ')}) — cuplikan:\n ${head}`);
|
||||
const hay = String(out).toLowerCase();
|
||||
const fam = hay.includes('hioso') ? 'Hioso' : hay.includes('hsgq') ? 'HSGQ' : hay.includes('c-data') || hay.includes('cdata') ? 'C-Data' : hay.includes('vsol') ? 'V-SOL' : null;
|
||||
if (fam) console.log(` [i] Keluarga CLI terdeteksi: ${fam}`);
|
||||
} catch (e) {
|
||||
result.telnet = { ok: false, error: e.message || String(e) };
|
||||
console.log(' ✖ Telnet gagal: ' + (e.message || String(e)));
|
||||
// Diagnostik: tangkap banner mentah 4 detik agar terlihat prompt/flow-nya
|
||||
try {
|
||||
const raw = await new Promise((resolve) => {
|
||||
const sock = new net.Socket();
|
||||
let buf = '';
|
||||
const t = setTimeout(() => { try { sock.destroy(); } catch (e2) {} resolve(buf); }, 4000);
|
||||
sock.setTimeout(4000);
|
||||
sock.on('data', (d) => { buf += d.toString('utf8').replace(/\0/g, ''); });
|
||||
sock.once('error', () => { clearTimeout(t); resolve(buf); });
|
||||
sock.once('timeout', () => { clearTimeout(t); try { sock.destroy(); } catch (e2) {} resolve(buf); });
|
||||
sock.connect(telnetPort, host, () => {});
|
||||
});
|
||||
const clean = String(raw).replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').replace(/[^\x20-\x7E\r\n]/g, '').trim();
|
||||
result.telnet.rawBanner = truncate(clean, 600);
|
||||
console.log(' [i] Banner telnet mentah:\n ' + truncate(clean, 400).split('\n').join('\n '));
|
||||
} catch (e2) {}
|
||||
|
||||
// Fallback: mode raw (prompt longgar) — tetap READ-ONLY
|
||||
try {
|
||||
console.log(' [i] Mencoba mode telnet raw (prompt longgar)…');
|
||||
const raw = await rawTelnetRun(host, telnetPort, user, pass, cmds);
|
||||
result.telnetRaw = { ok: true, banner: truncate(raw.banner, 500), outputs: raw.outputs.map(o => truncate(o, 1200)) };
|
||||
const joined = (raw.banner + '\n' + raw.outputs.join('\n'));
|
||||
const head = String(joined).split('\n').map(l => l.trim()).filter(Boolean).slice(0, 10).join('\n ');
|
||||
console.log(' ✅ Telnet RAW berhasil — cuplikan:\n ' + head);
|
||||
const hay = joined.toLowerCase();
|
||||
const fam = hay.includes('hioso') ? 'Hioso' : hay.includes('hsgq') ? 'HSGQ' : hay.includes('c-data') || hay.includes('cdata') ? 'C-Data' : hay.includes('vsol') ? 'V-SOL' : hay.includes('gpon') ? 'GPON-generik' : hay.includes('epon') ? 'EPON-generik' : null;
|
||||
if (fam) console.log(' [i] Keluarga CLI terdeteksi (raw): ' + fam);
|
||||
} catch (e3) {
|
||||
console.log(' ✖ Telnet raw juga gagal: ' + (e3.message || String(e3)));
|
||||
}
|
||||
}
|
||||
|
||||
/* 5) Web UI */
|
||||
console.log('\n-- Web UI --');
|
||||
for (const url of [`http://${host}/`, `https://${host}/`]) {
|
||||
const w = await httpProbe(url, 5000);
|
||||
if (w.ok) {
|
||||
result.web = { url, ...w };
|
||||
console.log(` ${url} → HTTP ${w.status} | Server: ${w.server || '-'} | Title: ${w.title || '-'}`);
|
||||
break;
|
||||
} else {
|
||||
console.log(` ${url} → ${w.why}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* 6) kesimpulan */
|
||||
console.log('\n=== KESIMPULAN ===');
|
||||
const live = [...new Set(result.profiles.filter(p => p.ok).map(p => p.brandKey))];
|
||||
if (result.snmp && result.snmp.ok) {
|
||||
if (live.length) console.log(`✅ Pilih brand di aplikasi: ${live.map(s => s.toUpperCase()).join(' / ')} (monitoring SNMP siap)`);
|
||||
else console.log('⚠️ SNMP hidup tapi tidak ada profil cocok → perlu profil baru (kirim JSON hasil ini).');
|
||||
} else {
|
||||
console.log('⚠️ SNMP mati/kosong. Jika telnet hidup, monitoring bisa dibuat via driver CLI (kirim cuplikan telnet ini).');
|
||||
}
|
||||
if (result.telnet && result.telnet.ok) console.log('ℹ️ Telnet hidup → jalur rename/authorize CLI memungkinkan (tergantung keluarga CLI).');
|
||||
console.log('ℹ️ Rename nama pelanggan→OLT saat ini: hioso/hsgq (SNMP) & zte/huawei (CLI). Brand lain perlu adapter.');
|
||||
|
||||
if (jsonOut) {
|
||||
require('fs').writeFileSync(String(jsonOut), JSON.stringify(result, null, 2));
|
||||
console.log(`\n[i] Hasil lengkap disimpan: ${jsonOut} (kirim file ini untuk analisis lanjutan)`);
|
||||
}
|
||||
process.exit(0);
|
||||
})().catch((e) => { console.error('Probe error:', e); process.exit(1); });
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Script untuk restart otomatis aplikasi jika terjadi error
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const APP_SCRIPT = 'app-whatsapp-only.js';
|
||||
const LOG_FILE = 'logs/restart.log';
|
||||
const MAX_RESTARTS = 5;
|
||||
const RESTART_DELAY = 10000; // 10 detik
|
||||
|
||||
let restartCount = 0;
|
||||
let isRestarting = false;
|
||||
|
||||
// Fungsi untuk menulis log
|
||||
function writeLog(message) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = `[${timestamp}] ${message}\n`;
|
||||
|
||||
// Pastikan direktori logs ada
|
||||
const logDir = path.dirname(LOG_FILE);
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.appendFileSync(LOG_FILE, logEntry);
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
// Fungsi untuk start aplikasi
|
||||
function startApp() {
|
||||
if (isRestarting) {
|
||||
writeLog('App is already restarting, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
writeLog(`Starting application (attempt ${restartCount + 1}/${MAX_RESTARTS})`);
|
||||
|
||||
const app = spawn('node', [APP_SCRIPT], {
|
||||
stdio: ['inherit', 'inherit', 'inherit'],
|
||||
detached: false
|
||||
});
|
||||
|
||||
app.on('error', (error) => {
|
||||
writeLog(`Error starting app: ${error.message}`);
|
||||
handleAppError();
|
||||
});
|
||||
|
||||
app.on('exit', (code, signal) => {
|
||||
writeLog(`App exited with code ${code} and signal ${signal}`);
|
||||
|
||||
if (code !== 0 && restartCount < MAX_RESTARTS) {
|
||||
handleAppError();
|
||||
} else if (restartCount >= MAX_RESTARTS) {
|
||||
writeLog(`Maximum restart attempts (${MAX_RESTARTS}) reached. Stopping restart attempts.`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process termination
|
||||
process.on('SIGINT', () => {
|
||||
writeLog('Received SIGINT, terminating app...');
|
||||
app.kill('SIGINT');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
writeLog('Received SIGTERM, terminating app...');
|
||||
app.kill('SIGTERM');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Fungsi untuk handle error aplikasi
|
||||
function handleAppError() {
|
||||
if (isRestarting) return;
|
||||
|
||||
isRestarting = true;
|
||||
restartCount++;
|
||||
|
||||
writeLog(`App crashed, restarting in ${RESTART_DELAY/1000} seconds...`);
|
||||
|
||||
setTimeout(() => {
|
||||
isRestarting = false;
|
||||
startApp();
|
||||
}, RESTART_DELAY);
|
||||
}
|
||||
|
||||
// Main execution
|
||||
writeLog('Starting application with auto-restart enabled');
|
||||
startApp();
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OPEN-ISP — Seed Data Demo untuk Tester
|
||||
* ======================================
|
||||
* Mengisi database dengan data contoh agar dashboard bisa langsung dilihat:
|
||||
* paket, area, pelanggan, tagihan (sebagian lunas), tiket, dan voucher.
|
||||
*
|
||||
* Jalankan (SEKALI saja, aman — tidak menimpa bila sudah ada data):
|
||||
* node scripts/seed-demo.js
|
||||
*
|
||||
* Untuk Docker:
|
||||
* docker exec open-isp node scripts/seed-demo.js
|
||||
*
|
||||
* Login default hasil seed:
|
||||
* ADMIN : admin / demo-isp123
|
||||
* PELANGGAN: lihat "Login Pelanggan" di output (ID Pelanggan / No. WhatsApp)
|
||||
*/
|
||||
const db = require('../config/database');
|
||||
const customerSvc = require('../services/customerService');
|
||||
const billingSvc = require('../services/billingService');
|
||||
|
||||
const log = (...a) => console.log('[seed-demo]', ...a);
|
||||
|
||||
function count(tbl) {
|
||||
try { return db.prepare(`SELECT COUNT(*) c FROM ${tbl}`).get().c; } catch (e) { return 0; }
|
||||
}
|
||||
|
||||
(async () => {
|
||||
/* ── Guard: jangan timpa data yang sudah ada ─────────────────────────── */
|
||||
const existing = count('customers') + count('packages');
|
||||
if (existing > 0) {
|
||||
log('Database sudah berisi data (customers/packages > 0) — seed DILEWATI.');
|
||||
log('(Hapus isi database bila ingin seed ulang di instalasi bersih.)');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/* ── 1. Paket ─────────────────────────────────────────────────────────── */
|
||||
const pkgs = [
|
||||
{ name: 'Demo Lite 10 Mbps', speed_down: 10, speed_up: 10, price: 100000, description: 'Paket hemat untuk kebutuhan dasar', billing_type: 'postpaid' },
|
||||
{ name: 'Demo Home 20 Mbps', speed_down: 20, speed_up: 20, price: 130000, description: 'Paket favorit keluarga', billing_type: 'postpaid' },
|
||||
{ name: 'Demo Pro 30 Mbps', speed_down: 30, speed_up: 30, price: 175000, description: 'Bekerja & streaming lancar', billing_type: 'postpaid' },
|
||||
{ name: 'Demo Bisnis 50 Mbps', speed_down: 50, speed_up: 50, price: 275000, description: 'Untuk kantor & usaha', billing_type: 'postpaid' },
|
||||
];
|
||||
const pkgIds = [];
|
||||
for (const p of pkgs) {
|
||||
try { const r = customerSvc.createPackage(p); pkgIds.push(r.lastInsertRowid); log('Paket:', p.name); }
|
||||
catch (e) { log('Gagal paket', p.name, e.message); }
|
||||
}
|
||||
|
||||
/* ── 2. Area ──────────────────────────────────────────────────────────── */
|
||||
const areas = ['Demo Area Pusat', 'Demo Area Timur', 'Demo Area Barat'];
|
||||
for (const a of areas) {
|
||||
try { db.prepare('INSERT OR IGNORE INTO areas (name, description) VALUES (?, ?)').run(a, 'Area contoh (data demo)'); log('Area:', a); }
|
||||
catch (e) { log('Gagal area', a, e.message); }
|
||||
}
|
||||
|
||||
/* ── 3. Pelanggan ─────────────────────────────────────────────────────── */
|
||||
const custs = [
|
||||
['Budi Santoso', '081200000001', 'Jl. Merdeka No. 1'],
|
||||
['Siti Aminah', '081200000002', 'Jl. Sudirman No. 12'],
|
||||
['Agus Wijaya', '081200000003', 'Jl. Diponegoro No. 3'],
|
||||
['Dewi Lestari', '081200000004', 'Jl. Gajah Mada No. 44'],
|
||||
['Rudi Hartono', '081200000005', 'Jl. Hayam Wuruk No. 8'],
|
||||
['Sri Wahyuni', '081200000006', 'Jl. Pahlawan No. 21'],
|
||||
['Joko Susilo', '081200000007', 'Jl. Ahmad Yani No. 5'],
|
||||
['Ratna Sari', '081200000008', 'Jl. Kartini No. 17'],
|
||||
['Hendra Gunawan', '081200000009', 'Jl. Cendrawasih No. 9'],
|
||||
['Maya Putri', '081200000010', 'Jl. Melati No. 2'],
|
||||
['Anton Prasetyo', '081200000011', 'Jl. Kenanga No. 30'],
|
||||
['Lina Marlina', '081200000012', 'Jl. Anggrek No. 7'],
|
||||
];
|
||||
const today = new Date();
|
||||
const installDate = new Date(today.getTime() - 35 * 86400000).toISOString().slice(0, 10);
|
||||
const custIds = [];
|
||||
let i = 0;
|
||||
for (const [name, phone, address] of custs) {
|
||||
i++;
|
||||
const pkgId = pkgIds[(i - 1) % (pkgIds.length || 1)];
|
||||
try {
|
||||
const r = customerSvc.createCustomer({
|
||||
name, phone, address,
|
||||
area: areas[(i - 1) % areas.length],
|
||||
package_id: pkgId,
|
||||
status: (i === 11 || i === 12) ? 'inactive' : 'active', // 2 pelanggan nonaktif utk variasi
|
||||
install_date: installDate,
|
||||
connection_type: 'pppoe',
|
||||
pppoe_username: `demo${String(i).padStart(2, '0')}@demo.id`,
|
||||
pppoe_password: 'demo-isp123',
|
||||
auto_isolate: 1,
|
||||
isolate_day: 10,
|
||||
notes: 'Data demo — silakan hapus',
|
||||
});
|
||||
custIds.push({ id: r.lastInsertRowid, name, phone });
|
||||
log('Pelanggan:', name);
|
||||
} catch (e) { log('Gagal pelanggan', name, e.message); }
|
||||
}
|
||||
|
||||
/* ── 4. Tagihan bulan ini + sebagian dibuat LUNAS ─────────────────────── */
|
||||
const month = today.getMonth() + 1;
|
||||
const year = today.getFullYear();
|
||||
let invCount = 0;
|
||||
try { invCount = billingSvc.generateMonthlyInvoices(month, year); log(`Generate tagihan ${month}/${year}:`, invCount); }
|
||||
catch (e) { log('Gagal generate tagihan:', e.message); }
|
||||
|
||||
// 8 pelanggan pertama -> LUNAS, sisanya dibiarkan belum bayar
|
||||
let paid = 0;
|
||||
for (let k = 0; k < Math.min(8, custIds.length); k++) {
|
||||
try { billingSvc.payInvoiceForCustomerPeriod(custIds[k].id, month, year, 'Admin Demo', 'Pembayaran demo'); paid++; }
|
||||
catch (e) { log('Gagal pelunasan', custIds[k].name, e.message); }
|
||||
}
|
||||
log(`Pelunasan demo: ${paid} pelanggan`);
|
||||
|
||||
/* ── 5. Tiket contoh ──────────────────────────────────────────────────── */
|
||||
try {
|
||||
const ticketSvc = require('../services/ticketService');
|
||||
if (custIds[0]) ticketSvc.createTicket(custIds[0].id, 'Koneksi lambat', 'Sejak tadi malam internet terasa lambat, mohon dicek. (data demo)');
|
||||
if (custIds[1]) ticketSvc.createTicket(custIds[1].id, 'Modem mati', 'Lampu modem tidak menyala sejak pagi. (data demo)');
|
||||
log('Tiket demo: 2');
|
||||
} catch (e) { log('Tiket dilewati:', e.message); }
|
||||
|
||||
/* ── Ringkasan ────────────────────────────────────────────────────────── */
|
||||
const demo = db.prepare("SELECT customer_code, name, phone FROM customers WHERE notes LIKE 'Data demo%' LIMIT 2").all();
|
||||
log('==================== SELESAI ====================');
|
||||
log('Login ADMIN : admin / demo-isp123');
|
||||
log('Login PELANGGAN: gunakan ID Pelanggan atau No. WhatsApp di bawah ini');
|
||||
demo.forEach(d => log(` • ${d.name} — ${d.customer_code} / ${d.phone}`));
|
||||
log('=================================================');
|
||||
process.exit(0);
|
||||
})().catch(e => { console.error('[seed-demo] ERROR:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Jalankan manual sinkronisasi ONU (auto-isi MAC pelanggan + rename nama ONU).
|
||||
*
|
||||
* Contoh:
|
||||
* node scripts/sync-onu-names.js --dry-run
|
||||
* node scripts/sync-onu-names.js --olt 1
|
||||
* node scripts/sync-onu-names.js --skip-fill
|
||||
* node scripts/sync-onu-names.js
|
||||
*/
|
||||
const onuSync = require('../services/onuSyncService');
|
||||
|
||||
function printOltSummary(s) {
|
||||
console.log(`${s.oltName} (id=${s.oltId}): total=${s.total}, rename=${s.renamed}, sama=${s.same}, belum-match=${s.unmatch}, gagal=${s.failed}`);
|
||||
for (const e of s.errors || []) {
|
||||
console.log(` GAGAL ${e.onu || ''}${e.sn ? ' (' + e.sn + ')' : ''} => ${e.target || ''}: ${e.error || ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printMacSummary(m) {
|
||||
if (!m || m.skipped) {
|
||||
console.log('Auto-isi mac_address: dilewati (--skip-fill)');
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Auto-isi mac_address: total=${m.total}, diisi=${m.filled}, diperbarui=${m.refreshed}, ` +
|
||||
`bukan-MAC-ONU=${m.skippedNonOnu}, tanpa-pelanggan=${m.noCustomer}, konflik=${m.conflict}, gagal=${m.errors.length}`
|
||||
);
|
||||
for (const e of m.errors) console.log(` GAGAL ${e.customer}: ${e.error}`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const skipFill = args.includes('--skip-fill');
|
||||
const skipNames = args.includes('--skip-names');
|
||||
const oltIdx = args.indexOf('--olt');
|
||||
const oltId = oltIdx >= 0 && args[oltIdx + 1] ? args[oltIdx + 1] : null;
|
||||
|
||||
if (dryRun) console.log('MODE: DRY-RUN (tidak ada perubahan nyata)');
|
||||
|
||||
const { mac, olts } = await onuSync.runSyncOnu({ dryRun, oltId, skipFill, skipNames });
|
||||
|
||||
console.log('-- Auto-isi/perbarui mac_address pelanggan (dari PPPoE aktif) --');
|
||||
printMacSummary(mac);
|
||||
|
||||
if (!skipNames) {
|
||||
console.log('-- Sinkronisasi nama ONU --');
|
||||
for (const s of olts || []) printOltSummary(s);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
})().catch((e) => {
|
||||
console.error('FATAL:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Script untuk test admin functionality
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Load settings
|
||||
const settings = require('../settings.json');
|
||||
|
||||
console.log('=== Test Admin Functionality ===\n');
|
||||
|
||||
// Test admin numbers
|
||||
console.log('📋 Admin Configuration:');
|
||||
console.log(`Admin numbers: ${JSON.stringify(settings.admins)}`);
|
||||
console.log(`Technician numbers: ${JSON.stringify(settings.technician_numbers)}`);
|
||||
console.log('');
|
||||
|
||||
// Test isAdminNumber function
|
||||
function testIsAdminNumber(number) {
|
||||
try {
|
||||
const cleanNumber = number.replace(/\D/g, '');
|
||||
|
||||
// Cek admin dari settings.json
|
||||
const adminNumbers = settings.admins || [];
|
||||
for (const adminNumber of adminNumbers) {
|
||||
const cleanAdminNumber = adminNumber.replace(/\D/g, '');
|
||||
if (cleanNumber === cleanAdminNumber) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Cek technician numbers dari settings.json
|
||||
const technicianNumbers = settings.technician_numbers || [];
|
||||
for (const techNumber of technicianNumbers) {
|
||||
const cleanTechNumber = techNumber.replace(/\D/g, '');
|
||||
if (cleanNumber === cleanTechNumber) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error in testIsAdminNumber:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Test beberapa nomor
|
||||
const testNumbers = [
|
||||
'6281947215703',
|
||||
'081947215703',
|
||||
'083807665697',
|
||||
'082218094778',
|
||||
'081234567890' // Nomor test yang bukan admin
|
||||
];
|
||||
|
||||
console.log('🔍 Testing Admin Number Validation:');
|
||||
for (const number of testNumbers) {
|
||||
const isAdmin = testIsAdminNumber(number);
|
||||
console.log(`${number}: ${isAdmin ? '✅ Admin' : '❌ Not Admin'}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Test message
|
||||
const testMessage = `🧪 *TEST ADMIN BOT*\n\n` +
|
||||
`✅ Ini adalah pesan test untuk memverifikasi fungsi admin\n` +
|
||||
`📅 Waktu: ${new Date().toLocaleString()}\n\n` +
|
||||
`🔧 Jika Anda menerima pesan ini, berarti:\n` +
|
||||
`• Fungsi isAdminNumber berfungsi dengan baik\n` +
|
||||
`• Pengiriman pesan ke admin berhasil\n` +
|
||||
`• Bot siap digunakan\n\n` +
|
||||
`🏢 *ALIJAYA DIGITAL NETWORK*`;
|
||||
|
||||
console.log('📝 Test message yang akan dikirim:');
|
||||
console.log(testMessage);
|
||||
console.log('');
|
||||
|
||||
// Cek file superadmin.txt
|
||||
try {
|
||||
const superAdminPath = path.join(__dirname, '../config/superadmin.txt');
|
||||
if (fs.existsSync(superAdminPath)) {
|
||||
const superAdmin = fs.readFileSync(superAdminPath, 'utf8').trim();
|
||||
console.log(`👑 Super admin: ${superAdmin}`);
|
||||
} else {
|
||||
console.log('❌ File superadmin.txt tidak ditemukan');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Error reading superadmin.txt:', error.message);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('✅ Script test admin selesai.');
|
||||
console.log('💡 Tips: Jalankan aplikasi dengan "node scripts/restart-on-error.js"');
|
||||
console.log('📱 Test dengan mengirim pesan "menu" atau "admin" ke bot WhatsApp');
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Script untuk test pengiriman pesan ke admin
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Load settings
|
||||
const settings = require('../settings.json');
|
||||
|
||||
console.log('=== Test Pengiriman Pesan WhatsApp ===\n');
|
||||
|
||||
// Cek konfigurasi admin
|
||||
console.log('📋 Konfigurasi Admin:');
|
||||
console.log(`Admin numbers: ${JSON.stringify(settings.admins)}`);
|
||||
console.log(`WhatsApp timeout: ${settings.whatsapp_timeout}ms`);
|
||||
console.log(`Notification timeout: ${settings.notification_timeout}ms`);
|
||||
console.log('');
|
||||
|
||||
// Cek file superadmin.txt
|
||||
try {
|
||||
const superAdminPath = path.join(__dirname, '../config/superadmin.txt');
|
||||
if (fs.existsSync(superAdminPath)) {
|
||||
const superAdmin = fs.readFileSync(superAdminPath, 'utf8').trim();
|
||||
console.log(`Super admin: ${superAdmin}`);
|
||||
} else {
|
||||
console.log('❌ File superadmin.txt tidak ditemukan');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Error reading superadmin.txt:', error.message);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// Test message
|
||||
const testMessage = `🧪 *TEST PESAN BOT*\n\n` +
|
||||
`✅ Ini adalah pesan test untuk memverifikasi koneksi WhatsApp\n` +
|
||||
`📅 Waktu: ${new Date().toLocaleString()}\n\n` +
|
||||
`🔧 Jika Anda menerima pesan ini, berarti:\n` +
|
||||
`• Koneksi WhatsApp berfungsi dengan baik\n` +
|
||||
`• Pengiriman pesan ke admin berhasil\n` +
|
||||
`• Bot siap digunakan\n\n` +
|
||||
`🏢 *ALIJAYA DIGITAL NETWORK*`;
|
||||
|
||||
console.log('📝 Pesan test yang akan dikirim:');
|
||||
console.log(testMessage);
|
||||
console.log('');
|
||||
|
||||
console.log('✅ Script test selesai. Jalankan aplikasi utama untuk test pengiriman pesan.');
|
||||
console.log('💡 Tips: Gunakan "node scripts/restart-on-error.js" untuk menjalankan dengan auto-restart');
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Test script untuk Mikhmon parser
|
||||
* Format: :put (",rem,4000,2d,5000,,Disable,");
|
||||
*/
|
||||
|
||||
// Copy parser dari customerPortal.js
|
||||
function parseMikhmonOnLogin(script) {
|
||||
if (!script) return null;
|
||||
const s = String(script).trim();
|
||||
|
||||
// Cari pattern :put (",rem, ... , ... , ...
|
||||
// Updated regex untuk support format: :put (",rem,4000,2d,5000,,Disable,");
|
||||
const putMatch = s.match(/:\s*put\s*\(\s*[",]rem[",]?\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)/i);
|
||||
if (putMatch) {
|
||||
const cost = String(putMatch[1] || '').trim();
|
||||
const validity = String(putMatch[2] || '').trim();
|
||||
const priceStr = String(putMatch[3] || '').trim();
|
||||
const price = Number(priceStr.replace(/[^\d]/g, '')) || 0;
|
||||
|
||||
if (validity && price > 0) {
|
||||
return { validity, price, cost: Number(cost.replace(/[^\d]/g, '')) || 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: split by comma
|
||||
const parts = s.split(',').map(p => String(p).trim());
|
||||
let remIdx = -1;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i].includes('rem')) {
|
||||
remIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (remIdx >= 0 && remIdx + 3 < parts.length) {
|
||||
const cost = String(parts[remIdx + 1] || '').trim();
|
||||
const validity = String(parts[remIdx + 2] || '').trim();
|
||||
const priceStr = String(parts[remIdx + 3] || '').trim();
|
||||
const price = Number(priceStr.replace(/[^\d]/g, '')) || 0;
|
||||
|
||||
if (validity && price > 0) {
|
||||
return { validity, price, cost: Number(cost.replace(/[^\d]/g, '')) || 0 };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Test cases
|
||||
const testCases = [
|
||||
{
|
||||
name: 'Format Mikhmon Baru (ROS6/ROS7)',
|
||||
script: ':put (",rem,4000,2d,5000,,Disable,");',
|
||||
expected: { validity: '2d', price: 5000, cost: 4000 }
|
||||
},
|
||||
{
|
||||
name: 'Format Mikhmon Baru (tanpa koma di akhir)',
|
||||
script: ':put (",rem,4000,2d,5000,,Disable,")',
|
||||
expected: { validity: '2d', price: 5000, cost: 4000 }
|
||||
},
|
||||
{
|
||||
name: 'Format Mikhmon Baru (dengan spasi)',
|
||||
script: ':put ( "rem" , 4000 , 2d , 5000 , , Disable , ) ;',
|
||||
expected: { validity: '2d', price: 5000, cost: 4000 }
|
||||
},
|
||||
{
|
||||
name: 'Format Mikhmon Lama (tanpa rem)',
|
||||
script: ':put (",10000,1d,15000,");',
|
||||
expected: null
|
||||
},
|
||||
{
|
||||
name: 'Script lengkap dengan banyak baris',
|
||||
script: `:put (",rem,4000,2d,5000,,Disable,");
|
||||
:local comment [ /ip hotspot user get [/ip hotspot user find where name="$user"] comment];
|
||||
:local ucode [:pic $comment 0 2];
|
||||
:if ($ucode = "vc" or $ucode = "up" or $comment = "") do={
|
||||
:local date [ /system clock get date ];
|
||||
:local year [ :pick $date 7 11 ];
|
||||
:local month [ :pick $date 0 3 ];
|
||||
/sys sch add name="$user" disable=no start-date=$date interval="2d";
|
||||
:delay 5s;
|
||||
:local exp [ /sys sch get [ /sys sch find where name="$user" ] next-run];
|
||||
:local getxp [len $exp];
|
||||
:if ($getxp = 15) do={
|
||||
:local d [:pic $exp 0 6];
|
||||
:local t [:pic $exp 7 16];
|
||||
:local s ("/");
|
||||
:local exp ("$d$s$year $t");
|
||||
/ip hotspot user set comment="$exp" [find where name="$user"];
|
||||
};
|
||||
:if ($getxp = 8) do={
|
||||
/ip hotspot user set comment="$date $exp" [find where name="$user"];
|
||||
};
|
||||
:if ($getxp > 15) do={
|
||||
/ip hotspot user set comment="$exp" [find where name="$user"];
|
||||
};
|
||||
:delay 5s;
|
||||
/sys sch remove [find where name="$user"]
|
||||
}`,
|
||||
expected: { validity: '2d', price: 5000, cost: 4000 }
|
||||
}
|
||||
];
|
||||
|
||||
console.log('=== TEST MIKHMON PARSER ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const test of testCases) {
|
||||
const result = parseMikhmonOnLogin(test.script);
|
||||
const success = result &&
|
||||
result.validity === test.expected.validity &&
|
||||
result.price === test.expected.price &&
|
||||
result.cost === test.expected.cost;
|
||||
|
||||
if (success) {
|
||||
console.log(`✅ ${test.name}`);
|
||||
console.log(` Result: validity=${result.validity}, price=${result.price}, cost=${result.cost}\n`);
|
||||
passed++;
|
||||
} else if (!result && !test.expected) {
|
||||
console.log(`✅ ${test.name} (expected null)`);
|
||||
console.log(` Result: null (correct)\n`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(`❌ ${test.name}`);
|
||||
console.log(` Expected: validity=${test.expected.validity}, price=${test.expected.price}, cost=${test.expected.cost}`);
|
||||
console.log(` Got: ${JSON.stringify(result)}\n`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== RINGKASAN ===');
|
||||
console.log(`Total: ${testCases.length}`);
|
||||
console.log(`✅ Passed: ${passed}`);
|
||||
console.log(`❌ Failed: ${failed}`);
|
||||
|
||||
if (failed === 0) {
|
||||
console.log('\n🎉 Semua test passed!');
|
||||
} else {
|
||||
console.log('\n⚠️ Ada test yang gagal!');
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Test regex dari referensi billing-rtrw-main
|
||||
const tests = [
|
||||
':put (",rem,4000,2d,5000,,Disable,");',
|
||||
':put (",rem,4000,2d,5000,,Disable,"); {:local comment [ /ip hotspot user get [/ip hotspot user find where name="$user"] comment];}',
|
||||
':put (",rem,10000,1d,15000,,Disable,");',
|
||||
':put (",rem,0,30m,3000,,Disable,");',
|
||||
// Format tanpa harga beli (COST=0)
|
||||
':put (",rem,,1h,5000,,Disable,");',
|
||||
];
|
||||
|
||||
// Regex dari referensi
|
||||
function parseMikhmonRef(script) {
|
||||
if (!script) return null;
|
||||
const m = String(script).match(/",rem,.*?,(.*?),(.*?),.*?"/);
|
||||
if (!m) return null;
|
||||
const validity = String(m[1] || '').trim();
|
||||
const price = Number(String(m[2] || '').replace(/[^\d]/g, '')) || 0;
|
||||
return { validity, price };
|
||||
}
|
||||
|
||||
// Regex yang sedang dipakai di project
|
||||
function parseMikhmonCurrent(script) {
|
||||
if (!script) return null;
|
||||
const s = String(script).trim();
|
||||
const putMatch = s.match(/:\s*put\s*\(\s*[",]rem[",]?\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)/i);
|
||||
if (putMatch) {
|
||||
const cost = String(putMatch[1] || '').trim();
|
||||
const validity = String(putMatch[2] || '').trim();
|
||||
const priceStr = String(putMatch[3] || '').trim();
|
||||
const price = Number(priceStr.replace(/[^\d]/g, '')) || 0;
|
||||
if (validity && price > 0) {
|
||||
return { validity, price, cost: Number(cost.replace(/[^\d]/g, '')) || 0 };
|
||||
}
|
||||
}
|
||||
// Fallback split
|
||||
const parts = s.split(',').map(p => String(p).trim());
|
||||
let remIdx = -1;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i].includes('rem')) { remIdx = i; break; }
|
||||
}
|
||||
if (remIdx >= 0 && remIdx + 3 < parts.length) {
|
||||
const cost = String(parts[remIdx + 1] || '').trim();
|
||||
const validity = String(parts[remIdx + 2] || '').trim();
|
||||
const priceStr = String(parts[remIdx + 3] || '').trim();
|
||||
const price = Number(priceStr.replace(/[^\d]/g, '')) || 0;
|
||||
if (validity && price > 0) {
|
||||
return { validity, price, cost: Number(cost.replace(/[^\d]/g, '')) || 0 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('=== PERBANDINGAN REGEX ===\n');
|
||||
for (const t of tests) {
|
||||
const ref = parseMikhmonRef(t);
|
||||
const cur = parseMikhmonCurrent(t);
|
||||
const match = JSON.stringify(ref) === JSON.stringify({validity: cur?.validity, price: cur?.price});
|
||||
console.log('Script:', t.substring(0, 60));
|
||||
console.log(' Referensi :', ref);
|
||||
console.log(' Sekarang :', cur);
|
||||
console.log(' Sama? :', match ? '✅' : '❌');
|
||||
console.log();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Script untuk memverifikasi dan memperbaiki struktur database
|
||||
* Menambahkan kolom yang hilang jika diperlukan
|
||||
*/
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '../database/billing.db');
|
||||
console.log('Memeriksa database:', dbPath);
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// Fungsi untuk cek apakah kolom ada
|
||||
function columnExists(tableName, columnName) {
|
||||
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||
return columns.some(col => col.name === columnName);
|
||||
}
|
||||
|
||||
// Daftar kolom yang harus ada di tabel customers
|
||||
const requiredColumns = [
|
||||
{ name: 'hotspot_username', type: 'TEXT DEFAULT \'\'', description: 'Username Hotspot' },
|
||||
{ name: 'hotspot_password', type: 'TEXT DEFAULT \'\'', description: 'Password Hotspot' },
|
||||
{ name: 'hotspot_profile', type: 'TEXT DEFAULT \'\'', description: 'Profile Hotspot' },
|
||||
{ name: 'mac_address', type: 'TEXT', description: 'MAC Address' },
|
||||
{ name: 'connection_type', type: 'TEXT DEFAULT \'pppoe\'', description: 'Tipe Koneksi' },
|
||||
{ name: 'static_ip', type: 'TEXT', description: 'Static IP' },
|
||||
{ name: 'auto_isolate', type: 'INTEGER DEFAULT 1', description: 'Auto Isolir' },
|
||||
{ name: 'isolate_day', type: 'INTEGER DEFAULT 10', description: 'Hari Isolir' },
|
||||
{ name: 'email', type: 'TEXT DEFAULT \'\'', description: 'Email' },
|
||||
{ name: 'router_id', type: 'INTEGER REFERENCES routers(id) ON DELETE SET NULL', description: 'Router ID' },
|
||||
{ name: 'olt_id', type: 'INTEGER REFERENCES olts(id) ON DELETE SET NULL', description: 'OLT ID' },
|
||||
{ name: 'pon_port', type: 'TEXT DEFAULT \'\'', description: 'PON Port' },
|
||||
{ name: 'odp_id', type: 'INTEGER REFERENCES odps(id) ON DELETE SET NULL', description: 'ODP ID' },
|
||||
{ name: 'lat', type: 'TEXT', description: 'Latitude' },
|
||||
{ name: 'lng', type: 'TEXT', description: 'Longitude' },
|
||||
{ name: 'cable_path', type: 'TEXT', description: 'Cable Path' },
|
||||
{ name: 'promo_cycles_used', type: 'INTEGER DEFAULT 0', description: 'Promo Cycles Used' }
|
||||
];
|
||||
|
||||
console.log('\n=== VERIFIKASI TABEL CUSTOMERS ===\n');
|
||||
|
||||
let missingColumns = [];
|
||||
let existingColumns = [];
|
||||
|
||||
for (const col of requiredColumns) {
|
||||
const exists = columnExists('customers', col.name);
|
||||
if (exists) {
|
||||
existingColumns.push(col.name);
|
||||
console.log(`✓ Kolom '${col.name}' sudah ada`);
|
||||
} else {
|
||||
missingColumns.push(col);
|
||||
console.log(`✗ Kolom '${col.name}' TIDAK ADA`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== RINGKASAN ===`);
|
||||
console.log(`Total kolom yang diperiksa: ${requiredColumns.length}`);
|
||||
console.log(`Kolom yang sudah ada: ${existingColumns.length}`);
|
||||
console.log(`Kolom yang hilang: ${missingColumns.length}`);
|
||||
|
||||
if (missingColumns.length > 0) {
|
||||
console.log(`\n=== MENAMBAHKAN KOLOM YANG HILANG ===\n`);
|
||||
|
||||
for (const col of missingColumns) {
|
||||
try {
|
||||
const sql = `ALTER TABLE customers ADD COLUMN ${col.name} ${col.type}`;
|
||||
console.log(`Menambahkan: ${col.name} (${col.description})`);
|
||||
db.exec(sql);
|
||||
console.log(`✓ Berhasil menambahkan kolom '${col.name}'`);
|
||||
} catch (e) {
|
||||
console.error(`✗ Gagal menambahkan kolom '${col.name}':`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== SELESAI ===`);
|
||||
console.log(`Silakan restart aplikasi untuk menerapkan perubahan.`);
|
||||
} else {
|
||||
console.log(`\n✓ Semua kolom sudah lengkap!`);
|
||||
}
|
||||
|
||||
db.close();
|
||||
Reference in new issue
Block a user