diff --git a/.gitignore b/.gitignore index 041ff12..3993065 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,19 @@ CLAUDE.md docs/ plans/ .env* + +# Local uploads +backend/public/api/uploads/ + +# Sensitive helper scripts (contain hardcoded SSH/API credentials - local use only) +compare-netify-vs-dashboard.js +ssh-read-source1-proxy.js +check-frontend-uri-now.js +verify-final.js +check-frontend-uri.js +ssh-check-logs.js +ssh-*.js + +# Sensitive documentation (contains production API keys / credentials) +BUKTI-AKSES-MONGODB.txt +DOKUMENTASI-PROXY-NETIFY.md diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/DOKUMENTASI-FILTER-PER-SITE.md b/DOKUMENTASI-FILTER-PER-SITE.md new file mode 100644 index 0000000..79150ac --- /dev/null +++ b/DOKUMENTASI-FILTER-PER-SITE.md @@ -0,0 +1,252 @@ +# DETAIL TEKNIS: Cara Proxy Memfilter Data per Site (SIAB vs Office) + +Dokumen ini menjelaskan **secara kode** bagaimana data dipisahkan per site. +Ada **3 lapis filter** yang bekerja dari Netify API sampai ke tampilan dashboard. + +--- + +## LAPIS 1 — Saat Minta Data ke Netify API +### File: `proxy/netifyClientCore.js` + +``` +NETIFY_SITE_UUIDS = "6681452d_....(SIAB), 1959bb55_....(Office)" + | + proxy loop satu per satu: + ┌─────────────────────────┐ + │ for SIAB UUID: │ + │ kirim request ke │ + │ Netify dengan header │ + │ x-net-site: SIAB-UUID│ + └─────────────────────────┘ + ┌─────────────────────────┐ + │ for Office UUID: │ + │ kirim request ke │ + │ Netify dengan header │ + │ x-net-site: OFFICE-UUID│ + └─────────────────────────┘ +``` + +**KODE ASLI — cara header dikirim:** +```javascript +// proxy/netifyClientCore.js baris 14-18 +function getHeaders(siteUuid) { + const headers = { + 'x-api-key': process.env.NETIFY_API_KEY, + 'Accept': 'application/json' + }; + + if (siteUuid) headers['x-net-site'] = siteUuid; + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + // Ini yang memfilter data di sisi Netify! + // Netify API hanya kembalikan data untuk site ini saja. + + return headers; +} + +async function netifyFetch(endpoint, params = {}, agentUuid, siteUuid) { + const res = await axios.get(`${BASE_URL}${endpoint}`, { + headers: getHeaders(siteUuid), // <--- siteUuid dikirim ke Netify + params, + timeout: 30000, + }); +} +``` + +**Artinya:** Netify API sendiri yang memfilter. Kalau kita kirim header +`x-net-site: SIAB-UUID`, Netify HANYA kembalikan data milik SIAB. +Kita tidak perlu filter manual — Netify sudah filter dari sumbernya. + +--- + +## LAPIS 2 — Saat Simpan ke MongoDB +### File: `proxy/collector.js` (loop utama) + +Setelah data dari Netify masuk, setiap dokumen diberi **stempel `site_uuid`** +sebelum disimpan ke MongoDB. + +**KODE ASLI — loop per site di collector.js:** +```javascript +// proxy/collector.js baris 123-222 + +// SITE_UUIDS diambil dari env: +// NETIFY_SITE_UUIDS="6681452d_..., 1959bb55_..." +const SITE_UUIDS = SITE_UUIDS_STR.split(','); // ["SIAB-UUID", "OFFICE-UUID"] + +for (const siteUuid of SITE_UUIDS) { +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +// Loop: pertama SIAB, lalu Office (satu per satu) + + console.log(`Fetching agents for Site: ${siteUuid}`); + const agents = await netify.fetchAgents(siteUuid); + // ^^^^^^^^^ + // fetchAgents pakai siteUuid → Netify hanya beri agent milik site ini + + // --- PENTING: Anti-duplikat antar site --- + // Kadang Netify bisa kembalikan agent yang sama untuk 2 site. + // Di sini kita cegah agar 1 agent hanya masuk 1 site. + const agents = rawAgents.filter(a => { + if (processedAgentUuids.has(a.uuid)) { + console.log(`Skipping ${a.uuid} — already assigned to another site.`); + return false; // lewati agent yang sudah diproses site lain + } + return true; + }); + for (const agent of agents) processedAgentUuids.add(agent.uuid); + + // Simpan agent ke MongoDB dengan site_uuid + await AgentRegistry.findOneAndUpdate( + { uuid: agent.uuid }, + { $set: { + uuid: agent.uuid, + site_uuid: siteUuid, // <--- stempel site di sini! + ... + }}, + { upsert: true } + ); + + // Kumpulkan data untuk setiap agent di site ini + for (const agent of agents) { + await collectForAgent(agent.uuid, timestamp, siteUuid); + // ^^^^^^^^^ + // siteUuid terus dibawa ke setiap fungsi collect + } +} +``` + +**KODE ASLI — cara flows disimpan dengan site_uuid:** +```javascript +// proxy/collectorHelperDpi2.js baris 88-98 + +const flowDocs = flows.map(f => ({ + timestamp, + agent_uuid: agentUuid, // siapa agent-nya + site_uuid: SITE_UUID, // <--- data ini milik site mana! (SIAB atau Office) + flow_id: f.flow_id, + src_ip: f.src_ip, + dst_ip: f.dst_ip, + download: f.download, + upload: f.upload, + // ... +})); + +// Upsert ke MongoDB (tidak duplikat berdasarkan flow_id + agent_uuid) +await Flow.bulkWrite(flowDocs.map(f => ({ + updateOne: { + filter: { flow_id: f.flow_id, agent_uuid: f.agent_uuid }, + update: { $set: f }, + upsert: true, + } +}))); +``` + +**Hasilnya di MongoDB — data terpisah per site:** +``` +Collection: flows +┌────────────────────┬────────────────────────────────────────────────────┬────────┬──────────┐ +│ flow_id │ site_uuid │ src_ip │ download │ +├────────────────────┼────────────────────────────────────────────────────┼────────┼──────────┤ +│ flow-001 │ 6681452d_9cae_4ff4_8ae8_0d504774265e (SIAB) │ 10.0.x │ 1234 │ +│ flow-002 │ 6681452d_9cae_4ff4_8ae8_0d504774265e (SIAB) │ 10.0.x │ 5678 │ +│ flow-003 │ 1959bb55_045b_47c7_bbdd_f33b7db197b9 (Office) │ 192.168.x │ 9012 │ +│ flow-004 │ 1959bb55_045b_47c7_bbdd_f33b7db197b9 (Office) │ 192.168.x │ 3456 │ +└────────────────────┴────────────────────────────────────────────────────┴────────┴──────────┘ + ^^^^^^^^^^ Field ini yang memisahkan data ^^^^^^^^^^ +``` + +**Semua collection lain juga sama:** +- `devices` → tiap dokumen ada `site_uuid` +- `threats` → tiap dokumen ada `site_uuid` +- `events` → tiap dokumen ada `site_uuid` +- `summaries` → tiap dokumen ada `site_uuid` +- `telemetry` → tiap dokumen ada `site_uuid` + +--- + +## LAPIS 3 — Saat Dashboard Baca dari MongoDB +### File: `backend/routes/dashboard/flows.js` (contoh) + +Ketika user login sebagai admin SIAB dan buka halaman Flows, +backend hanya query dokumen dengan `site_uuid` yang sesuai: + +```javascript +// backend/routes/dashboard/flows.js (contoh query) +const userSiteUuid = req.user.site_uuid; +// → "6681452d_9cae_4ff4_8ae8_0d504774265e" (SIAB) + +const flows = await Flow.find({ + site_uuid: userSiteUuid, // <--- hanya ambil data site ini! + // ...filter waktu, pagination, dsb +}).limit(50); +``` + +Admin Office login → `site_uuid = 1959bb55_...` → hanya lihat data Office. +Admin SIAB login → `site_uuid = 6681452d_...` → hanya lihat data SIAB. +Super Admin → bisa pilih site mana yang ingin dilihat. + +--- + +## RINGKASAN — Alur Lengkap Filter Data + +``` +Netify API + | + |-- Lapis 1: Header x-net-site dikirim ke Netify + | Netify hanya kirim data milik site tersebut + | + v +Proxy Server (setiap 5 menit) + | + |-- Lapis 2: Setiap dokumen diberi stempel site_uuid + | - SIAB data → { site_uuid: "6681452d_..." } + | - Office data → { site_uuid: "1959bb55_..." } + | - Anti-duplikat: 1 agent hanya masuk 1 site + | + v +MongoDB (semua data tercampur tapi ter-tag per site) + | + |-- Lapis 3: Backend query MongoDB dengan filter site_uuid + | - Admin SIAB login → WHERE site_uuid = SIAB-UUID + | - Admin Office login → WHERE site_uuid = OFFICE-UUID + | + v +Web Dashboard (tampil hanya data site yang sesuai) +``` + +--- + +## Skenario Konkret + +**Skenario:** Network agent "F6-2V-DT-8A" ada di SIAB. Network agent "23-TE-6L-I2" ada di Office. + +### Langkah 1 — Proxy request ke Netify +``` +[Iter 1] siteUuid = "6681452d..." (SIAB) + → GET /data/flows + Header: x-net-site: 6681452d... + → Netify kembalikan: flows dari F6-2V-DT-8A (agent SIAB) + → Simpan ke MongoDB: { site_uuid: "6681452d...", agent_uuid: "F6-2V-DT-8A", flow_id: ... } + +[Iter 2] siteUuid = "1959bb55..." (Office) + → GET /data/flows + Header: x-net-site: 1959bb55... + → Netify kembalikan: flows dari 23-TE-6L-I2 (agent Office) + → Simpan ke MongoDB: { site_uuid: "1959bb55...", agent_uuid: "23-TE-6L-I2", flow_id: ... } +``` + +### Langkah 2 — Dashboard tampilkan +``` +User siab login: + req.user.site_uuid = "6681452d..." + DB query: Flow.find({ site_uuid: "6681452d..." }) + Hasil: hanya flow dari F6-2V-DT-8A ✓ + +User office login: + req.user.site_uuid = "1959bb55..." + DB query: Flow.find({ site_uuid: "1959bb55..." }) + Hasil: hanya flow dari 23-TE-6L-I2 ✓ +``` + +**Data tidak pernah tercampur** karena ada 3 lapis isolasi ini. + +--- +*Dokumentasi teknis Source 2 — 29 Juli 2026* diff --git a/README.md b/README.md index 2b50f81..559c70b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Produk BackOne oleh **PT. Data Bisnis Solusi** — Dashboard monitoring jaringan ## 📡 Proxy — 2 Mode Pengambilan Data -Proxy server (port 4000) mendukung 3 mode yang dikontrol via environment variable: +Proxy server (port 4000) mendukung 2 mode yang dikontrol via environment variable: ### Mode 1: Semua Network Agent (Admin BackOne) @@ -62,26 +62,14 @@ PROXY_AGENT_UUID=2F-TF-1D-GK # UUID Network Agent CPI Balaraja Proxy hanya mengambil data dari **satu Network Agent spesifik** (berdasarkan UUID). Data agent lain tidak pernah masuk ke database. Cocok untuk deployment di sisi client (Pihak A, B, C) agar mereka hanya punya data milik mereka sendiri. -### Mode 3: Beberapa Agent Spesifik (Multi-Agent) - -```env -# .env.local -PROXY_COLLECT_MODE=agents -PROXY_AGENT_UUIDS=UUID-AGENT-A,UUID-AGENT-B,UUID-AGENT-C # comma-separated list -PROXY_AGENT_DELAY_MS=5000 # delay antar agent (default: 5000ms) -``` - -Proxy mengambil data dari **beberapa Network Agent spesifik** (berdasarkan daftar UUID yang dipisahkan koma). Data agent di luar daftar tidak pernah masuk ke database. Delay antar agent dapat diatur dengan `PROXY_AGENT_DELAY_MS` untuk menghindari rate-limit. - ### Contoh Multi-Tenant Deployment -| Deployment | PROXY_COLLECT_MODE | PROXY_AGENT_UUID / PROXY_AGENT_UUIDS | Data yang disimpan | +| Deployment | PROXY_COLLECT_MODE | PROXY_AGENT_UUID | Data yang disimpan | |---|---|---|---| | Kantor BackOne (Admin) | `all` | _(kosong)_ | Semua agent | | Pihak A | `agent` | `UUID-AGENT-A` | Hanya data Pihak A | | Pihak B | `agent` | `UUID-AGENT-B` | Hanya data Pihak B | | Pihak C | `agent` | `UUID-AGENT-C` | Hanya data Pihak C | -| Multi-Client | `agents` | `UUID-A,UUID-B` | Data Pihak A dan B | --- @@ -94,7 +82,6 @@ Proxy mengambil data dari **beberapa Network Agent spesifik** (berdasarkan dafta | GET | `/agents` | List semua agent UUID yang ada di MongoDB | | POST | `/collect/all` | Trigger manual — kumpulkan semua agent | | POST | `/collect/:uuid` | Trigger manual — kumpulkan agent spesifik | -| POST | `/collect/agents` | Trigger manual — kumpulkan multiple agents (body: `{"uuids": [...], "delay_ms": 5000}`) | --- diff --git a/SKILLS.md b/SKILLS.md index 25e2810..5d975a9 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -26,9 +26,7 @@ contract, not just a task description. **Responsibilities** - Implement API/data-layer logic. -- Wire the mock-vs-live routing required by the Demo/Live switch (`AGENTS.md` §5) and - the Cloud/Local endpoint switch (`AGENTS.md` §6) — both must resolve through the - same contract so swapping either setting never changes calling code. +- Ensure all endpoints aggregate real-time data from MongoDB and Netify, avoiding any mock or simulated responses. - Keep business logic out of route handlers; route handlers stay thin. **When invoked**: any task touching data, APIs, or service integration. @@ -39,8 +37,7 @@ QA the list of new/changed endpoints and their expected error modes. ## 3. Frontend Engineer **Responsibilities** -- Implement UI for the task, including the Demo/Live and Cloud/Local switcher - controls where relevant. +- Implement UI for the task. - Consume the Backend's contract rather than reaching around it. - Keep components small and composable, respecting the 256-LOC rule. diff --git a/backend/Dockerfile.bun b/backend/Dockerfile.bun new file mode 100644 index 0000000..2a5595a --- /dev/null +++ b/backend/Dockerfile.bun @@ -0,0 +1,15 @@ +FROM oven/bun:1-alpine + +WORKDIR /app + +COPY backend/package*.json ./ +RUN bun install --production + +COPY backend/ . + +EXPOSE 3001 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \ + CMD bun -e "require('http').get('http://localhost:3001/api/health', r => r.statusCode === 200 ? process.exit(0) : process.exit(1)).on('error', () => process.exit(1))" + +CMD ["bun", "run", "server.js"] diff --git a/backend/check_devices.js b/backend/check_devices.js new file mode 100644 index 0000000..e72ab28 --- /dev/null +++ b/backend/check_devices.js @@ -0,0 +1,3 @@ +const db = require('better-sqlite3')('backend/netify_data.db'); +console.log('Devices:', db.prepare("SELECT * FROM devices WHERE ip_address = '192.168.9.2'").all()); +console.log('Discovery:', db.prepare("SELECT * FROM intel_device_discovery WHERE ip_address = '192.168.9.2'").all()); diff --git a/backend/check_events.js b/backend/check_events.js new file mode 100644 index 0000000..d8ef55e --- /dev/null +++ b/backend/check_events.js @@ -0,0 +1,22 @@ +const mongoose = require('mongoose'); +require('dotenv').config({path: '../.env.local'}); +mongoose.connect(process.env.MONGODB_URI).then(async () => { + const db = mongoose.connection; + const highEvents = await db.collection('events').find({ + $or: [ + {severity: {$in: ['Critical', 'High']}}, + {category_label: 'Cybersecurity'} + ] + }).toArray(); + + if (highEvents.length > 0) { + console.log("High Events timestamps:"); + highEvents.forEach(e => { + console.log("- event_at:", e.event_at, " | timestamp:", e.timestamp); + }); + } else { + console.log("No high events found in array"); + } + + process.exit(0); +}).catch(e => console.error(e)); diff --git a/backend/check_overview.js b/backend/check_overview.js new file mode 100644 index 0000000..4bb0d7c --- /dev/null +++ b/backend/check_overview.js @@ -0,0 +1,44 @@ +const mongoose = require('mongoose'); + +mongoose.connect('mongodb://backone_user:SusuKudaLiar@103.80.237.29:27017/backone_dpi?authSource=backone_dpi') + .then(async () => { + const db = mongoose.connection.useDb('backone_dpi'); + const yesterday = new Date(Date.now() - 24 * 3600 * 1000); + const SIAB = '6681452d_9cae_4ff4_8ae8_0d504774265e'; + + const catCount = await db.db.collection('appcategorystats').countDocuments({ site_uuid: SIAB, timestamp: { $gte: yesterday } }); + const catSum = await db.db.collection('appcategorystats').aggregate([ + { $match: { site_uuid: SIAB, timestamp: { $gte: yesterday } } }, + { $group: { _id: null, dl: { $sum: '$download' }, ul: { $sum: '$upload' } } } + ]).toArray(); + + const sumCount = await db.db.collection('summaries').countDocuments({ site_uuid: SIAB, timestamp: { $gte: yesterday } }); + const sumSum = await db.db.collection('summaries').aggregate([ + { $match: { site_uuid: SIAB, timestamp: { $gte: yesterday } } }, + { $group: { _id: null, dl: { $sum: '$bandwidth_down' }, ul: { $sum: '$bandwidth_up' } } } + ]).toArray(); + + const flowCount = await db.db.collection('flows').countDocuments({ site_uuid: SIAB, timestamp: { $gte: yesterday } }); + const flowSum = await db.db.collection('flows').aggregate([ + { $match: { site_uuid: SIAB, timestamp: { $gte: yesterday } } }, + { $group: { _id: null, dl: { $sum: '$download' }, ul: { $sum: '$upload' } } } + ]).toArray(); + + // Check latest timestamp in each collection for SIAB + const latestCat = await db.db.collection('appcategorystats').findOne({ site_uuid: SIAB }, { sort: { timestamp: -1 } }); + const latestFlow = await db.db.collection('flows').findOne({ site_uuid: SIAB }, { sort: { timestamp: -1 } }); + const latestSum = await db.db.collection('summaries').findOne({ site_uuid: SIAB }, { sort: { timestamp: -1 } }); + + console.log('=== SIAB Site Data Check (Last 24h) ==='); + console.log('AppCatStats (24h):', catCount, 'docs | Sum:', JSON.stringify(catSum[0])); + console.log('Summaries (24h) :', sumCount, 'docs | Sum:', JSON.stringify(sumSum[0])); + console.log('Flows (24h) :', flowCount, 'docs | Sum:', JSON.stringify(flowSum[0])); + console.log(''); + console.log('=== Latest Timestamps ==='); + console.log('Latest AppCat :', latestCat?.timestamp); + console.log('Latest Flow :', latestFlow?.timestamp); + console.log('Latest Summary :', latestSum?.timestamp); + + mongoose.disconnect(); + }) + .catch(e => { console.error('Error:', e.message); process.exit(1); }); diff --git a/backend/check_server.js b/backend/check_server.js new file mode 100644 index 0000000..0684919 --- /dev/null +++ b/backend/check_server.js @@ -0,0 +1,31 @@ +const { Client } = require('ssh2'); +const conn = new Client(); + +conn.on('ready', () => { + const cmd = [ + 'export PM2=/home/adminbackend/.npm-global/bin/pm2', + '$PM2 list', + 'echo "=== MEMORY ==="', + 'free -m', + 'echo "=== DISK ==="', + 'df -h /', + 'echo "=== FRONTEND LOGS ==="', + '$PM2 logs backone-frontend --lines 20 --nostream 2>&1', + 'echo "=== BACKEND LOGS ==="', + '$PM2 logs backone-backend --lines 10 --nostream 2>&1', + ].join(' && '); + + conn.exec(cmd, (err, stream) => { + if (err) { console.error(err); conn.end(); return; } + stream.on('data', d => process.stdout.write(d.toString())); + stream.stderr.on('data', d => process.stderr.write(d.toString())); + stream.on('close', () => conn.end()); + }); +}).connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', +}); + +conn.on('error', e => console.error('SSH Error:', e.message)); diff --git a/backend/check_threats.js b/backend/check_threats.js new file mode 100644 index 0000000..e26573d --- /dev/null +++ b/backend/check_threats.js @@ -0,0 +1,15 @@ +const mongoose = require('mongoose'); +require('dotenv').config({path: '../.env.local'}); +mongoose.connect(process.env.MONGODB_URI).then(async () => { + const db = mongoose.connection; + const threats = await db.collection('threats').countDocuments(); + const events = await db.collection('events').countDocuments(); + const highEvents = await db.collection('events').countDocuments({ + $or: [ + {severity: {$in: ['Critical', 'High']}}, + {category_label: 'Cybersecurity'} + ] + }); + console.log({threats, events, highEvents}); + process.exit(0); +}).catch(e => console.error(e)); diff --git a/backend/check_types.js b/backend/check_types.js new file mode 100644 index 0000000..fec586b --- /dev/null +++ b/backend/check_types.js @@ -0,0 +1,10 @@ +const mongoose = require('mongoose'); +require('dotenv').config({path: '../.env.local'}); +mongoose.connect(process.env.MONGODB_URI).then(async () => { + const db = mongoose.connection; + const threats = await db.collection('threats').aggregate([{ $group: { _id: '$threat_type', count: { $sum: 1 } } }]).toArray(); + console.log('Threat types:', threats); + const events = await db.collection('events').aggregate([{ $group: { _id: '$event_type', count: { $sum: 1 } } }]).toArray(); + console.log('Event types:', events); + process.exit(0); +}); diff --git a/backend/database.js b/backend/database.js new file mode 100644 index 0000000..dcdc472 --- /dev/null +++ b/backend/database.js @@ -0,0 +1,2146 @@ +// backend/database.js +const Database = require('better-sqlite3'); +const path = require('path'); +const bcrypt = require('bcryptjs'); + +const DB_PATH = path.join(__dirname, 'netify_data.db'); +let db; + +// Agent UUID mappings to MAC addresses and numeric interface IDs +const AGENT_MAC_MAP = { + '2F-TF-1D-GK': ['60:be:b4:1f:05:96'], + '8A-V3-PB-85': [ + 'bc:45:5b:ca:d5:be', 'de:ed:cc:57:58:34', 'aa:b2:5e:30:51:30', + '76:32:c3:dd:b2:bb', '5c:ba:ef:d5:80:a1', '5a:e8:2f:f4:99:3d', + '8e:91:0f:6e:24:63', '12:46:2e:63:2c:b7', '92:df:61:3e:ff:5e', + '14:ea:63:96:40:78', '44:e5:17:b9:0d:07', '78:93:c3:08:41:ea', + '0e:15:c3:8e:29:a8', '58:a0:23:ae:f2:72', 'f2:69:9d:a1:5e:11', + '60:be:b4:2a:39:b0', 'ae:5d:99:33:67:2c' + ], + 'F6-2V-DT-8A': [ + '2c:7b:a0:d8:86:91', '16:11:ac:73:34:1d', 'f4:6d:3f:ef:01:a0', + '60:be:b4:29:d3:36', '60:be:b4:29:d3:33', '60:be:b4:26:4c:d6', + '60:be:b4:29:d3:32', '04:f4:1c:ce:c2:e6' + ], + '1R-79-J9-YE': [ + '70:85:6c:6d:f7:17', '70:85:6c:81:50:d4', 'a2:cc:8e:7d:39:51' + ], +}; + +const AGENT_NUMERIC_IDS = { + '2F-TF-1D-GK': ['4897042839'], + '8A-V3-PB-85': ['4895530456'], + 'F6-2V-DT-8A': ['4894730147'], + '1R-79-J9-YE': ['4895853843'], +}; + +function getAgentTrafficRatio(agentUuid) { + const d = getDB(); + const macs = AGENT_MAC_MAP[agentUuid]; + if (!macs || macs.length === 0) return 0; + + const latest = d.prepare("SELECT MAX(fetched_at) as t FROM mac_bandwidth").get(); + if (!latest?.t) return 0; + + const siteTotal = d.prepare("SELECT SUM(total) as val FROM mac_bandwidth WHERE fetched_at = ?").get(latest.t)?.val || 1; + + const placeholders = macs.map(() => '?').join(','); + const agentTotal = d.prepare(`SELECT SUM(total) as val FROM mac_bandwidth WHERE fetched_at = ? AND mac_address IN (${placeholders})`).get(latest.t, ...macs)?.val || 0; + + return siteTotal > 0 ? (agentTotal / siteTotal) : 0; +} + +function getDB() { + if (!db) { + db = new Database(DB_PATH); + db.pragma('journal_mode = WAL'); + db.pragma('synchronous = NORMAL'); + initSchema(); + } + return db; +} + +function initSchema() { + const d = getDB(); + + // ─── AUTHENTICATION ───────────────────────────────────────────────────────── + d.exec(`CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'AGENT_VIEWER', + site_uuid TEXT DEFAULT NULL, + agent_uuid TEXT DEFAULT NULL, + account_name TEXT DEFAULT NULL, + profile_picture TEXT DEFAULT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + )`); + + // Alter users table to add agent_uuid if it was created on an older schema + try { + d.exec("ALTER TABLE users ADD COLUMN agent_uuid TEXT DEFAULT NULL"); + } catch (e) { + // Column already exists, safe to ignore + } + + // Alter bandwidth_timeline table to add new speed columns dynamically if they do not exist + try { + d.exec("ALTER TABLE bandwidth_timeline ADD COLUMN download_speed INTEGER DEFAULT 0"); + } catch (_) {} + try { + d.exec("ALTER TABLE bandwidth_timeline ADD COLUMN upload_speed INTEGER DEFAULT 0"); + } catch (_) {} + try { + d.exec("ALTER TABLE bandwidth_timeline ADD COLUMN flow_speed INTEGER DEFAULT 0"); + } catch (_) {} + + try { + d.exec("ALTER TABLE users ADD COLUMN account_name TEXT DEFAULT NULL"); + d.exec("ALTER TABLE users ADD COLUMN profile_picture TEXT DEFAULT NULL"); + } catch (e) { + // Columns already exist, safe to ignore + } + + const adminExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'admin'").get(); + if (adminExists.count === 0) { + const hash = bcrypt.hashSync('admin', 10); + d.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)").run('admin', hash, 'SUPER_ADMIN'); + console.log('[DB] Created default admin user (admin / admin)'); + } + + d.exec(`CREATE TABLE IF NOT EXISTS tls_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + tls_version TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS tls_ciphers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + tls_cipher TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS tls_security ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + tls_security TEXT, + color TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS netbios_hostnames ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + hostname TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + // ─── DPI Fields 12-21 ────────────────────────────────────────────────────── + + d.exec(`CREATE TABLE IF NOT EXISTS dhcp_fingerprints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + fingerprint TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS http_user_agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + user_agent TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS sni_hostnames ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + sni_hostname TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS ssl_server_cn ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + ssl_server_cn TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS quic_hostnames ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + quic_hostname TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS bittorrent_hashes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + info_hash TEXT, + label TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS ssh_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + ssh_version TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS mdns_hostnames ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + mdns_hostname TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + // ─── Intelligence API 22-30 ──────────────────────────────────────────────── + + d.exec(`CREATE TABLE IF NOT EXISTS intel_crypto_mining ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + detected_at TEXT, + ip_address TEXT, mac_address TEXT, + pool_host TEXT, pool_ip TEXT, + protocol TEXT, app_label TEXT, + confidence REAL, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS intel_device_discovery ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + detected_at TEXT, + ip_address TEXT, mac_address TEXT, + device_label TEXT, device_type TEXT, + os_label TEXT, manufacturer TEXT, + is_new INTEGER DEFAULT 1 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS intel_encryption_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + detected_at TEXT, + ip_address TEXT, mac_address TEXT, + device_label TEXT, + encrypted_pct REAL, + unencrypted INTEGER DEFAULT 0, + encrypted INTEGER DEFAULT 0, + total INTEGER DEFAULT 0, + risk_level TEXT + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS intel_insecure_protocols ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + detected_at TEXT, + protocol TEXT, + ip_address TEXT, mac_address TEXT, + dst_ip TEXT, dst_port INTEGER, + app_label TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + risk TEXT, + source TEXT DEFAULT 'api' + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS intel_ip_reputation ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + detected_at TEXT, + ip_address TEXT, local_ip TEXT, + mac_address TEXT, reputation TEXT, + score REAL, country TEXT, + app_label TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + blacklisted INTEGER DEFAULT 1 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS intel_server_discovery ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + detected_at TEXT, + ip_address TEXT, mac_address TEXT, + server_type TEXT, hostname TEXT, + port INTEGER, protocol TEXT, + os_label TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS intel_tor_detection ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + detected_at TEXT, + ip_address TEXT, mac_address TEXT, + exit_node TEXT, circuit_id TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + country TEXT + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS intel_unencrypted_passwords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + detected_at TEXT, + ip_address TEXT, mac_address TEXT, + dst_ip TEXT, dst_port INTEGER, + protocol TEXT, username TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + severity TEXT DEFAULT 'Critical' + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS intel_vpn_detection ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + detected_at TEXT, + ip_address TEXT, mac_address TEXT, + vpn_type TEXT, remote_ip TEXT, + protocol TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + country TEXT, confidence REAL + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS discovery_os ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + os_label TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + // Tabel baru fitur 1-11 + d.exec(`CREATE TABLE IF NOT EXISTS app_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + category_label TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS continents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + continent_name TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + region_name TEXT, region_code TEXT, + country_name TEXT, country_code TEXT, + download INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS cities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + city_name TEXT, region_name TEXT, + country_name TEXT, country_code TEXT, + download INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS vlans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + vlan_id INTEGER, vlan_label TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS interfaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + iface_id INTEGER, iface_name TEXT, + iface_role TEXT, agent_id TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS flow_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + flow_type_label TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS flow_origins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + flow_origin_label TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS ip_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + ip_version_label TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS remote_ips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + remote_ip TEXT, ip_version INTEGER, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS mac_bandwidth ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + mac_address TEXT, manufacturer TEXT, + download INTEGER DEFAULT 0, + upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS bandwidth_apps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + app_id INTEGER, app_label TEXT, app_tag TEXT, category TEXT, + favicon TEXT, download INTEGER DEFAULT 0, upload INTEGER DEFAULT 0, + total INTEGER DEFAULT 0, flow_count INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + mac_address TEXT, ip_address TEXT, device_label TEXT, + device_type TEXT, os_label TEXT, manufacturer TEXT, + download INTEGER DEFAULT 0, upload INTEGER DEFAULT 0, last_seen TEXT + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS flows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + flow_id TEXT, src_ip TEXT, src_mac TEXT, + dst_ip TEXT, dst_port INTEGER, protocol TEXT, + app_label TEXT, domain TEXT, + bytes_download INTEGER DEFAULT 0, bytes_upload INTEGER DEFAULT 0, + first_seen TEXT, last_seen TEXT + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS threats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + threat_id TEXT, threat_type TEXT, severity TEXT, + mac_address TEXT, ip_address TEXT, dst_ip TEXT, + app_label TEXT, domain TEXT, description TEXT, detected_at TEXT + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS bandwidth_protocols ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + protocol_id INTEGER, protocol_label TEXT, + download INTEGER DEFAULT 0, upload INTEGER DEFAULT 0, + flow_count INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS bandwidth_countries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + country_code TEXT, country_name TEXT, + download INTEGER DEFAULT 0, upload INTEGER DEFAULT 0, + flow_count INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS dns_queries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + domain TEXT, query_count INTEGER DEFAULT 0, + app_label TEXT, category TEXT + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + event_id TEXT, event_type TEXT, severity TEXT, + mac_address TEXT, ip_address TEXT, + description TEXT, event_at TEXT + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS bandwidth_timeline ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT, + fetched_at TEXT NOT NULL, + total_download INTEGER DEFAULT 0, total_upload INTEGER DEFAULT 0, + total_flows INTEGER DEFAULT 0, active_devices INTEGER DEFAULT 0, + download_speed INTEGER DEFAULT 0, upload_speed INTEGER DEFAULT 0, + flow_speed INTEGER DEFAULT 0 + )`); + + d.exec(`CREATE TABLE IF NOT EXISTS geoip_cache ( + ip_address TEXT PRIMARY KEY, + isp TEXT, + country TEXT, + city TEXT, + as_org TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + )`); + + // Ensure agent_uuid exists in all core data tables + const tables = [ + 'bandwidth_apps', 'devices', 'flows', 'threats', 'bandwidth_protocols', 'bandwidth_countries', + 'dns_queries', 'events', 'bandwidth_timeline', + 'app_categories', 'continents', 'regions', 'cities', + 'vlans', 'interfaces', 'flow_types', 'flow_origins', + 'ip_versions', 'remote_ips', 'mac_bandwidth', + 'tls_versions', 'tls_ciphers', 'tls_security', 'netbios_hostnames', + 'dhcp_fingerprints', 'http_user_agents', 'sni_hostnames', 'ssl_server_cn', + 'quic_hostnames', 'bittorrent_hashes', 'ssh_versions', 'mdns_hostnames', + 'intel_crypto_mining', 'intel_device_discovery', 'intel_encryption_audit', + 'intel_insecure_protocols', 'intel_ip_reputation', 'intel_server_discovery', + 'intel_tor_detection', 'intel_unencrypted_passwords', 'intel_vpn_detection', + 'discovery_os' + ]; + for (const table of tables) { + try { + d.exec(`ALTER TABLE ${table} ADD COLUMN agent_uuid TEXT DEFAULT NULL`); + } catch (_) {} + } + + console.log('[DB] Schema siap.'); +} + +// ─── INSERT FUNCTIONS ───────────────────────────────────────────────────────── + +function insertBandwidthApps(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(` + INSERT INTO bandwidth_apps + (agent_uuid, site_uuid, fetched_at, app_id, app_label, app_tag, category, favicon, download, upload, total, flow_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + d.transaction((items) => { + for (const r of items) { + stmt.run( + agentUuid, siteUuid, fetchedAt, + r.app_id ?? null, + r.app_label ?? 'Unknown', + r.app_tag ?? null, + r.category ?? null, + r.favicon ?? null, + r.download ?? 0, + r.upload ?? 0, + r.total ?? (r.download ?? 0) + (r.upload ?? 0), + r.flows ?? 0 + ); + } + })(rows); +} + +function insertDevices(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(` + INSERT INTO devices + (agent_uuid, site_uuid, fetched_at, mac_address, ip_address, device_label, device_type, os_label, manufacturer, download, upload, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + d.transaction((items) => { + for (const r of items) { + stmt.run( + agentUuid, siteUuid, fetchedAt, + r.mac_address ?? null, + r.ip_address ?? null, + r.device_label ?? r.ip_address ?? 'Unknown', + r.device_type ?? null, + r.os_label ?? null, + r.manufacturer ?? null, + r.download ?? 0, + r.upload ?? 0, + r.last_seen ?? fetchedAt + ); + } + })(rows); +} + +function insertFlows(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(` + INSERT INTO flows + (agent_uuid, site_uuid, fetched_at, flow_id, src_ip, src_mac, dst_ip, dst_port, protocol, app_label, domain, bytes_download, bytes_upload, first_seen, last_seen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + d.transaction((items) => { + for (const r of items) { + stmt.run( + agentUuid, siteUuid, fetchedAt, + r.flow_id ?? null, + r.src_ip ?? null, + r.src_mac ?? null, + r.dst_ip ?? null, + r.dst_port ?? null, + r.protocol ?? null, + r.app_label ?? null, + r.domain ?? null, + r.download ?? 0, + r.upload ?? 0, + r.first_seen ?? fetchedAt, + r.last_seen ?? fetchedAt + ); + } + })(rows); +} + +function insertThreats(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(` + INSERT INTO threats + (agent_uuid, site_uuid, fetched_at, threat_id, threat_type, severity, mac_address, ip_address, dst_ip, app_label, domain, description, detected_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + d.transaction((items) => { + for (const r of items) { + stmt.run( + agentUuid, siteUuid, fetchedAt, + r.threat_id ?? null, + r.threat_type ?? null, + r.severity ?? null, + r.mac_address ?? null, + r.ip_address ?? null, + r.dst_ip ?? null, + r.app_label ?? null, + r.domain ?? null, + r.description ?? null, + r.detected_at ?? fetchedAt + ); + } + })(rows); +} + +function insertProtocols(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(` + INSERT INTO bandwidth_protocols + (agent_uuid, site_uuid, fetched_at, protocol_id, protocol_label, download, upload, flow_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + d.transaction((items) => { + for (const r of items) { + // Data sudah di-flatten oleh netify.js — akses langsung tanpa nested + stmt.run( + agentUuid, siteUuid, fetchedAt, + r.protocol_id ?? null, + r.protocol_label ?? 'Unknown', + r.download ?? 0, + r.upload ?? 0, + r.flows ?? 0 + ); + } + })(rows); +} + +function insertCountries(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(` + INSERT INTO bandwidth_countries + (agent_uuid, site_uuid, fetched_at, country_code, country_name, download, upload, flow_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + d.transaction((items) => { + for (const r of items) { + // Data sudah di-flatten oleh netify.js — akses langsung tanpa nested + stmt.run( + agentUuid, siteUuid, fetchedAt, + r.country_code ?? null, + r.country_name ?? 'Unknown', + r.download ?? 0, + r.upload ?? 0, + r.flows ?? 0 + ); + } + })(rows); +} + +function insertDNS(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(` + INSERT INTO dns_queries + (agent_uuid, site_uuid, fetched_at, domain, query_count, app_label, category) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + d.transaction((items) => { + for (const r of items) { + stmt.run( + agentUuid, siteUuid, fetchedAt, + r.domain ?? null, + r.query_count ?? 0, + r.app_label ?? null, + r.category ?? null + ); + } + })(rows); +} + +function insertEvents(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(` + INSERT INTO events + (agent_uuid, site_uuid, fetched_at, event_id, event_type, severity, mac_address, ip_address, description, event_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + d.transaction((items) => { + for (const r of items) { + stmt.run( + agentUuid, siteUuid, fetchedAt, + r.event_id ?? null, + r.event_type ?? null, + r.severity ?? null, + r.mac_address ?? null, + r.ip_address ?? null, + r.description ?? null, + r.event_at ?? fetchedAt + ); + } + })(rows); +} + +function insertBandwidthTimeline(summary, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + d.prepare(` + INSERT INTO bandwidth_timeline + (agent_uuid, site_uuid, fetched_at, total_download, total_upload, total_flows, active_devices, download_speed, upload_speed, flow_speed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + agentUuid, + siteUuid, + fetchedAt, + summary.download ?? 0, + summary.upload ?? 0, + summary.flows ?? 0, + summary.devices ?? 0, + summary.download_speed ?? 0, + summary.upload_speed ?? 0, + summary.flow_speed ?? 0 + ); +} + +// ─── QUERY FUNCTIONS ────────────────────────────────────────────────────────── + +function getLatestBandwidthApps(limit = 20, siteUuid = null, agentUuid = null) { + const d = getDB(); + const siteClause = siteUuid ? 'site_uuid = @siteUuid' : '1=1'; + + const appsLatest = agentUuid + ? d.prepare(`SELECT MAX(fetched_at) as t FROM bandwidth_apps WHERE ${siteClause} AND agent_uuid = @agentUuid`).get({ siteUuid, agentUuid }) + : d.prepare(`SELECT MAX(fetched_at) as t FROM bandwidth_apps WHERE ${siteClause} AND agent_uuid IS NULL`).get({ siteUuid }); + + if (!appsLatest?.t) return []; + + const rows = agentUuid + ? d.prepare(`SELECT * FROM bandwidth_apps WHERE ${siteClause} AND agent_uuid = @agentUuid AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit`).all({ siteUuid, agentUuid, fetched_at: appsLatest.t, limit }) + : d.prepare(`SELECT * FROM bandwidth_apps WHERE ${siteClause} AND agent_uuid IS NULL AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit`).all({ siteUuid, fetched_at: appsLatest.t, limit }); + + return correlateAppLabels(rows); +} + +function resolveDeviceMetadata(ip, mac, dbLabel, dbManufacturer, dbType) { + let label = dbLabel || ip; + let manufacturer = dbManufacturer || 'Unknown'; + let type = dbType || 'Generic Client'; + let os = 'Unknown'; + + if (manufacturer.includes('Routerboard') || manufacturer.includes('MikroTik')) { + manufacturer = 'MikroTik'; + type = 'Router/Network'; + os = 'RouterOS'; + } else if (manufacturer.includes('Fortinet')) { + manufacturer = 'Fortinet'; + type = 'Firewall/Network'; + os = 'FortiOS'; + } else if (manufacturer.includes('WatchGuard')) { + manufacturer = 'WatchGuard'; + type = 'Firewall/Network'; + os = 'Fireware'; + } else if (manufacturer.includes('Juniper')) { + manufacturer = 'Juniper'; + type = 'Switch/Network'; + os = 'Junos'; + } else if (manufacturer.includes('Apple')) { + manufacturer = 'Apple'; + type = 'Smart Device'; + os = 'iOS/macOS'; + } else if (manufacturer.includes('Samsung')) { + manufacturer = 'Samsung'; + type = 'Smart TV'; + os = 'Tizen OS'; + } else if (manufacturer.includes('LCFC') || manufacturer.includes('Lenovo')) { + manufacturer = 'Lenovo'; + type = 'Workstation'; + os = 'Windows/Linux'; + } else if (manufacturer.includes('Huawei')) { + manufacturer = 'Huawei'; + type = 'Mobile'; + os = 'Android'; + } else if (manufacturer.includes('Dahua')) { + manufacturer = 'Dahua'; + type = 'IP Camera'; + os = 'Embedded OS'; + } + + const labelLower = label.toLowerCase(); + if (labelLower.includes('windows') || labelLower.includes('microsoft')) { + os = 'Windows'; + type = 'Workstation'; + manufacturer = manufacturer === 'Unknown' ? 'Microsoft' : manufacturer; + } else if (labelLower.includes('apple') || labelLower.includes('iphone') || labelLower.includes('ipad') || labelLower.includes('mac')) { + os = labelLower.includes('mac') ? 'macOS' : 'Apple iOS'; + type = labelLower.includes('mac') ? 'Workstation' : 'Mobile'; + manufacturer = 'Apple'; + } else if (labelLower.includes('android') || labelLower.includes('oppo') || labelLower.includes('samsung phone')) { + os = 'Android'; + type = 'Mobile'; + if (labelLower.includes('oppo')) manufacturer = 'Oppo'; + if (labelLower.includes('samsung')) manufacturer = 'Samsung'; + } else if (labelLower.includes('samsung tv') || labelLower.includes('tizen')) { + os = 'Tizen OS'; + type = 'Smart TV'; + manufacturer = 'Samsung'; + } else if (labelLower.includes('agent device')) { + os = 'Linux'; + type = 'Security Agent'; + manufacturer = 'S-Bluetech'; + } + + const isRouterIP = ['10.6.50.25', '10.6.12.242', '192.168.9.1', '10.6.11.208', '10.6.10.4'].includes(ip); + if (type === 'Router/Network' && !isRouterIP) { + type = 'LAN Client'; + manufacturer = 'Unknown'; + os = 'Windows/Linux'; + } + + return { label, manufacturer, type, os }; +} + +function deviceMatchesAgent(ip, mac, agentUuid) { + if (!agentUuid) return true; + const macs = AGENT_MAC_MAP[agentUuid]; + if (!macs) return false; + + // 1. Direct MAC check + if (macs.includes(mac)) { + // If it is the routed gateway MAC, only allow if the IP belongs to the agent's subnet + if (mac === '04:f4:1c:ce:c2:e6') { + return ip && ip.startsWith('10.6.'); + } + return true; + } + + // 2. Subnet checks for client IPs + if (ip) { + if (agentUuid === '8A-V3-PB-85') { + return ip.startsWith('10.250.0.'); + } + if (agentUuid === 'F6-2V-DT-8A') { + return (ip.startsWith('10.250.') && !ip.startsWith('10.250.0.')) || + ip.startsWith('192.168.') || + ip.startsWith('10.121.') || + ip.startsWith('10.6.'); + } + if (agentUuid === '2F-TF-1D-GK') { + return ip.startsWith('10.0.') || ip.startsWith('10.1.') || ip.startsWith('10.26.') || + ip.startsWith('10.43.') || ip.startsWith('10.35.') || ip.startsWith('10.21.') || + ip.startsWith('10.7.') || ip.startsWith('10.182.') || ip.startsWith('10.109.') || + ip.startsWith('10.181.') || ip.startsWith('10.75.') || ip.startsWith('10.202.') || + ip.startsWith('10.93.'); + } + if (agentUuid === '1R-79-J9-YE') { + return ip.startsWith('192.168.201.'); + } + } + + return false; +} + +function getLatestDevices(limit = 100, siteUuid = null, agentUuid = null, search = null) { + const d = getDB(); + const siteClause = siteUuid ? 'site_uuid = @siteUuid' : '1=1'; + + const latest = agentUuid + ? d.prepare(`SELECT MAX(fetched_at) as t FROM devices WHERE ${siteClause} AND agent_uuid = @agentUuid`).get({ siteUuid, agentUuid }) + : d.prepare(`SELECT MAX(fetched_at) as t FROM devices WHERE ${siteClause} AND agent_uuid IS NULL`).get({ siteUuid }); + + if (!latest?.t) return []; + + const intelList = d.prepare("SELECT * FROM intel_device_discovery").all(); + const intelMap = new Map(intelList.map(i => [i.ip_address, i])); + + // Get active flow bandwidth in latest flows snapshot for this agent + const latestFlowFetch = agentUuid + ? d.prepare(`SELECT MAX(fetched_at) as t FROM flows WHERE ${siteClause} AND agent_uuid = @agentUuid`).get({ siteUuid, agentUuid }) + : d.prepare(`SELECT MAX(fetched_at) as t FROM flows WHERE ${siteClause} AND agent_uuid IS NULL`).get({ siteUuid }); + + let flowsBandwidth = []; + if (latestFlowFetch?.t) { + flowsBandwidth = agentUuid + ? d.prepare(` + SELECT src_ip, src_mac, SUM(bytes_download) as flow_download, SUM(bytes_upload) as flow_upload + FROM flows + WHERE ${siteClause} AND agent_uuid = @agentUuid AND fetched_at = @fetched_at + GROUP BY src_ip + `).all({ siteUuid, agentUuid, fetched_at: latestFlowFetch.t }) + : d.prepare(` + SELECT src_ip, src_mac, SUM(bytes_download) as flow_download, SUM(bytes_upload) as flow_upload + FROM flows + WHERE ${siteClause} AND agent_uuid IS NULL AND fetched_at = @fetched_at + GROUP BY src_ip + `).all({ siteUuid, fetched_at: latestFlowFetch.t }); + } + const flowMap = new Map(flowsBandwidth.map(f => [f.src_ip, f])); + + // Build historical MAC lookup from ALL flows (not just latest snapshot) + // This catches devices that appeared before with a MAC address + const historicalMacs = agentUuid + ? d.prepare(` + SELECT src_ip, src_mac + FROM flows + WHERE ${siteClause} AND agent_uuid = @agentUuid AND src_mac IS NOT NULL + GROUP BY src_ip + ORDER BY COUNT(*) DESC + `).all({ siteUuid, agentUuid }) + : d.prepare(` + SELECT src_ip, src_mac + FROM flows + WHERE ${siteClause} AND agent_uuid IS NULL AND src_mac IS NOT NULL + GROUP BY src_ip + ORDER BY COUNT(*) DESC + `).all({ siteUuid }); + const historicalMacMap = new Map(historicalMacs.map(f => [f.src_ip, f.src_mac])); + + // Get all devices in latest snapshot from devices table + const devices = agentUuid + ? d.prepare(`SELECT * FROM devices WHERE ${siteClause} AND agent_uuid = @agentUuid AND fetched_at = @fetched_at`).all({ siteUuid, agentUuid, fetched_at: latest.t }) + : d.prepare(`SELECT * FROM devices WHERE ${siteClause} AND agent_uuid IS NULL AND fetched_at = @fetched_at`).all({ siteUuid, fetched_at: latest.t }); + + const resolved = []; + const seenIps = new Set(); + + function processDevice(ip, mac, dbDev, flowInfo) { + const intelInfo = intelMap.get(ip); + const dbLabel = dbDev ? dbDev.device_label : (intelInfo ? intelInfo.device_label : null); + const dbMan = dbDev ? dbDev.manufacturer : (intelInfo ? intelInfo.manufacturer : null); + const dbType = intelInfo ? intelInfo.device_type : null; + + // Enrich MAC — fallback chain: + // 1. devices.mac_address (most accurate, from API) + // 2. intel_device_discovery.mac_address (from device discovery intel) + // 3. flows.src_mac latest snapshot + // 4. flows.src_mac historical (all time) + const resolvedMac = mac + || (intelInfo && intelInfo.mac_address ? intelInfo.mac_address : null) + || (flowInfo && flowInfo.src_mac ? flowInfo.src_mac : null) + || historicalMacMap.get(ip) + || null; + + const meta = resolveDeviceMetadata(ip, resolvedMac, dbLabel, dbMan, dbType); + const isRouted = resolvedMac === '04:f4:1c:ce:c2:e6' && ip !== '10.6.50.25' && ip !== '10.6.12.242'; + + const download = flowInfo ? flowInfo.flow_download : (dbDev ? dbDev.download : 0); + const upload = flowInfo ? flowInfo.flow_upload : (dbDev ? dbDev.upload : 0); + + return { + id: dbDev ? dbDev.id : null, + site_uuid: dbDev ? dbDev.site_uuid : siteUuid, + fetched_at: latest.t, + mac_address: resolvedMac, + ip_address: ip, + device_label: meta.label, + device_type: meta.type, + os_label: meta.os, + manufacturer: meta.manufacturer, + download: download || 0, + upload: upload || 0, + total: (download || 0) + (upload || 0), + is_gateway_routed: isRouted ? 1 : 0 + }; + } + + // 1. Process all devices in the devices table + for (const dev of devices) { + if (!dev.ip_address) continue; + if (seenIps.has(dev.ip_address)) continue; + seenIps.add(dev.ip_address); + const flowInfo = flowMap.get(dev.ip_address); + resolved.push(processDevice(dev.ip_address, dev.mac_address, dev, flowInfo)); + } + + // 2. Process any active flow IPs that are NOT present in the devices table + for (const flow of flowsBandwidth) { + if (!flow.src_ip || seenIps.has(flow.src_ip)) continue; + seenIps.add(flow.src_ip); + resolved.push(processDevice(flow.src_ip, flow.src_mac, null, flow)); + } + + resolved.sort((a, b) => b.download - a.download); + + // We no longer filter out devices with 0 traffic for agents. + // This allows the Devices page to show offline/idle devices properly. + let filtered = resolved; + + return filtered.slice(0, limit); +} + +function correlateFlows(flows) { + if (!Array.isArray(flows) || flows.length === 0) return flows; + + const d = getDB(); + + // 1. Build cache maps for local IPs and public IPs in history + const devices = d.prepare(` + SELECT ip_address, device_label, manufacturer, device_type + FROM devices + WHERE ip_address IS NOT NULL + `).all(); + + const devMap = new Map(); + for (const dev of devices) { + const label = dev.device_label || (dev.manufacturer && dev.manufacturer !== 'Unknown' ? `${dev.manufacturer} Device` : null); + if (label) { + devMap.set(dev.ip_address, label); + } + } + + const flowIPs = d.prepare(` + SELECT dst_ip, domain, app_label, COUNT(*) as count + FROM flows + WHERE dst_ip IS NOT NULL + AND (domain IS NOT NULL OR (app_label IS NOT NULL AND app_label NOT LIKE 'Port %')) + GROUP BY dst_ip, domain, app_label + ORDER BY count DESC + `).all(); + + const publicIpMap = new Map(); + for (const row of flowIPs) { + if (!publicIpMap.has(row.dst_ip)) { + publicIpMap.set(row.dst_ip, { + domain: row.domain, + app_label: row.app_label + }); + } + } + + // Matches map for standard ports + const matches = { + "1433": "MSSQL Database Server", + "1434": "MSSQL Monitor Server", + "3306": "MySQL/MariaDB", + "5432": "PostgreSQL", + "1521": "Oracle DB Server", + "27017": "MongoDB", + "6379": "Redis Cache", + "80": "HTTP Web Server", + "443": "HTTPS/TLS Secure Connection", + "22": "SSH Remote Management", + "21": "FTP File Storage", + "23": "Telnet Command Insecure", + "25": "SMTP Mail Delivery", + "587": "Secure SMTP Mail", + "110": "POP3 Mail Retrieval", + "993": "Secure IMAP Mail", + "53": "DNS Domain Directory Query", + "123": "NTP Network Time", + "161": "SNMP Monitoring Service", + "3389": "RDP Remote Windows Desktop", + "445": "SMB Windows File Share", + "137": "NetBIOS Name Service", + "138": "NetBIOS Datagram Service", + "139": "NetBIOS Session Service", + "1812": "RADIUS Auth Server", + "1813": "RADIUS Accounting", + "5060": "SIP VoIP Service" + }; + + return flows.map(f => { + let appLabel = f.app_label; + let domain = f.domain; + + const isPortLabel = !appLabel || appLabel.startsWith("Port ") || appLabel.toLowerCase().includes("port"); + + if (isPortLabel) { + const dstIp = f.dst_ip; + const dstPort = String(f.dst_port); + const proto = f.protocol || "TCP"; + + // Case A: Intranet IP + const isIntranet = dstIp && ( + dstIp.startsWith("10.") || + dstIp.startsWith("192.168.") || + dstIp.startsWith("172.16.") || + dstIp.startsWith("172.17.") || + dstIp.startsWith("172.18.") || + dstIp.startsWith("172.19.") || + dstIp.startsWith("172.20.") || + dstIp.startsWith("172.21.") || + dstIp.startsWith("172.22.") || + dstIp.startsWith("172.23.") || + dstIp.startsWith("172.24.") || + dstIp.startsWith("172.25.") || + dstIp.startsWith("172.26.") || + dstIp.startsWith("172.27.") || + dstIp.startsWith("172.28.") || + dstIp.startsWith("172.29.") || + dstIp.startsWith("172.30.") || + dstIp.startsWith("172.31.") + ); + + if (isIntranet) { + let friendlyName = devMap.get(dstIp); + if (!friendlyName) { + if (dstIp.startsWith("10.250.0.")) { + friendlyName = "IFG Client"; + } else if (dstIp.startsWith("10.6.") || (dstIp.startsWith("10.250.") && !dstIp.startsWith("10.250.0.")) || dstIp.startsWith("192.168.") || dstIp.startsWith("10.121.")) { + friendlyName = "CPI Client"; + } else if ( + dstIp.startsWith("10.0.") || dstIp.startsWith("10.1.") || dstIp.startsWith("10.26.") || + dstIp.startsWith("10.43.") || dstIp.startsWith("10.35.") || dstIp.startsWith("10.21.") || + dstIp.startsWith("10.7.") || dstIp.startsWith("10.182.") || dstIp.startsWith("10.109.") || + dstIp.startsWith("10.181.") || dstIp.startsWith("10.75.") || dstIp.startsWith("10.202.") || + dstIp.startsWith("10.93.") + ) { + friendlyName = "JRP Client"; + } else { + friendlyName = "Intranet Client"; + } + } + appLabel = `${friendlyName} (${dstIp})`; + domain = `Port ${dstPort} (${proto})`; + } else { + // Case B: Public IP + const cached = publicIpMap.get(dstIp); + if (cached) { + appLabel = cached.domain || cached.app_label || appLabel; + domain = `Port ${dstPort} (${proto})`; + } else { + const stdName = matches[dstPort]; + if (stdName) { + appLabel = stdName; + domain = `Port ${dstPort} (${proto})`; + } else { + appLabel = `Public IP: ${dstIp}`; + domain = `Port ${dstPort} (${proto})`; + } + } + } + } + + return { + ...f, + app_label: appLabel, + domain: domain + }; + }); +} + +function correlateAppLabels(apps) { + if (!Array.isArray(apps) || apps.length === 0) return apps; + + const d = getDB(); + + const devices = d.prepare(` + SELECT ip_address, device_label, manufacturer, device_type + FROM devices + WHERE ip_address IS NOT NULL + `).all(); + + const devMap = new Map(); + for (const dev of devices) { + const label = dev.device_label || (dev.manufacturer && dev.manufacturer !== 'Unknown' ? `${dev.manufacturer} Device` : null); + if (label) { + devMap.set(dev.ip_address, label); + } + } + + const flowIPs = d.prepare(` + SELECT dst_ip, domain, app_label, COUNT(*) as count + FROM flows + WHERE dst_ip IS NOT NULL + AND (domain IS NOT NULL OR (app_label IS NOT NULL AND app_label NOT LIKE 'Port %')) + GROUP BY dst_ip, domain, app_label + ORDER BY count DESC + `).all(); + + const publicIpMap = new Map(); + for (const row of flowIPs) { + if (!publicIpMap.has(row.dst_ip)) { + publicIpMap.set(row.dst_ip, { + domain: row.domain, + app_label: row.app_label + }); + } + } + + function getFriendlyIpName(ip) { + if (devMap.has(ip)) return devMap.get(ip); + if (publicIpMap.has(ip)) { + const pub = publicIpMap.get(ip); + return pub.domain || pub.app_label; + } + if (ip.startsWith('10.6.')) return 'IFG Client'; + if (ip.startsWith('10.250.') || ip.startsWith('192.168.') || ip.startsWith('10.121.')) return 'CPI Client'; + if ( + ip.startsWith('10.0.') || ip.startsWith('10.1.') || ip.startsWith('10.26.') || + ip.startsWith('10.43.') || ip.startsWith('10.35.') || ip.startsWith('10.21.') || + ip.startsWith('10.7.') || ip.startsWith('10.182.') || ip.startsWith('10.109.') || + ip.startsWith('10.181.') || ip.startsWith('10.75.') || ip.startsWith('10.202.') || + ip.startsWith('10.93.') + ) return 'JRP Client'; + return 'Intranet Client'; + } + + const matches = { + "1433": "MSSQL Database Server", + "1434": "MSSQL Monitor Server", + "3306": "MySQL/MariaDB", + "5432": "PostgreSQL", + "1521": "Oracle DB Server", + "27017": "MongoDB", + "6379": "Redis Cache", + "80": "HTTP Web Server", + "443": "HTTPS/TLS Secure Connection", + "22": "SSH Remote Management", + "21": "FTP File Storage", + "23": "Telnet Command Insecure", + "25": "SMTP Mail Delivery", + "587": "Secure SMTP Mail", + "110": "POP3 Mail Retrieval", + "993": "Secure IMAP Mail", + "53": "DNS Domain Directory Query", + "123": "NTP Network Time", + "161": "SNMP Monitoring Service", + "3389": "RDP Remote Windows Desktop", + "445": "SMB Windows File Share", + "137": "NetBIOS Name Service", + "138": "NetBIOS Datagram Service", + "139": "NetBIOS Session Service", + "1812": "RADIUS Auth Server", + "1813": "RADIUS Accounting", + "5060": "SIP VoIP Service" + }; + + return apps.map(app => { + let label = app.app_label; + if (!label) return app; + + const isPortLabel = label.startsWith("Port ") || label.toLowerCase().includes("port"); + + if (isPortLabel) { + const portStr = label.replace("Port ", "").trim(); + + const flow = d.prepare(` + SELECT dst_ip, protocol, dst_port + FROM flows + WHERE app_label = ? OR domain = ? OR dst_port = ? + GROUP BY dst_ip, protocol, dst_port + ORDER BY COUNT(*) DESC + LIMIT 1 + `).get(label, label, portStr); + + if (flow && flow.dst_ip) { + const friendlyName = getFriendlyIpName(flow.dst_ip); + label = `${friendlyName} (Port ${portStr})`; + } else { + const stdName = matches[portStr]; + if (stdName) { + label = `${stdName} (Port ${portStr})`; + } + } + } + + return { + ...app, + app_label: label + }; + }); +} + +function getLatestFlows(limit = 100, siteUuid = null, agentUuid = null) { + const d = getDB(); + const siteClause = siteUuid ? 'site_uuid = @siteUuid' : '1=1'; + + const latest = agentUuid + ? d.prepare(`SELECT MAX(fetched_at) as t FROM flows WHERE ${siteClause} AND agent_uuid = @agentUuid`).get({ siteUuid, agentUuid }) + : d.prepare(`SELECT MAX(fetched_at) as t FROM flows WHERE ${siteClause} AND agent_uuid IS NULL`).get({ siteUuid }); + + if (!latest?.t) return []; + + const rows = agentUuid + ? d.prepare(`SELECT * FROM flows WHERE ${siteClause} AND agent_uuid = @agentUuid AND fetched_at = @fetched_at ORDER BY bytes_download DESC LIMIT @limit`).all({ siteUuid, agentUuid, fetched_at: latest.t, limit }) + : d.prepare(`SELECT * FROM flows WHERE ${siteClause} AND agent_uuid IS NULL AND fetched_at = @fetched_at ORDER BY bytes_download DESC LIMIT @limit`).all({ siteUuid, fetched_at: latest.t, limit }); + + const mapped = rows.map(r => ({ + ...r, + download: r.bytes_download ?? 0, + upload: r.bytes_upload ?? 0 + })); + + return correlateFlows(mapped); +} + +function getLatestThreats(limit = 50, siteUuid = null, agentUuid = null) { + const d = getDB(); + const siteClause = siteUuid ? 'site_uuid = @siteUuid' : '1=1'; + return agentUuid + ? d.prepare(`SELECT * FROM threats WHERE ${siteClause} AND agent_uuid = @agentUuid ORDER BY fetched_at DESC LIMIT @limit`).all({ siteUuid, agentUuid, limit }) + : d.prepare(`SELECT * FROM threats WHERE ${siteClause} AND agent_uuid IS NULL ORDER BY fetched_at DESC LIMIT @limit`).all({ siteUuid, limit }); +} + +function getLatestProtocols(limit = 20, siteUuid = null, agentUuid = null) { + const d = getDB(); + const siteClause = siteUuid ? 'site_uuid = @siteUuid' : '1=1'; + + const protoLatest = agentUuid + ? d.prepare(`SELECT MAX(fetched_at) as t FROM bandwidth_protocols WHERE ${siteClause} AND agent_uuid = @agentUuid`).get({ siteUuid, agentUuid }) + : d.prepare(`SELECT MAX(fetched_at) as t FROM bandwidth_protocols WHERE ${siteClause} AND agent_uuid IS NULL`).get({ siteUuid }); + + if (!protoLatest?.t) return []; + + return agentUuid + ? d.prepare(`SELECT * FROM bandwidth_protocols WHERE ${siteClause} AND agent_uuid = @agentUuid AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit`).all({ siteUuid, agentUuid, fetched_at: protoLatest.t, limit }) + : d.prepare(`SELECT * FROM bandwidth_protocols WHERE ${siteClause} AND agent_uuid IS NULL AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit`).all({ siteUuid, fetched_at: protoLatest.t, limit }); +} + +function getLatestCountries(limit = 15, siteUuid = null, agentUuid = null) { + const d = getDB(); + const siteClause = siteUuid ? 'site_uuid = @siteUuid' : '1=1'; + + const countryLatest = agentUuid + ? d.prepare(`SELECT MAX(fetched_at) as t FROM bandwidth_countries WHERE ${siteClause} AND agent_uuid = @agentUuid`).get({ siteUuid, agentUuid }) + : d.prepare(`SELECT MAX(fetched_at) as t FROM bandwidth_countries WHERE ${siteClause} AND agent_uuid IS NULL`).get({ siteUuid }); + + if (!countryLatest?.t) return []; + + return agentUuid + ? d.prepare(`SELECT * FROM bandwidth_countries WHERE ${siteClause} AND agent_uuid = @agentUuid AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit`).all({ siteUuid, agentUuid, fetched_at: countryLatest.t, limit }) + : d.prepare(`SELECT * FROM bandwidth_countries WHERE ${siteClause} AND agent_uuid IS NULL AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit`).all({ siteUuid, fetched_at: countryLatest.t, limit }); +} + +function getLatestDNS(limit = 20, siteUuid = null, agentUuid = null) { + const d = getDB(); + const siteClause = siteUuid ? 'site_uuid = @siteUuid' : '1=1'; + + const dnsLatest = agentUuid + ? d.prepare(`SELECT MAX(fetched_at) as t FROM dns_queries WHERE ${siteClause} AND agent_uuid = @agentUuid`).get({ siteUuid, agentUuid }) + : d.prepare(`SELECT MAX(fetched_at) as t FROM dns_queries WHERE ${siteClause} AND agent_uuid IS NULL`).get({ siteUuid }); + + if (!dnsLatest?.t) return []; + + return agentUuid + ? d.prepare(`SELECT * FROM dns_queries WHERE ${siteClause} AND agent_uuid = @agentUuid AND fetched_at = @fetched_at ORDER BY query_count DESC LIMIT @limit`).all({ siteUuid, agentUuid, fetched_at: dnsLatest.t, limit }) + : d.prepare(`SELECT * FROM dns_queries WHERE ${siteClause} AND agent_uuid IS NULL AND fetched_at = @fetched_at ORDER BY query_count DESC LIMIT @limit`).all({ siteUuid, fetched_at: dnsLatest.t, limit }); +} + +function getLatestEvents(limit = 50, siteUuid = null, agentUuid = null) { + const d = getDB(); + const siteClause = siteUuid ? 'site_uuid = @siteUuid' : '1=1'; + return agentUuid + ? d.prepare(`SELECT * FROM events WHERE ${siteClause} AND agent_uuid = @agentUuid ORDER BY fetched_at DESC LIMIT @limit`).all({ siteUuid, agentUuid, limit }) + : d.prepare(`SELECT * FROM events WHERE ${siteClause} AND agent_uuid IS NULL ORDER BY fetched_at DESC LIMIT @limit`).all({ siteUuid, limit }); +} + +function getBandwidthTimeline(points = 60, siteUuid = null, agentUuid = null) { + const d = getDB(); + const siteClause = siteUuid ? 'site_uuid = @siteUuid' : '1=1'; + const rows = agentUuid + ? d.prepare(`SELECT * FROM bandwidth_timeline WHERE ${siteClause} AND agent_uuid = @agentUuid ORDER BY fetched_at DESC LIMIT @limit`).all({ siteUuid, agentUuid, limit: points }) + : d.prepare(`SELECT * FROM bandwidth_timeline WHERE ${siteClause} AND agent_uuid IS NULL ORDER BY fetched_at DESC LIMIT @limit`).all({ siteUuid, limit: points }); + return rows.reverse(); +} + +function getStats(siteUuid = null, agentUuid = null) { + const d = getDB(); + const siteClause = siteUuid ? 'site_uuid = @siteUuid' : '1=1'; + + const latestDevFetch = agentUuid + ? d.prepare(`SELECT MAX(fetched_at) as t FROM devices WHERE ${siteClause} AND agent_uuid = @agentUuid`).get({ siteUuid, agentUuid }) + : d.prepare(`SELECT MAX(fetched_at) as t FROM devices WHERE ${siteClause} AND agent_uuid IS NULL`).get({ siteUuid }); + + const totalDevices = latestDevFetch?.t + ? (agentUuid + ? (d.prepare(`SELECT COUNT(*) as n FROM devices WHERE ${siteClause} AND agent_uuid = @agentUuid AND fetched_at = @fetched_at AND download > 0`).get({ siteUuid, agentUuid, fetched_at: latestDevFetch.t })?.n ?? 0) + : (d.prepare(`SELECT COUNT(*) as n FROM devices WHERE ${siteClause} AND agent_uuid IS NULL AND fetched_at = @fetched_at`).get({ siteUuid, fetched_at: latestDevFetch.t })?.n ?? 0)) + : 0; + + const latestFlowFetch = agentUuid + ? d.prepare(`SELECT MAX(fetched_at) as t FROM flows WHERE ${siteClause} AND agent_uuid = @agentUuid`).get({ siteUuid, agentUuid }) + : d.prepare(`SELECT MAX(fetched_at) as t FROM flows WHERE ${siteClause} AND agent_uuid IS NULL`).get({ siteUuid }); + + const activeFlows = latestFlowFetch?.t + ? (agentUuid + ? (d.prepare(`SELECT COUNT(*) as n FROM flows WHERE ${siteClause} AND agent_uuid = @agentUuid AND fetched_at = @fetched_at`).get({ siteUuid, agentUuid, fetched_at: latestFlowFetch.t })?.n ?? 0) + : (d.prepare(`SELECT COUNT(*) as n FROM flows WHERE ${siteClause} AND agent_uuid IS NULL AND fetched_at = @fetched_at`).get({ siteUuid, fetched_at: latestFlowFetch.t })?.n ?? 0)) + : 0; + + const totalThreats = agentUuid + ? (d.prepare(`SELECT COUNT(*) as n FROM threats WHERE ${siteClause} AND agent_uuid = @agentUuid`).get({ siteUuid, agentUuid })?.n ?? 0) + : (d.prepare(`SELECT COUNT(*) as n FROM threats WHERE ${siteClause} AND agent_uuid IS NULL`).get({ siteUuid })?.n ?? 0); + + const totalEvents = agentUuid + ? (d.prepare(`SELECT COUNT(*) as n FROM events WHERE ${siteClause} AND agent_uuid = @agentUuid`).get({ siteUuid, agentUuid })?.n ?? 0) + : (d.prepare(`SELECT COUNT(*) as n FROM events WHERE ${siteClause} AND agent_uuid IS NULL`).get({ siteUuid })?.n ?? 0); + + const lastFetch = d.prepare(`SELECT MAX(fetched_at) as t FROM bandwidth_timeline`).get()?.t ?? null; + const latestBw = agentUuid + ? d.prepare(`SELECT * FROM bandwidth_timeline WHERE ${siteClause} AND agent_uuid = @agentUuid ORDER BY fetched_at DESC LIMIT 1`).get({ siteUuid, agentUuid }) + : d.prepare(`SELECT * FROM bandwidth_timeline WHERE ${siteClause} AND agent_uuid IS NULL ORDER BY fetched_at DESC LIMIT 1`).get({ siteUuid }); + + return { totalDevices, activeFlows, totalThreats, totalEvents, lastFetch, latestBw }; +} + +// ─── INSERT FITUR BARU 1-11 ─────────────────────────────────────────────────── +function insertAppCategories(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO app_categories + (agent_uuid, site_uuid, fetched_at, category_label, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.category_label, r.download, r.upload, r.total); + })(rows); +} + +function insertContinents(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO continents + (agent_uuid, site_uuid, fetched_at, continent_name, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.continent_name, r.download, r.upload, r.total); + })(rows); +} + +function insertRegions(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO regions + (agent_uuid, site_uuid, fetched_at, region_name, region_code, country_name, country_code, download) + VALUES (?, ?, ?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.region_name, r.region_code, r.country_name, r.country_code, r.download); + })(rows); +} + +function insertCities(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO cities + (agent_uuid, site_uuid, fetched_at, city_name, region_name, country_name, country_code, download) + VALUES (?, ?, ?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.city_name, r.region_name, r.country_name, r.country_code, r.download); + })(rows); +} + +function insertVLANs(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO vlans + (agent_uuid, site_uuid, fetched_at, vlan_id, vlan_label, download, upload, total) + VALUES (?, ?, ?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.vlan_id, r.vlan_label, r.download, r.upload, r.total); + })(rows); +} + +function insertInterfaces(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO interfaces + (agent_uuid, site_uuid, fetched_at, iface_id, iface_name, iface_role, agent_id, download, upload, total) + VALUES (?, ?, ?,?,?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.iface_id, r.iface_name, r.iface_role, String(r.agent_id ?? ''), r.download, r.upload, r.total); + })(rows); +} + +function insertFlowTypes(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO flow_types + (agent_uuid, site_uuid, fetched_at, flow_type_label, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.flow_type_label, r.download, r.upload, r.total); + })(rows); +} + +function insertFlowOrigins(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO flow_origins + (agent_uuid, site_uuid, fetched_at, flow_origin_label, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.flow_origin_label, r.download, r.upload, r.total); + })(rows); +} + +function insertIPVersions(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO ip_versions + (agent_uuid, site_uuid, fetched_at, ip_version_label, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.ip_version_label, r.download, r.upload, r.total); + })(rows); +} + +function insertRemoteIPs(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO remote_ips + (agent_uuid, site_uuid, fetched_at, remote_ip, ip_version, download, upload, total) + VALUES (?, ?, ?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.remote_ip, r.ip_version, r.download, r.upload, r.total); + })(rows); +} + +function insertMACBandwidth(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO mac_bandwidth + (agent_uuid, site_uuid, fetched_at, mac_address, manufacturer, download, upload, total) + VALUES (?, ?, ?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.mac_address, r.manufacturer, r.download, r.upload, r.total); + })(rows); +} + +// ─── QUERY FITUR BARU ───────────────────────────────────────────────────────── +function getLatest(table, orderBy = 'download', limit = 50, siteUuid = null, agentUuid = null) { + const d = getDB(); + const siteClause = siteUuid ? 'site_uuid = @siteUuid' : '1=1'; + + const latest = agentUuid + ? d.prepare(`SELECT MAX(fetched_at) as t FROM ${table} WHERE ${siteClause} AND agent_uuid = @agentUuid`).get({ siteUuid, agentUuid }) + : d.prepare(`SELECT MAX(fetched_at) as t FROM ${table} WHERE ${siteClause} AND agent_uuid IS NULL`).get({ siteUuid }); + + if (!latest?.t) return []; + + const rows = agentUuid + ? d.prepare(`SELECT * FROM ${table} WHERE ${siteClause} AND agent_uuid = @agentUuid AND fetched_at = @fetched_at ORDER BY ${orderBy} DESC LIMIT @limit`).all({ siteUuid, agentUuid, fetched_at: latest.t, limit }) + : d.prepare(`SELECT * FROM ${table} WHERE ${siteClause} AND agent_uuid IS NULL AND fetched_at = @fetched_at ORDER BY ${orderBy} DESC LIMIT @limit`).all({ siteUuid, fetched_at: latest.t, limit }); + + return rows; +} + +// DPI Fields +function insertTLSVersions(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO tls_versions + (agent_uuid, site_uuid, fetched_at, tls_version, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.tls_version, r.download, r.upload, r.total); + })(rows); +} + +function insertTLSCiphers(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO tls_ciphers + (agent_uuid, site_uuid, fetched_at, tls_cipher, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.tls_cipher, r.download, r.upload, r.total); + })(rows); +} + +function insertTLSSecurity(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO tls_security + (agent_uuid, site_uuid, fetched_at, tls_security, color, download, upload, total) + VALUES (?, ?, ?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.tls_security, r.color, r.download, r.upload, r.total); + })(rows); +} + +function insertNetBIOSHostnames(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO netbios_hostnames + (agent_uuid, site_uuid, fetched_at, hostname, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.hostname, r.download, r.upload, r.total); + })(rows); +} + +function insertDiscoveryOS(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO discovery_os + (agent_uuid, site_uuid, fetched_at, os_label, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.os_label, r.download, r.upload, r.total); + })(rows); +} + +// ─── INSERT DPI 12-21 ──────────────────────────────────────────────────────── + +function insertDHCPFingerprints(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO dhcp_fingerprints + (agent_uuid, site_uuid, fetched_at, fingerprint, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.fingerprint, r.download, r.upload, r.total); + })(rows); +} + +function insertHTTPUserAgents(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO http_user_agents + (agent_uuid, site_uuid, fetched_at, user_agent, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.user_agent, r.download, r.upload, r.total); + })(rows); +} + +function insertSNIHostnames(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO sni_hostnames + (agent_uuid, site_uuid, fetched_at, sni_hostname, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.sni_hostname, r.download, r.upload, r.total); + })(rows); +} + +function insertSSLServerCN(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO ssl_server_cn + (agent_uuid, site_uuid, fetched_at, ssl_server_cn, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.ssl_server_cn, r.download, r.upload, r.total); + })(rows); +} + +function insertQUICHostnames(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO quic_hostnames + (agent_uuid, site_uuid, fetched_at, quic_hostname, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.quic_hostname, r.download, r.upload, r.total); + })(rows); +} + +function insertBitTorrentHashes(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO bittorrent_hashes + (agent_uuid, site_uuid, fetched_at, info_hash, label, download, upload, total) + VALUES (?, ?, ?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.info_hash, r.label, r.download, r.upload, r.total); + })(rows); +} + +function insertSSHVersions(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO ssh_versions + (agent_uuid, site_uuid, fetched_at, ssh_version, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.ssh_version, r.download, r.upload, r.total); + })(rows); +} + +function insertMDNSHostnames(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO mdns_hostnames + (agent_uuid, site_uuid, fetched_at, mdns_hostname, download, upload, total) + VALUES (?, ?, ?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.mdns_hostname, r.download, r.upload, r.total); + })(rows); +} + +// ─── INSERT INTELLIGENCE 22-30 ──────────────────────────────────────────────── + +function insertCryptoMining(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO intel_crypto_mining + (agent_uuid, site_uuid, fetched_at, detected_at, ip_address, mac_address, pool_host, pool_ip, protocol, app_label, confidence, download, upload) + VALUES (?, ?, ?,?,?,?,?,?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.detected_at ?? fetchedAt, r.ip_address, r.mac_address, + r.pool_host, r.pool_ip, r.protocol, r.app_label, r.confidence, + r.download ?? 0, r.upload ?? 0 + ); + })(rows); +} + +function insertDeviceDiscovery(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO intel_device_discovery + (agent_uuid, site_uuid, fetched_at, detected_at, ip_address, mac_address, device_label, device_type, os_label, manufacturer, is_new) + VALUES (?, ?, ?,?,?,?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.detected_at ?? fetchedAt, r.ip_address, r.mac_address, + r.device_label, r.device_type, r.os_label, r.manufacturer, + r.is_new ? 1 : 0 + ); + })(rows); +} + +function insertEncryptionAudit(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO intel_encryption_audit + (agent_uuid, site_uuid, fetched_at, detected_at, ip_address, mac_address, device_label, encrypted_pct, unencrypted, encrypted, total, risk_level) + VALUES (?, ?, ?,?,?,?,?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.detected_at ?? fetchedAt, r.ip_address, r.mac_address, + r.device_label, r.encrypted_pct, r.unencrypted ?? 0, r.encrypted ?? 0, + r.total ?? 0, r.risk_level + ); + })(rows); +} + +function insertInsecureProtocols(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO intel_insecure_protocols + (agent_uuid, site_uuid, fetched_at, detected_at, protocol, ip_address, mac_address, dst_ip, dst_port, app_label, download, upload, risk, source) + VALUES (?, ?, ?,?,?,?,?,?,?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.detected_at ?? fetchedAt, r.protocol, r.ip_address, r.mac_address, + r.dst_ip, r.dst_port, r.app_label, r.download ?? 0, r.upload ?? 0, + r.risk ?? 'Medium', r.source ?? 'api' + ); + })(rows); +} + +function insertIPReputation(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO intel_ip_reputation + (agent_uuid, site_uuid, fetched_at, detected_at, ip_address, local_ip, mac_address, reputation, score, country, app_label, download, upload, blacklisted) + VALUES (?, ?, ?,?,?,?,?,?,?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.detected_at ?? fetchedAt, r.ip_address, r.local_ip, r.mac_address, + r.reputation, r.score, r.country, r.app_label, + r.download ?? 0, r.upload ?? 0, r.blacklisted ? 1 : 0 + ); + })(rows); +} + +function insertServerDiscovery(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO intel_server_discovery + (agent_uuid, site_uuid, fetched_at, detected_at, ip_address, mac_address, server_type, hostname, port, protocol, os_label, download, upload) + VALUES (?, ?, ?,?,?,?,?,?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.detected_at ?? fetchedAt, r.ip_address, r.mac_address, + r.server_type, r.hostname, r.port, r.protocol, r.os_label, + r.download ?? 0, r.upload ?? 0 + ); + })(rows); +} + +function insertTorDetection(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO intel_tor_detection + (agent_uuid, site_uuid, fetched_at, detected_at, ip_address, mac_address, exit_node, circuit_id, download, upload, country) + VALUES (?, ?, ?,?,?,?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.detected_at ?? fetchedAt, r.ip_address, r.mac_address, + r.exit_node, r.circuit_id, r.download ?? 0, r.upload ?? 0, r.country + ); + })(rows); +} + +function insertUnencryptedPasswords(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO intel_unencrypted_passwords + (agent_uuid, site_uuid, fetched_at, detected_at, ip_address, mac_address, dst_ip, dst_port, protocol, username, download, upload, severity) + VALUES (?, ?, ?,?,?,?,?,?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.detected_at ?? fetchedAt, r.ip_address, r.mac_address, + r.dst_ip, r.dst_port, r.protocol, r.username, + r.download ?? 0, r.upload ?? 0, r.severity ?? 'Critical' + ); + })(rows); +} + +function insertVPNDetection(rows, fetchedAt, siteUuid, agentUuid) { + const d = getDB(); + const stmt = d.prepare(`INSERT INTO intel_vpn_detection + (agent_uuid, site_uuid, fetched_at, detected_at, ip_address, mac_address, vpn_type, remote_ip, protocol, download, upload, country, confidence) + VALUES (?, ?, ?,?,?,?,?,?,?,?,?,?,?)`); + d.transaction(items => { + for (const r of items) stmt.run( + agentUuid, siteUuid, fetchedAt, r.detected_at ?? fetchedAt, r.ip_address, r.mac_address, + r.vpn_type, r.remote_ip, r.protocol, + r.download ?? 0, r.upload ?? 0, r.country, r.confidence + ); + })(rows); +} + +// ─── QUERY Intelligence — ambil semua row tanpa batasan fetched_at ──────────── +// (karena event ini tidak diposting ulang tiap menit, simpan kumulatif) +function getIntelData(table, limit = 100, siteUuid = null, agentUuid = null) { + const d = getDB(); + + if (!agentUuid) { + // Admin mode: return latest global snapshot (agent_uuid IS NULL) + return d.prepare(`SELECT * FROM ${table} WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND agent_uuid IS NULL ORDER BY fetched_at DESC LIMIT @limit`).all({ limit, siteUuid }); + } + + // Agent mode: try agent_uuid filter first (direct, most accurate) + const directResult = d.prepare(`SELECT * FROM ${table} WHERE agent_uuid = @agentUuid ORDER BY fetched_at DESC LIMIT @limit`).all({ agentUuid, limit }); + if (directResult.length > 0) { + return directResult; + } + + // Fallback: MAC-based filter for tables that may have been saved without agent_uuid + if (AGENT_MAC_MAP[agentUuid]) { + const macs = AGENT_MAC_MAP[agentUuid]; + const placeholders = macs.map(() => '?').join(','); + // For reputation, the column is local_ip, not ip_address + const ipField = table === 'intel_ip_reputation' ? 'local_ip' : 'ip_address'; + return d.prepare(` + SELECT * FROM ${table} + WHERE mac_address IN (${placeholders}) OR ${ipField} IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders})) + ORDER BY fetched_at DESC + LIMIT ? + `).all(...macs, ...macs, limit); + } + + return []; +} + +function getIntelStats(siteUuid = null, agentUuid = null) { + const d = getDB(); + const tables = [ + 'intel_crypto_mining', 'intel_device_discovery', 'intel_encryption_audit', + 'intel_insecure_protocols', 'intel_ip_reputation', 'intel_server_discovery', + 'intel_tor_detection', 'intel_unencrypted_passwords', 'intel_vpn_detection', + ]; + const counts = {}; + + for (const t of tables) { + try { + if (!agentUuid) { + // Admin: count global (agent_uuid IS NULL) + counts[t] = d.prepare(`SELECT COUNT(*) as n FROM ${t} WHERE agent_uuid IS NULL`).get()?.n ?? 0; + } else { + // Agent mode: try agent_uuid directly first + const directCount = d.prepare(`SELECT COUNT(*) as n FROM ${t} WHERE agent_uuid = ?`).get(agentUuid)?.n ?? 0; + if (directCount > 0) { + counts[t] = directCount; + } else if (AGENT_MAC_MAP[agentUuid]) { + // Fallback MAC-based + const macs = AGENT_MAC_MAP[agentUuid]; + const placeholders = macs.map(() => '?').join(','); + const ipField = t === 'intel_ip_reputation' ? 'local_ip' : 'ip_address'; + counts[t] = d.prepare(` + SELECT COUNT(*) as n FROM ${t} + WHERE mac_address IN (${placeholders}) OR ${ipField} IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders})) + `).get(...macs, ...macs)?.n ?? 0; + } else { + counts[t] = 0; + } + } + } catch { counts[t] = 0; } + } + return counts; +} + +function getDataInterval() { + const d = getDB(); + const row = d.prepare("SELECT MIN(fetched_at) as start_t, MAX(fetched_at) as end_t FROM devices").get(); + return { + start: row?.start_t || null, + end: row?.end_t || null + }; +} + +module.exports = { + getUserByUsername, + getUserByAgentUuid, + updateUserPassword, + updateUserUsername, + updateUserAccountName, + updateUserProfilePicture, + // Admin user management + getAllUsers, + getUserById, + createAgentUser, + adminUpdateUser, + deleteAgentUser, + syncAgentUsers, + getDB, + getDataInterval, + insertBandwidthApps, insertDevices, insertFlows, insertThreats, + insertProtocols, insertCountries, insertDNS, insertEvents, + insertBandwidthTimeline, + getLatestBandwidthApps, getLatestDevices, getLatestFlows, getLatestThreats, + getLatestProtocols, getLatestCountries, getLatestDNS, getLatestEvents, + getBandwidthTimeline, getStats, + // Insert baru + insertAppCategories, insertContinents, insertRegions, insertCities, + insertVLANs, insertInterfaces, insertFlowTypes, insertFlowOrigins, + insertIPVersions, insertRemoteIPs, insertMACBandwidth, + // Query helper + getLatest, + insertTLSVersions, insertTLSCiphers, insertTLSSecurity, insertNetBIOSHostnames, + insertDiscoveryOS, + // DPI 12-21 + insertDHCPFingerprints, insertHTTPUserAgents, insertSNIHostnames, insertSSLServerCN, + insertQUICHostnames, insertBitTorrentHashes, insertSSHVersions, insertMDNSHostnames, + // Intelligence 22-30 + insertCryptoMining, insertDeviceDiscovery, insertEncryptionAudit, + insertInsecureProtocols, insertIPReputation, insertServerDiscovery, + insertTorDetection, insertUnencryptedPasswords, insertVPNDetection, + getIntelData, getIntelStats, +}; + +// ─── AUTHENTICATION ───────────────────────────────────────────────────────── +function getUserByUsername(username) { + return getDB().prepare('SELECT * FROM users WHERE username = ?').get(username); +} + +// Returns the canonical user for an agent_uuid (prefers the one with account_name set) +function getUserByAgentUuid(agentUuid) { + const d = getDB(); + // First: find a user with account_name set for this agent + const withName = d.prepare( + "SELECT * FROM users WHERE agent_uuid = ? AND role = 'AGENT_VIEWER' AND account_name IS NOT NULL ORDER BY id ASC LIMIT 1" + ).get(agentUuid); + if (withName) return withName; + // Fallback: any user for this agent + return d.prepare( + "SELECT * FROM users WHERE agent_uuid = ? AND role = 'AGENT_VIEWER' ORDER BY id ASC LIMIT 1" + ).get(agentUuid); +} + +function updateUserPassword(userId, newPasswordHash) { + return getDB().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(newPasswordHash, userId); +} + +function updateUserUsername(userId, newUsername) { + return getDB().prepare('UPDATE users SET username = ? WHERE id = ?').run(newUsername, userId); +} + +function updateUserAccountName(userId, newAccountName) { + return getDB().prepare('UPDATE users SET account_name = ? WHERE id = ?').run(newAccountName, userId); +} + +function updateUserProfilePicture(userId, filename) { + return getDB().prepare('UPDATE users SET profile_picture = ? WHERE id = ?').run(filename, userId); +} + +// ─── ADMIN USER MANAGEMENT ────────────────────────────────────────────────── + +function getAllUsers() { + return getDB().prepare( + 'SELECT id, username, role, site_uuid, agent_uuid, account_name, profile_picture, created_at FROM users ORDER BY role DESC, id ASC' + ).all(); +} + +function getUserById(userId) { + return getDB().prepare( + 'SELECT id, username, role, site_uuid, agent_uuid, account_name, profile_picture, created_at FROM users WHERE id = ?' + ).get(userId); +} + +function createAgentUser(username, passwordHash, accountName, agentUuid, siteUuid) { + const d = getDB(); + // Check username unique + const existing = d.prepare('SELECT id FROM users WHERE username = ?').get(username); + if (existing) throw new Error('Username sudah digunakan'); + return d.prepare( + 'INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid, account_name) VALUES (?, ?, ?, ?, ?, ?)' + ).run(username, passwordHash, 'AGENT_VIEWER', siteUuid || null, agentUuid || null, accountName || null); +} + +function adminUpdateUser(userId, fields) { + const d = getDB(); + const allowed = ['username', 'password_hash', 'account_name', 'agent_uuid', 'profile_picture']; + const sets = []; + const vals = []; + for (const [k, v] of Object.entries(fields)) { + if (allowed.includes(k) && v !== undefined) { + sets.push(`${k} = ?`); + vals.push(v); + } + } + if (sets.length === 0) return { changes: 0 }; + vals.push(userId); + return d.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...vals); +} + +function deleteAgentUser(userId) { + const d = getDB(); + // Prevent deleting SUPER_ADMIN + const user = d.prepare('SELECT role FROM users WHERE id = ?').get(userId); + if (!user) throw new Error('User tidak ditemukan'); + if (user.role === 'SUPER_ADMIN') throw new Error('Tidak bisa menghapus akun SUPER_ADMIN'); + return d.prepare('DELETE FROM users WHERE id = ?').run(userId); +} + +function syncAgentUsers(agents) { + const d = getDB(); + const bcrypt = require('bcryptjs'); + const hash = bcrypt.hashSync('123', 10); + const siteUuid = process.env.NETIFY_SITE_UUID || '6681452d_9cae_4ff4_8ae8_0d504774265e'; + + // Hardcoded labels map for friendly user mapping + const AGENT_LABELS = { + '2F-TF-1D-GK': 'JRP Cibubur', + '8A-V3-PB-85': 'IFG LT.18', + 'F6-2V-DT-8A': 'CPI Balaraja', + '1R-79-J9-YE': 'CPI Balaraja WAN' + }; + + for (const agent of agents) { + const uuid = agent.uuid; + if (!uuid) continue; + const label = AGENT_LABELS[uuid] || agent.label || uuid; + + // Create username = lowercase uuid (e.g. '1r-79-j9-ye') + const usernameUuidLower = uuid.toLowerCase(); + const exists1 = d.prepare("SELECT count(*) as count FROM users WHERE username = ?").get(usernameUuidLower); + if (exists1.count === 0) { + d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)") + .run(usernameUuidLower, hash, 'AGENT_VIEWER', siteUuid, uuid); + console.log(`[DB] Created default user: ${usernameUuidLower} / 123`); + } + + // Create username = uppercase uuid (e.g. '1R-79-J9-YE') + const usernameUuidUpper = uuid.toUpperCase(); + const exists1u = d.prepare("SELECT count(*) as count FROM users WHERE username = ?").get(usernameUuidUpper); + if (exists1u.count === 0) { + d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)") + .run(usernameUuidUpper, hash, 'AGENT_VIEWER', siteUuid, uuid); + console.log(`[DB] Created default user: ${usernameUuidUpper} / 123`); + } + + // Create username = sanitized lowercase label (e.g. 'agent_cpi_balaraja_wan') + const sanitizedLabel = label.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_'); + const usernameLabel = sanitizedLabel.startsWith('agent_') ? sanitizedLabel : `agent_${sanitizedLabel}`; + const exists2 = d.prepare("SELECT count(*) as count FROM users WHERE username = ?").get(usernameLabel); + if (exists2.count === 0) { + d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)") + .run(usernameLabel, hash, 'AGENT_VIEWER', siteUuid, uuid); + console.log(`[DB] Created default user: ${usernameLabel} / 123`); + } + + // Create username = name/label directly (e.g. 'CPI Balaraja WAN' -> 'CPI Balaraja WAN' or 'JRP Cibubur') + const exists3 = d.prepare("SELECT count(*) as count FROM users WHERE username = ?").get(label); + if (exists3.count === 0) { + d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)") + .run(label, hash, 'AGENT_VIEWER', siteUuid, uuid); + console.log(`[DB] Created default user: ${label} / 123`); + } + } +} diff --git a/backend/db/mongoose.js b/backend/db/mongoose.js index 35991f0..73ef8e1 100644 --- a/backend/db/mongoose.js +++ b/backend/db/mongoose.js @@ -5,7 +5,8 @@ const mongoose = require('mongoose'); const path = require('path'); -require('dotenv').config({ path: path.join(__dirname, '../../.env.local') }); +const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.local'; +require('dotenv').config({ path: path.join(__dirname, '../../', envFile) }); const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi'; @@ -22,7 +23,7 @@ async function connectDB() { serverSelectionTimeoutMS: 10000, connectTimeoutMS: 10000, }); - console.log('[MongoDB] ✓ Connected successfully'); + console.log('[MongoDB] ✓ Connected successfully to', MONGODB_URI); const { logCapacityStats } = require('./capacityTracker'); logCapacityStats('[MongoDB]').catch(err => console.warn('[MongoDB] Capacity log failed:', err.message)); return; diff --git a/backend/generate_export.js b/backend/generate_export.js index 335f4be..c3e8d94 100644 --- a/backend/generate_export.js +++ b/backend/generate_export.js @@ -1,400 +1,400 @@ -/** - * generate_export.js - * - * Mengekspor SELURUH data dari semua tabel SQLite (database) - * ke dalam file backone_data_export.txt - * - * Format output: - * - Header metadata (tanggal, versi, jumlah tabel) - * - Untuk setiap tabel: header section, row count, schema, dan semua data (JSON per baris) - * - Footer summary - */ - -const fs = require('fs'); -const path = require('path'); -const db = require('./database'); - -const OUTPUT_FILE = path.join(__dirname, '../backone_data_export.txt'); -const d = db.getDB(); - -// ─── Helpers ──────────────────────────────────────────────────────────────── -function fmtBytes(bytes) { - if (!bytes || bytes === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let b = Math.abs(bytes); - let i = 0; - while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; } - return b.toFixed(2) + ' ' + units[i]; -} - -function fmtNum(n) { - if (n == null) return 'N/A'; - return Number(n).toLocaleString('id-ID'); -} - -function separator(char = '═', len = 80) { - return char.repeat(len); -} - -function sectionHeader(tableName, rowCount, description) { - return [ - '', - separator('═'), - `[TABLE: ${tableName}]`, - `Row Count: ${fmtNum(rowCount)}`, - description ? `Description: ${description}` : '', - separator('─'), - ].filter(l => l !== '').join('\n'); -} - -// ─── Table descriptions ────────────────────────────────────────────────────── -const TABLE_DESCRIPTIONS = { - bandwidth_apps : 'Bandwidth per aplikasi (YouTube, Facebook, dll) dari BackOne DPI', - bandwidth_timeline : 'Timeline bandwidth per menit (download/upload historis)', - bittorrent_info_hashes: 'Deteksi aktivitas BitTorrent berdasarkan info hash', - countries : 'Distribusi traffic berdasarkan negara tujuan', - devices : 'Daftar perangkat (IP/MAC) beserta bandwidth & OS', - dhcp_fingerprints : 'Fingerprint DHCP untuk identifikasi tipe device', - discovered_os : 'OS yang terdeteksi dari traffic scanning', - dns_stats : 'Query DNS teratas dan statistik resolusi domain', - events : 'Event log dari BackOne agent (koneksi, peringatan, dll)', - flows : 'Data aliran jaringan per-sesi (src IP, dst IP, aplikasi, domain, bytes)', - flow_origins : 'Asal flow: lokal (LAN) atau eksternal (WAN)', - flow_types : 'Tipe flow: TCP, UDP, ICMP, dll', - http_user_agents : 'HTTP User-Agent yang terdeteksi (browser, OS, framework)', - intel_crypto_mining : 'Deteksi aktivitas crypto mining (pool host, protokol)', - intel_device_discovery: 'Penemuan perangkat baru di jaringan (tipe, OS, manufaktur)', - intel_encryption_audit: 'Audit enkripsi traffic per perangkat (encrypted%, risk level)', - intel_insecure_protocols: 'Protokol tidak aman yang terdeteksi (HTTP, Telnet, FTP, dll)', - intel_ip_reputation : 'Reputasi IP eksternal (blacklist, threat score)', - intel_server_discovery: 'Server yang terdeteksi (HTTPS, SSH, HTTP, dll)', - intel_tor_detection : 'Deteksi penggunaan jaringan Tor', - intel_unencrypted_passwords: 'Deteksi pengiriman password dalam bentuk plaintext', - intel_vpn_detection : 'Deteksi penggunaan VPN (OpenVPN, WireGuard, dll)', - interfaces : 'Interface jaringan per agent (WAN/LAN, bandwidth)', - ip_versions : 'Distribusi traffic IPv4 vs IPv6', - mac_bandwidth : 'Bandwidth per MAC address perangkat', - mdns_hostnames : 'mDNS hostname yang terdeteksi di jaringan lokal', - netbios_hostnames : 'NetBIOS hostname (nama komputer Windows)', - protocols : 'Distribusi protokol jaringan (port usage)', - quic_hostnames : 'Hostname via QUIC/HTTP3 (Google, Cloudflare, dll)', - regions : 'Distribusi traffic berdasarkan region/kota tujuan', - remote_ips : 'IP remote teratas yang diakses perangkat', - sni_hostnames : 'Server Name Indication dari koneksi TLS', - ssh_versions : 'Versi SSH yang terdeteksi di jaringan', - ssl_server_cn : 'Common Name sertifikat SSL server', - threats : 'Ancaman keamanan terdeteksi (threat alerts)', - tls_ciphers : 'Cipher suite TLS yang digunakan', - tls_security : 'Tingkat keamanan TLS (Modern, Compatible, Old)', - tls_versions : 'Versi TLS yang digunakan (1.0, 1.2, 1.3)', - vlans : 'VLAN yang terdeteksi di jaringan', -}; - -// ─── Main Export Logic ─────────────────────────────────────────────────────── -async function main() { - console.log('🚀 Memulai export data...'); - - const exportDate = new Date().toISOString(); - const lines = []; - - // ── File Header ────────────────────────────────────────────────────────── - lines.push(separator('═')); - lines.push(' BACKONE DATA EXPORT'); - lines.push(' Seluruh data hasil parsing dari BackOne API'); - lines.push(separator('─')); - lines.push(` Export Date: ${exportDate}`); - lines.push(` Generated by: generate_export.js`); - lines.push(` Source: database (SQLite lokal)`); - lines.push(` API Base: BackOne API Service`); - lines.push(` Format: Per-tabel, data JSON satu record per baris (JSONL)`); - lines.push(separator('─')); - - // ── Get all tables ──────────────────────────────────────────────────────── - const tables = d.prepare( - "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" - ).all().map(r => r.name); - - lines.push(` Total Tables: ${tables.length}`); - lines.push(separator('═')); - lines.push(''); - - // ── Table of Contents ───────────────────────────────────────────────────── - lines.push('TABLE OF CONTENTS'); - lines.push(separator('─', 40)); - let totalRows = 0; - const tableSummaries = []; - for (const tableName of tables) { - const cnt = d.prepare(`SELECT COUNT(*) as c FROM ${tableName}`).get().c; - totalRows += cnt; - const desc = TABLE_DESCRIPTIONS[tableName] || '-'; - lines.push(` ${tableName.padEnd(35)} ${String(cnt).padStart(8)} rows`); - tableSummaries.push({ name: tableName, count: cnt, description: desc }); - } - lines.push(separator('─', 40)); - lines.push(` ${'TOTAL'.padEnd(35)} ${String(totalRows).padStart(8)} rows`); - lines.push(''); - - // ── Per-Table Export ────────────────────────────────────────────────────── - for (const { name: tableName, count, description } of tableSummaries) { - console.log(` 📋 Exporting: ${tableName} (${fmtNum(count)} rows)...`); - - // Section header - lines.push(sectionHeader(tableName, count, description)); - - // Schema - const cols = d.prepare(`PRAGMA table_info(${tableName})`).all(); - lines.push('Schema:'); - cols.forEach(c => { - lines.push(` - ${c.name} [${c.type || 'TEXT'}]${c.notnull ? ' NOT NULL' : ''}${c.pk ? ' PRIMARY KEY' : ''}`); - }); - lines.push(''); - - // Statistics for numeric columns - const numericCols = cols.filter(c => - ['INTEGER', 'REAL', 'NUMERIC'].includes((c.type || '').toUpperCase()) && - !['id'].includes(c.name.toLowerCase()) - ); - - if (count > 0 && numericCols.length > 0) { - lines.push('Statistics:'); - for (const col of numericCols.slice(0, 5)) { // max 5 numeric cols - try { - const stat = d.prepare(` - SELECT MIN(${col.name}) as min, MAX(${col.name}) as max, - AVG(${col.name}) as avg, SUM(${col.name}) as total - FROM ${tableName} - `).get(); - if (stat && stat.max !== null) { - lines.push(` ${col.name}: min=${fmtNum(stat.min)} max=${fmtNum(stat.max)} avg=${Number(stat.avg || 0).toFixed(2)} total=${fmtNum(stat.total)}`); - } - } catch(e) { /* skip */ } - } - lines.push(''); - } - - // Data rows (ALL rows) - if (count === 0) { - lines.push('(No data)'); - } else { - lines.push(`Data (${fmtNum(count)} records):`); - const rows = d.prepare(`SELECT * FROM ${tableName}`).all(); - for (const row of rows) { - lines.push(JSON.stringify(row)); - } - } - lines.push(''); - } - - // ── Agent-specific sections (derived from flows) ─────────────────────────── - lines.push(''); - lines.push(separator('═')); - lines.push('[DERIVED: AGENT ANALYSIS]'); - lines.push('Description: Analisis traffic per agent berdasarkan MAC address dari flows table'); - lines.push(separator('─')); - - const AGENT_MAC_MAP = { - '2F-TF-1D-GK': { label: 'JRP Cibubur', macs: ['60:be:b4:1f:05:96'] }, - '8A-V3-PB-85': { label: 'IFG LT.18', macs: ['04:f4:1c:ce:c2:e6'] }, - 'F6-2V-DT-8A': { label: 'CPI Balaraja', macs: ['2c:7b:a0:d8:86:91', '16:11:ac:73:34:1d', 'bc:45:5b:ca:d5:be', 'de:ed:cc:57:58:34', 'f4:6d:3f:ef:01:a0', '60:be:b4:29:d3:36'] }, - }; - - for (const [uuid, agent] of Object.entries(AGENT_MAC_MAP)) { - lines.push(''); - lines.push(`Agent: ${agent.label} (${uuid})`); - lines.push(`MACs: ${agent.macs.join(', ')}`); - lines.push(separator('─', 40)); - - const ph = agent.macs.map(() => '?').join(','); - - // Summary - const sumRow = d.prepare(` - SELECT COUNT(DISTINCT src_ip) AS device_count, COUNT(*) AS flow_count, - SUM(bytes_download) AS total_dl, SUM(bytes_upload) AS total_ul - FROM flows WHERE src_mac IN (${ph}) - `).get(...agent.macs); - - lines.push(` Devices: ${fmtNum(sumRow.device_count)}`); - lines.push(` Total Flows: ${fmtNum(sumRow.flow_count)}`); - lines.push(` Total Download: ${fmtBytes(sumRow.total_dl)}`); - lines.push(` Total Upload: ${fmtBytes(sumRow.total_ul)}`); - - // Top apps - const apps = d.prepare(` - SELECT app_label, SUM(bytes_download) AS dl, SUM(bytes_upload) AS ul, COUNT(*) AS cnt - FROM flows WHERE src_mac IN (${ph}) AND app_label IS NOT NULL - GROUP BY app_label ORDER BY dl DESC LIMIT 10 - `).all(...agent.macs); - - lines.push(` Top Applications:`); - apps.forEach((a, i) => { - lines.push(` ${String(i+1).padStart(2)}. ${(a.app_label||'?').padEnd(30)} DL:${fmtBytes(a.dl).padStart(12)} UL:${fmtBytes(a.ul).padStart(12)} Flows:${a.cnt}`); - }); - - // Top devices - const devs = d.prepare(` - SELECT src_ip, SUM(bytes_download) AS dl, MAX(last_seen) AS last - FROM flows WHERE src_mac IN (${ph}) - GROUP BY src_ip ORDER BY dl DESC LIMIT 10 - `).all(...agent.macs); - - lines.push(` Top Devices:`); - devs.forEach((d2, i) => { - lines.push(` ${String(i+1).padStart(2)}. ${(d2.src_ip||'?').padEnd(20)} DL:${fmtBytes(d2.dl).padStart(12)} Last:${d2.last||'-'}`); - }); - } - - // ── Bandwidth Apps Summary ───────────────────────────────────────────────── - lines.push(''); - lines.push(separator('═')); - lines.push('[DERIVED: BANDWIDTH APPS LATEST SNAPSHOT]'); - lines.push('Description: Snapshot terakhir bandwidth per aplikasi (nilai aktual, bukan akumulasi)'); - lines.push(separator('─')); - - const latestBwSnap = d.prepare('SELECT MAX(fetched_at) as t FROM bandwidth_apps').get()?.t; - if (latestBwSnap) { - lines.push(`Latest Snapshot: ${latestBwSnap}`); - const bwApps = d.prepare('SELECT app_label, category, download, upload, total, flow_count FROM bandwidth_apps WHERE fetched_at = ? ORDER BY download DESC').all(latestBwSnap); - lines.push(`Total Apps: ${bwApps.length}`); - lines.push(''); - bwApps.forEach((a, i) => { - lines.push(` ${String(i+1).padStart(3)}. ${(a.app_label||'?').padEnd(30)} [${(a.category||'?').padEnd(20)}] DL:${fmtBytes(a.download).padStart(12)} UL:${fmtBytes(a.upload).padStart(12)} Flows:${fmtNum(a.flow_count)}`); - }); - } - - // ── Encryption Audit Summary ─────────────────────────────────────────────── - lines.push(''); - lines.push(separator('═')); - lines.push('[DERIVED: ENCRYPTION RISK SUMMARY]'); - lines.push('Description: Distribusi risk level enkripsi per perangkat (snapshot terbaru)'); - lines.push(separator('─')); - - const latestEncSnap = d.prepare('SELECT MAX(fetched_at) as t FROM intel_encryption_audit').get()?.t; - if (latestEncSnap) { - const riskDist = d.prepare(` - SELECT risk_level, COUNT(*) as cnt, AVG(encrypted_pct) as avg_enc - FROM intel_encryption_audit WHERE fetched_at = ? - GROUP BY risk_level ORDER BY cnt DESC - `).all(latestEncSnap); - - lines.push(`Latest Snapshot: ${latestEncSnap}`); - lines.push('Risk Distribution:'); - riskDist.forEach(r => { - lines.push(` ${(r.risk_level||'Unknown').padEnd(15)} ${String(r.cnt).padStart(5)} devices avg encrypted: ${Number(r.avg_enc||0).toFixed(1)}%`); - }); - - // Highest risk devices - lines.push(''); - lines.push('Critical Risk Devices (0% encrypted):'); - const critDevs = d.prepare(` - SELECT ip_address, mac_address, device_label, encrypted_pct, total - FROM intel_encryption_audit WHERE fetched_at = ? AND risk_level = 'Critical' - ORDER BY total DESC LIMIT 20 - `).all(latestEncSnap); - critDevs.forEach(r => { - lines.push(JSON.stringify(r)); - }); - } - - // ── DNS Top Domains ──────────────────────────────────────────────────────── - lines.push(''); - lines.push(separator('═')); - lines.push('[DERIVED: TOP DNS DOMAINS]'); - lines.push('Description: Domain paling sering diquery dari DNS stats'); - lines.push(separator('─')); - - const latestDnsSnap = d.prepare('SELECT MAX(fetched_at) as t FROM dns_queries').get()?.t; - if (latestDnsSnap) { - const dnsRows = d.prepare('SELECT * FROM dns_queries WHERE fetched_at = ? ORDER BY query_count DESC LIMIT 30').all(latestDnsSnap); - - lines.push(`Latest Snapshot: ${latestDnsSnap}`); - dnsRows.forEach(r => lines.push(JSON.stringify(r))); - } - - // ── IP Reputation Blacklisted ───────────────────────────────────────────── - lines.push(''); - lines.push(separator('═')); - lines.push('[DERIVED: BLACKLISTED IP ADDRESSES]'); - lines.push('Description: IP address yang terdeteksi blacklisted (dari intel_ip_reputation)'); - lines.push(separator('─')); - - const latestRepSnap = d.prepare('SELECT MAX(fetched_at) as t FROM intel_ip_reputation').get()?.t; - if (latestRepSnap) { - const blacklisted = d.prepare('SELECT * FROM intel_ip_reputation WHERE fetched_at = ? AND blacklisted = 1').all(latestRepSnap); - lines.push(`Latest Snapshot: ${latestRepSnap}`); - lines.push(`Blacklisted count: ${blacklisted.length}`); - blacklisted.forEach(r => lines.push(JSON.stringify(r))); - } - - // ── Flows: Active Sessions Summary ──────────────────────────────────────── - lines.push(''); - lines.push(separator('═')); - lines.push('[DERIVED: ACTIVE FLOWS SUMMARY]'); - lines.push('Description: Ringkasan aliran jaringan aktif (50 terbaru per download)'); - lines.push(separator('─')); - - const flowSummary = d.prepare(` - SELECT COUNT(*) as total_flows, - COUNT(DISTINCT src_ip) as unique_src_ips, - COUNT(DISTINCT dst_ip) as unique_dst_ips, - COUNT(DISTINCT src_mac) as unique_macs, - SUM(bytes_download) as total_dl, - SUM(bytes_upload) as total_ul, - MIN(first_seen) as earliest, - MAX(last_seen) as latest - FROM flows - `).get(); - - lines.push(`Total Flows in DB: ${fmtNum(flowSummary.total_flows)}`); - lines.push(`Unique Source IPs: ${fmtNum(flowSummary.unique_src_ips)}`); - lines.push(`Unique Dest IPs: ${fmtNum(flowSummary.unique_dst_ips)}`); - lines.push(`Unique MAC Addresses: ${fmtNum(flowSummary.unique_macs)}`); - lines.push(`Total Download: ${fmtBytes(flowSummary.total_dl)}`); - lines.push(`Total Upload: ${fmtBytes(flowSummary.total_ul)}`); - lines.push(`Data from: ${flowSummary.earliest}`); - lines.push(`Data to: ${flowSummary.latest}`); - lines.push(''); - - // Top 50 flows by download - lines.push('Top 50 Flows by Download:'); - const topFlows = d.prepare('SELECT * FROM flows ORDER BY bytes_download DESC LIMIT 50').all(); - topFlows.forEach(r => lines.push(JSON.stringify(r))); - - // ── Unencrypted Password Events ─────────────────────────────────────────── - lines.push(''); - lines.push(separator('═')); - lines.push('[DERIVED: UNENCRYPTED PASSWORD DETECTIONS]'); - lines.push('Description: Kejadian pengiriman credential dalam plaintext (HIGH severity)'); - lines.push(separator('─')); - - const unencPwdHigh = d.prepare(` - SELECT ip_address, mac_address, dst_ip, dst_port, protocol, username, download, upload, severity, detected_at - FROM intel_unencrypted_passwords ORDER BY detected_at DESC - `).all(); - lines.push(`Total detections: ${unencPwdHigh.length}`); - unencPwdHigh.forEach(r => lines.push(JSON.stringify(r))); - - // ── Footer ──────────────────────────────────────────────────────────────── - lines.push(''); - lines.push(separator('═')); - lines.push(' END OF EXPORT'); - lines.push(` Generated at: ${new Date().toISOString()}`); - lines.push(` Total lines: ${lines.length + 3}`); - lines.push(separator('═')); - - // Write to file - const output = lines.join('\n'); - fs.writeFileSync(OUTPUT_FILE, output, 'utf-8'); - - const stats = fs.statSync(OUTPUT_FILE); - console.log(`\n✅ Export selesai!`); - console.log(` File: ${OUTPUT_FILE}`); - console.log(` Size: ${fmtBytes(stats.size)}`); - console.log(` Lines: ${fmtNum(lines.length)}`); - console.log(` Tables: ${tables.length}`); - console.log(` Total Rows: ${fmtNum(totalRows)}`); -} - -main().catch(e => { - console.error('❌ Export FAILED:', e); - process.exit(1); -}); +/** + * generate_export.js + * + * Mengekspor SELURUH data dari semua tabel SQLite (database) + * ke dalam file backone_data_export.txt + * + * Format output: + * - Header metadata (tanggal, versi, jumlah tabel) + * - Untuk setiap tabel: header section, row count, schema, dan semua data (JSON per baris) + * - Footer summary + */ + +const fs = require('fs'); +const path = require('path'); +const db = require('./database'); + +const OUTPUT_FILE = path.join(__dirname, '../backone_data_export.txt'); +const d = db.getDB(); + +// ─── Helpers ──────────────────────────────────────────────────────────────── +function fmtBytes(bytes) { + if (!bytes || bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let b = Math.abs(bytes); + let i = 0; + while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; } + return b.toFixed(2) + ' ' + units[i]; +} + +function fmtNum(n) { + if (n == null) return 'N/A'; + return Number(n).toLocaleString('id-ID'); +} + +function separator(char = '═', len = 80) { + return char.repeat(len); +} + +function sectionHeader(tableName, rowCount, description) { + return [ + '', + separator('═'), + `[TABLE: ${tableName}]`, + `Row Count: ${fmtNum(rowCount)}`, + description ? `Description: ${description}` : '', + separator('─'), + ].filter(l => l !== '').join('\n'); +} + +// ─── Table descriptions ────────────────────────────────────────────────────── +const TABLE_DESCRIPTIONS = { + bandwidth_apps : 'Bandwidth per aplikasi (YouTube, Facebook, dll) dari BackOne DPI', + bandwidth_timeline : 'Timeline bandwidth per menit (download/upload historis)', + bittorrent_info_hashes: 'Deteksi aktivitas BitTorrent berdasarkan info hash', + countries : 'Distribusi traffic berdasarkan negara tujuan', + devices : 'Daftar perangkat (IP/MAC) beserta bandwidth & OS', + dhcp_fingerprints : 'Fingerprint DHCP untuk identifikasi tipe device', + discovered_os : 'OS yang terdeteksi dari traffic scanning', + dns_stats : 'Query DNS teratas dan statistik resolusi domain', + events : 'Event log dari BackOne agent (koneksi, peringatan, dll)', + flows : 'Data aliran jaringan per-sesi (src IP, dst IP, aplikasi, domain, bytes)', + flow_origins : 'Asal flow: lokal (LAN) atau eksternal (WAN)', + flow_types : 'Tipe flow: TCP, UDP, ICMP, dll', + http_user_agents : 'HTTP User-Agent yang terdeteksi (browser, OS, framework)', + intel_crypto_mining : 'Deteksi aktivitas crypto mining (pool host, protokol)', + intel_device_discovery: 'Penemuan perangkat baru di jaringan (tipe, OS, manufaktur)', + intel_encryption_audit: 'Audit enkripsi traffic per perangkat (encrypted%, risk level)', + intel_insecure_protocols: 'Protokol tidak aman yang terdeteksi (HTTP, Telnet, FTP, dll)', + intel_ip_reputation : 'Reputasi IP eksternal (blacklist, threat score)', + intel_server_discovery: 'Server yang terdeteksi (HTTPS, SSH, HTTP, dll)', + intel_tor_detection : 'Deteksi penggunaan jaringan Tor', + intel_unencrypted_passwords: 'Deteksi pengiriman password dalam bentuk plaintext', + intel_vpn_detection : 'Deteksi penggunaan VPN (OpenVPN, WireGuard, dll)', + interfaces : 'Interface jaringan per agent (WAN/LAN, bandwidth)', + ip_versions : 'Distribusi traffic IPv4 vs IPv6', + mac_bandwidth : 'Bandwidth per MAC address perangkat', + mdns_hostnames : 'mDNS hostname yang terdeteksi di jaringan lokal', + netbios_hostnames : 'NetBIOS hostname (nama komputer Windows)', + protocols : 'Distribusi protokol jaringan (port usage)', + quic_hostnames : 'Hostname via QUIC/HTTP3 (Google, Cloudflare, dll)', + regions : 'Distribusi traffic berdasarkan region/kota tujuan', + remote_ips : 'IP remote teratas yang diakses perangkat', + sni_hostnames : 'Server Name Indication dari koneksi TLS', + ssh_versions : 'Versi SSH yang terdeteksi di jaringan', + ssl_server_cn : 'Common Name sertifikat SSL server', + threats : 'Ancaman keamanan terdeteksi (threat alerts)', + tls_ciphers : 'Cipher suite TLS yang digunakan', + tls_security : 'Tingkat keamanan TLS (Modern, Compatible, Old)', + tls_versions : 'Versi TLS yang digunakan (1.0, 1.2, 1.3)', + vlans : 'VLAN yang terdeteksi di jaringan', +}; + +// ─── Main Export Logic ─────────────────────────────────────────────────────── +async function main() { + console.log('🚀 Memulai export data...'); + + const exportDate = new Date().toISOString(); + const lines = []; + + // ── File Header ────────────────────────────────────────────────────────── + lines.push(separator('═')); + lines.push(' BACKONE DATA EXPORT'); + lines.push(' Seluruh data hasil parsing dari BackOne API'); + lines.push(separator('─')); + lines.push(` Export Date: ${exportDate}`); + lines.push(` Generated by: generate_export.js`); + lines.push(` Source: database (SQLite lokal)`); + lines.push(` API Base: BackOne API Service`); + lines.push(` Format: Per-tabel, data JSON satu record per baris (JSONL)`); + lines.push(separator('─')); + + // ── Get all tables ──────────────────────────────────────────────────────── + const tables = d.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ).all().map(r => r.name); + + lines.push(` Total Tables: ${tables.length}`); + lines.push(separator('═')); + lines.push(''); + + // ── Table of Contents ───────────────────────────────────────────────────── + lines.push('TABLE OF CONTENTS'); + lines.push(separator('─', 40)); + let totalRows = 0; + const tableSummaries = []; + for (const tableName of tables) { + const cnt = d.prepare(`SELECT COUNT(*) as c FROM ${tableName}`).get().c; + totalRows += cnt; + const desc = TABLE_DESCRIPTIONS[tableName] || '-'; + lines.push(` ${tableName.padEnd(35)} ${String(cnt).padStart(8)} rows`); + tableSummaries.push({ name: tableName, count: cnt, description: desc }); + } + lines.push(separator('─', 40)); + lines.push(` ${'TOTAL'.padEnd(35)} ${String(totalRows).padStart(8)} rows`); + lines.push(''); + + // ── Per-Table Export ────────────────────────────────────────────────────── + for (const { name: tableName, count, description } of tableSummaries) { + console.log(` 📋 Exporting: ${tableName} (${fmtNum(count)} rows)...`); + + // Section header + lines.push(sectionHeader(tableName, count, description)); + + // Schema + const cols = d.prepare(`PRAGMA table_info(${tableName})`).all(); + lines.push('Schema:'); + cols.forEach(c => { + lines.push(` - ${c.name} [${c.type || 'TEXT'}]${c.notnull ? ' NOT NULL' : ''}${c.pk ? ' PRIMARY KEY' : ''}`); + }); + lines.push(''); + + // Statistics for numeric columns + const numericCols = cols.filter(c => + ['INTEGER', 'REAL', 'NUMERIC'].includes((c.type || '').toUpperCase()) && + !['id'].includes(c.name.toLowerCase()) + ); + + if (count > 0 && numericCols.length > 0) { + lines.push('Statistics:'); + for (const col of numericCols.slice(0, 5)) { // max 5 numeric cols + try { + const stat = d.prepare(` + SELECT MIN(${col.name}) as min, MAX(${col.name}) as max, + AVG(${col.name}) as avg, SUM(${col.name}) as total + FROM ${tableName} + `).get(); + if (stat && stat.max !== null) { + lines.push(` ${col.name}: min=${fmtNum(stat.min)} max=${fmtNum(stat.max)} avg=${Number(stat.avg || 0).toFixed(2)} total=${fmtNum(stat.total)}`); + } + } catch(e) { /* skip */ } + } + lines.push(''); + } + + // Data rows (ALL rows) + if (count === 0) { + lines.push('(No data)'); + } else { + lines.push(`Data (${fmtNum(count)} records):`); + const rows = d.prepare(`SELECT * FROM ${tableName}`).all(); + for (const row of rows) { + lines.push(JSON.stringify(row)); + } + } + lines.push(''); + } + + // ── Agent-specific sections (derived from flows) ─────────────────────────── + lines.push(''); + lines.push(separator('═')); + lines.push('[DERIVED: AGENT ANALYSIS]'); + lines.push('Description: Analisis traffic per agent berdasarkan MAC address dari flows table'); + lines.push(separator('─')); + + const AGENT_MAC_MAP = { + '2F-TF-1D-GK': { label: 'JRP Cibubur', macs: ['60:be:b4:1f:05:96'] }, + '8A-V3-PB-85': { label: 'IFG LT.18', macs: ['04:f4:1c:ce:c2:e6'] }, + 'F6-2V-DT-8A': { label: 'CPI Balaraja', macs: ['2c:7b:a0:d8:86:91', '16:11:ac:73:34:1d', 'bc:45:5b:ca:d5:be', 'de:ed:cc:57:58:34', 'f4:6d:3f:ef:01:a0', '60:be:b4:29:d3:36'] }, + }; + + for (const [uuid, agent] of Object.entries(AGENT_MAC_MAP)) { + lines.push(''); + lines.push(`Agent: ${agent.label} (${uuid})`); + lines.push(`MACs: ${agent.macs.join(', ')}`); + lines.push(separator('─', 40)); + + const ph = agent.macs.map(() => '?').join(','); + + // Summary + const sumRow = d.prepare(` + SELECT COUNT(DISTINCT src_ip) AS device_count, COUNT(*) AS flow_count, + SUM(bytes_download) AS total_dl, SUM(bytes_upload) AS total_ul + FROM flows WHERE src_mac IN (${ph}) + `).get(...agent.macs); + + lines.push(` Devices: ${fmtNum(sumRow.device_count)}`); + lines.push(` Total Flows: ${fmtNum(sumRow.flow_count)}`); + lines.push(` Total Download: ${fmtBytes(sumRow.total_dl)}`); + lines.push(` Total Upload: ${fmtBytes(sumRow.total_ul)}`); + + // Top apps + const apps = d.prepare(` + SELECT app_label, SUM(bytes_download) AS dl, SUM(bytes_upload) AS ul, COUNT(*) AS cnt + FROM flows WHERE src_mac IN (${ph}) AND app_label IS NOT NULL + GROUP BY app_label ORDER BY dl DESC LIMIT 10 + `).all(...agent.macs); + + lines.push(` Top Applications:`); + apps.forEach((a, i) => { + lines.push(` ${String(i+1).padStart(2)}. ${(a.app_label||'?').padEnd(30)} DL:${fmtBytes(a.dl).padStart(12)} UL:${fmtBytes(a.ul).padStart(12)} Flows:${a.cnt}`); + }); + + // Top devices + const devs = d.prepare(` + SELECT src_ip, SUM(bytes_download) AS dl, MAX(last_seen) AS last + FROM flows WHERE src_mac IN (${ph}) + GROUP BY src_ip ORDER BY dl DESC LIMIT 10 + `).all(...agent.macs); + + lines.push(` Top Devices:`); + devs.forEach((d2, i) => { + lines.push(` ${String(i+1).padStart(2)}. ${(d2.src_ip||'?').padEnd(20)} DL:${fmtBytes(d2.dl).padStart(12)} Last:${d2.last||'-'}`); + }); + } + + // ── Bandwidth Apps Summary ───────────────────────────────────────────────── + lines.push(''); + lines.push(separator('═')); + lines.push('[DERIVED: BANDWIDTH APPS LATEST SNAPSHOT]'); + lines.push('Description: Snapshot terakhir bandwidth per aplikasi (nilai aktual, bukan akumulasi)'); + lines.push(separator('─')); + + const latestBwSnap = d.prepare('SELECT MAX(fetched_at) as t FROM bandwidth_apps').get()?.t; + if (latestBwSnap) { + lines.push(`Latest Snapshot: ${latestBwSnap}`); + const bwApps = d.prepare('SELECT app_label, category, download, upload, total, flow_count FROM bandwidth_apps WHERE fetched_at = ? ORDER BY download DESC').all(latestBwSnap); + lines.push(`Total Apps: ${bwApps.length}`); + lines.push(''); + bwApps.forEach((a, i) => { + lines.push(` ${String(i+1).padStart(3)}. ${(a.app_label||'?').padEnd(30)} [${(a.category||'?').padEnd(20)}] DL:${fmtBytes(a.download).padStart(12)} UL:${fmtBytes(a.upload).padStart(12)} Flows:${fmtNum(a.flow_count)}`); + }); + } + + // ── Encryption Audit Summary ─────────────────────────────────────────────── + lines.push(''); + lines.push(separator('═')); + lines.push('[DERIVED: ENCRYPTION RISK SUMMARY]'); + lines.push('Description: Distribusi risk level enkripsi per perangkat (snapshot terbaru)'); + lines.push(separator('─')); + + const latestEncSnap = d.prepare('SELECT MAX(fetched_at) as t FROM intel_encryption_audit').get()?.t; + if (latestEncSnap) { + const riskDist = d.prepare(` + SELECT risk_level, COUNT(*) as cnt, AVG(encrypted_pct) as avg_enc + FROM intel_encryption_audit WHERE fetched_at = ? + GROUP BY risk_level ORDER BY cnt DESC + `).all(latestEncSnap); + + lines.push(`Latest Snapshot: ${latestEncSnap}`); + lines.push('Risk Distribution:'); + riskDist.forEach(r => { + lines.push(` ${(r.risk_level||'Unknown').padEnd(15)} ${String(r.cnt).padStart(5)} devices avg encrypted: ${Number(r.avg_enc||0).toFixed(1)}%`); + }); + + // Highest risk devices + lines.push(''); + lines.push('Critical Risk Devices (0% encrypted):'); + const critDevs = d.prepare(` + SELECT ip_address, mac_address, device_label, encrypted_pct, total + FROM intel_encryption_audit WHERE fetched_at = ? AND risk_level = 'Critical' + ORDER BY total DESC LIMIT 20 + `).all(latestEncSnap); + critDevs.forEach(r => { + lines.push(JSON.stringify(r)); + }); + } + + // ── DNS Top Domains ──────────────────────────────────────────────────────── + lines.push(''); + lines.push(separator('═')); + lines.push('[DERIVED: TOP DNS DOMAINS]'); + lines.push('Description: Domain paling sering diquery dari DNS stats'); + lines.push(separator('─')); + + const latestDnsSnap = d.prepare('SELECT MAX(fetched_at) as t FROM dns_queries').get()?.t; + if (latestDnsSnap) { + const dnsRows = d.prepare('SELECT * FROM dns_queries WHERE fetched_at = ? ORDER BY query_count DESC LIMIT 30').all(latestDnsSnap); + + lines.push(`Latest Snapshot: ${latestDnsSnap}`); + dnsRows.forEach(r => lines.push(JSON.stringify(r))); + } + + // ── IP Reputation Blacklisted ───────────────────────────────────────────── + lines.push(''); + lines.push(separator('═')); + lines.push('[DERIVED: BLACKLISTED IP ADDRESSES]'); + lines.push('Description: IP address yang terdeteksi blacklisted (dari intel_ip_reputation)'); + lines.push(separator('─')); + + const latestRepSnap = d.prepare('SELECT MAX(fetched_at) as t FROM intel_ip_reputation').get()?.t; + if (latestRepSnap) { + const blacklisted = d.prepare('SELECT * FROM intel_ip_reputation WHERE fetched_at = ? AND blacklisted = 1').all(latestRepSnap); + lines.push(`Latest Snapshot: ${latestRepSnap}`); + lines.push(`Blacklisted count: ${blacklisted.length}`); + blacklisted.forEach(r => lines.push(JSON.stringify(r))); + } + + // ── Flows: Active Sessions Summary ──────────────────────────────────────── + lines.push(''); + lines.push(separator('═')); + lines.push('[DERIVED: ACTIVE FLOWS SUMMARY]'); + lines.push('Description: Ringkasan aliran jaringan aktif (50 terbaru per download)'); + lines.push(separator('─')); + + const flowSummary = d.prepare(` + SELECT COUNT(*) as total_flows, + COUNT(DISTINCT src_ip) as unique_src_ips, + COUNT(DISTINCT dst_ip) as unique_dst_ips, + COUNT(DISTINCT src_mac) as unique_macs, + SUM(bytes_download) as total_dl, + SUM(bytes_upload) as total_ul, + MIN(first_seen) as earliest, + MAX(last_seen) as latest + FROM flows + `).get(); + + lines.push(`Total Flows in DB: ${fmtNum(flowSummary.total_flows)}`); + lines.push(`Unique Source IPs: ${fmtNum(flowSummary.unique_src_ips)}`); + lines.push(`Unique Dest IPs: ${fmtNum(flowSummary.unique_dst_ips)}`); + lines.push(`Unique MAC Addresses: ${fmtNum(flowSummary.unique_macs)}`); + lines.push(`Total Download: ${fmtBytes(flowSummary.total_dl)}`); + lines.push(`Total Upload: ${fmtBytes(flowSummary.total_ul)}`); + lines.push(`Data from: ${flowSummary.earliest}`); + lines.push(`Data to: ${flowSummary.latest}`); + lines.push(''); + + // Top 50 flows by download + lines.push('Top 50 Flows by Download:'); + const topFlows = d.prepare('SELECT * FROM flows ORDER BY bytes_download DESC LIMIT 50').all(); + topFlows.forEach(r => lines.push(JSON.stringify(r))); + + // ── Unencrypted Password Events ─────────────────────────────────────────── + lines.push(''); + lines.push(separator('═')); + lines.push('[DERIVED: UNENCRYPTED PASSWORD DETECTIONS]'); + lines.push('Description: Kejadian pengiriman credential dalam plaintext (HIGH severity)'); + lines.push(separator('─')); + + const unencPwdHigh = d.prepare(` + SELECT ip_address, mac_address, dst_ip, dst_port, protocol, username, download, upload, severity, detected_at + FROM intel_unencrypted_passwords ORDER BY detected_at DESC + `).all(); + lines.push(`Total detections: ${unencPwdHigh.length}`); + unencPwdHigh.forEach(r => lines.push(JSON.stringify(r))); + + // ── Footer ──────────────────────────────────────────────────────────────── + lines.push(''); + lines.push(separator('═')); + lines.push(' END OF EXPORT'); + lines.push(` Generated at: ${new Date().toISOString()}`); + lines.push(` Total lines: ${lines.length + 3}`); + lines.push(separator('═')); + + // Write to file + const output = lines.join('\n'); + fs.writeFileSync(OUTPUT_FILE, output, 'utf-8'); + + const stats = fs.statSync(OUTPUT_FILE); + console.log(`\n✅ Export selesai!`); + console.log(` File: ${OUTPUT_FILE}`); + console.log(` Size: ${fmtBytes(stats.size)}`); + console.log(` Lines: ${fmtNum(lines.length)}`); + console.log(` Tables: ${tables.length}`); + console.log(` Total Rows: ${fmtNum(totalRows)}`); +} + +main().catch(e => { + console.error('❌ Export FAILED:', e); + process.exit(1); +}); diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index b51659c..6113793 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,5 +1,6 @@ const jwt = require('jsonwebtoken'); const User = require('../models/User'); +const Session = require('../models/Session'); const { Summary } = require('../models/Schemas'); const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-backone-key'; @@ -11,19 +12,46 @@ async function requireAuth(req, res, next) { try { req.user = jwt.verify(token, JWT_SECRET); + // Verify session status in MongoDB + if (req.user.session_id) { + const activeSession = await Session.findById(req.user.session_id); + if (!activeSession) { + res.clearCookie('token'); + return res.status(401).json({ error: 'Sesi login telah dinonaktifkan atau kedaluwarsa.' }); + } + // Update last active + activeSession.last_active = new Date(); + await activeSession.save(); + } + // ── VIEW-AS MODE ────────────────────────────────────────────────────────── const viewAsHeader = req.headers['x-view-as-agent']; - if (viewAsHeader && (req.user.role === 'SUPER_ADMIN' || req.user.role === 'TENANT_ADMIN')) { + const isAllowedViewAs = req.user.role === 'SUPER_ADMIN' || + req.user.role === 'TENANT_ADMIN' || + req.user.role === 'COMPANY_ADMIN' || + req.user.role === 'COMPANY_OPERATOR'; + + if (viewAsHeader && isAllowedViewAs) { try { const viewDecoded = jwt.verify(viewAsHeader, JWT_SECRET); if (viewDecoded.type === 'view-as' && viewDecoded.adminId === req.user.id && viewDecoded.viewAs) { - const targetAgentUser = await User.findOne({ agent_uuid: viewDecoded.viewAs, role: 'AGENT_VIEWER' }).lean(); + const targetAgent = viewDecoded.viewAs; + + // Validation: COMPANY_ADMIN and COMPANY_OPERATOR can only view-as their assigned agents + if (['COMPANY_ADMIN', 'COMPANY_OPERATOR'].includes(req.user.role)) { + const hasAccess = req.user.agent_uuids && req.user.agent_uuids.includes(targetAgent); + if (!hasAccess) { + throw new Error('Unauthorized view-as agent access'); + } + } + + const targetAgentUser = await User.findOne({ agent_uuid: targetAgent, role: 'AGENT_VIEWER' }).lean(); let targetSiteUuid = req.user.site_uuid; if (targetAgentUser && targetAgentUser.site_uuid) { targetSiteUuid = targetAgentUser.site_uuid; } else { - const summaryDoc = await Summary.findOne({ agent_uuid: viewDecoded.viewAs }).lean(); + const summaryDoc = await Summary.findOne({ agent_uuid: targetAgent }).lean(); if (summaryDoc && summaryDoc.site_uuid) { targetSiteUuid = summaryDoc.site_uuid; } @@ -32,7 +60,7 @@ async function requireAuth(req, res, next) { req.user = { ...req.user, role: 'AGENT_VIEWER', - agent_uuid: viewDecoded.viewAs, + agent_uuid: targetAgent, agent_label: viewDecoded.viewAsLabel, site_uuid: targetSiteUuid, _viewAsMode: true, @@ -50,12 +78,25 @@ async function requireAuth(req, res, next) { } } -function requireAdmin(req, res, next) { +async function requireAdmin(req, res, next) { const token = req.cookies?.token; if (!token) return res.status(401).json({ error: 'Not authenticated' }); try { const decoded = jwt.verify(token, JWT_SECRET); - if (decoded.role !== 'SUPER_ADMIN' && decoded.role !== 'TENANT_ADMIN' && decoded.role !== 'SOC_ANALYST') { + + // Verify session status in MongoDB + if (decoded.session_id) { + const activeSession = await Session.findById(decoded.session_id); + if (!activeSession) { + res.clearCookie('token'); + return res.status(401).json({ error: 'Sesi login telah dinonaktifkan atau kedaluwarsa.' }); + } + activeSession.last_active = new Date(); + await activeSession.save(); + } + + const validAdminRoles = ['SUPER_ADMIN', 'COMPANY_ADMIN', 'COMPANY_OPERATOR', 'TENANT_ADMIN', 'SOC_ANALYST']; + if (!validAdminRoles.includes(decoded.role)) { return res.status(403).json({ error: 'Forbidden' }); } req.adminUser = decoded; diff --git a/backend/models/Schemas.js b/backend/models/Schemas.js index 1beafbf..8302b90 100644 --- a/backend/models/Schemas.js +++ b/backend/models/Schemas.js @@ -1,184 +1,187 @@ -// backend/models/Schemas.js -// ───────────────────────────────────────────────────────────────────────────── -// MongoDB Schemas untuk BackOne Backend (READ-ONLY) -// -// PENTING: Schema ini harus sinkron dengan proxy/models/Schemas.js -// Proxy yang MENULIS data, backend yang MEMBACA data. -// -// Setiap dokumen di-tag dengan: -// agent_uuid → identifikasi Network Agent spesifik (isolasi per tenant) -// site_uuid → identifikasi site DPI (BackOne) -// timestamp → waktu data dikumpulkan -// ───────────────────────────────────────────────────────────────────────────── - -const mongoose = require('mongoose'); - -const baseOptions = { - timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } -}; - -// ─── Bandwidth Summary (per agent, per collection cycle) ─────────────────────── -const SummarySchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, // null = global/all agents - site_uuid: { type: String, index: true }, - bandwidth_down: Number, - bandwidth_up: Number, - active_flows: Number, - download_speed: Number, - upload_speed: Number, - total_devices: Number, - total_threats: Number, - packet_drops: Number, - peak_flow_rate: Number, - cpu_usage: Number, - memory_usage: Number, - queue_depth: Number, -}, baseOptions); - -// ─── Top Applications (per agent) ───────────────────────────────────────────── -const AppStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - app_label: { type: String, required: true }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - -// ─── Protocol Statistics (per agent) ────────────────────────────────────────── -const ProtocolStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - protocol_label: { type: String, required: true }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - -// ─── Discovered Devices (per agent, includes IP + MAC + device info) ─────────── -const DeviceStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - ip_address: { type: String, required: true, index: true }, - mac_address: { type: String, index: true }, - device_label: String, - device_type: String, - os_label: String, - manufacturer: String, - download: Number, - upload: Number, - flows: Number, - last_seen: String, -}, baseOptions); - -// ─── Network Flows (per agent) ───────────────────────────────────────────────── -const FlowSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - flow_id: String, - src_ip: { type: String, index: true }, - src_mac: String, - dst_ip: { type: String, index: true }, - dst_port: Number, - protocol: String, - app_label: String, - domain: { type: String, index: true }, - download: Number, - upload: Number, - first_seen: String, - last_seen: String, -}, baseOptions); - -// ─── Cyber Threats (per agent) ───────────────────────────────────────────────── -const ThreatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - threat_type: String, - severity: String, - src_ip: String, - dst_ip: String, - dst_port: Number, - protocol: String, - description: String, - event_at: String, -}, baseOptions); - -// ─── App Categories (per agent) ─────────────────────────────────────────────── -const AppCategoryStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - category_label: { type: String, required: true }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - -// ─── System Events (per agent) ───────────────────────────────────────────────── -const EventSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - event_id: Number, - event_type: String, - severity: String, - description: String, - category_label: String, - ip_address: String, - mac_address: String, - event_at: Date, -}, baseOptions); - -// ─── Compound Indexes for common dashboard queries ───────────────────────────── -SummarySchema.index({ agent_uuid: 1, timestamp: -1 }); -AppStatSchema.index({ agent_uuid: 1, timestamp: -1, download: -1 }); -DeviceStatSchema.index({ agent_uuid: 1, ip_address: 1 }, { unique: true }); -FlowSchema.index({ agent_uuid: 1, timestamp: -1 }); -FlowSchema.index({ agent_uuid: 1, flow_id: 1 }); -FlowSchema.index({ agent_uuid: 1, protocol: 1, timestamp: -1 }); -FlowSchema.index({ agent_uuid: 1, domain: 1, timestamp: -1 }); -FlowSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 }); -ThreatSchema.index({ agent_uuid: 1, timestamp: -1 }); -AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 }); -EventSchema.index({ agent_uuid: 1, timestamp: -1 }); - -// ── Per-Device Per-Application Stats (synced from proxy) ───────────────── -const DeviceAppStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - ip_address: { type: String, required: true, index: true }, - app_label: { type: String, required: true }, - app_id: Number, - download: { type: Number, default: 0 }, - upload: { type: Number, default: 0 }, - flows: { type: Number, default: 0 }, - last_seen: String, -}, baseOptions); -DeviceAppStatSchema.index({ agent_uuid: 1, ip_address: 1, timestamp: -1 }); -DeviceAppStatSchema.index({ ip_address: 1, app_label: 1, timestamp: -1 }); -DeviceAppStatSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 }); - -const telemetrySchemas = require('./SchemasTelemetry'); -const auxSchemas = require('./SchemasAux'); - -module.exports = { - Summary: mongoose.model('Summary', SummarySchema), - AppStat: mongoose.model('AppStat', AppStatSchema), - ProtocolStat: mongoose.model('ProtocolStat', ProtocolStatSchema), - DeviceStat: mongoose.model('DeviceStat', DeviceStatSchema), - DeviceAppStat: mongoose.model('DeviceAppStat', DeviceAppStatSchema), - Flow: mongoose.model('Flow', FlowSchema), - Threat: mongoose.model('Threat', ThreatSchema), - AppCategoryStat: mongoose.model('AppCategoryStat', AppCategoryStatSchema), - Event: mongoose.model('Event', EventSchema), - ...auxSchemas, - ...telemetrySchemas -}; - +// backend/models/Schemas.js +// ───────────────────────────────────────────────────────────────────────────── +// MongoDB Schemas untuk BackOne Backend (READ-ONLY) +// +// PENTING: Schema ini harus sinkron dengan proxy/models/Schemas.js +// Proxy yang MENULIS data, backend yang MEMBACA data. +// +// Setiap dokumen di-tag dengan: +// agent_uuid → identifikasi Network Agent spesifik (isolasi per tenant) +// site_uuid → identifikasi site DPI (BackOne) +// timestamp → waktu data dikumpulkan +// ───────────────────────────────────────────────────────────────────────────── + +const mongoose = require('mongoose'); + +const baseOptions = { + timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } +}; + +// ─── Bandwidth Summary (per agent, per collection cycle) ─────────────────────── +const SummarySchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, // null = global/all agents + site_uuid: { type: String, index: true }, + bandwidth_down: Number, + bandwidth_up: Number, + active_flows: Number, + download_speed: Number, + upload_speed: Number, + total_devices: Number, + total_threats: Number, + packet_drops: Number, + peak_flow_rate: Number, + cpu_usage: Number, + memory_usage: Number, + queue_depth: Number, +}, baseOptions); + +// ─── Top Applications (per agent) ───────────────────────────────────────────── +const AppStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + app_label: { type: String, required: true }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +// ─── Protocol Statistics (per agent) ────────────────────────────────────────── +const ProtocolStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + protocol_label: { type: String, required: true }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +// ─── Discovered Devices (per agent, includes IP + MAC + device info) ─────────── +const DeviceStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + ip_address: { type: String, required: true, index: true }, + mac_address: { type: String, index: true }, + device_label: String, + device_type: String, + os_label: String, + manufacturer: String, + download: Number, + upload: Number, + flows: Number, + last_seen: String, +}, baseOptions); + +// ─── Network Flows (per agent) ───────────────────────────────────────────────── +const FlowSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + flow_id: String, + src_ip: { type: String, index: true }, + src_mac: { type: String, index: true }, + dst_ip: { type: String, index: true }, + dst_port: Number, + protocol: String, + app_label: String, + domain: { type: String, index: true }, + download: Number, + upload: Number, + first_seen: String, + last_seen: String, +}, baseOptions); + +// ─── Cyber Threats (per agent) ───────────────────────────────────────────────── +const ThreatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + threat_type: String, + severity: String, + src_ip: String, + dst_ip: String, + dst_port: Number, + protocol: String, + description: String, + event_at: String, + flow_id: { type: String, index: true }, +}, baseOptions); + +// ─── App Categories (per agent) ─────────────────────────────────────────────── +const AppCategoryStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + category_label: { type: String, required: true }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +// ─── System Events (per agent) ───────────────────────────────────────────────── +const EventSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + event_id: Number, + event_type: String, + severity: String, + description: String, + category_label: String, + ip_address: String, + mac_address: String, + event_at: Date, + flow_id: { type: String, index: true }, +}, baseOptions); + +// ─── Compound Indexes for common dashboard queries ───────────────────────────── +SummarySchema.index({ agent_uuid: 1, timestamp: -1 }); +AppStatSchema.index({ agent_uuid: 1, timestamp: -1, download: -1 }); +DeviceStatSchema.index({ agent_uuid: 1, ip_address: 1 }, { unique: true }); +FlowSchema.index({ agent_uuid: 1, timestamp: -1 }); +FlowSchema.index({ agent_uuid: 1, flow_id: 1 }); +FlowSchema.index({ agent_uuid: 1, protocol: 1, timestamp: -1 }); +FlowSchema.index({ agent_uuid: 1, domain: 1, timestamp: -1 }); +FlowSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 }); +FlowSchema.index({ site_uuid: 1, src_mac: 1, timestamp: -1 }); +ThreatSchema.index({ agent_uuid: 1, timestamp: -1 }); +AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 }); +EventSchema.index({ agent_uuid: 1, timestamp: -1 }); + +// ── Per-Device Per-Application Stats (synced from proxy) ───────────────── +const DeviceAppStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + ip_address: { type: String, required: true, index: true }, + app_label: { type: String, required: true }, + app_id: Number, + download: { type: Number, default: 0 }, + upload: { type: Number, default: 0 }, + flows: { type: Number, default: 0 }, + last_seen: String, +}, baseOptions); +DeviceAppStatSchema.index({ agent_uuid: 1, ip_address: 1, timestamp: -1 }); +DeviceAppStatSchema.index({ ip_address: 1, app_label: 1, timestamp: -1 }); +DeviceAppStatSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 }); + +const telemetrySchemas = require('./SchemasTelemetry'); +const auxSchemas = require('./SchemasAux'); + +module.exports = { + Summary: mongoose.model('Summary', SummarySchema), + AppStat: mongoose.model('AppStat', AppStatSchema), + ProtocolStat: mongoose.model('ProtocolStat', ProtocolStatSchema), + DeviceStat: mongoose.model('DeviceStat', DeviceStatSchema), + DeviceAppStat: mongoose.model('DeviceAppStat', DeviceAppStatSchema), + Flow: mongoose.model('Flow', FlowSchema), + Threat: mongoose.model('Threat', ThreatSchema), + AppCategoryStat: mongoose.model('AppCategoryStat', AppCategoryStatSchema), + Event: mongoose.model('Event', EventSchema), + ...auxSchemas, + ...telemetrySchemas +}; + diff --git a/backend/models/Session.js b/backend/models/Session.js new file mode 100644 index 0000000..29c8de8 --- /dev/null +++ b/backend/models/Session.js @@ -0,0 +1,22 @@ +// backend/models/Session.js +// ───────────────────────────────────────────────────────────────────────────── +// MongoDB User Session Schema for remote revocation capability +// ───────────────────────────────────────────────────────────────────────────── + +const mongoose = require('mongoose'); + +const SessionSchema = new mongoose.Schema({ + user_id: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true }, + ip_address: { type: String, default: 'Unknown' }, + user_agent: { type: String, default: 'Unknown' }, + session_token: { type: String, required: true, unique: true }, // JWT JTI or unique token hash + last_active: { type: Date, default: Date.now }, + expires_at: { type: Date, required: true }, // MongoDB TTL Index specified below via SessionSchema.index +}, { + timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } +}); + +// TTL index to automatically remove expired sessions from MongoDB +SessionSchema.index({ expires_at: 1 }, { expireAfterSeconds: 0 }); + +module.exports = mongoose.model('Session', SessionSchema); diff --git a/backend/models/User.js b/backend/models/User.js index 5fbf12a..96310d7 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -14,11 +14,15 @@ const UserSchema = new mongoose.Schema({ password_hash: { type: String, required: true }, account_name: { type: String, default: null }, profile_picture: { type: String, default: null }, - role: { type: String, enum: ['SUPER_ADMIN', 'TENANT_ADMIN', 'SOC_ANALYST', 'ENGINEER', 'AGENT_VIEWER'], default: 'AGENT_VIEWER' }, + role: { type: String, enum: ['SUPER_ADMIN', 'EXECUTIVE', 'TENANT_ADMIN', 'SOC_ANALYST', 'ENGINEER', 'AGENT_VIEWER', 'COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'], default: 'AGENT_VIEWER' }, site_uuid: { type: String, default: null, index: true }, agent_uuid: { type: String, default: null }, + company_name: { type: String, default: null, index: true }, + agent_uuids: { type: [String], default: [] }, created_by: { type: String, default: null, index: true }, is_active: { type: Boolean, default: true }, + login_attempts: { type: Number, default: 0 }, + lockout_until: { type: Date, default: null }, }, { timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } }); diff --git a/backend/netify.js b/backend/netify.js new file mode 100644 index 0000000..a4ab2a6 --- /dev/null +++ b/backend/netify.js @@ -0,0 +1,2718 @@ +// backend/netify.js +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') }); +const axios = require('axios'); + +const BASE_URL = process.env.BACKONE_INFORMATICS_BASE_URL || process.env.NETIFY_INFORMATICS_BASE_URL || 'https://informatics.netify.ai/api/v1'; +const JWT_TOKEN = process.env.BACKONE_JWT_TOKEN || process.env.NETIFY_TOKEN || process.env.NETIFY_JWT_TOKEN; +const SITE_UUID = process.env.BACKONE_SITE_UUID || process.env.NETIFY_SITE_UUID; +const agentMap = {}; + +function headersSite(useApiKey = false) { + const token = process.env.BACKONE_API_KEY || process.env.NETIFY_API_KEY || JWT_TOKEN; + const headers = { 'x-api-key': token, 'Accept': 'application/json' }; + if (SITE_UUID) { + headers['x-net-site'] = SITE_UUID; + } + return headers; +} + + + +function fixDates(obj) { + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) fixDates(obj[i]); + } else if (obj !== null && typeof obj === 'object') { + for (const key in obj) { + if ((key === 'first_seen_at' || key === 'last_seen_at' || key.endsWith('_at')) && obj[key] && typeof obj[key].date === 'string') { + let d = obj[key].date; + if (d.includes(' ') && !d.endsWith('Z')) { + obj[key].date = d.replace(' ', 'T') + 'Z'; + } else if (!d.endsWith('Z') && !d.includes('+') && d.includes('T')) { + obj[key].date = d + 'Z'; + } + } else if (typeof obj[key] === 'object') { + fixDates(obj[key]); + } + } + } +} + +async function netifyFetch(path, params = {}, useApiKey = false, agentUuid = null) { + if (agentUuid) { + const agentId = agentMap[agentUuid]; + if (agentId) { + params.filter_agents = `[${agentId}]`; + } else { + params.settings_agent = agentUuid; + } + } + if (!JWT_TOKEN || !SITE_UUID || JWT_TOKEN === 'YOUR_JWT_TOKEN' || SITE_UUID === 'YOUR_SITE_UUID' || JWT_TOKEN.startsWith('change_me') || JWT_TOKEN.startsWith('YOUR_')) { + console.error('[Netify] Failed due to invalid config', {hasToken: !!JWT_TOKEN, SITE_UUID}); + return null; + } + try { + const res = await axios.get(`${BASE_URL}${path}`, { + headers: headersSite(useApiKey), params, timeout: 30000, + }); + const json = res.data; + if (json?.status_code !== 0) { + console.error(`[Netify] API Error ${json?.status_code} pada ${path}: ${json?.status_message}`); + return null; + } + if (json && json.data) fixDates(json.data); + return json?.data ?? null; + } catch (err) { + const s = err.response?.status; + const msg = JSON.stringify(err.response?.data ?? err.message); + console.error(`[Netify] ${s ?? 'ERR'} ${path}: ${msg}`); + return null; + } +} + +// ─── TOP APPS: download + upload digabung ───────────────────────────────────── +async function fetchTopApps(interval = 1440, limit = 20, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/application/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/application/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + + // Buat map upload berdasarkan app_id + const ulMap = {}; + if (ulData) { + for (const r of ulData) { + const id = r.application?.id; + if (id) ulMap[id] = r.upload ?? 0; + } + } + + return dlData.map(r => ({ + app_id : r.application?.id ?? null, + app_label : r.application?.label ?? 'Unknown', + app_tag : r.application?.tag ?? null, + category : r.application?.category?.label ?? null, + favicon : r.application?.favicon ?? null, + download : r.download ?? 0, + upload : ulMap[r.application?.id] ?? 0, + total : (r.download ?? 0) + (ulMap[r.application?.id] ?? 0), + flows : r.flows ?? 0, + })); +} + +// ─── TOP DEVICES: download + upload digabung ────────────────────────────────── +async function fetchTopDevices(interval = 1440, limit = 50, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/local_ip/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/local_ip/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + + // Map upload berdasarkan IP address + const ulMap = {}; + if (ulData) { + for (const r of ulData) { + const ip = r.local_ip?.address ?? String(r.local_ip); + ulMap[ip] = r.upload ?? 0; + } + } + + return dlData.map(r => { + const ip = r.local_ip?.address ?? String(r.local_ip ?? ''); + return { + ip_address : ip, + mac_address : null, + device_label : ip, + device_type : null, + os_label : null, + manufacturer : null, + download : r.download ?? 0, + upload : ulMap[ip] ?? 0, + last_seen : null, + }; + }); +} + +// ─── PORT-TO-SERVICE MAPPING — untuk enrichment flows ──────────────────────── +const PORT_SERVICE_MAP = { + 80: 'HTTP', 443: 'HTTPS / TLS', 8080: 'HTTP Alt', 8443: 'HTTPS Alt', + 53: 'DNS', 5353: 'mDNS', 853: 'DNS-over-TLS', + 25: 'SMTP', 587: 'SMTP TLS', 465: 'SMTPS', 110: 'POP3', 143: 'IMAP', + 22: 'SSH', 23: 'Telnet', 3389: 'RDP', 5900: 'VNC', + 21: 'FTP', 20: 'FTP Data', 989: 'FTPS', 990: 'FTPS Control', + 3306: 'MySQL', 5432: 'PostgreSQL', 6379: 'Redis', 27017: 'MongoDB', + 1194: 'OpenVPN', 51820: 'WireGuard', 500: 'IPSec IKE', 4500: 'IPSec NAT-T', + 1723: 'PPTP', 1701: 'L2TP', + 67: 'DHCP', 68: 'DHCP Client', 123: 'NTP', + 161: 'SNMP', 162: 'SNMP Trap', 514: 'Syslog', + 6881: 'BitTorrent', 6882: 'BitTorrent', 6883: 'BitTorrent', + 9993: 'ZeroTier VPN', 3478: 'STUN/TURN', 5004: 'RTP Media', + 5060: 'SIP', 5061: 'SIP TLS', + 8883: 'MQTT TLS', 1883: 'MQTT', + 179: 'BGP', 520: 'RIP', +}; + +// ─── FLOWS: endpoint /data/flows dengan enrichment app/domain ───────────────── +// NOTE: API flows tidak punya field app/domain — enrichment dilakukan via: +// 1. PORT_SERVICE_MAP (reliable, berdasarkan dst port) +// 2. tls_sni field (satu-satunya SNI field valid, tapi nilai sering kosong) +async function fetchFlows(limit = 500, agentUuid = null) { + // Hanya fetch flows + tls_sni (satu-satunya SNI field yang valid = bukan 422) + const [raw, tslSniRaw] = await Promise.all([ + netifyFetch('/data/flows', { settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/tls_sni/download', { filter_interval: 1440, settings_limit: 50 }, false, agentUuid), + ]); + + if (!raw || !Array.isArray(raw)) return null; + + // Build TLS SNI lookup — filter yang tidak kosong + const sniList = []; + if (tslSniRaw && Array.isArray(tslSniRaw)) { + for (const r of tslSniRaw) { + const sni = r.tls_sni; + if (sni && sni.trim() !== '') { + sniList.push(sni.replace(/^\*\./, '').trim()); + } + } + } + + return raw.map(r => { + const port = r.remote_port ?? null; + // Primary enrichment: port → service name + const portService = port ? (PORT_SERVICE_MAP[port] ?? `Port ${port}`) : null; + + let domain = null; + let appLabel = portService; + + // Secondary enrichment: for HTTPS flows, use SNI list if available + if ((port === 443 || port === 8443) && sniList.length > 0) { + domain = sniList[0]; + } + + return { + flow_id : String(r.flow_id ?? ''), + src_ip : r.local_ip?.address ?? null, + src_mac : r.local_mac ?? r.mac?.address ?? null, + dst_ip : r.remote_ip?.address ?? null, + dst_port : port, + protocol : r.ip_protocol?.label ?? null, + app_label : appLabel, + domain : domain, + download : r.download ?? 0, + upload : r.upload ?? 0, + first_seen : r.first_seen_at?.date ?? null, + last_seen : r.last_seen_at?.date ?? null, + }; + }); +} + + +// ─── PROTOCOLS ──────────────────────────────────────────────────────────────── +async function fetchTopProtocols(interval = 1440, limit = 20, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/protocol/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/protocol/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + + const ulMap = {}; + if (ulData) { + for (const r of ulData) { + const id = r.protocol?.id; + if (id) ulMap[id] = r.upload ?? 0; + } + } + + return dlData.map(r => ({ + protocol_id : r.protocol?.id ?? null, + protocol_label : r.protocol?.label ?? 'Unknown', + download : r.download ?? 0, + upload : ulMap[r.protocol?.id] ?? 0, + flows : r.flows ?? 0, + })); +} + +// ─── COUNTRIES ──────────────────────────────────────────────────────────────── +async function fetchTopCountries(interval = 1440, limit = 15, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/country/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/country/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + + const ulMap = {}; + if (ulData) { + for (const r of ulData) { + const code = r.country?.code; + if (code) ulMap[code] = r.upload ?? 0; + } + } + + return dlData.map(r => ({ + country_code : r.country?.code ?? null, + country_name : r.country?.label ?? 'Unknown', + download : r.download ?? 0, + upload : ulMap[r.country?.code] ?? 0, + flows : r.flows ?? 0, + })); +} + +// ─── DNS/HOSTNAME — gunakan tls_sni karena hostname endpoint timeout ────────── +async function fetchTopDomains(interval = 1440, limit = 50, agentUuid = null) { + // NOTE: /data/stats/top/hostname/download selalu timeout + // Gunakan tls_sni yang confirmed bekerja di API ini + const raw = await netifyFetch('/data/stats/top/tls_sni/download', { + filter_interval: interval, settings_limit: limit, + }, false, agentUuid); + if (!raw) return null; + + return raw + .filter(r => r.tls_sni && r.tls_sni.trim() !== '') + .map(r => ({ + domain : r.tls_sni.replace(/^\*\./, '').trim(), + app_label : null, + category : 'HTTPS/TLS', + download : r.download ?? 0, + query_count : r.download ?? 0, + })); +} + + +// ─── BANDWIDTH SUMMARY untuk timeline ───────────────────────────────────────── +async function fetchBandwidthSummary(interval = 30, agentUuid = null) { + const rawSummary = await netifyFetch('/data/stats/summary', { filter_interval: interval }, false, agentUuid); + if (rawSummary) { + return { + download: rawSummary.download ?? 0, + upload: rawSummary.upload ?? 0, + flows: rawSummary.flow_hourly_count ?? 0, + download_speed: rawSummary.download_speed ?? 0, + upload_speed: rawSummary.upload_speed ?? 0, + flow_speed: rawSummary.flow_speed ?? 0, + devices: 0, + }; + } + + // Fallback to old way (aggregate top 100 application stats) + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/application/download', { filter_interval: interval, settings_limit: 100 }, false, agentUuid), + netifyFetch('/data/stats/top/application/upload', { filter_interval: interval, settings_limit: 100 }, false, agentUuid), + ]); + + const download = (dlData ?? []).reduce((s, r) => s + (r.download ?? 0), 0); + const upload = (ulData ?? []).reduce((s, r) => s + (r.upload ?? 0), 0); + const flows = (dlData ?? []).reduce((s, r) => s + (r.flows ?? 0), 0); + + return { + download, + upload, + flows, + download_speed: 0, + upload_speed: 0, + flow_speed: 0, + devices: dlData?.length ?? 0 + }; +} + +// ─── THREATS — gunakan flows dengan filter anomali sebagai fallback ──────────── +async function fetchCyberThreats(limit = 50, agentUuid = null) { + // Events/threats belum ada di v1 — return array kosong agar tidak error + // Akan diisi saat endpoint ditemukan + return []; +} + +// ─── EVENTS ─────────────────────────────────────────────────────────────────── +async function fetchEvents(limit = 50, agentUuid = null) { + const raw = await netifyFetch('/event/events', { settings_limit: limit }, false, agentUuid); + if (!raw || !Array.isArray(raw)) return []; + + return raw.map(r => { + let msg = r.label || ''; + if (r.description) { + try { + const descObj = JSON.parse(r.description); + msg = descObj.default || r.label || ''; + if (descObj.tags) { + for (const k in descObj.tags) { + const val = Array.isArray(descObj.tags[k]) ? descObj.tags[k][0] : descObj.tags[k]; + msg = msg.replace(`{{ ${k} }}`, val).replace(`{{${k}}}`, val); + } + } + } catch (e) { + msg = r.description; + } + } + + let sevLabel = 'Info'; + if (r.severity >= 30) sevLabel = 'Critical'; + else if (r.severity >= 20) sevLabel = 'High'; + else if (r.severity >= 10) sevLabel = 'Warning'; + + let srcIp = null; + if (r.description) { + try { + const descObj = JSON.parse(r.description); + srcIp = descObj.tags?.device_ip || descObj.tags?.ip || null; + } catch {} + } + + return { + event_id: r.id || null, + event_type: r.basename || 'unknown', + severity: sevLabel, + mac_address: r.additional?.device?.mac?.address || null, + ip_address: srcIp, + description: msg, + event_at: r.created_at?.date || new Date().toISOString() + }; + }); +} + +async function fetchDiscoveredDevices(interval = 1440, limit = 500, agentUuid = null) { + // Ambil lebih banyak bandwidth data (limit x2) supaya coverage IP lebih luas + const [intelRaw, dlData, ulData, flowsRaw] = await Promise.all([ + netifyFetch('/intelligence/discovery/devices', { settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/local_ip/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/local_ip/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/flows', { settings_limit: 500 }, false, agentUuid), + ]); + + if (!intelRaw || !Array.isArray(intelRaw)) return []; + + // Map bandwidth per IP + const dlMap = {}; + const ulMap = {}; + if (dlData) { + for (const r of dlData) { + const ip = r.local_ip?.address ?? String(r.local_ip); + if (ip) dlMap[ip] = r.download ?? 0; + } + } + if (ulData) { + for (const r of ulData) { + const ip = r.local_ip?.address ?? String(r.local_ip); + if (ip) ulMap[ip] = r.upload ?? 0; + } + } + + // Enrich bandwidth from flows — aggregate per local_ip as fallback + // Bagi device yang IP-nya tidak ada di top stats (traffic kecil) + const dlFlowMap = {}; + const ulFlowMap = {}; + const macFlowMap = {}; + const lastSeenFlowMap = {}; + + if (flowsRaw && Array.isArray(flowsRaw)) { + for (const f of flowsRaw) { + const ip = f.local_ip?.address; + if (!ip) continue; + + if (!dlMap[ip]) { + dlFlowMap[ip] = (dlFlowMap[ip] ?? 0) + (f.download ?? 0); + } + if (!ulMap[ip]) { + ulFlowMap[ip] = (ulFlowMap[ip] ?? 0) + (f.upload ?? 0); + } + + // Correlate MAC Address from flow + if (!macFlowMap[ip]) { + const flowMac = f.local_mac ?? f.mac?.address; + if (flowMac && flowMac !== '00:00:00:00:00:00') { + macFlowMap[ip] = flowMac; + } + } + + // Correlate Timestamp from flow + const flowTime = f.last_seen_at?.date || f.created_at?.date; + if (flowTime) { + if (!lastSeenFlowMap[ip] || new Date(flowTime) > new Date(lastSeenFlowMap[ip])) { + lastSeenFlowMap[ip] = flowTime; + } + } + } + } + + const resolvedList = intelRaw.map(r => { + const ip = r.ip?.address ?? null; + let mac = r.mac_address ?? r.discovery_mac?.address ?? null; + if (!mac && ip && macFlowMap[ip]) mac = macFlowMap[ip]; + + const oui = mac ? mac.substring(0, 8).toUpperCase() : null; + const mfr = r.mac_vendor !== 'Unknown' && r.mac_vendor !== 'Local' ? r.mac_vendor : (OUI_MAP[oui] ?? r.mac_vendor ?? null); + + // Get device_type — prefer discovery_type if meaningful, else device_type + const rawType = r.discovery_type?.label; + const deviceType = (rawType && rawType !== 'Unknown' && rawType !== 'Unclassified') + ? rawType + : (r.device_type?.label && r.device_type.label !== 'Unclassified' ? r.device_type.label : rawType ?? null); + + // Get OS — prefer discovery_os if meaningful, else device_os + const rawOs = r.discovery_os?.label; + const osLabel = (rawOs && rawOs !== 'Unknown' && rawOs !== 'Unclassified') + ? rawOs + : (r.device_os?.label && r.device_os.label !== 'Unclassified' ? r.device_os.label : rawOs ?? null); + + // Bandwidth: prioritize top stats, fallback to flows aggregation + const dl = ip ? (dlMap[ip] ?? dlFlowMap[ip] ?? 0) : 0; + const ul = ip ? (ulMap[ip] ?? ulFlowMap[ip] ?? 0) : 0; + + let last_seen = r.last_seen_at?.date || r.discovery_mac?.last_seen_at?.date || null; + if (!last_seen && ip && lastSeenFlowMap[ip]) last_seen = lastSeenFlowMap[ip]; + + return { + ip_address : ip, + mac_address : mac, + device_label : r.device?.label || r.discovery_mac?.discovery_hardware || ip || 'Unknown', + device_type : deviceType, + os_label : osLabel, + manufacturer : mfr, + download : dl, + upload : ul, + last_seen : last_seen, + }; + }); + + const seenIps = new Set(resolvedList.map(d => d.ip_address).filter(Boolean)); + const allActiveIps = new Set([ + ...Object.keys(dlMap), + ...Object.keys(ulMap) + ]); + + for (const ip of allActiveIps) { + if (!seenIps.has(ip)) { + seenIps.add(ip); + const dl = dlMap[ip] ?? dlFlowMap[ip] ?? 0; + const ul = ulMap[ip] ?? ulFlowMap[ip] ?? 0; + resolvedList.push({ + ip_address : ip, + mac_address : macFlowMap[ip] || null, + device_label : ip, + device_type : 'LAN Client', + os_label : 'Windows/Linux', + manufacturer : 'Unknown', + download : dl, + upload : ul, + last_seen : lastSeenFlowMap[ip] || new Date().toISOString() + }); + } + } + + return resolvedList.sort((a, b) => (b.download + b.upload) - (a.download + a.upload)); +} + +// OUI lookup — manufacturer dari 3 oktet pertama MAC +const OUI_MAP = { + '60:BE:B4' : 'Ruckus Networks', + '74:6F:88' : 'Ruckus Networks', + '04:F4:1C' : 'MikroTik', + 'F4:6D:3F' : 'TP-Link', + 'B8:27:EB' : 'Raspberry Pi', + 'DC:A6:32' : 'Raspberry Pi', + 'E4:5F:01' : 'Raspberry Pi', + '00:50:56' : 'VMware', + '08:00:27' : 'VirtualBox', + 'AC:17:02' : 'ASUSTek', + 'B4:2E:99' : 'ASUSTek', + '18:31:BF' : 'ASUSTek', + '74:D0:2B' : 'Cisco', + 'F8:72:EA' : 'Cisco', + '00:1A:A0' : 'Cisco', + 'FC:FB:FB' : 'Cisco Meraki', + '88:15:44' : 'Cisco Meraki', + '00:18:0A' : 'Ubiquiti', + '04:18:D6' : 'Ubiquiti', + '24:A4:3C' : 'Ubiquiti', + '78:8A:20' : 'Ubiquiti', + 'DC:9F:DB' : 'Ubiquiti', + '80:2A:A8' : 'Ubiquiti', + 'FC:EC:DA' : 'Ubiquiti', + '00:27:22' : 'Ubiquiti', + 'B4:FB:E4' : 'Samsung', + '8C:79:F0' : 'Samsung', + 'F0:25:B7' : 'Samsung', + '78:BD:BC' : 'Samsung', + '3C:28:6D' : 'Apple', + 'A4:C3:F0' : 'Apple', + 'F8:FF:C2' : 'Apple', + '98:01:A7' : 'Apple', + '3C:22:FB' : 'Apple', + 'F0:DB:F8' : 'Huawei', + '00:E0:FC' : 'Huawei', + '54:89:98' : 'Huawei', + '28:31:52' : 'Xiaomi', + '64:09:80' : 'Xiaomi', + 'AC:C1:EE' : 'Xiaomi', + '50:64:2B' : 'Xiaomi', + '00:0C:29' : 'VMware', + '00:15:5D' : 'Microsoft Hyper-V', + '52:54:00' : 'QEMU/KVM', +}; + +// ─── TAMBAHAN FITUR 1-11 ────────────────────────────────────────────────────── + +// 1. Top Application Category +async function fetchTopAppCategories(interval = 1440, limit = 15, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/application_category/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/application_category/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + const key = r.application_category?.label ?? r.application_category; + if (key) ulMap[key] = r.upload ?? 0; + } + return dlData.map(r => { + const label = r.application_category?.label ?? String(r.application_category ?? 'Unknown'); + return { + category_label : label, + download : r.download ?? 0, + upload : ulMap[label] ?? 0, + total : (r.download ?? 0) + (ulMap[label] ?? 0), + }; + }); +} + +// 2. Top Continent +async function fetchTopContinents(interval = 1440, limit = 10, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/continent/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/continent/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + const key = r.continent?.label ?? String(r.continent ?? ''); + if (key) ulMap[key] = r.upload ?? 0; + } + return dlData.map(r => { + const label = r.continent?.label ?? String(r.continent ?? 'Unknown'); + return { + continent_name : label, + download : r.download ?? 0, + upload : ulMap[label] ?? 0, + total : (r.download ?? 0) + (ulMap[label] ?? 0), + }; + }); +} + +// 3. Top Region +async function fetchTopRegions(interval = 1440, limit = 20, agentUuid = null) { + const raw = await netifyFetch('/data/stats/top/region/download', { + filter_interval: interval, settings_limit: limit, + }, false, agentUuid); + if (!raw) return null; + return raw.map(r => ({ + region_name : r.region?.region_name?.trim() || '(Unknown Region)', + region_code : r.region?.region_code?.trim() || null, + country_name : r.region?.country_name ?? null, + country_code : r.region?.country_code ?? null, + download : r.download ?? 0, + })); +} + +// 4. Top City +async function fetchTopCities(interval = 1440, limit = 20, agentUuid = null) { + const raw = await netifyFetch('/data/stats/top/city/download', { + filter_interval: interval, settings_limit: limit, + }, false, agentUuid); + if (!raw) return null; + return raw.map(r => ({ + city_name : r.city?.city?.trim() || '(Unknown City)', + region_name : r.city?.region_name?.trim() || null, + country_name : r.city?.country_name ?? null, + country_code : r.city?.country_code ?? null, + download : r.download ?? 0, + })); +} + +// 5. Top VLAN +async function fetchTopVLANs(interval = 1440, limit = 20, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/vlan/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/vlan/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + const key = r.vlan?.id ?? 0; + ulMap[key] = r.upload ?? 0; + } + return dlData.map(r => ({ + vlan_id : r.vlan?.id ?? 0, + vlan_label : r.vlan?.label ?? `VLAN ${r.vlan?.id ?? 0}`, + download : r.download ?? 0, + upload : ulMap[r.vlan?.id ?? 0] ?? 0, + total : (r.download ?? 0) + (ulMap[r.vlan?.id ?? 0] ?? 0), + })); +} + +// 6. Top Interface +async function fetchTopInterfaces(interval = 1440, limit = 20, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/interface/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/interface/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + const key = r.interface?.id; + if (key !== undefined) ulMap[key] = r.upload ?? 0; + } + return dlData.map(r => ({ + iface_id : r.interface?.id ?? null, + iface_name : r.interface?.name ?? 'Unknown', + iface_role : r.interface?.role ?? null, + agent_id : r.interface?.agent_id ?? null, + download : r.download ?? 0, + upload : ulMap[r.interface?.id] ?? 0, + total : (r.download ?? 0) + (ulMap[r.interface?.id] ?? 0), + })); +} + +// 7. Top Flow Type +async function fetchTopFlowTypes(interval = 1440, limit = 10, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/flow_type/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/flow_type/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + const key = r.flow_type?.label ?? String(r.flow_type ?? ''); + if (key) ulMap[key] = r.upload ?? 0; + } + return dlData.map(r => { + const label = r.flow_type?.label ?? String(r.flow_type ?? 'Unknown'); + return { + flow_type_label : label, + download : r.download ?? 0, + upload : ulMap[label] ?? 0, + total : (r.download ?? 0) + (ulMap[label] ?? 0), + }; + }); +} + +// 8. Top Flow Origin +async function fetchTopFlowOrigins(interval = 1440, limit = 10, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/flow_origin/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/flow_origin/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + const key = r.flow_origin?.label ?? String(r.flow_origin ?? ''); + if (key) ulMap[key] = r.upload ?? 0; + } + return dlData.map(r => { + const label = r.flow_origin?.label ?? String(r.flow_origin ?? 'Unknown'); + return { + flow_origin_label : label, + download : r.download ?? 0, + upload : ulMap[label] ?? 0, + total : (r.download ?? 0) + (ulMap[label] ?? 0), + }; + }); +} + +// 9. Top IP Version +async function fetchTopIPVersions(interval = 1440, limit = 5, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/ip_version/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/ip_version/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + const key = r.ip_version?.label ?? String(r.ip_version ?? ''); + if (key) ulMap[key] = r.upload ?? 0; + } + return dlData.map(r => { + const label = r.ip_version?.label ?? String(r.ip_version ?? 'Unknown'); + return { + ip_version_label : label, + download : r.download ?? 0, + upload : ulMap[label] ?? 0, + total : (r.download ?? 0) + (ulMap[label] ?? 0), + }; + }); +} + +// 10. Top Remote IP +async function fetchTopRemoteIPs(interval = 1440, limit = 20, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/remote_ip/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/remote_ip/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + const key = r.remote_ip?.address ?? String(r.remote_ip ?? ''); + if (key) ulMap[key] = r.upload ?? 0; + } + return dlData.map(r => { + const addr = r.remote_ip?.address ?? String(r.remote_ip ?? 'Unknown'); + return { + remote_ip : addr, + ip_version : r.remote_ip?.version ?? null, + download : r.download ?? 0, + upload : ulMap[addr] ?? 0, + total : (r.download ?? 0) + (ulMap[addr] ?? 0), + }; + }); +} + +// 11. Top Local MAC + Discovery OS +async function fetchTopLocalMACs(interval = 1440, limit = 50, agentUuid = null) { + const [dlData, ulData, osData] = await Promise.all([ + netifyFetch('/data/stats/top/local_mac/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/local_mac/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/discovery_os/download', { filter_interval: interval, settings_limit: 20 }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + if (r.local_mac) ulMap[r.local_mac] = r.upload ?? 0; + } + // OS summary untuk info panel + const osList = (osData ?? []).map(r => ({ + os_label : r.discovery_os?.label ?? String(r.discovery_os ?? 'Unknown'), + download : r.download ?? 0, + })); + return dlData.map(r => { + const mac = r.local_mac ?? 'Unknown'; + const oui = mac.substring(0, 8).toUpperCase(); + return { + mac_address : mac, + manufacturer : OUI_MAP[oui] ?? null, + download : r.download ?? 0, + upload : ulMap[mac] ?? 0, + total : (r.download ?? 0) + (ulMap[mac] ?? 0), + _os_summary : osList, // disertakan di item pertama saja + }; + }); +} + +// ─── DISCOVERY OS (standalone) ─────────────────────────────────────────────── +async function fetchTopDiscoveryOS(interval = 1440, limit = 20, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/discovery_os/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/discovery_os/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + const key = r.discovery_os?.label ?? String(r.discovery_os ?? ''); + if (key) ulMap[key] = r.upload ?? 0; + } + return dlData.map(r => { + const label = r.discovery_os?.label ?? String(r.discovery_os ?? 'Unknown'); + return { + os_label : label, + download : r.download ?? 0, + upload : ulMap[label] ?? 0, + total : (r.download ?? 0) + (ulMap[label] ?? 0), + }; + }); +} + +// ─── DPI FIELDS ─────────────────────────────────────────────────────────────── + +// TLS Version +async function fetchTLSVersions(interval = 1440, limit = 10, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/tls_version/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/tls_version/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + const key = r.tls_version?.label ?? String(r.tls_version ?? ''); + if (key) ulMap[key] = r.upload ?? 0; + } + return dlData.map(r => { + const label = r.tls_version?.label ?? String(r.tls_version ?? 'Unknown'); + return { + tls_version : label, + download : r.download ?? 0, + upload : ulMap[label] ?? 0, + total : (r.download ?? 0) + (ulMap[label] ?? 0), + }; + }); +} + +// TLS Cipher +async function fetchTLSCiphers(interval = 1440, limit = 15, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/tls_cipher/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/tls_cipher/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + const key = r.tls_cipher?.label ?? String(r.tls_cipher ?? ''); + if (key) ulMap[key] = r.upload ?? 0; + } + return dlData.map(r => { + const label = r.tls_cipher?.label ?? String(r.tls_cipher ?? 'Unknown'); + return { + tls_cipher : label, + download : r.download ?? 0, + upload : ulMap[label] ?? 0, + total : (r.download ?? 0) + (ulMap[label] ?? 0), + }; + }); +} + +// TLS Security Level +async function fetchTLSSecurity(interval = 1440, limit = 10, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/tls_security/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/tls_security/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + const key = r.tls_security?.label ?? String(r.tls_security ?? ''); + if (key) ulMap[key] = r.upload ?? 0; + } + return dlData.map(r => { + const label = r.tls_security?.label ?? String(r.tls_security ?? 'Unknown'); + // Assign warna berdasarkan label untuk UI + const color = label === 'Recommended' ? 'green' + : label === 'Secure' ? 'blue' + : label === 'Weak' ? 'orange' + : label === 'Insecure' ? 'red' + : 'gray'; + return { + tls_security : label, + color : color, + download : r.download ?? 0, + upload : ulMap[label] ?? 0, + total : (r.download ?? 0) + (ulMap[label] ?? 0), + }; + }); +} + +// NetBIOS Hostname (nama PC Windows) +async function fetchNetBIOSHostnames(interval = 1440, limit = 30, agentUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/netbios_hostname/download', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch('/data/stats/top/netbios_hostname/upload', { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) for (const r of ulData) { + const key = r.netbios_hostname?.name ?? String(r.netbios_hostname ?? ''); + if (key) ulMap[key] = r.upload ?? 0; + } + return dlData.map(r => { + const name = r.netbios_hostname?.name ?? String(r.netbios_hostname ?? 'Unknown'); + return { + hostname : name, + download : r.download ?? 0, + upload : ulMap[name] ?? 0, + total : (r.download ?? 0) + (ulMap[name] ?? 0), + }; + }); +} + +async function fetchLookupApplications(page = 1, limit = 50, search = '', agentUuid = null) { + if (!JWT_TOKEN || !SITE_UUID || JWT_TOKEN === 'YOUR_JWT_TOKEN' || SITE_UUID === 'YOUR_SITE_UUID' || JWT_TOKEN.startsWith('change_me') || JWT_TOKEN.startsWith('YOUR_')) { + return { + applications: [ + { id: 1, label: 'YouTube', tag: 'video', category: { label: 'Streaming' } }, + { id: 2, label: 'Netflix', tag: 'video', category: { label: 'Streaming' } }, + { id: 3, label: 'Web Browsing', tag: 'web', category: { label: 'General' } }, + { id: 4, label: 'Google Services', tag: 'google', category: { label: 'Tech' } }, + { id: 5, label: 'WhatsApp', tag: 'im', category: { label: 'Chat' } } + ], + pagination: { total_records: 5, current_page: 1, total_pages: 1 } + }; + } + const params = { + settings_page: page, + settings_limit: limit, + }; + if (search) { + params.filter_application = search; + } + try { + const res = await axios.get(`${BASE_URL}/lookup/applications`, { + headers: headersSite(true), params, timeout: 30000, + }); + const json = res.data; + if (json?.status_code !== 0) { + console.error(`[Netify] API Error ${json?.status_code}: ${json?.status_message}`); + return { applications: [], pagination: {} }; + } + return { + applications: json.data || [], + pagination: json.data_info || {} + }; + } catch (err) { + console.error('[Netify] Lookup error:', err.message); + return { applications: [], pagination: {} }; + } +} + +// ─── DETAIL FETCHERS FOR INTERACTIVE MODALS (DB-BASED — API ignores filter params) ───────── +// +// NOTE: Netify Informatics API does NOT filter by filter_agent / filter_local_ip — +// all filter params are silently ignored and global data is returned. +// All detail queries therefore use the local SQLite DB (flows, devices tables). +// +// Agent ↔ src_mac mapping (determined empirically from flows data): +// 2F-TF-1D-GK (JRP Cibubur) → src_mac '60:be:b4:1f:05:96' (IPs 10.1.x.x, 10.0.x.x) +// 8A-V3-PB-85 (IFG LT.18) → src_mac '04:f4:1c:ce:c2:e6' (IPs 10.6.x.x, 192.168.9.x) +// F6-2V-DT-8A (CPI Balaraja) → src_mac '2c:7b:a0:d8:86:91', '16:11:ac:73:34:1d', etc (IPs 10.250.x.x) + +const AGENT_LABELS = { + '2F-TF-1D-GK': 'JRP Cibubur', + '8A-V3-PB-85': 'IFG LT.18', + 'F6-2V-DT-8A': 'CPI Balaraja', + '1R-79-J9-YE': 'CPI Balaraja WAN', +}; + +// Maps each agent UUID to its gateway/interface MAC address(es) in flows +const AGENT_MAC_MAP = { + '2F-TF-1D-GK': ['60:be:b4:1f:05:96'], + '8A-V3-PB-85': ['04:f4:1c:ce:c2:e6'], + 'F6-2V-DT-8A': ['2c:7b:a0:d8:86:91', '16:11:ac:73:34:1d', 'bc:45:5b:ca:d5:be', + 'de:ed:cc:57:58:34', 'f4:6d:3f:ef:01:a0', '60:be:b4:29:d3:36', + '60:be:b4:29:d3:33', '60:be:b4:26:4c:d6', '60:be:b4:29:d3:32'], + '1R-79-J9-YE': ['70:85:6c:6d:f7:17', '70:85:6c:81:50:d4', 'a2:cc:8e:7d:39:51', + 'f2:69:9d:a1:5e:11', '8e:91:0f:6e:24:63'], +}; + +// Build SQL IN clause placeholders +function inClause(arr) { + return arr.map(() => '?').join(','); +} + +// Fetch all data for a specific agent (by UUID) — from local DB flows +async function fetchAgentDetails(agentUuid) { + const db = require('./database'); + const d = db.getDB(); + + const label = AGENT_LABELS[agentUuid] || agentUuid; + const macs = AGENT_MAC_MAP[agentUuid]; + + const emptyResult = { agent_uuid: agentUuid, agent_label: label, summary: null, devices: [], flows: [], top_apps: [], security: { encryption_audit: [], insecure_protocols: [], unencrypted_passwords: [], ip_reputation: [], tor_detections: [], vpn_detections: [] }, events: [], mac_bandwidth: [], server_discovery: [] }; + + if (!macs || macs.length === 0) return emptyResult; + + const ph = inClause(macs); + + // ── 1. Top apps by this agent (from flows) ───────────────────────────────── + const appRows = db.getLatestBandwidthApps(15, null, agentUuid); + + const top_apps = appRows.map(r => ({ + app_id : null, + app_label : r.app_label, + category : null, + favicon : null, + download : r.download ?? 0, + upload : r.upload ?? 0, + })); + + // ── 2. Distinct devices for this agent (using aligned subnet and MAC mapping) ─ + let resolvedDevices = db.getLatestDevices(100, null, agentUuid); + + // FALLBACK: If agent-specific API failed to return devices, extract from global using MACs + if (resolvedDevices.length === 0 && macs.length > 0) { + const latestDevGlobal = d.prepare(`SELECT MAX(fetched_at) as t FROM devices WHERE agent_uuid IS NULL`).get()?.t; + if (latestDevGlobal) { + resolvedDevices = d.prepare(`SELECT * FROM devices WHERE fetched_at = ? AND agent_uuid IS NULL AND mac_address IN (${ph})`).all(latestDevGlobal, ...macs); + } + } + + const agentIPs = resolvedDevices.map(d => d.ip_address).filter(Boolean); + const phIPs = agentIPs.length > 0 ? agentIPs.map(() => '?').join(',') : null; + + const latestEncAudit = d.prepare(`SELECT MAX(fetched_at) AS t FROM intel_encryption_audit`).get()?.t; + const riskMap = {}; + if (latestEncAudit) { + const riskRows = d.prepare(`SELECT ip_address, encrypted_pct, risk_level FROM intel_encryption_audit WHERE fetched_at = ?`).all(latestEncAudit); + for (const r of riskRows) { + if (r.ip_address) riskMap[r.ip_address] = { encrypted_pct: r.encrypted_pct, risk_level: r.risk_level }; + } + } + + const insecureIPs = new Set( + phIPs ? d.prepare(`SELECT DISTINCT ip_address FROM intel_insecure_protocols WHERE ip_address IN (${phIPs})`).all(...agentIPs).map(r => r.ip_address) : [] + ); + + const devices = resolvedDevices.map(r => ({ + ip_address : r.ip_address, + mac_address : r.mac_address, + device_label : r.device_label || r.ip_address || 'Unknown', + device_type : r.device_type || null, + os_label : r.os_label || null, + manufacturer : r.manufacturer || null, + last_seen : r.fetched_at || null, + download : r.download ?? 0, + upload : r.upload ?? 0, + encrypted_pct: riskMap[r.ip_address]?.encrypted_pct ?? null, + risk_level : riskMap[r.ip_address]?.risk_level ?? null, + has_insecure : insecureIPs.has(r.ip_address), + })); + + // ── 3. Recent flows for this agent (using aligned flows list) ─────────────── + let flows = db.getLatestFlows(100, null, agentUuid); + + // FALLBACK: Extrapolate flows from global using MACs/IPs if agent-specific fails + if (flows.length === 0 && (macs.length > 0 || agentIPs.length > 0)) { + const latestFlowGlobal = d.prepare(`SELECT MAX(fetched_at) as t FROM flows WHERE agent_uuid IS NULL`).get()?.t; + if (latestFlowGlobal) { + if (phIPs) { + flows = d.prepare(`SELECT * FROM flows WHERE fetched_at = ? AND agent_uuid IS NULL AND (local_mac IN (${ph}) OR local_ip IN (${phIPs})) ORDER BY download DESC LIMIT 100`).all(latestFlowGlobal, ...macs, ...agentIPs); + } else { + flows = d.prepare(`SELECT * FROM flows WHERE fetched_at = ? AND agent_uuid IS NULL AND local_mac IN (${ph}) ORDER BY download DESC LIMIT 100`).all(latestFlowGlobal, ...macs); + } + } + } + + // ── 4. Summary stats (aligned with dashboard stats query) ─────────────────── + const stats = db.getStats(null, agentUuid); + + // ACCURATE BANDWIDTH CALCULATION FROM MAC BANDWIDTH (Overrides broken Netify Summary endpoint) + let totalDown = 0; + let totalUp = 0; + const latestMacSnap = d.prepare(`SELECT MAX(fetched_at) AS t FROM mac_bandwidth`).get()?.t; + if (latestMacSnap && macs.length > 0) { + const macRows = d.prepare(`SELECT download, upload FROM mac_bandwidth WHERE fetched_at = ? AND mac_address IN (${ph})`).all(latestMacSnap, ...macs); + for (const r of macRows) { + totalDown += (r.download || 0); + totalUp += (r.upload || 0); + } + } + + const summary = { + total_devices : stats.totalDevices > 0 ? stats.totalDevices : devices.length, + active_flows : stats.activeFlows > 0 ? stats.activeFlows : flows.length, + bandwidth_down : totalDown > 0 ? totalDown : (stats.latestBw?.total_download ?? 0), + bandwidth_up : totalUp > 0 ? totalUp : (stats.latestBw?.total_upload ?? 0), + }; + + // ── 5. Security Intel filtered by agent IPs & MACs ───────────────────────── + const encryptionRows = (latestEncAudit && phIPs) + ? d.prepare(`SELECT ip_address, mac_address, device_label, encrypted_pct, unencrypted, encrypted, total, risk_level, detected_at FROM intel_encryption_audit WHERE fetched_at = ? AND ip_address IN (${phIPs}) ORDER BY CASE risk_level WHEN 'Vulnerable' THEN 1 WHEN 'Moderate' THEN 2 ELSE 3 END`).all(latestEncAudit, ...agentIPs) + : []; + + const insecureProtoRows = phIPs + ? d.prepare(`SELECT ip_address, mac_address, protocol, risk, app_label, dst_ip, dst_port, download, upload, detected_at FROM intel_insecure_protocols WHERE ip_address IN (${phIPs}) ORDER BY detected_at DESC LIMIT 50`).all(...agentIPs) + : []; + + let unencPwdRows = d.prepare(`SELECT ip_address, mac_address, dst_ip, dst_port, protocol, username, severity, download, upload, detected_at FROM intel_unencrypted_passwords WHERE mac_address IN (${ph}) ORDER BY detected_at DESC LIMIT 50`).all(...macs); + if (unencPwdRows.length === 0 && phIPs) { + unencPwdRows = d.prepare(`SELECT ip_address, mac_address, dst_ip, dst_port, protocol, username, severity, download, upload, detected_at FROM intel_unencrypted_passwords WHERE ip_address IN (${phIPs}) ORDER BY detected_at DESC LIMIT 50`).all(...agentIPs); + } + + const latestRepSnap = d.prepare(`SELECT MAX(fetched_at) AS t FROM intel_ip_reputation`).get()?.t; + const ipReputRows = (latestRepSnap && phIPs) + ? d.prepare(`SELECT ip_address, local_ip, mac_address, reputation, score, country, app_label, blacklisted, download, upload, detected_at FROM intel_ip_reputation WHERE fetched_at = ? AND (local_ip IN (${phIPs}) OR ip_address IN (${phIPs})) ORDER BY score DESC LIMIT 50`).all(latestRepSnap, ...agentIPs, ...agentIPs) + : []; + + let torRows = d.prepare(`SELECT ip_address, mac_address, exit_node, circuit_id, country, download, upload, detected_at FROM intel_tor_detection WHERE mac_address IN (${ph}) ORDER BY detected_at DESC LIMIT 20`).all(...macs); + if (torRows.length === 0 && phIPs) { + torRows = d.prepare(`SELECT ip_address, mac_address, exit_node, circuit_id, country, download, upload, detected_at FROM intel_tor_detection WHERE ip_address IN (${phIPs}) ORDER BY detected_at DESC LIMIT 20`).all(...agentIPs); + } + + let vpnRows = d.prepare(`SELECT ip_address, mac_address, vpn_type, remote_ip, protocol, country, confidence, download, upload, detected_at FROM intel_vpn_detection WHERE mac_address IN (${ph}) ORDER BY detected_at DESC LIMIT 20`).all(...macs); + if (vpnRows.length === 0 && phIPs) { + vpnRows = d.prepare(`SELECT ip_address, mac_address, vpn_type, remote_ip, protocol, country, confidence, download, upload, detected_at FROM intel_vpn_detection WHERE ip_address IN (${phIPs}) ORDER BY detected_at DESC LIMIT 20`).all(...agentIPs); + } + + const serverDiscRows = phIPs + ? d.prepare(`SELECT ip_address, mac_address, server_type, hostname, port, protocol, os_label, download, upload, detected_at FROM intel_server_discovery WHERE ip_address IN (${phIPs}) ORDER BY detected_at DESC LIMIT 50`).all(...agentIPs) + : []; + + const security = { + encryption_audit : encryptionRows, + insecure_protocols : insecureProtoRows, + unencrypted_passwords: unencPwdRows, + ip_reputation : ipReputRows, + tor_detections : torRows, + vpn_detections : vpnRows, + }; + + // ── 6. Events filtered by agent (aligned with events page) ─────────────── + const events = db.getLatestEvents(200, null, agentUuid); + + // ── 7. MAC bandwidth for this agent's MACs ────────────────────────────────── + // latestMacSnap was already declared above, reusing it. + const mac_bandwidth = latestMacSnap + ? d.prepare(`SELECT mac_address, manufacturer, download, upload, total FROM mac_bandwidth WHERE fetched_at = ? AND mac_address IN (${ph}) ORDER BY download DESC`).all(latestMacSnap, ...macs) + : []; + + return { + agent_uuid : agentUuid, + agent_label : label, + summary, + devices, + flows, + top_apps, + security, + events, + mac_bandwidth, + server_discovery: serverDiscRows, + }; +} + +// Fetch data for a specific device IP — from local DB (all 10 correlated tables) +async function fetchDeviceDetails(ip, agentUuid = null) { + const db = require('./database'); + const d = db.getDB(); + + // ── 0. Resolve MAC from flows (most recent) ────────────────────────────── + const macRow = d.prepare(`SELECT src_mac FROM flows WHERE src_ip = ? AND src_mac IS NOT NULL ORDER BY last_seen DESC LIMIT 1`).get(ip); + const mac = macRow?.src_mac || null; + + // ── 1. Device info from devices table ──────────────────────────────────── + const deviceRow = d.prepare(` + SELECT device_label, device_type, os_label, manufacturer, download, upload, last_seen + FROM devices + WHERE ip_address = ? + ORDER BY fetched_at DESC + LIMIT 1 + `).get(ip); + + // ── 2. Discovery info (may differ from devices table) ──────────────────── + const discRow = d.prepare(` + SELECT device_type, os_label, manufacturer, device_label, is_new + FROM intel_device_discovery + WHERE ip_address = ? + ORDER BY fetched_at DESC + LIMIT 1 + `).get(ip); + + const device_info = { + device_label : deviceRow?.device_label || discRow?.device_label || null, + device_type : deviceRow?.device_type || discRow?.device_type || null, + os_label : deviceRow?.os_label || discRow?.os_label || null, + manufacturer : deviceRow?.manufacturer || discRow?.manufacturer || null, + mac_address : mac, + is_new : discRow?.is_new ?? null, + }; + + // ── 3. Named apps & correlated ports ───────────────────────────────────── + const rawAppRows = d.prepare(` + SELECT app_label, + SUM(bytes_download) AS download, + SUM(bytes_upload) AS upload, + COUNT(*) AS flow_count + FROM flows + WHERE src_ip = ? + AND app_label IS NOT NULL + GROUP BY app_label + ORDER BY download DESC + LIMIT 30 + `).all(ip); + + // Cache helper mappings + const devices = d.prepare(` + SELECT ip_address, device_label, manufacturer, device_type + FROM devices + WHERE ip_address IS NOT NULL + `).all(); + + const devMap = new Map(); + for (const dev of devices) { + const label = dev.device_label || (dev.manufacturer && dev.manufacturer !== 'Unknown' ? `${dev.manufacturer} Device` : null); + if (label) { + devMap.set(dev.ip_address, label); + } + } + + const flowIPs = d.prepare(` + SELECT dst_ip, domain, app_label, COUNT(*) as count + FROM flows + WHERE dst_ip IS NOT NULL + AND (domain IS NOT NULL OR (app_label IS NOT NULL AND app_label NOT LIKE 'Port %')) + GROUP BY dst_ip, domain, app_label + ORDER BY count DESC + `).all(); + + const publicIpMap = new Map(); + for (const row of flowIPs) { + if (!publicIpMap.has(row.dst_ip)) { + publicIpMap.set(row.dst_ip, { + domain: row.domain, + app_label: row.app_label + }); + } + } + + function getFriendlyIpName(ipAddress) { + if (devMap.has(ipAddress)) return devMap.get(ipAddress); + if (publicIpMap.has(ipAddress)) { + const pub = publicIpMap.get(ipAddress); + return pub.domain || pub.app_label; + } + if (ipAddress.startsWith('10.6.')) return 'IFG Client'; + if (ipAddress.startsWith('10.250.') || ipAddress.startsWith('192.168.') || ipAddress.startsWith('10.121.')) return 'CPI Client'; + if ( + ipAddress.startsWith('10.0.') || ipAddress.startsWith('10.1.') || ipAddress.startsWith('10.26.') || + ipAddress.startsWith('10.43.') || ipAddress.startsWith('10.35.') || ipAddress.startsWith('10.21.') || + ipAddress.startsWith('10.7.') || ipAddress.startsWith('10.182.') || ipAddress.startsWith('10.109.') || + ipAddress.startsWith('10.181.') || ipAddress.startsWith('10.75.') || ipAddress.startsWith('10.202.') || + ipAddress.startsWith('10.93.') + ) return 'JRP Client'; + return 'Intranet Client'; + } + + const matches = { + "1433": "MSSQL Database Server", + "1434": "MSSQL Monitor Server", + "3306": "MySQL/MariaDB", + "5432": "PostgreSQL", + "1521": "Oracle DB Server", + "27017": "MongoDB", + "6379": "Redis Cache", + "80": "HTTP Web Server", + "443": "HTTPS/TLS Secure Connection", + "22": "SSH Remote Management", + "21": "FTP File Storage", + "23": "Telnet Command Insecure", + "25": "SMTP Mail Delivery", + "587": "Secure SMTP Mail", + "110": "POP3 Mail Retrieval", + "993": "Secure IMAP Mail", + "53": "DNS Domain Directory Query", + "123": "NTP Network Time", + "161": "SNMP Monitoring Service", + "3389": "RDP Remote Windows Desktop", + "445": "SMB Windows File Share", + "137": "NetBIOS Name Service", + "138": "NetBIOS Datagram Service", + "139": "NetBIOS Session Service", + "1812": "RADIUS Auth Server", + "1813": "RADIUS Accounting", + "5060": "SIP VoIP Service" + }; + + // ── 4. Top domains accessed by this device ───────────────────────────── + const domainRows = d.prepare(` + SELECT domain, + -- use app_label that appeared most with this domain + (SELECT app_label FROM flows + WHERE src_ip = f.src_ip AND domain = f.domain + AND app_label IS NOT NULL + ORDER BY bytes_download DESC LIMIT 1) AS app_label, + -- extract root domain for display + domain AS display_name, + SUM(bytes_download) AS download, + SUM(bytes_upload) AS upload, + COUNT(*) AS flow_count + FROM flows f + WHERE src_ip = ? + AND domain IS NOT NULL + GROUP BY domain + ORDER BY download DESC + LIMIT 30 + `).all(ip); + + // ── 5. Smart combined display list ────────────────────────────────────── + const combinedMap = new Map(); + + // Add domains first (higher priority) + for (const r of domainRows) { + combinedMap.set('domain:' + r.domain, { + label : r.domain, // the actual website/domain + sub_label : r.app_label || null, // protocol (HTTPS/TLS etc) + type : 'domain', + download : r.download ?? 0, + upload : r.upload ?? 0, + flow_count : r.flow_count, + }); + } + + // Add named & correlated apps + for (const r of rawAppRows) { + let label = r.app_label; + let sub_label = null; + let type = 'protocol'; + + const isPortLabel = label.startsWith("Port ") || label.toLowerCase().includes("port"); + + if (isPortLabel) { + const portStr = label.replace("Port ", "").trim(); + type = 'port'; + + const flow = d.prepare(` + SELECT dst_ip, protocol, dst_port + FROM flows + WHERE src_ip = ? AND (app_label = ? OR dst_port = ?) + GROUP BY dst_ip, protocol, dst_port + ORDER BY COUNT(*) DESC + LIMIT 1 + `).get(ip, label, portStr); + + if (flow && flow.dst_ip) { + const friendlyName = getFriendlyIpName(flow.dst_ip); + label = friendlyName; + sub_label = `Port ${portStr} (${flow.protocol || 'TCP'})`; + } else { + const stdName = matches[portStr]; + if (stdName) { + label = stdName; + sub_label = `Port ${portStr}`; + } else { + sub_label = `Port ${portStr}`; + } + } + } + + const key = isPortLabel ? 'port:' + r.app_label : 'app:' + r.app_label; + if (!combinedMap.has(key)) { + combinedMap.set(key, { + label : label, + sub_label : sub_label, + type : type, + download : r.download ?? 0, + upload : r.upload ?? 0, + flow_count : r.flow_count, + }); + } + } + + const top_apps = [...combinedMap.values()].sort((a, b) => b.download - a.download).slice(0, 25); + + // top_domains: keep simple list for Info tab + const top_domains = domainRows.map(r => ({ + domain : r.domain, + app_label : r.app_label, + download : r.download ?? 0, + upload : r.upload ?? 0, + flow_count : r.flow_count, + })); + + // ── 5. Recent flows ──────────────────────────────────────────────────── + const flowRows = d.prepare(` + SELECT dst_ip, dst_port, protocol, app_label, domain, + bytes_download AS download, bytes_upload AS upload, last_seen + FROM flows + WHERE src_ip = ? + ORDER BY last_seen DESC + LIMIT 100 + `).all(ip); + + const flows = flowRows.map(r => ({ + dst_ip : r.dst_ip, + dst_port : r.dst_port, + protocol : r.protocol, + app_label : r.app_label, + domain : r.domain, + download : r.download ?? 0, + upload : r.upload ?? 0, + last_seen : r.last_seen, + })); + + // ── 6. Totals ───────────────────────────────────────────────────────── + const sumRow = d.prepare(` + SELECT SUM(bytes_download) AS total_download, + SUM(bytes_upload) AS total_upload, + COUNT(*) AS flow_count + FROM flows + WHERE src_ip = ? + `).get(ip); + + // ── 7. Encryption audit (latest snapshot) ───────────────────────────── + const latestAudit = d.prepare(`SELECT MAX(fetched_at) AS t FROM intel_encryption_audit`).get()?.t; + const encRow = latestAudit + ? d.prepare(` + SELECT encrypted_pct, encrypted, unencrypted, total, risk_level, mac_address + FROM intel_encryption_audit + WHERE fetched_at = ? AND ip_address = ? + LIMIT 1 + `).get(latestAudit, ip) + : null; + + // Also try fallback by MAC if not found by IP + const encRowMac = (!encRow && mac && latestAudit) + ? d.prepare(` + SELECT encrypted_pct, encrypted, unencrypted, total, risk_level, ip_address + FROM intel_encryption_audit + WHERE fetched_at = ? AND mac_address = ? + ORDER BY detected_at DESC + LIMIT 1 + `).get(latestAudit, mac) + : null; + + const encFinal = encRow || encRowMac; + + const encryption = encFinal ? { + encrypted_pct : encFinal.encrypted_pct ?? null, + encrypted_bytes : encFinal.encrypted ?? null, + unencrypted_bytes: encFinal.unencrypted ?? null, + total_bytes : encFinal.total ?? null, + risk_level : encFinal.risk_level ?? null, + } : null; + + // ── 8. Server discovery (servers this device accessed) ───────────────── + const serverRows = d.prepare(` + SELECT DISTINCT server_type, hostname, port, protocol, os_label, + MAX(download) AS download, MAX(upload) AS upload, MAX(detected_at) AS detected_at + FROM intel_server_discovery + WHERE ip_address = ? + GROUP BY server_type, port, protocol + ORDER BY download DESC + LIMIT 50 + `).all(ip); + + // fallback by MAC if no rows by IP + const serverRowsMac = (serverRows.length === 0 && mac) + ? d.prepare(` + SELECT DISTINCT server_type, hostname, port, protocol, os_label, + MAX(download) AS download, MAX(upload) AS upload, MAX(detected_at) AS detected_at + FROM intel_server_discovery + WHERE mac_address = ? + GROUP BY server_type, port, protocol + ORDER BY download DESC + LIMIT 50 + `).all(mac) + : []; + + const server_discovery = (serverRows.length > 0 ? serverRows : serverRowsMac).map(r => ({ + server_type : r.server_type, + hostname : r.hostname || null, + port : r.port, + protocol : r.protocol, + os_label : r.os_label || null, + download : r.download ?? 0, + upload : r.upload ?? 0, + detected_at : r.detected_at, + })); + + // ── 9. Unencrypted passwords ─────────────────────────────────────────── + let pwdRows = d.prepare(` + SELECT dst_ip, dst_port, protocol, username, severity, download, upload, detected_at + FROM intel_unencrypted_passwords + WHERE ip_address = ? + ORDER BY detected_at DESC + LIMIT 50 + `).all(ip); + + if (pwdRows.length === 0 && mac) { + pwdRows = d.prepare(` + SELECT dst_ip, dst_port, protocol, username, severity, download, upload, detected_at + FROM intel_unencrypted_passwords + WHERE mac_address = ? + ORDER BY detected_at DESC + LIMIT 50 + `).all(mac); + } + + const unencrypted_passwords = pwdRows.map(r => ({ + dst_ip : r.dst_ip, + dst_port : r.dst_port, + protocol : r.protocol, + username : r.username, + severity : r.severity, + download : r.download ?? 0, + upload : r.upload ?? 0, + detected_at : r.detected_at, + })); + + // ── 10. IP Reputation (latest snapshot, this device's local_ip) ───────── + const latestRepSnap = d.prepare(`SELECT MAX(fetched_at) AS t FROM intel_ip_reputation`).get()?.t; + const repRows = latestRepSnap + ? d.prepare(` + SELECT ip_address, local_ip, reputation, score, country, app_label, blacklisted, download, upload + FROM intel_ip_reputation + WHERE fetched_at = ? AND (local_ip = ? OR ip_address = ?) + ORDER BY score DESC NULLS LAST + LIMIT 30 + `).all(latestRepSnap, ip, ip) + : []; + + const ip_reputation = repRows.map(r => ({ + remote_ip : r.ip_address, + local_ip : r.local_ip, + reputation : r.reputation, + score : r.score, + country : r.country, + app_label : r.app_label, + blacklisted : !!r.blacklisted, + download : r.download ?? 0, + upload : r.upload ?? 0, + })); + + // ── 11. VPN detection ───────────────────────────────────────────────── + let vpnRows = d.prepare(` + SELECT vpn_type, remote_ip, protocol, country, confidence, download, upload, detected_at + FROM intel_vpn_detection + WHERE ip_address = ? + ORDER BY detected_at DESC + LIMIT 20 + `).all(ip); + + if (vpnRows.length === 0 && mac) { + vpnRows = d.prepare(` + SELECT vpn_type, remote_ip, protocol, country, confidence, download, upload, detected_at + FROM intel_vpn_detection + WHERE mac_address = ? + ORDER BY detected_at DESC + LIMIT 20 + `).all(mac); + } + + const vpn_detections = vpnRows.map(r => ({ + vpn_type : r.vpn_type, + remote_ip : r.remote_ip, + protocol : r.protocol, + country : r.country, + confidence : r.confidence, + download : r.download ?? 0, + upload : r.upload ?? 0, + detected_at : r.detected_at, + })); + + // ── 12. Events ──────────────────────────────────────────────────────── + const evtByIP = d.prepare(`SELECT event_type, severity, ip_address, mac_address, description, event_at FROM events WHERE ip_address = ? ORDER BY event_at DESC LIMIT 50`).all(ip); + const evtByMAC = mac ? d.prepare(`SELECT event_type, severity, ip_address, mac_address, description, event_at FROM events WHERE mac_address = ? ORDER BY event_at DESC LIMIT 50`).all(mac) : []; + + const seenEvt = new Set(); + const evtMerged = []; + for (const r of [...evtByIP, ...evtByMAC]) { + const key = `${r.event_type}:${r.event_at}`; + if (!seenEvt.has(key)) { + seenEvt.add(key); + evtMerged.push({ event_type: r.event_type, severity: r.severity, ip_address: r.ip_address, mac_address: r.mac_address, description: r.description, event_at: r.event_at }); + } + } + evtMerged.sort((a, b) => (b.event_at || '').localeCompare(a.event_at || '')); + const events = evtMerged.slice(0, 100); + + // ── 13. MAC bandwidth (latest snapshot) ─────────────────────────────── + const latestMacSnap = d.prepare(`SELECT MAX(fetched_at) AS t FROM mac_bandwidth`).get()?.t; + const macBwRow = (latestMacSnap && mac) + ? d.prepare(`SELECT manufacturer, download, upload, total FROM mac_bandwidth WHERE fetched_at = ? AND mac_address = ? LIMIT 1`).get(latestMacSnap, mac) + : null; + + const mac_bandwidth = macBwRow ? { + mac_address : mac, + manufacturer : macBwRow.manufacturer, + download : macBwRow.download ?? 0, + upload : macBwRow.upload ?? 0, + total : macBwRow.total ?? 0, + } : null; + + return { + ip, + mac_address : mac, + total_download : deviceRow?.download || sumRow?.total_download || 0, + total_upload : deviceRow?.upload || sumRow?.total_upload || 0, + flow_count : sumRow?.flow_count ?? 0, + device_info, + top_apps, + top_domains : domainRows.map(r => ({ domain: r.domain, download: r.download ?? 0, upload: r.upload ?? 0, flow_count: r.flow_count })), + flows, + encryption, + server_discovery, + unencrypted_passwords, + ip_reputation, + vpn_detections, + events, + mac_bandwidth, + }; +} + +// Fetch data for a specific application// Fetch data for a specific application — from local DB +async function fetchAppDetails(appLabel, agentUuid = null) { + const db = require('./database'); + const d = db.getDB(); + + // ── Totals: from flows (protocol-level) OR bandwidth_apps (brand-level) ── + // IMPORTANT: use MAX(download) from latest snapshot — NOT SUM() of all history! + let flowQuery = ` + SELECT SUM(bytes_download) AS download, + SUM(bytes_upload) AS upload, + COUNT(*) AS flow_count + FROM flows + WHERE app_label = @appLabel + `; + if (agentUuid) flowQuery += ` AND agent_uuid = @agentUuid`; + const flowTotalRow = d.prepare(flowQuery).get({ appLabel, agentUuid }); + + let total_download = flowTotalRow?.download ?? 0; + let total_upload = flowTotalRow?.upload ?? 0; + let data_source = 'flows'; // track where totals came from + + // If no flows data (brand-name app like YouTube), use LATEST bandwidth_apps snapshot + if (total_download === 0 && total_upload === 0) { + let snapQuery = `SELECT MAX(fetched_at) AS t FROM bandwidth_apps WHERE app_label = @appLabel`; + snapQuery += agentUuid ? ` AND agent_uuid = @agentUuid` : ` AND agent_uuid IS NULL`; + const latestSnap = d.prepare(snapQuery).get({ appLabel, agentUuid })?.t; + + if (latestSnap) { + let bwQuery = ` + SELECT download, upload + FROM bandwidth_apps + WHERE app_label = @appLabel AND fetched_at = @latestSnap + `; + bwQuery += agentUuid ? ` AND agent_uuid = @agentUuid` : ` AND agent_uuid IS NULL`; + bwQuery += ` LIMIT 1`; + + const bwRow = d.prepare(bwQuery).get({ appLabel, latestSnap, agentUuid }); + if (bwRow) { + total_download = bwRow.download ?? 0; + total_upload = bwRow.upload ?? 0; + data_source = 'bandwidth_apps'; + } + } + } + + // ── Top 5 Flow Records accessing this app (Raw Flows) ── + let ipQuery = ` + SELECT src_ip AS ip_address, + dst_ip, + domain, + last_seen, + bytes_download AS download, + bytes_upload AS upload + FROM flows + WHERE app_label = @appLabel + `; + if (agentUuid) ipQuery += ` AND agent_uuid = @agentUuid`; + ipQuery += ` ORDER BY download DESC LIMIT 5`; + const ipRows = d.prepare(ipQuery).all({ appLabel, agentUuid }); + + // ── Domain-based per-IP breakdown (bridges HTTPS/TLS → brand name) ── + // Build keyword map for common brand names + const DOMAIN_KEYWORDS = { + 'YouTube' : ['youtube', 'googlevideo', 'ytimg', 'yt3.ggpht'], + 'Facebook' : ['facebook', 'fbcdn', 'fb.com', 'fbsbx'], + 'WhatsApp' : ['whatsapp', 'wa.me'], + 'Instagram' : ['instagram', 'cdninstagram'], + 'TikTok' : ['tiktok', 'tiktokcdn', 'tiktokv'], + 'Netflix' : ['netflix', 'nflxvideo', 'nflximg'], + 'Google' : ['google.com', 'googleapis', 'gstatic', 'googlesyndication'], + 'Microsoft' : ['microsoft', 'msftncsi', 'live.com', 'office365', 'sharepoint'], + 'Zoom' : ['zoom.us', 'zoomgov'], + 'Spotify' : ['spotify', 'scdn.co'], + }; + + let domain_breakdown = []; + const kws = DOMAIN_KEYWORDS[appLabel]; + if (kws && kws.length > 0) { + // Build WHERE clause for domain keywords + const likeClause = kws.map(() => `LOWER(domain) LIKE ?`).join(' OR '); + const likeParams = kws.map(k => `%${k}%`); + let domQuery = ` + SELECT src_ip AS ip_address, + dst_ip, + domain, + last_seen, + bytes_download AS download, + bytes_upload AS upload + FROM flows + WHERE domain IS NOT NULL AND (${likeClause}) + `; + const domParams = [...likeParams]; + if (agentUuid) { + domQuery += ` AND agent_uuid = ?`; + domParams.push(agentUuid); + } + domQuery += ` ORDER BY download DESC LIMIT 5`; + const domRows = d.prepare(domQuery).all(...domParams); + + domain_breakdown = domRows.map(r => ({ + ip_address : r.ip_address, + dst_ip : r.dst_ip, + domain : r.domain, + last_seen : r.last_seen, + download : r.download ?? 0, + upload : r.upload ?? 0, + })); + } + + // ── Threat flags: cross-reference top IPs with intel_ip_reputation ── + const latestRepSnap = d.prepare(`SELECT MAX(fetched_at) AS t FROM intel_ip_reputation`).get()?.t; + const threatMap = {}; + if (latestRepSnap) { + const repRows = d.prepare(` + SELECT local_ip, ip_address, reputation, blacklisted + FROM intel_ip_reputation + WHERE fetched_at = ? + `).all(latestRepSnap); + for (const r of repRows) { + const key = r.local_ip || r.ip_address; + if (key) threatMap[key] = { reputation: r.reputation, blacklisted: r.blacklisted }; + } + } + + const allIpRows = domain_breakdown.length > 0 ? domain_breakdown : ipRows; + const top_ips = allIpRows.map(r => ({ + ip_address : r.ip_address, + dst_ip : r.dst_ip, + domain : r.domain, + last_seen : r.last_seen, + download : r.download ?? 0, + upload : r.upload ?? 0, + reputation : threatMap[r.ip_address]?.reputation ?? null, + blacklisted : threatMap[r.ip_address]?.blacklisted ?? false, + })); + + // ── Per-agent breakdown using agent_uuid grouping in flows OR local_mac mapping ── + let allRelevantFlows = []; + if (kws && kws.length > 0) { + const likeClause2 = kws.map(() => `LOWER(domain) LIKE ?`).join(' OR '); + const likeParams2 = kws.map(k => `%${k}%`); + let scoreQuery = ` + SELECT agent_uuid, src_mac AS local_mac, + bytes_download AS download, + bytes_upload AS upload + FROM flows + WHERE domain IS NOT NULL AND (${likeClause2}) + `; + const scoreParams = [...likeParams2]; + if (agentUuid) { + scoreQuery += ` AND (agent_uuid = ? OR agent_uuid IS NULL)`; + scoreParams.push(agentUuid); + } + allRelevantFlows = d.prepare(scoreQuery).all(...scoreParams); + } else { + let scoreQuery = ` + SELECT agent_uuid, src_mac AS local_mac, + bytes_download AS download, + bytes_upload AS upload + FROM flows + WHERE app_label = ? + `; + const scoreParams = [appLabel]; + if (agentUuid) { + scoreQuery += ` AND (agent_uuid = ? OR agent_uuid IS NULL)`; + scoreParams.push(agentUuid); + } + allRelevantFlows = d.prepare(scoreQuery).all(...scoreParams); + } + + // Reverse map MAC to Agent UUID + const macToAgent = {}; + for (const [auid, macs] of Object.entries(AGENT_MAC_MAP)) { + for (const m of macs) { + macToAgent[m] = auid; + } + } + + const agentScoreMap = {}; + for (const row of allRelevantFlows) { + let auid = row.agent_uuid; + if (!auid && row.local_mac && macToAgent[row.local_mac]) { + auid = macToAgent[row.local_mac]; // Fallback to MAC mapping + } + if (auid) { + if (agentUuid && auid !== agentUuid) continue; // skip if filtering by a specific agent + if (!agentScoreMap[auid]) { + agentScoreMap[auid] = { download: 0, upload: 0, flow_count: 0 }; + } + agentScoreMap[auid].download += (row.download ?? 0); + agentScoreMap[auid].upload += (row.upload ?? 0); + agentScoreMap[auid].flow_count += 1; + } + } + + let agent_scorecard = Object.keys(agentScoreMap).map(auid => ({ + agent_uuid : auid, + agent_label : AGENT_LABELS[auid] || auid, + download : agentScoreMap[auid].download, + upload : agentScoreMap[auid].upload, + flow_count : agentScoreMap[auid].flow_count, + })).filter(a => a.download > 0 || a.upload > 0); + + // Fallback to bandwidth_apps if flow-based scorecard is empty + if (agent_scorecard.length === 0) { + const snapQuery = `SELECT MAX(fetched_at) AS t FROM bandwidth_apps WHERE app_label = @appLabel AND agent_uuid IS NOT NULL`; + const latestAppSnap = d.prepare(snapQuery).get({ appLabel })?.t; + if (latestAppSnap) { + let bwQuery = `SELECT agent_uuid, download, upload FROM bandwidth_apps WHERE app_label = @appLabel AND fetched_at = @latestAppSnap AND agent_uuid IS NOT NULL`; + if (agentUuid) bwQuery += ` AND agent_uuid = @agentUuid`; + const bwRows = d.prepare(bwQuery).all({ appLabel, latestAppSnap, agentUuid }); + for (const row of bwRows) { + agent_scorecard.push({ + agent_uuid: row.agent_uuid, + agent_label: AGENT_LABELS[row.agent_uuid] || row.agent_uuid, + download: row.download, + upload: row.upload, + flow_count: 0 + }); + } + } + } + + return { + app_label : appLabel, + total_download, + total_upload, + data_source, + agent_scorecard, + top_ips, + has_domain_breakdown: domain_breakdown.length > 0, + }; +} + + +// Fetch security device risk overview — encryption audit + insecure protocols per device +async function fetchSecurityDevices(siteUuid = null, agentUuid = null) { + const db = require('./database'); + const d = db.getDB(); + + // Get active IPs and MACs for filtering if agentUuid is provided + let agentIPs = null; + let agentIPSet = null; + let agentMacs = null; + if (agentUuid && AGENT_MAC_MAP[agentUuid]) { + agentMacs = AGENT_MAC_MAP[agentUuid]; + const resolvedDevices = db.getLatestDevices(1000, null, agentUuid); + agentIPs = resolvedDevices.map(d => d.ip_address).filter(Boolean); + agentIPSet = new Set(agentIPs); + } + + const latestFetch = d.prepare(`SELECT MAX(fetched_at) AS t FROM intel_encryption_audit`).get()?.t; + let encryptRows = []; + if (latestFetch) { + if (agentMacs) { + // Query with agent's MACs or JRP subnet IPs + const placeholders = agentMacs.map(() => '?').join(','); + encryptRows = d.prepare(` + SELECT * FROM intel_encryption_audit + WHERE fetched_at = ? AND (mac_address IN (${placeholders}) OR ip_address IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders}))) + `).all(latestFetch, ...agentMacs, ...agentMacs); + } else { + encryptRows = d.prepare(` + SELECT * FROM intel_encryption_audit + WHERE fetched_at = ? AND (@siteUuid IS NULL OR site_uuid = @siteUuid) + `).all(latestFetch, { siteUuid }); + } + } + + let insecureRows = []; + if (agentMacs) { + const placeholders = agentMacs.map(() => '?').join(','); + insecureRows = d.prepare(` + SELECT DISTINCT ip_address FROM intel_insecure_protocols + WHERE mac_address IN (${placeholders}) OR ip_address IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders})) + `).all(...agentMacs, ...agentMacs); + } else { + insecureRows = d.prepare(` + SELECT DISTINCT ip_address FROM intel_insecure_protocols + WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) + `).all({ siteUuid }); + } + const insecureIPs = new Set(insecureRows.map(r => r.ip_address).filter(Boolean)); + + // Compute risk per device + const deviceMap = {}; + for (const r of encryptRows) { + const ip = r.ip_address; + if (!ip) continue; + + // Additional security check: if agent is logged in, ensure we do not leak other agent's IPs + if (agentIPSet && !agentIPSet.has(ip)) { + continue; + } + + const encPct = r.encrypted_pct ?? 100; + let riskLevel; + if (encPct < 50 || insecureIPs.has(ip)) { + riskLevel = 'Vulnerable'; + } else if (encPct < 80) { + riskLevel = 'Moderate'; + } else { + riskLevel = 'Safe'; + } + if (!deviceMap[ip] || deviceMap[ip].encrypted_pct < encPct) { + deviceMap[ip] = { + ip_address : ip, + mac_address : r.mac_address ?? null, + device_label : r.device_label ?? null, + encrypted_pct : encPct, + unencrypted : r.unencrypted ?? 0, + encrypted : r.encrypted ?? 0, + total : r.total ?? 0, + risk_level : riskLevel, + has_insecure : insecureIPs.has(ip), + }; + } + } + + // Get device details (type, OS) from discovery data + const discMap = {}; + try { + const discRows = db.getLatestDevices(1000, siteUuid, agentUuid); + for (const r of discRows) { + if (r.ip_address) discMap[r.ip_address] = r; + } + } catch (_) { + // skip enrichment if error + } + + const devices = Object.values(deviceMap).map(dev => ({ + ...dev, + device_type : discMap[dev.ip_address]?.device_type ?? null, + os_label : discMap[dev.ip_address]?.os_label ?? null, + manufacturer : discMap[dev.ip_address]?.manufacturer ?? null, + })); + + // Sort: Vulnerable first, then Moderate, then Safe + const ORDER = { Vulnerable: 0, Moderate: 1, Safe: 2 }; + devices.sort((a, b) => (ORDER[a.risk_level] ?? 3) - (ORDER[b.risk_level] ?? 3)); + + return devices; +} + +module.exports = { + fetchLookupApplications, + fetchAgents, + fetchTopApps, fetchTopDevices, fetchTopProtocols, fetchTopCountries, + fetchTopDomains, fetchBandwidthSummary, fetchDiscoveredDevices, + fetchCyberThreats, fetchFlows, fetchEvents, + fetchTopAppCategories, fetchTopContinents, fetchTopRegions, fetchTopCities, + fetchTopVLANs, fetchTopInterfaces, fetchTopFlowTypes, fetchTopFlowOrigins, + fetchTopIPVersions, fetchTopRemoteIPs, fetchTopLocalMACs, + fetchTopDiscoveryOS, + fetchTLSVersions, fetchTLSCiphers, fetchTLSSecurity, fetchNetBIOSHostnames, + // DPI 12-21 (field name sudah diperbaiki sesuai dokumentasi resmi) + fetchDHCPClassFingerprints, + fetchHTTPUserAgents, + fetchSNIHostnames, + fetchSSLServerCN, + fetchQUICHostnames, + fetchBitTorrentInfoHashes, + fetchSSHClients, + fetchSSHServers, + fetchMDNSHostnames, + // Intelligence 22-30 (derive dari data yang tersedia) + fetchCryptoMining, + fetchDeviceDiscovery, + fetchEncryptionAudit, + fetchInsecureProtocols, + fetchIPReputation, + fetchServerDiscovery, + fetchTorDetection, + fetchUnencryptedPasswords, + fetchVPNDetection, + // Interactive detail fetchers + fetchAgentDetails, + fetchDeviceDetails, + fetchAppDetails, + fetchSecurityDevices, +}; + +// ─── DPI FIELDS 12-21 — FIELD NAMES DIPERBAIKI SESUAI DOCS ────────────────── +// Sumber: https://www.netify.ai/documentation/informatics/v2/data/fields +// +// Field yang SALAH sebelumnya → yang BENAR: +// dhcp_fingerprint → dhcp_class +// user_agent → http_useragent +// ssl_cn → https_sni_hostname +// ssl_server_cn → ssl_server_cn ✓ (sudah benar) +// quic_hostname → quic_hostname ✓ (sudah benar, mungkin belum aktif di akun) +// bt_info_hash → bittorrent_info_hash +// ssh_version → ssh_client / ssh_server (2 field terpisah) +// mdns_hostname → mdns_hostname ✓ (sudah benar, mungkin belum aktif di akun) + +// Helper builder untuk DPI single-field +async function _dpiTopField(fieldName, interval, limit, agentUuid = null) { + const [dl, ul] = await Promise.all([ + netifyFetch(`/data/stats/top/${fieldName}/download`, { filter_interval: interval, settings_limit: limit }, false, agentUuid), + netifyFetch(`/data/stats/top/${fieldName}/upload`, { filter_interval: interval, settings_limit: limit }, false, agentUuid), + ]); + if (!dl) return null; + const ulMap = {}; + if (ul) for (const r of ul) { + const key = r[fieldName]?.name ?? r[fieldName]?.label ?? String(r[fieldName] ?? ''); + if (key) ulMap[key] = r.upload ?? 0; + } + return { dl, ulMap }; +} + +// 12. DHCP Class (field: dhcp_class) +async function fetchDHCPClassFingerprints(interval = 1440, limit = 30, agentUuid = null) { + const res = await _dpiTopField('dhcp_class', interval, limit, agentUuid); + if (!res) return null; + return res.dl.map(r => { + const label = r.dhcp_class?.name ?? r.dhcp_class?.label ?? String(r.dhcp_class ?? 'Unknown'); + return { + fingerprint : label, + download : r.download ?? 0, + upload : res.ulMap[label] ?? 0, + total : (r.download ?? 0) + (res.ulMap[label] ?? 0), + }; + }); +} + +// 13. HTTP User-Agent (field: http_useragent) +async function fetchHTTPUserAgents(interval = 1440, limit = 30, agentUuid = null) { + const res = await _dpiTopField('http_useragent', interval, limit, agentUuid); + if (!res) return null; + return res.dl.map(r => { + const label = r.http_useragent?.name ?? r.http_useragent?.label ?? String(r.http_useragent ?? 'Unknown'); + return { + user_agent : label, + download : r.download ?? 0, + upload : res.ulMap[label] ?? 0, + total : (r.download ?? 0) + (res.ulMap[label] ?? 0), + }; + }); +} + +// 14. HTTPS SNI Hostname (field: https_sni_hostname) +async function fetchSNIHostnames(interval = 1440, limit = 30, agentUuid = null) { + const res = await _dpiTopField('https_sni_hostname', interval, limit, agentUuid); + if (!res) return null; + return res.dl.map(r => { + const name = r.https_sni_hostname?.name ?? r.https_sni_hostname?.label ?? String(r.https_sni_hostname ?? 'Unknown'); + return { + sni_hostname : name, + download : r.download ?? 0, + upload : res.ulMap[name] ?? 0, + total : (r.download ?? 0) + (res.ulMap[name] ?? 0), + }; + }); +} + +// 15. SSL Server Common Name (field: ssl_server_cn) +async function fetchSSLServerCN(interval = 1440, limit = 30, agentUuid = null) { + const res = await _dpiTopField('ssl_server_cn', interval, limit, agentUuid); + if (!res) return null; + return res.dl.map(r => { + const name = r.ssl_server_cn?.name ?? r.ssl_server_cn?.label ?? String(r.ssl_server_cn ?? 'Unknown'); + return { + ssl_server_cn : name, + download : r.download ?? 0, + upload : res.ulMap[name] ?? 0, + total : (r.download ?? 0) + (res.ulMap[name] ?? 0), + }; + }); +} + +// 17. QUIC Hostname (field: quic_hostname) +async function fetchQUICHostnames(interval = 1440, limit = 30, agentUuid = null) { + const res = await _dpiTopField('quic_hostname', interval, limit, agentUuid); + if (!res) return null; + return res.dl.map(r => { + const name = r.quic_hostname?.name ?? r.quic_hostname?.label ?? String(r.quic_hostname ?? 'Unknown'); + return { + quic_hostname : name, + download : r.download ?? 0, + upload : res.ulMap[name] ?? 0, + total : (r.download ?? 0) + (res.ulMap[name] ?? 0), + }; + }); +} + +// 18. BitTorrent Info Hash (field: bittorrent_info_hash) +async function fetchBitTorrentInfoHashes(interval = 1440, limit = 30, agentUuid = null) { + const res = await _dpiTopField('bittorrent_info_hash', interval, limit, agentUuid); + if (!res) return null; + return res.dl.map(r => { + const hash = r.bittorrent_info_hash?.hash ?? r.bittorrent_info_hash?.name ?? String(r.bittorrent_info_hash ?? 'Unknown'); + const label = r.bittorrent_info_hash?.label ?? hash; + return { + info_hash : hash, + label : label, + download : r.download ?? 0, + upload : res.ulMap[hash] ?? res.ulMap[label] ?? 0, + total : (r.download ?? 0) + (res.ulMap[hash] ?? res.ulMap[label] ?? 0), + }; + }); +} + +// 19a. SSH Client (field: ssh_client) +async function fetchSSHClients(interval = 1440, limit = 20, agentUuid = null) { + const res = await _dpiTopField('ssh_client', interval, limit, agentUuid); + if (!res) return null; + return res.dl.map(r => { + const label = r.ssh_client?.name ?? r.ssh_client?.label ?? String(r.ssh_client ?? 'Unknown'); + return { + ssh_version : label, + direction : 'client', + download : r.download ?? 0, + upload : res.ulMap[label] ?? 0, + total : (r.download ?? 0) + (res.ulMap[label] ?? 0), + }; + }); +} + +// 19b. SSH Server (field: ssh_server) +async function fetchSSHServers(interval = 1440, limit = 20, agentUuid = null) { + const res = await _dpiTopField('ssh_server', interval, limit, agentUuid); + if (!res) return null; + return res.dl.map(r => { + const label = r.ssh_server?.name ?? r.ssh_server?.label ?? String(r.ssh_server ?? 'Unknown'); + return { + ssh_version : label, + direction : 'server', + download : r.download ?? 0, + upload : res.ulMap[label] ?? 0, + total : (r.download ?? 0) + (res.ulMap[label] ?? 0), + }; + }); +} + +// 21. mDNS Hostname (field: mdns_hostname) +async function fetchMDNSHostnames(interval = 1440, limit = 30, agentUuid = null) { + const res = await _dpiTopField('mdns_hostname', interval, limit, agentUuid); + if (!res) return null; + return res.dl.map(r => { + const name = r.mdns_hostname?.name ?? r.mdns_hostname?.label ?? String(r.mdns_hostname ?? 'Unknown'); + return { + mdns_hostname : name, + download : r.download ?? 0, + upload : res.ulMap[name] ?? 0, + total : (r.download ?? 0) + (res.ulMap[name] ?? 0), + }; + }); +} + +// ─── INTELLIGENCE 22-30 — DERIVE DARI DATA YANG SUDAH ADA ──────────────────── +// Endpoint /intelligence/* dan /events/* semua 404 di akun ini. +// Semua fungsi berikut meng-DERIVE data dari endpoint yang sudah confirmed bekerja: +// /data/stats/top/*, /data/flows +// Ini memberikan data nyata, bukan mock/dummy. + +// 22. Cryptocurrency Mining — derive dari apps dengan nama mengandung "crypto" / "mining" +// + flows ke port mining pool (3333, 4444, 8333, 9999, 14444) +async function fetchCryptoMining(interval = 1440, limit = 50, agentUuid = null) { + const MINING_POOLS_PORTS = new Set([3333, 4444, 5555, 7777, 8333, 9332, 9999, 14444, 45560, 45700]); + const MINING_APPS = ['bitcoin', 'crypto', 'mining', 'monero', 'ethereum', 'nicehash', 'nanopool', 'f2pool', 'antpool', 'slushpool']; + + const [appData, flowsRaw] = await Promise.all([ + netifyFetch('/data/stats/top/application/download', { filter_interval: interval, settings_limit: 100 }, false, agentUuid), + netifyFetch('/data/flows', { settings_limit: 500 }, false, agentUuid), + ]); + + const results = []; + + // Derive dari aplikasi + if (appData) { + for (const r of appData) { + const name = (r.application?.label ?? '').toLowerCase(); + const tag = (r.application?.tag ?? '').toLowerCase(); + if (MINING_APPS.some(k => name.includes(k) || tag.includes(k))) { + results.push({ + detected_at : null, + ip_address : null, + mac_address : null, + pool_host : r.application?.label ?? null, + pool_ip : null, + protocol : null, + app_label : r.application?.label ?? null, + confidence : 0.7, + download : r.download ?? 0, + upload : r.upload ?? 0, + source : 'derived:app', + }); + } + } + } + + // Derive dari flows ke port mining + if (flowsRaw) { + for (const r of flowsRaw) { + const port = r.remote_port ?? 0; + if (MINING_POOLS_PORTS.has(port)) { + results.push({ + detected_at : r.first_seen_at?.date ?? null, + ip_address : r.local_ip?.address ?? null, + mac_address : r.local_mac ?? null, + pool_host : null, + pool_ip : r.remote_ip?.address ?? null, + protocol : r.ip_protocol?.label ?? null, + app_label : null, + confidence : 0.85, + download : r.download ?? 0, + upload : r.upload ?? 0, + source : 'derived:flow', + }); + } + } + } + + return results.slice(0, limit); +} + +// 23. Device Discovery — derive dari flows (perangkat unik dengan MAC) +async function fetchDeviceDiscovery(limit = 100, agentUuid = null) { + const [flowsRaw, dlData] = await Promise.all([ + netifyFetch('/data/flows', { settings_limit: 500 }, false, agentUuid), + netifyFetch('/data/stats/top/local_ip/download', { filter_interval: 1440, settings_limit: 200 }, false, agentUuid), + ]); + + const seen = new Map(); + if (flowsRaw) { + for (const r of flowsRaw) { + const ip = r.local_ip?.address; + const mac = r.local_mac; + if (!ip) continue; + if (!seen.has(ip)) { + seen.set(ip, { + detected_at : r.first_seen_at?.date ?? null, + ip_address : ip, + mac_address : mac ?? null, + device_label : r.device?.label ?? null, + device_type : r.mac?.discovery_hardware ?? null, + os_label : r.discovery_os?.label ?? null, + manufacturer : mac ? (OUI_MAP[mac.substring(0,8).toUpperCase()] ?? null) : null, + is_new : true, + }); + } + } + } + + // Tambah IP yang punya bandwidth tapi tidak ada di flows + if (dlData) { + for (const r of dlData) { + const ip = r.local_ip?.address ?? String(r.local_ip ?? ''); + if (ip && !seen.has(ip)) { + seen.set(ip, { + detected_at : null, + ip_address : ip, + mac_address : null, + device_label : null, + device_type : null, + os_label : null, + manufacturer : null, + is_new : true, + }); + } + } + } + + return Array.from(seen.values()).slice(0, limit); +} + +// 24. Encryption Audit — derive dari TLS security per-IP dari flows +async function fetchEncryptionAudit(limit = 50, agentUuid = null) { + const [tlsSec, flowsRaw] = await Promise.all([ + netifyFetch('/data/stats/top/tls_security/download', { filter_interval: 1440, settings_limit: 10 }, false, agentUuid), + netifyFetch('/data/flows', { settings_limit: 500 }, false, agentUuid), + ]); + + // Hitung per-IP: encrypted vs unencrypted flows + const ipStats = {}; + if (flowsRaw) { + for (const r of flowsRaw) { + const ip = r.local_ip?.address; + const port = r.remote_port ?? 0; + const mac = r.local_mac ?? null; + if (!ip) continue; + if (!ipStats[ip]) ipStats[ip] = { mac, encrypted: 0, unencrypted: 0, total_bytes: 0 }; + const bytes = (r.download ?? 0) + (r.upload ?? 0); + // Port 443, 8443, 465, 993, 995, 22 = encrypted + const isEnc = [443, 8443, 465, 993, 995, 22, 853].includes(port); + if (isEnc) ipStats[ip].encrypted += bytes; + else ipStats[ip].unencrypted += bytes; + ipStats[ip].total_bytes += bytes; + } + } + + return Object.entries(ipStats) + .map(([ip, s]) => { + const total = s.encrypted + s.unencrypted; + const enc_pct = total > 0 ? (s.encrypted / total) * 100 : null; + const risk = enc_pct === null ? null + : enc_pct >= 90 ? 'Low' + : enc_pct >= 60 ? 'Medium' + : enc_pct >= 30 ? 'High' + : 'Critical'; + const oui = s.mac ? s.mac.substring(0,8).toUpperCase() : null; + return { + ip_address : ip, + mac_address : s.mac, + device_label : OUI_MAP[oui] ?? null, + encrypted_pct : enc_pct != null ? Math.round(enc_pct * 10) / 10 : null, + encrypted : s.encrypted, + unencrypted : s.unencrypted, + total : total, + risk_level : risk, + detected_at : null, + }; + }) + .sort((a, b) => (a.encrypted_pct ?? 101) - (b.encrypted_pct ?? 101)) + .slice(0, limit); +} + +// 25. Insecure Protocols — derive dari top protocols (confirmed bekerja) +async function fetchInsecureProtocols(interval = 1440, limit = 50, agentUuid = null) { + const INSECURE = { + 'HTTP' : { port: 80, risk: 'High' }, + 'FTP' : { port: 21, risk: 'Critical' }, + 'Telnet' : { port: 23, risk: 'Critical' }, + 'SMTP' : { port: 25, risk: 'Medium' }, + 'POP3' : { port: 110, risk: 'Medium' }, + 'IMAP' : { port: 143, risk: 'Medium' }, + 'DNS' : { port: 53, risk: 'Low' }, + 'SNMP' : { port: 161, risk: 'High' }, + 'LDAP' : { port: 389, risk: 'High' }, + 'RDP' : { port: 3389, risk: 'High' }, + 'NTP' : { port: 123, risk: 'Low' }, + 'TFTP' : { port: 69, risk: 'High' }, + 'rsh' : { port: 514, risk: 'Critical' }, + 'rlogin' : { port: 513, risk: 'Critical' }, + }; + + const [protoData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/protocol/download', { filter_interval: interval, settings_limit: 100 }, false, agentUuid), + netifyFetch('/data/stats/top/protocol/upload', { filter_interval: interval, settings_limit: 100 }, false, agentUuid), + ]); + if (!protoData) return []; + + const ulMap = {}; + if (ulData) for (const r of ulData) { + const id = r.protocol?.id; + if (id) ulMap[id] = r.upload ?? 0; + } + + return protoData + .filter(r => INSECURE[r.protocol?.label]) + .map(r => { + const label = r.protocol?.label ?? 'Unknown'; + const info = INSECURE[label]; + return { + protocol : label, + ip_address : null, + mac_address : null, + dst_ip : null, + dst_port : info.port, + app_label : null, + download : r.download ?? 0, + upload : ulMap[r.protocol?.id] ?? 0, + risk : info.risk, + detected_at : null, + source : 'derived', + }; + }) + .slice(0, limit); +} + +// 26. IP Reputation — derive dari top remote_ip + cross-check negara berisiko tinggi +async function fetchIPReputation(limit = 50, agentUuid = null) { + // Negara dengan risiko tinggi berdasarkan threat intel umum + const HIGH_RISK_COUNTRIES = new Set([ + 'China', 'Russia', 'Iran', 'North Korea', 'Nigeria', 'Romania', + 'Brazil', 'Ukraine', 'Vietnam', 'Indonesia', + ]); + + const [ipData, countryData] = await Promise.all([ + netifyFetch('/data/stats/top/remote_ip/download', { filter_interval: 1440, settings_limit: 100 }, false, agentUuid), + netifyFetch('/data/stats/top/country/download', { filter_interval: 1440, settings_limit: 50 }, false, agentUuid), + ]); + + const results = []; + + if (ipData) { + // Tandai IP dari negara berisiko (informasi negara tidak ada per-IP dari API, + // jadi kita pakai country data untuk konteks) + for (const r of ipData) { + const ip = r.remote_ip?.address ?? String(r.remote_ip ?? ''); + if (!ip) continue; + results.push({ + ip_address : ip, + local_ip : null, + mac_address : null, + reputation : 'Unknown', + score : null, + country : null, + app_label : null, + download : r.download ?? 0, + upload : r.upload ?? 0, + detected_at : null, + blacklisted : false, + source : 'derived:top_ip', + }); + } + } + + // Tambah entri untuk negara berisiko yang terdeteksi + if (countryData) { + for (const r of countryData) { + const country = r.country?.label ?? ''; + if (HIGH_RISK_COUNTRIES.has(country)) { + results.push({ + ip_address : null, + local_ip : null, + mac_address : null, + reputation : 'High-Risk Country', + score : 0.7, + country : country, + app_label : null, + download : r.download ?? 0, + upload : r.upload ?? 0, + detected_at : null, + blacklisted : false, + source : 'derived:country', + }); + } + } + } + + return results.slice(0, limit); +} + +// 27. Server Discovery — derive dari flows dengan flow_origin=Server + port well-known server +async function fetchServerDiscovery(limit = 100, agentUuid = null) { + const SERVER_PORTS = { + 80: 'HTTP', 443: 'HTTPS', 22: 'SSH', 21: 'FTP', 25: 'SMTP', + 110: 'POP3', 143: 'IMAP', 3306: 'MySQL', 5432: 'PostgreSQL', + 6379: 'Redis', 27017: 'MongoDB', 8080: 'HTTP-Alt', 8443: 'HTTPS-Alt', + 53: 'DNS', 3389: 'RDP', 5900: 'VNC', 161: 'SNMP', 123: 'NTP', + 389: 'LDAP', 636: 'LDAPS', 5060: 'SIP', 1194: 'OpenVPN', + }; + + const [flowOriginData, flowsRaw] = await Promise.all([ + netifyFetch('/data/stats/top/flow_origin/download', { filter_interval: 1440, settings_limit: 10 }, false, agentUuid), + netifyFetch('/data/flows', { settings_limit: 500 }, false, agentUuid), + ]); + + const serverMap = {}; + if (flowsRaw) { + for (const r of flowsRaw) { + const localIP = r.local_ip?.address; + const localPort = r.local_port ?? r.remote_port; + const proto = r.ip_protocol?.label ?? null; + const mac = r.local_mac ?? null; + if (!localIP) continue; + + // Deteksi server: local device listening di port well-known + const svcName = SERVER_PORTS[localPort] ?? SERVER_PORTS[r.remote_port]; + if (svcName) { + const key = `${localIP}:${localPort ?? r.remote_port}`; + if (!serverMap[key]) { + const oui = mac ? mac.substring(0,8).toUpperCase() : null; + serverMap[key] = { + detected_at : r.first_seen_at?.date ?? null, + ip_address : localIP, + mac_address : mac, + server_type : svcName, + hostname : r.device?.label ?? null, + port : localPort ?? r.remote_port, + protocol : proto, + os_label : null, + download : 0, + upload : 0, + }; + } + serverMap[key].download += r.download ?? 0; + serverMap[key].upload += r.upload ?? 0; + } + } + } + + return Object.values(serverMap) + .sort((a, b) => (b.download + b.upload) - (a.download + a.upload)) + .slice(0, limit); +} + +// 28. Tor Detection — derive dari top remote_ip + app/hostname matching Tor +async function fetchTorDetection(limit = 50, agentUuid = null) { + const TOR_INDICATORS = ['tor', '.onion', 'torproject', 'torbrowser']; + + const [appData, domainData] = await Promise.all([ + netifyFetch('/data/stats/top/application/download', { filter_interval: 1440, settings_limit: 100 }, false, agentUuid), + netifyFetch('/data/stats/top/hostname/download', { filter_interval: 1440, settings_limit: 100 }, false, agentUuid), + ]); + + const results = []; + + if (appData) { + for (const r of appData) { + const name = (r.application?.label ?? '').toLowerCase(); + const tag = (r.application?.tag ?? '').toLowerCase(); + if (TOR_INDICATORS.some(k => name.includes(k) || tag.includes(k))) { + results.push({ + detected_at : null, + ip_address : null, + mac_address : null, + exit_node : null, + circuit_id : null, + download : r.download ?? 0, + upload : r.upload ?? 0, + country : null, + source : 'derived:app', + label : r.application?.label, + }); + } + } + } + + if (domainData) { + for (const r of domainData) { + const host = (r.hostname?.name ?? '').toLowerCase(); + if (TOR_INDICATORS.some(k => host.includes(k))) { + results.push({ + detected_at : null, + ip_address : null, + mac_address : null, + exit_node : null, + circuit_id : null, + download : r.download ?? 0, + upload : 0, + country : null, + source : 'derived:hostname', + label : r.hostname?.name, + }); + } + } + } + + return results.slice(0, limit); +} + +// 29. Unencrypted Passwords — derive dari flows ke port cleartext auth +async function fetchUnencryptedPasswords(limit = 50, agentUuid = null) { + // Port yang dikenal mengirim kredensial cleartext + const CLEARTEXT_AUTH_PORTS = { + 21 : { protocol: 'FTP', severity: 'Critical' }, + 23 : { protocol: 'Telnet', severity: 'Critical' }, + 25 : { protocol: 'SMTP', severity: 'High' }, + 80 : { protocol: 'HTTP', severity: 'High' }, + 110 : { protocol: 'POP3', severity: 'High' }, + 143 : { protocol: 'IMAP', severity: 'High' }, + 389 : { protocol: 'LDAP', severity: 'Critical' }, + 512 : { protocol: 'rexec', severity: 'Critical' }, + 513 : { protocol: 'rlogin', severity: 'Critical' }, + 514 : { protocol: 'rsh', severity: 'Critical' }, + }; + + const flowsRaw = await netifyFetch('/data/flows', { settings_limit: 500 }, false, agentUuid); + if (!flowsRaw) return []; + + const seen = new Map(); + for (const r of flowsRaw) { + const port = r.remote_port ?? 0; + const info = CLEARTEXT_AUTH_PORTS[port]; + if (!info) continue; + + const key = `${r.local_ip?.address}:${r.remote_ip?.address}:${port}`; + if (!seen.has(key)) { + seen.set(key, { + detected_at : r.first_seen_at?.date ?? null, + ip_address : r.local_ip?.address ?? null, + mac_address : r.local_mac ?? null, + dst_ip : r.remote_ip?.address ?? null, + dst_port : port, + protocol : info.protocol, + username : null, + download : 0, + upload : 0, + severity : info.severity, + }); + } + seen.get(key).download += r.download ?? 0; + seen.get(key).upload += r.upload ?? 0; + } + + return Array.from(seen.values()) + .sort((a, b) => (b.download + b.upload) - (a.download + a.upload)) + .slice(0, limit); +} + +// 30. VPN Detection — derive dari apps/protocols/hostnames yang mengindikasikan VPN +async function fetchVPNDetection(limit = 50, agentUuid = null) { + const VPN_APPS = [ + 'openvpn', 'wireguard', 'nordvpn', 'expressvpn', 'surfshark', 'tunnelbear', + 'mullvad', 'protonvpn', 'ipvanish', 'pia', 'private internet access', + 'hotspot shield', 'cyberghost', 'vpn', 'pptp', 'l2tp', 'ipsec', 'sstp', + 'shadowsocks', 'v2ray', 'trojan', 'outline', + ]; + const VPN_PROTOCOLS = new Set(['OpenVPN', 'WireGuard', 'IPSec', 'PPTP', 'L2TP', 'GRE', 'SSTP']); + // Port yang umum digunakan VPN + const VPN_PORTS = new Set([1194, 51820, 500, 4500, 1701, 1723, 8388, 443]); + + const [appData, protoData, flowsRaw] = await Promise.all([ + netifyFetch('/data/stats/top/application/download', { filter_interval: 1440, settings_limit: 100 }, false, agentUuid), + netifyFetch('/data/stats/top/protocol/download', { filter_interval: 1440, settings_limit: 50 }, false, agentUuid), + netifyFetch('/data/flows', { settings_limit: 500 }, false, agentUuid), + ]); + + const results = []; + + if (appData) { + for (const r of appData) { + const name = (r.application?.label ?? '').toLowerCase(); + const tag = (r.application?.tag ?? '').toLowerCase(); + if (VPN_APPS.some(k => name.includes(k) || tag.includes(k))) { + results.push({ + detected_at : null, + ip_address : null, + mac_address : null, + vpn_type : r.application?.label ?? 'Unknown VPN', + remote_ip : null, + protocol : null, + download : r.download ?? 0, + upload : r.upload ?? 0, + country : null, + confidence : 0.9, + source : 'derived:app', + }); + } + } + } + + if (protoData) { + for (const r of protoData) { + const label = r.protocol?.label ?? ''; + if (VPN_PROTOCOLS.has(label)) { + results.push({ + detected_at : null, + ip_address : null, + mac_address : null, + vpn_type : label, + remote_ip : null, + protocol : label, + download : r.download ?? 0, + upload : r.upload ?? 0, + country : null, + confidence : 0.85, + source : 'derived:protocol', + }); + } + } + } + + if (flowsRaw) { + const portSeen = new Set(); + for (const r of flowsRaw) { + const port = r.remote_port ?? 0; + if (VPN_PORTS.has(port) && !portSeen.has(port)) { + portSeen.add(port); + results.push({ + detected_at : r.first_seen_at?.date ?? null, + ip_address : r.local_ip?.address ?? null, + mac_address : r.local_mac ?? null, + vpn_type : `Port ${port}`, + remote_ip : r.remote_ip?.address ?? null, + protocol : r.ip_protocol?.label ?? null, + download : r.download ?? 0, + upload : r.upload ?? 0, + country : null, + confidence : 0.75, + source : 'derived:port', + }); + } + } + } + + return results.slice(0, limit); +} + +async function fetchAgents() { + const data = await netifyFetch('/data/stats/top/agent/download', { filter_interval: 43200, settings_limit: 100 }, false, null); + if (!data || !Array.isArray(data)) return []; + const list = data.map(r => ({ + id: r.agent?.id, + uuid: r.agent?.uuid, + label: r.agent?.label, + })); + for (const a of list) { + if (a.uuid && a.id) { + agentMap[a.uuid] = a.id; + } + } + return list; +} \ No newline at end of file diff --git a/backend/scheduler.js b/backend/scheduler.js new file mode 100644 index 0000000..60e3726 --- /dev/null +++ b/backend/scheduler.js @@ -0,0 +1,312 @@ +// backend/scheduler.js +const cron = require('node-cron'); +const netify = require('./netify'); +const db = require('./database'); +const SITE_UUID = process.env.NETIFY_SITE_UUID || 'dummy_site_uuid'; + +let isRunning = false; + +async function runPoll() { + if (isRunning) { + console.log('[Scheduler] Poll sedang berjalan, skip.'); + return; + } + isRunning = true; + const fetchedAt = new Date().toISOString(); + console.log(`[Scheduler] Mulai polling... (${fetchedAt})`); + + try { + // 0. Sync agents and seed default user accounts dynamically + try { + apiAgents = await netify.fetchAgents(); + if (apiAgents && apiAgents.length > 0) { + db.syncAgentUsers(apiAgents); + console.log(`[Scheduler] OK Sync Agents : ${apiAgents.length} agen terdeteksi`); + } + } catch (err) { + console.error('[Scheduler] Gagal sync agent users:', err.message); + } + + async function fetchAndStore(fetchedAt, agentUuid) { + const agentLabel = agentUuid ? agentUuid : 'Global'; + console.log(`[Scheduler] Fetching data for ${agentLabel}`); +// 1. Top Aplikasi + const apps = await netify.fetchTopApps(1440, 20, agentUuid); + if (apps && Array.isArray(apps)) { + db.insertBandwidthApps(apps, fetchedAt, SITE_UUID, agentUuid); + console.log(`[Scheduler] OK Apps : ${apps.length} baris`); + } else { + console.log(`[Scheduler] -- Apps : tidak ada data`); + } + + // 2. Top Devices — pakai fetchDiscoveredDevices yg sudah dinormalisasi + const devices = await netify.fetchDiscoveredDevices(1440, 200, agentUuid); + if (devices && Array.isArray(devices)) { + db.insertDevices(devices, fetchedAt, SITE_UUID, agentUuid); + console.log(`[Scheduler] OK Devices : ${devices.length} baris`); + } else { + console.log(`[Scheduler] -- Devices : tidak ada data`); + } + + // 3. Top Protokol + const protocols = await netify.fetchTopProtocols(1440, 20, agentUuid); + if (protocols && Array.isArray(protocols)) { + db.insertProtocols(protocols, fetchedAt, SITE_UUID, agentUuid); + console.log(`[Scheduler] OK Protocols : ${protocols.length} baris`); + } else { + console.log(`[Scheduler] -- Protocols : tidak ada data`); + } + + // 4. Top Negara + const countries = await netify.fetchTopCountries(1440, 15, agentUuid); + if (countries && Array.isArray(countries)) { + db.insertCountries(countries, fetchedAt, SITE_UUID, agentUuid); + console.log(`[Scheduler] OK Countries : ${countries.length} baris`); + } else { + console.log(`[Scheduler] -- Countries : tidak ada data`); + } + + // 5. Top Domain/DNS + const domains = await netify.fetchTopDomains(1440, 20, agentUuid); + if (domains && Array.isArray(domains)) { + db.insertDNS(domains, fetchedAt, SITE_UUID, agentUuid); + console.log(`[Scheduler] OK DNS : ${domains.length} baris`); + } else { + console.log(`[Scheduler] -- DNS : tidak ada data`); + } + + // 6. Flows — pakai local_ip sebagai proxy + const flows = await netify.fetchFlows(200, agentUuid); + if (flows && Array.isArray(flows)) { + db.insertFlows(flows, fetchedAt, SITE_UUID, agentUuid); + console.log(`[Scheduler] OK Flows : ${flows.length} baris`); + } else { + console.log(`[Scheduler] -- Flows : tidak ada data`); + } + + // 7. Threats — dari Events Status + const threats = await netify.fetchCyberThreats(1440, 50, agentUuid); + if (threats && Array.isArray(threats)) { + db.insertThreats(threats, fetchedAt, SITE_UUID, agentUuid); + console.log(`[Scheduler] OK Threats : ${threats.length} baris`); + } else { + console.log(`[Scheduler] -- Threats : tidak ada data`); + } + + // 8. Events Log + const events = await netify.fetchEvents(50, agentUuid); + if (events && Array.isArray(events)) { + db.insertEvents(events, fetchedAt, SITE_UUID, agentUuid); + console.log(`[Scheduler] OK Events : ${events.length} baris`); + } else { + console.log(`[Scheduler] -- Events : tidak ada data`); + } + + // 10. App Categories + const appCats = await netify.fetchTopAppCategories(1440, 15, agentUuid); + if (appCats?.length) { db.insertAppCategories(appCats, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK AppCats : ${appCats.length} baris`); } + else console.log(`[Scheduler] -- AppCats : tidak ada data`); + + // 11. Continents + const continents = await netify.fetchTopContinents(1440, 10, agentUuid); + if (continents?.length) { db.insertContinents(continents, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK Continents : ${continents.length} baris`); } + else console.log(`[Scheduler] -- Continents : tidak ada data`); + + // 12. Regions + const regions = await netify.fetchTopRegions(1440, 20, agentUuid); + if (regions?.length) { db.insertRegions(regions, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK Regions : ${regions.length} baris`); } + else console.log(`[Scheduler] -- Regions : tidak ada data`); + + // 13. Cities + const cities = await netify.fetchTopCities(1440, 20, agentUuid); + if (cities?.length) { db.insertCities(cities, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK Cities : ${cities.length} baris`); } + else console.log(`[Scheduler] -- Cities : tidak ada data`); + + // 14. VLANs + const vlans = await netify.fetchTopVLANs(1440, 20, agentUuid); + if (vlans?.length) { db.insertVLANs(vlans, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK VLANs : ${vlans.length} baris`); } + else console.log(`[Scheduler] -- VLANs : tidak ada data`); + + // 15. Interfaces + const ifaces = await netify.fetchTopInterfaces(1440, 20, agentUuid); + if (ifaces?.length) { db.insertInterfaces(ifaces, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK Interfaces : ${ifaces.length} baris`); } + else console.log(`[Scheduler] -- Interfaces : tidak ada data`); + + // 16. Flow Types + const flowTypes = await netify.fetchTopFlowTypes(1440, 10, agentUuid); + if (flowTypes?.length) { db.insertFlowTypes(flowTypes, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK FlowTypes : ${flowTypes.length} baris`); } + else console.log(`[Scheduler] -- FlowTypes : tidak ada data`); + + // 17. Flow Origins + const flowOrigins = await netify.fetchTopFlowOrigins(1440, 10, agentUuid); + if (flowOrigins?.length) { db.insertFlowOrigins(flowOrigins, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK FlowOrigin : ${flowOrigins.length} baris`); } + else console.log(`[Scheduler] -- FlowOrigin : tidak ada data`); + + // 18. IP Versions + const ipVersions = await netify.fetchTopIPVersions(1440, 5, agentUuid); + if (ipVersions?.length) { db.insertIPVersions(ipVersions, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK IPVersions : ${ipVersions.length} baris`); } + else console.log(`[Scheduler] -- IPVersions : tidak ada data`); + + // 19. Remote IPs + const remoteIPs = await netify.fetchTopRemoteIPs(1440, 20, agentUuid); + if (remoteIPs?.length) { db.insertRemoteIPs(remoteIPs, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK RemoteIPs : ${remoteIPs.length} baris`); } + else console.log(`[Scheduler] -- RemoteIPs : tidak ada data`); + + // 20. MAC Bandwidth + const macBW = await netify.fetchTopLocalMACs(1440, 50, agentUuid); + if (macBW?.length) { db.insertMACBandwidth(macBW, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK MACBandwdh : ${macBW.length} baris`); } + else console.log(`[Scheduler] -- MACBandwdh : tidak ada data`); + + // 9. Bandwidth Timeline + const summary = await netify.fetchBandwidthSummary(1440, agentUuid); + const devCount = devices?.length ?? 0; + if (summary) { + db.insertBandwidthTimeline({ ...summary, devices: devCount }, fetchedAt, SITE_UUID, agentUuid); + console.log(`[Scheduler] OK Timeline : saved`); + } else { + console.log(`[Scheduler] -- Timeline : gagal ambil data`); + } + + // 21. TLS Versions + const tlsVer = await netify.fetchTLSVersions(1440, 10, agentUuid); + if (tlsVer?.length) { db.insertTLSVersions(tlsVer, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK TLS Ver : ${tlsVer.length} baris`); } + else console.log(`[Scheduler] -- TLS Ver : tidak ada data`); + + // 22. TLS Ciphers + const tlsCipher = await netify.fetchTLSCiphers(1440, 15, agentUuid); + if (tlsCipher?.length) { db.insertTLSCiphers(tlsCipher, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK TLS Cipher : ${tlsCipher.length} baris`); } + else console.log(`[Scheduler] -- TLS Cipher : tidak ada data`); + + // 23. TLS Security + const tlsSec = await netify.fetchTLSSecurity(1440, 10, agentUuid); + if (tlsSec?.length) { db.insertTLSSecurity(tlsSec, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK TLS Sec : ${tlsSec.length} baris`); } + else console.log(`[Scheduler] -- TLS Sec : tidak ada data`); + + // 24. NetBIOS Hostnames + const netbios = await netify.fetchNetBIOSHostnames(1440, 30, agentUuid); + if (netbios?.length) { db.insertNetBIOSHostnames(netbios, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK NetBIOS : ${netbios.length} baris`); } + else console.log(`[Scheduler] -- NetBIOS : tidak ada data`); + + // 25. Discovery OS (standalone — OS yang terdeteksi di jaringan) + const discOs = await netify.fetchTopDiscoveryOS(1440, 20, agentUuid); + if (discOs?.length) { db.insertDiscoveryOS(discOs, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK DiscOS : ${discOs.length} baris`); } + else console.log(`[Scheduler] -- DiscOS : tidak ada data`); + + // 26. DHCP Class Fingerprint + const dhcpFp = await netify.fetchDHCPClassFingerprints(1440, 30, agentUuid); + if (dhcpFp?.length) { db.insertDHCPFingerprints(dhcpFp, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK DHCP FP : ${dhcpFp.length} baris`); } + else console.log(`[Scheduler] -- DHCP FP : tidak ada data`); + + // 27. HTTP User-Agent + const userAgents = await netify.fetchHTTPUserAgents(1440, 30, agentUuid); + if (userAgents?.length) { db.insertHTTPUserAgents(userAgents, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK UserAgent : ${userAgents.length} baris`); } + else console.log(`[Scheduler] -- UserAgent : tidak ada data`); + + // 28. HTTPS SNI Hostname + const sniHosts = await netify.fetchSNIHostnames(1440, 30, agentUuid); + if (sniHosts?.length) { db.insertSNIHostnames(sniHosts, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK SNI Host : ${sniHosts.length} baris`); } + else console.log(`[Scheduler] -- SNI Host : tidak ada data`); + + // 29. SSL Server Common Name + const sslCN = await netify.fetchSSLServerCN(1440, 30, agentUuid); + if (sslCN?.length) { db.insertSSLServerCN(sslCN, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK SSL CN : ${sslCN.length} baris`); } + else console.log(`[Scheduler] -- SSL CN : tidak ada data`); + + // 30. QUIC Hostname + const quicHosts = await netify.fetchQUICHostnames(1440, 30, agentUuid); + if (quicHosts?.length) { db.insertQUICHostnames(quicHosts, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK QUIC Host : ${quicHosts.length} baris`); } + else console.log(`[Scheduler] -- QUIC Host : tidak ada data`); + + // 31. BitTorrent Info Hash + const btHashes = await netify.fetchBitTorrentInfoHashes(1440, 30, agentUuid); + if (btHashes?.length) { db.insertBitTorrentHashes(btHashes, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK BT Hash : ${btHashes.length} baris`); } + else console.log(`[Scheduler] -- BT Hash : tidak ada data`); + + // 32. SSH Client (field: ssh_client) + const sshClient = await netify.fetchSSHClients(1440, 20, agentUuid); + if (sshClient?.length) { db.insertSSHVersions(sshClient, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK SSH Client : ${sshClient.length} baris`); } + else console.log(`[Scheduler] -- SSH Client : tidak ada data`); + + // 32b. SSH Server (field: ssh_server) + const sshServer = await netify.fetchSSHServers(1440, 20, agentUuid); + if (sshServer?.length) { db.insertSSHVersions(sshServer, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK SSH Server : ${sshServer.length} baris`); } + else console.log(`[Scheduler] -- SSH Server : tidak ada data`); + + // 33. mDNS Hostname (Chromecast, Apple TV, etc.) + const mdnsHosts = await netify.fetchMDNSHostnames(1440, 30, agentUuid); + if (mdnsHosts?.length) { db.insertMDNSHostnames(mdnsHosts, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK mDNS Host : ${mdnsHosts.length} baris`); } + else console.log(`[Scheduler] -- mDNS Host : tidak ada data`); + + // ─── INTELLIGENCE 22-30 (derive dari data yang tersedia) ───────────────── + + // 34. Cryptocurrency Mining (derive dari apps + flows ke port mining) + const cryptoMining = await netify.fetchCryptoMining(50, agentUuid); + if (cryptoMining?.length) { db.insertCryptoMining(cryptoMining, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK CryptoMine : ${cryptoMining.length} baris`); } + else console.log(`[Scheduler] -- CryptoMine : tidak ada data`); + + // 35. Device Discovery (derive dari flows + bandwidth per-IP) + const devDisc = await netify.fetchDeviceDiscovery(100, agentUuid); + if (devDisc?.length) { db.insertDeviceDiscovery(devDisc, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK DevDisc : ${devDisc.length} baris`); } + else console.log(`[Scheduler] -- DevDisc : tidak ada data`); + + // 36. Encryption Audit (derive dari flows per-IP: port encrypted vs plain) + const encAudit = await netify.fetchEncryptionAudit(50, agentUuid); + if (encAudit?.length) { db.insertEncryptionAudit(encAudit, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK EncAudit : ${encAudit.length} baris`); } + else console.log(`[Scheduler] -- EncAudit : tidak ada data`); + + // 37. Insecure Protocols (derive dari top protocols) + const insecProto = await netify.fetchInsecureProtocols(1440, 50, agentUuid); + if (insecProto?.length) { db.insertInsecureProtocols(insecProto, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK InsecProto : ${insecProto.length} baris`); } + else console.log(`[Scheduler] -- InsecProto : tidak ada data`); + + // 38. IP Reputation (derive dari top remote_ip + high-risk countries) + const ipRep = await netify.fetchIPReputation(50, agentUuid); + if (ipRep?.length) { db.insertIPReputation(ipRep, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK IPRepute : ${ipRep.length} baris`); } + else console.log(`[Scheduler] -- IPRepute : tidak ada data`); + + // 39. Server Discovery (derive dari flows ke port server well-known) + const srvDisc = await netify.fetchServerDiscovery(100, agentUuid); + if (srvDisc?.length) { db.insertServerDiscovery(srvDisc, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK SrvDisc : ${srvDisc.length} baris`); } + else console.log(`[Scheduler] -- SrvDisc : tidak ada data`); + + // 40. Tor Detection (derive dari apps/hostnames mengandung "tor") + const torDet = await netify.fetchTorDetection(50, agentUuid); + if (torDet?.length) { db.insertTorDetection(torDet, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK TorDet : ${torDet.length} baris`); } + else console.log(`[Scheduler] -- TorDet : tidak ada data`); + + // 41. Unencrypted Password (derive dari flows ke port cleartext auth) + const unencPwd = await netify.fetchUnencryptedPasswords(50, agentUuid); + if (unencPwd?.length) { db.insertUnencryptedPasswords(unencPwd, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK UnencPwd : ${unencPwd.length} baris`); } + else console.log(`[Scheduler] -- UnencPwd : tidak ada data`); + + // 42. VPN Detection (derive dari apps/protocols/ports VPN) + const vpnDet = await netify.fetchVPNDetection(50, agentUuid); + if (vpnDet?.length) { db.insertVPNDetection(vpnDet, fetchedAt, SITE_UUID, agentUuid); console.log(`[Scheduler] OK VPNDet : ${vpnDet.length} baris`); } + else console.log(`[Scheduler] -- VPNDet : tidak ada data`); + + } + + // --- Main loop + await fetchAndStore(fetchedAt, null); + if (apiAgents && apiAgents.length > 0) { + for (const agent of apiAgents) { + if (agent && agent.uuid) { + await fetchAndStore(fetchedAt, agent.uuid); + } + } + } + } catch (err) { + console.error('[Scheduler] ERROR:', err); + } finally { + isRunning = false; + console.log(`[Scheduler] Poll selesai.\n`); + } +} + +function startScheduler() { + runPoll(); + cron.schedule('* * * * *', () => runPoll()); + console.log('[Scheduler] Aktif. Polling setiap 1 menit.\n'); +} + +module.exports = { startScheduler, runPoll }; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 98b1224..dff63ab 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,123 +1,163 @@ -// backend/server.js -// ───────────────────────────────────────────────────────────────────────────── -// BackOne Backend API Server -// -// Tanggung jawab backend ini adalah READ-ONLY dari MongoDB. -// Semua data collection (ingestion) dilakukan oleh Proxy Server (port 4000). -// Backend TIDAK memanggil DPI API secara langsung. -// -// Environment Variables: -// MONGODB_URI - MongoDB connection string -// BACKEND_PORT - Port server ini (default: 3001) -// JWT_SECRET - Secret untuk JWT auth -// ALLOWED_ORIGINS- Comma-separated allowed CORS origins -// PROXY_URL - URL proxy server (untuk trigger manual refresh) -// ───────────────────────────────────────────────────────────────────────────── - -const path = require('path'); -require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') }); - -const express = require('express'); -const cors = require('cors'); -const cookieParser = require('cookie-parser'); -const jwt = require('jsonwebtoken'); -const connectDB = require('./db/mongoose'); - -// ─── Connect to MongoDB (read-only mode) ────────────────────────────────────── -connectDB(); - -const app = express(); -const PORT = process.env.BACKEND_PORT || 3001; - -// ─── Middleware ──────────────────────────────────────────────────────────────── -const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS - ? process.env.ALLOWED_ORIGINS.split(',') - : ['http://localhost:3000', 'http://127.0.0.1:3000']; - -app.use(cors({ - origin: (origin, callback) => { - if (!origin) return callback(null, true); - if (ALLOWED_ORIGINS.includes(origin)) { - callback(null, true); - } else { - callback(new Error('Blocked by CORS policy (Unauthorized Origin)')); - } - }, - credentials: true -})); -app.use(express.json()); -app.use(cookieParser()); - -// ─── Public Routes ──────────────────────────────────────────────────────────── -const authRoutes = require('./routes/auth'); -const { getUploadsDir } = require('./routes/auth/helpers'); -app.use('/api/auth', authRoutes); -app.use('/api/uploads', express.static(getUploadsDir())); - -// ─── Auth Middleware ────────────────────────────────────────────────────────── -const { requireAuth } = require('./middleware/auth'); -const { getTimeFilter, getBaseFilter } = require('./routes/dashboard/helpers'); -const { - generateMacFromIp, - resolveVendorFromIp, - resolveDeviceTypeFromIp, - resolveOSFromIp, - generateAutoLabel -} = require('./deviceResolver'); - -// ─── Protected Dashboard Routes ─────────────────────────────────────────────── -const dashboardRoutes = require('./routes/dashboard'); - -// Override /api/dashboard/app-details to show real-time device mapping per application -app.get('/api/dashboard/app-details', requireAuth, (req, res) => { - require('./routes/appDetailsHandler')(req, res, { - getTimeFilter, - getBaseFilter - }); -}); - -// Override /api/dashboard/device-details to map real-time classifications (Facebook, YouTube, etc.) -app.get('/api/dashboard/device-details', requireAuth, (req, res) => { - require('./routes/deviceDetailsHandler')(req, res, { - getTimeFilter, - getBaseFilter, - generateMacFromIp, - resolveDeviceTypeFromIp, - resolveOSFromIp, - resolveVendorFromIp, - generateAutoLabel - }); -}); - -app.get('/api/dashboard/remote-ip-details', requireAuth, async (req, res) => { - require('./routes/remoteIpDetailsHandler')(req, res, { - getTimeFilter - }); -}); - -const metadataDetailRoutes = require('./routes/metadataDetail'); -app.use('/api/dashboard/metadata-detail', requireAuth, metadataDetailRoutes); - -const categoryDetailRoutes = require('./routes/categoryDetail'); -app.use('/api/dashboard/category-detail', requireAuth, categoryDetailRoutes); - -app.use('/api/dashboard', requireAuth, dashboardRoutes); - - - - -// ─── Health Check ───────────────────────────────────────────────────────────── -app.get('/api/health', (req, res) => { - res.json({ - ok: true, - message: 'BackOne Backend berjalan (MongoDB read-only mode)', - time: new Date().toISOString() - }); -}); - -// ─── Start Server ───────────────────────────────────────────────────────────── -app.listen(PORT, () => { - console.log(`\n🚀 BackOne API Server berjalan di http://localhost:${PORT}`); - console.log(`🔌 API Health : http://localhost:${PORT}/api/health`); - console.log(`📡 Mode : READ-ONLY dari MongoDB (data dikirim oleh Proxy Server)\n`); -}); +// backend/server.js +// ───────────────────────────────────────────────────────────────────────────── +// Polyfill global crypto for Node 18 compatibility (required by mongodb driver) +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = require('crypto'); +} + +// BackOne Backend API Server +// +// Tanggung jawab backend ini adalah READ-ONLY dari MongoDB. +// Semua data collection (ingestion) dilakukan oleh Proxy Server (port 4000). +// Backend TIDAK memanggil DPI API secara langsung. +// +// Environment Variables: +// MONGODB_URI - MongoDB connection string +// BACKEND_PORT - Port server ini (default: 3001) +// JWT_SECRET - Secret untuk JWT auth +// ALLOWED_ORIGINS- Comma-separated allowed CORS origins +// PROXY_URL - URL proxy server (untuk trigger manual refresh) +// ───────────────────────────────────────────────────────────────────────────── + +const path = require('path'); +const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.local'; +require('dotenv').config({ path: path.join(__dirname, '..', envFile) }); + +const express = require('express'); +const cors = require('cors'); +const cookieParser = require('cookie-parser'); +const jwt = require('jsonwebtoken'); +const connectDB = require('./db/mongoose'); + +// ─── Connect to MongoDB (read-only mode) ────────────────────────────────────── +connectDB(); + +const app = express(); +const PORT = process.env.BACKEND_PORT || 3001; + +// ─── Middleware ──────────────────────────────────────────────────────────────── +const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS + ? process.env.ALLOWED_ORIGINS.split(',') + : ['http://localhost:3000', 'http://127.0.0.1:3000']; + +app.use(cors({ + origin: (origin, callback) => { + if (!origin) return callback(null, true); + if (ALLOWED_ORIGINS.includes(origin)) { + callback(null, true); + } else { + callback(new Error('Blocked by CORS policy (Unauthorized Origin)')); + } + }, + credentials: true +})); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(cookieParser()); + +app.use((req, res, next) => { + if (req.originalUrl && req.originalUrl.includes('/api/dashboard')) { + try { + const fs = require('fs'); + const path = require('path'); + const logPath = path.join(__dirname, '../scratch/http_requests.log'); + const logLine = `[${new Date().toISOString()}] ${req.method} ${req.originalUrl} - Query: ${JSON.stringify(req.query)}\n`; + fs.appendFileSync(logPath, logLine); + } catch (e) { + console.error('Logger error:', e.message); + } + } + next(); +}); + + +// ─── Public Routes ──────────────────────────────────────────────────────────── +const authRoutes = require('./routes/auth'); +const { getUploadsDir } = require('./routes/auth/helpers'); +app.use('/api/auth', authRoutes); +app.use('/api/uploads', express.static(getUploadsDir())); + +// ─── Auth Middleware ────────────────────────────────────────────────────────── +const { requireAuth } = require('./middleware/auth'); +const { getTimeFilter, getBaseFilter } = require('./routes/dashboard/helpers'); +const { + generateMacFromIp, + resolveVendorFromIp, + resolveDeviceTypeFromIp, + resolveOSFromIp, + generateAutoLabel +} = require('./deviceResolver'); + +// ─── Protected Dashboard Routes ─────────────────────────────────────────────── +const dashboardRoutes = require('./routes/dashboard'); + +// Override /api/dashboard/app-details to show real-time device mapping per application +app.get('/api/dashboard/app-details', requireAuth, (req, res) => { + require('./routes/appDetailsHandler')(req, res, { + getTimeFilter, + getBaseFilter + }); +}); + +// Override /api/dashboard/device-details to map real-time classifications (Facebook, YouTube, etc.) +app.get('/api/dashboard/device-details', requireAuth, (req, res) => { + require('./routes/deviceDetailsHandler')(req, res, { + getTimeFilter, + getBaseFilter, + generateMacFromIp, + resolveDeviceTypeFromIp, + resolveOSFromIp, + resolveVendorFromIp, + generateAutoLabel + }); +}); + +app.get('/api/dashboard/remote-ip-details', requireAuth, async (req, res) => { + require('./routes/remoteIpDetailsHandler')(req, res, { + getTimeFilter + }); +}); + +const metadataDetailRoutes = require('./routes/metadataDetail'); +app.use('/api/dashboard/metadata-detail', requireAuth, metadataDetailRoutes); + +const categoryDetailRoutes = require('./routes/categoryDetail'); +app.use('/api/dashboard/category-detail', requireAuth, categoryDetailRoutes); + +app.use('/api/dashboard', requireAuth, dashboardRoutes); + + + + +// ─── Health Check ───────────────────────────────────────────────────────────── +app.get('/api/health', (req, res) => { + res.json({ + ok: true, + message: 'BackOne Backend berjalan (MongoDB read-only mode)', + time: new Date().toISOString() + }); +}); + +// ─── Global JSON Error Handler ──────────────────────────────────────────────── +// Menangkap semua error yang tidak di-handle (termasuk multer, mongoose, dll.) +// dan memastikan response selalu JSON, BUKAN HTML default Express. +// eslint-disable-next-line no-unused-vars +app.use((err, req, res, next) => { + console.error('[Global Error Handler]', err.message || err); + const status = err.status || err.statusCode || 500; + res.status(status).json({ + error: err.message || 'Internal server error', + code: err.code || undefined, + }); +}); + +// ─── Start Server ───────────────────────────────────────────────────────────── +// Bind to 127.0.0.1 in production to prevent direct external access to port 3001. +// All external traffic must go through the reverse proxy (Apache/Nginx) at port 80/443. +const BIND_HOST = process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0'; +app.listen(PORT, BIND_HOST, () => { + console.log(`\n🚀 BackOne API Server berjalan di http://${BIND_HOST}:${PORT}`); + console.log(`🔌 API Health : http://${BIND_HOST}:${PORT}/api/health`); + console.log(`📡 Mode : READ-ONLY dari MongoDB (data dikirim oleh Proxy Server)`); + console.log(`🔒 Security : Bound to ${BIND_HOST} (internal only in production)\n`); +}); diff --git a/backend/services/ingestionService.js b/backend/services/ingestionService.js new file mode 100644 index 0000000..53397d5 --- /dev/null +++ b/backend/services/ingestionService.js @@ -0,0 +1,135 @@ +const cron = require('node-cron'); +const netify = require('../netify'); +const { Summary, AppStat, ProtocolStat, DeviceStat, Flow, Threat } = require('../models/Schemas'); + +const SITE_UUID = process.env.NETIFY_SITE_UUID || process.env.BACKONE_SITE_UUID; +let isRunning = false; + +async function runPoll() { + if (isRunning) return; + isRunning = true; + const timestamp = new Date(); + console.log(`[Mongo-Ingestion] Started polling at ${timestamp.toISOString()}`); + + try { + const agents = await netify.fetchAgents(); + const agentList = agents && agents.length > 0 ? agents.map(a => a.uuid) : [null]; // null for global + + for (const agentUuid of agentList) { + console.log(`[Mongo-Ingestion] Fetching data for Agent: ${agentUuid || 'Global'}`); + + // 1. Summary + const summary = await netify.fetchBandwidthSummary(1440, agentUuid); + if (summary) { + await new Summary({ + timestamp, + agent_uuid: agentUuid, + site_uuid: SITE_UUID, + ...summary + }).save(); + } + + // 2. Apps + const apps = await netify.fetchTopApps(1440, 200, agentUuid); // high limit for data lake + if (apps && apps.length > 0) { + const appDocs = apps.map(app => ({ + timestamp, + agent_uuid: agentUuid, + site_uuid: SITE_UUID, + app_label: app.application?.label || 'Unknown', + download: app.download || 0, + upload: app.upload || 0, + flows: app.flows || 0 + })); + await AppStat.insertMany(appDocs); + } + + const devices = await netify.fetchDiscoveredDevices(1440, 500, agentUuid); + if (devices && devices.length > 0) { + const devDocs = devices.map(d => ({ + timestamp, + agent_uuid: agentUuid, + site_uuid: SITE_UUID, + ip_address: d.ip_address, + mac_address: d.mac_address, + device_label: d.device_label, + device_type: d.device_type, + os_label: d.os_label, + manufacturer: d.manufacturer, + download: d.download || 0, + upload: d.upload || 0, + flows: d.flows || 0, + last_seen: d.last_seen + })).filter(d => d.ip_address); // Ensure ip_address exists to avoid validation error + if (devDocs.length > 0) { + await DeviceStat.insertMany(devDocs); + } + } + + // 4. Flows + const flows = await netify.fetchFlows(500, agentUuid); + if (flows && flows.length > 0) { + const flowDocs = flows.map(f => ({ + timestamp, + agent_uuid: agentUuid, + site_uuid: SITE_UUID, + flow_id: f.flow_id, + src_ip: f.src_ip, + src_mac: f.src_mac, + dst_ip: f.dst_ip, + dst_port: f.dst_port, + protocol: f.protocol, + app_label: f.app_label, + domain: f.domain, + download: f.download || 0, + upload: f.upload || 0, + first_seen: f.first_seen, + last_seen: f.last_seen + })).filter(f => f.src_ip); + if (flowDocs.length > 0) { + await Flow.insertMany(flowDocs); + } + } + + // 5. Threats + const threats = await netify.fetchCyberThreats(agentUuid); + if (threats && threats.length > 0) { + const threatDocs = threats.map(t => ({ + timestamp, + agent_uuid: agentUuid, + site_uuid: SITE_UUID, + threat_type: t.threat_type || 'Unknown Threat', + severity: t.severity || 'Medium', + src_ip: t.src_ip, + dst_ip: t.dst_ip, + dst_port: t.dst_port, + protocol: t.protocol, + description: t.description, + event_at: t.event_at || new Date().toISOString() + })); + if (threatDocs.length > 0) { + await Threat.insertMany(threatDocs); + } + } + } + } catch (error) { + console.error('[Mongo-Ingestion] Error during polling:', error); + } finally { + isRunning = false; + } +} + +function startScheduler() { + // Run every 5 minutes + cron.schedule('*/5 * * * *', () => { + runPoll(); + }); + console.log('[Mongo-Ingestion] Scheduler started (every 5 minutes)'); + + // Initial run + runPoll(); +} + +module.exports = { startScheduler }; + + diff --git a/backend/test_api_tls.js b/backend/test_api_tls.js new file mode 100644 index 0000000..0f8ef0c --- /dev/null +++ b/backend/test_api_tls.js @@ -0,0 +1,14 @@ +const http = require('http'); + +http.get('http://localhost:3001/api/dashboard/tls-versions', { + headers: { + 'Cookie': 'token=test', // Just checking schema, if it requires auth we might need to mock or use the proxy + } +}, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + console.log("Response TLS Versions:"); + console.log(data.slice(0, 500)); + }); +}); diff --git a/backend/uploads/profile-1783856980162-234747538.png b/backend/uploads/profile-1783856980162-234747538.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/backend/uploads/profile-1783856980162-234747538.png differ diff --git a/backend/uploads/profile-1783925846234-644654635.png b/backend/uploads/profile-1783925846234-644654635.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/backend/uploads/profile-1783925846234-644654635.png differ diff --git a/backend/uploads/profile-1783926643277-796221976.png b/backend/uploads/profile-1783926643277-796221976.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/backend/uploads/profile-1783926643277-796221976.png differ diff --git a/check-mongo.js b/check-mongo.js new file mode 100644 index 0000000..0af2eb1 --- /dev/null +++ b/check-mongo.js @@ -0,0 +1,60 @@ +// check-mongo.js +// Script diagnostik untuk memverifikasi koneksi ke database Source 2 (backone_inspect_0) +// Jalankan: node check-mongo.js + +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '.env.local') }); + +const mongoose = require('mongoose'); + +const MONGODB_URI = process.env.MONGODB_URI; + +if (!MONGODB_URI) { + console.error('[ERROR] MONGODB_URI tidak ditemukan di .env.local'); + process.exit(1); +} + +console.log('\n╔════════════════════════════════════════════════╗'); +console.log('║ Source 2 — MongoDB Connection Diagnostic ║'); +console.log('╚════════════════════════════════════════════════╝\n'); +console.log(`[Check] Mencoba koneksi ke: ${MONGODB_URI}\n`); + +async function checkMongo() { + try { + await mongoose.connect(MONGODB_URI, { + serverSelectionTimeoutMS: 10000, + connectTimeoutMS: 10000, + }); + + const db = mongoose.connection.db; + const dbName = db.databaseName; + + console.log(`[OK] Berhasil terhubung ke MongoDB!`); + console.log(`[OK] Database: ${dbName}`); + + // Daftar koleksi yang ada + const collections = await db.listCollections().toArray(); + if (collections.length === 0) { + console.log('[INFO] Database masih kosong — belum ada koleksi.'); + } else { + console.log(`[INFO] Koleksi yang ada (${collections.length}):`); + for (const col of collections) { + const count = await db.collection(col.name).countDocuments(); + console.log(` - ${col.name}: ${count} dokumen`); + } + } + + console.log('\n[RESULT] ✅ STEP 8 PASS — Koneksi ke database Source 2 berhasil.\n'); + process.exit(0); + } catch (err) { + console.error(`[ERROR] Gagal terhubung ke MongoDB: ${err.message}`); + console.error('\nPossible causes:'); + console.error(' 1. Host "mongodb-netify" tidak bisa dijangkau (butuh VPN/SSH tunnel)'); + console.error(' 2. Kredensial backone_inspect:backone_inspect salah'); + console.error(' 3. MongoDB belum berjalan di server tujuan'); + console.error('\n[RESULT] ❌ STEP 8 FAIL — Hubungi atasan untuk verifikasi koneksi.\n'); + process.exit(1); + } +} + +checkMongo(); diff --git a/deploy-server-setup.sh b/deploy-server-setup.sh new file mode 100644 index 0000000..e6c312a --- /dev/null +++ b/deploy-server-setup.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# ============================================================================= +# deploy-server-setup.sh +# Script yang dijalankan di server setelah file di-upload +# Path: /home/adminbackend/web/dev.demoplace.my.id/public_html/ +# ============================================================================= + +set -e +DEPLOY_DIR="/home/adminbackend/web/dev.demoplace.my.id/public_html" +cd "$DEPLOY_DIR" + +echo "=== [1/6] Checking environment ===" +node --version +npm --version +pm2 --version || npm install -g pm2 + +echo "" +echo "=== [2/6] Installing backend dependencies ===" +cd "$DEPLOY_DIR/backend" +npm install --omit=dev --legacy-peer-deps +cd "$DEPLOY_DIR" + +echo "" +echo "=== [3/6] Installing proxy dependencies ===" +cd "$DEPLOY_DIR/proxy" +npm install --omit=dev --legacy-peer-deps +cd "$DEPLOY_DIR" + +echo "" +echo "=== [4/6] Creating required directories ===" +mkdir -p logs +mkdir -p scratch + +echo "" +echo "=== [5/6] Stopping old PM2 processes (if any) ===" +pm2 delete source2-proxy 2>/dev/null || echo "source2-proxy: not running" +pm2 delete source2-backend 2>/dev/null || echo "source2-backend: not running" +pm2 delete source2-frontend 2>/dev/null || echo "source2-frontend: not running" + +echo "" +echo "=== [6/6] Starting PM2 processes ===" +pm2 start ecosystem.config.js --env production +pm2 save +pm2 list + +echo "" +echo "=== DEPLOY COMPLETE ===" +echo "Frontend : http://127.0.0.1:3010" +echo "Backend : http://127.0.0.1:3011" +echo "Proxy : http://127.0.0.1:4010" +echo "" +echo "Check logs with: pm2 logs source2-backend --lines 30" diff --git a/docker-compose.yml b/docker-compose.yml index 049794e..98fd7f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,12 +53,9 @@ services: - MONGODB_URI=mongodb://mongodb:27017/backone_dpi - PROXY_PORT=4000 # Mode 1: kumpulkan SEMUA agent (default) - # Ubah ke PROXY_COLLECT_MODE=agent dan isi PROXY_AGENT_UUID untuk mode spesifik (1 agent) - # Ubah ke PROXY_COLLECT_MODE=agents dan isi PROXY_AGENT_UUIDS untuk mode multi-agent + # Ubah ke PROXY_COLLECT_MODE=agent dan isi PROXY_AGENT_UUID untuk mode spesifik - PROXY_COLLECT_MODE=${PROXY_COLLECT_MODE:-all} - PROXY_AGENT_UUID=${PROXY_AGENT_UUID:-} - - PROXY_AGENT_UUIDS=${PROXY_AGENT_UUIDS:-} - - PROXY_AGENT_DELAY_MS=${PROXY_AGENT_DELAY_MS:-5000} - PROXY_CRON_SCHEDULE=${PROXY_CRON_SCHEDULE:-*/5 * * * *} networks: - backone-infra diff --git a/ecosystem.config.js b/ecosystem.config.js index f0395e6..e9ea1bf 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -1,22 +1,64 @@ +// ecosystem.config.js — PM2 Configuration for Source 2 (dev.demoplace.my.id) module.exports = { apps: [ { - name: "backone-proxy", - script: "./proxy/index.js", - env_file: ".env.production" + name: 'source2-proxy', + script: './proxy/index.js', + cwd: '/home/adminbackend/web/dev.demoplace.my.id/public_html', + instances: 1, + exec_mode: 'fork', + watch: false, + node_args: '--max-old-space-size=1024', + max_memory_restart: '1200M', + restart_delay: 5000, + max_restarts: 10, + env_file: '.env.production', + env: { NODE_ENV: 'production' }, + error_file: './logs/proxy-error.log', + out_file: './logs/proxy-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, }, { - name: "backone-backend", - script: "./backend/server.js", - env_file: ".env.production" + name: 'source2-backend', + script: './backend/server.js', + cwd: '/home/adminbackend/web/dev.demoplace.my.id/public_html', + instances: 1, + exec_mode: 'fork', + watch: false, + node_args: '--max-old-space-size=256', + max_memory_restart: '400M', + restart_delay: 3000, + max_restarts: 10, + env_file: '.env.production', + env: { NODE_ENV: 'production' }, + error_file: './logs/backend-error.log', + out_file: './logs/backend-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, }, { - name: "backone-frontend", - script: "server.js", - env_file: ".env.production", + name: 'source2-frontend', + script: 'start-with-env.js', + cwd: '/home/adminbackend/web/dev.demoplace.my.id/public_html', + instances: 1, + exec_mode: 'fork', + watch: false, + node_args: '--max-old-space-size=512', + max_memory_restart: '700M', + restart_delay: 3000, + max_restarts: 10, + env_file: '.env.production', env: { - PORT: 8009 - } - } - ] + NODE_ENV: 'production', + PORT: 3010, + HOSTNAME: '127.0.0.1', + NEXT_TELEMETRY_DISABLED: '1', + }, + error_file: './logs/frontend-error.log', + out_file: './logs/frontend-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + }, + ], }; diff --git a/eslint.config.mjs b/eslint.config.mjs index 3e3fe71..13b020a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,21 +1,21 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; - -const eslintConfig = defineConfig([ - ...nextVitals, - ...nextTs, - // Override default ignores of eslint-config-next. - globalIgnores([ - // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - "backend/**", - "proxy/**", - "test/**", - ]), -]); - -export default eslintConfig; +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + "backend/**", + "proxy/**", + "test/**", + ]), +]); + +export default eslintConfig; diff --git a/git-push-iso.js b/git-push-iso.js new file mode 100644 index 0000000..32b850a --- /dev/null +++ b/git-push-iso.js @@ -0,0 +1,259 @@ +/** + * git-push-iso.js + * Push ke Gitea & GitHub menggunakan isomorphic-git (pure JS, tanpa system git) + * + * CARA KERJA: + * 1. Clone dari Gitea (untuk dapat history 75 commits) + * 2. Salin file proyek terbaru ke folder clone + * 3. Commit perubahan + * 4. Push ke Gitea + * 5. Push ke GitHub dengan remote tambahan + */ + +const git = require('isomorphic-git'); +const http = require('isomorphic-git/http/node'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// ─── CONFIG ──────────────────────────────────────────────────────────────── +const PROJECT_DIR = path.resolve(__dirname); + +// Gitea +const GITEA_URL = 'https://git.proit.id/rafif/Deep-Package-Inspection'; +const GITEA_BRANCH = 'Proxy_Server_API_backone.cloud'; +const GITEA_USER = 'rafif'; +const GITEA_PASS = 'NetWorking.0'; + +// GitHub +const GITHUB_URL = 'https://github.com/Rafif-Riqullah-Siregar/BackOne-Deep-Package-Inspection'; +const GITHUB_BRANCH = 'Proxy-Server-API-backone.cloud'; +// GitHub token (diisi nanti, atau bisa kosong dulu untuk test) +const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; + +// Commit message +const COMMIT_MSG = `feat(source2): push all latest files - device labeling, help system, proxy docs, database isolation fix + +- Added DOKUMENTASI-FILTER-PER-SITE.md (site isolation docs) +- Fixed start-with-env.js to force-load .env.production +- Fixed MONGODB_URI hostname from mongodb-netify to mongodb.prod.proit.id +- Updated .gitignore to exclude sensitive scripts and credential files +- Minor UI and labeling improvements`; + +// Author +const AUTHOR = { name: 'Rafif-Riqullah-Siregar', email: 'rafif@databisnis.id' }; + +// ─── GITIGNORE PATTERNS ────────────────────────────────────────────────────── +// File/folder yang TIDAK boleh di-push (dari .gitignore) +const EXCLUDED_PATTERNS = [ + 'node_modules', + '.next', + '.env', + '.env.local', + '.env.production', + '.env.development', + 'coverage', + 'build', + 'out', + '.DS_Store', + '*.log', + '*.pem', + '.vercel', + '*.tsbuildinfo', + 'next-env.d.ts', + '*.db', '*.db-shm', '*.db-wal', '*.sqlite', + 'Laporan_*.docx', + 'temp_docx', + '*.zip', + 'AGENTS.md', 'CLAUDE.md', '.agents', + 'docs', 'plans', + 'temp.json', 'scratch', + // Sensitive scripts + 'compare-netify-vs-dashboard.js', + 'ssh-read-source1-proxy.js', + 'check-frontend-uri-now.js', + 'verify-final.js', + 'check-frontend-uri.js', + 'ssh-check-logs.js', + // Sensitive docs + 'BUKTI-AKSES-MONGODB.txt', + 'DOKUMENTASI-PROXY-NETIFY.md', +]; + +function shouldExclude(filePath) { + const parts = filePath.split(/[/\\]/); + for (const pattern of EXCLUDED_PATTERNS) { + for (const part of parts) { + if (pattern.startsWith('*')) { + const ext = pattern.slice(1); + if (part.endsWith(ext)) return true; + } else if (part === pattern || filePath.includes(pattern)) { + return true; + } + } + } + return false; +} + +async function getGitFiles(dir, baseDir = dir) { + const files = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/'); + if (shouldExclude(relPath) || entry.name === '.git') continue; + if (entry.isDirectory()) { + files.push(...await getGitFiles(fullPath, baseDir)); + } else { + files.push(relPath); + } + } + return files; +} + +async function main() { + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ GIT PUSH (isomorphic-git) → Gitea + GitHub ║'); + console.log('╚══════════════════════════════════════════════════════════════╝\n'); + + // ─── STEP 1: Clone dari Gitea ke temp dir ────────────────────────────────── + const tmpDir = path.join(os.tmpdir(), `dpi-push-${Date.now()}`); + console.log(`[1] Cloning dari Gitea ke temp: ${tmpDir}`); + fs.mkdirSync(tmpDir, { recursive: true }); + + try { + await git.clone({ + fs, http, + dir: tmpDir, + url: GITEA_URL, + ref: GITEA_BRANCH, + singleBranch: true, + depth: 10, // ambil 10 commit terakhir saja (cukup untuk push) + onAuth: () => ({ username: GITEA_USER, password: GITEA_PASS }), + onProgress: ({ phase, loaded, total }) => { + if (total) process.stdout.write(`\r ${phase}: ${loaded}/${total} `); + }, + }); + console.log(`\n ✅ Clone berhasil dari Gitea branch ${GITEA_BRANCH}`); + } catch (err) { + console.error(`\n ❌ Clone dari Gitea gagal: ${err.message}`); + console.log(' → Coba dengan username tanpa domain (tanpa @databisnis.id)'); + process.exit(1); + } + + // ─── STEP 2: Salin file project terbaru ke temp dir ──────────────────────── + console.log(`\n[2] Menyalin file terbaru dari project ke clone...`); + const projectFiles = await getGitFiles(PROJECT_DIR); + let copied = 0; + for (const relPath of projectFiles) { + const src = path.join(PROJECT_DIR, relPath); + const dst = path.join(tmpDir, relPath); + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(src, dst); + copied++; + } + console.log(` ✅ ${copied} file disalin ke clone`); + + // ─── STEP 3: Stage semua perubahan ───────────────────────────────────────── + console.log(`\n[3] Staging semua perubahan...`); + const statusMatrix = await git.statusMatrix({ fs, dir: tmpDir }); + let staged = 0; + for (const [filepath, head, workdir, stage] of statusMatrix) { + if (workdir !== stage) { + if (workdir === 0) { + // File dihapus + await git.remove({ fs, dir: tmpDir, filepath }); + } else { + // File baru atau dimodifikasi + await git.add({ fs, dir: tmpDir, filepath }); + } + staged++; + } + } + console.log(` ✅ ${staged} file di-stage`); + + if (staged === 0) { + console.log(' ℹ️ Tidak ada perubahan yang perlu di-commit!'); + return cleanup(tmpDir); + } + + // ─── STEP 4: Commit ───────────────────────────────────────────────────────── + console.log(`\n[4] Membuat commit...`); + const sha = await git.commit({ + fs, + dir: tmpDir, + author: AUTHOR, + committer: AUTHOR, + message: COMMIT_MSG, + }); + console.log(` ✅ Commit dibuat: ${sha.slice(0, 8)}`); + + // ─── STEP 5: Push ke Gitea ────────────────────────────────────────────────── + console.log(`\n[5] Push ke Gitea (${GITEA_URL})...`); + try { + await git.push({ + fs, http, + dir: tmpDir, + remote: 'origin', + ref: GITEA_BRANCH, + onAuth: () => ({ username: GITEA_USER, password: GITEA_PASS }), + onProgress: ({ phase, loaded, total }) => { + if (total) process.stdout.write(`\r ${phase}: ${loaded}/${total} `); + }, + }); + console.log(`\n ✅ Push ke Gitea BERHASIL! Branch: ${GITEA_BRANCH}`); + } catch (err) { + console.error(`\n ❌ Push ke Gitea gagal: ${err.message}`); + } + + // ─── STEP 6: Push ke GitHub ───────────────────────────────────────────────── + console.log(`\n[6] Push ke GitHub (${GITHUB_URL})...`); + + // Tambah remote GitHub + const remotes = await git.listRemotes({ fs, dir: tmpDir }); + const hasGithub = remotes.some(r => r.remote === 'github'); + if (!hasGithub) { + await git.addRemote({ fs, dir: tmpDir, remote: 'github', url: GITHUB_URL }); + } + + // Coba push ke GitHub + if (!GITHUB_TOKEN) { + console.log(' ⚠️ GITHUB_TOKEN tidak di-set. GitHub push membutuhkan Personal Access Token.'); + console.log(' → Set environment variable: $env:GITHUB_TOKEN = "ghp_XXXX"'); + console.log(' → Lalu jalankan: node git-push-iso.js'); + } else { + try { + await git.push({ + fs, http, + dir: tmpDir, + remote: 'github', + ref: GITHUB_BRANCH, + remoteRef: GITHUB_BRANCH, + onAuth: () => ({ username: 'Rafif-Riqullah-Siregar', password: GITHUB_TOKEN }), + onProgress: ({ phase, loaded, total }) => { + if (total) process.stdout.write(`\r ${phase}: ${loaded}/${total} `); + }, + }); + console.log(`\n ✅ Push ke GitHub BERHASIL! Branch: ${GITHUB_BRANCH}`); + } catch (err) { + console.error(`\n ❌ Push ke GitHub gagal: ${err.message}`); + } + } + + cleanup(tmpDir); + console.log('\n╔══════════════════════════════════════════════════════════════╗'); + console.log('║ SELESAI ║'); + console.log('╚══════════════════════════════════════════════════════════════╝'); +} + +function cleanup(tmpDir) { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + console.log(`\n[cleanup] Temp dir dihapus: ${tmpDir}`); + } catch(e) {} +} + +main().catch(err => { + console.error('\n[FATAL]', err.message); + process.exit(1); +}); diff --git a/migration-temp/customagentlocations.json b/migration-temp/customagentlocations.json new file mode 100644 index 0000000..963a932 --- /dev/null +++ b/migration-temp/customagentlocations.json @@ -0,0 +1,35 @@ +[ + { + "_id": "6a584fdb36539224fa4cbfd9", + "agent_uuid": "F6-2V-DT-8A", + "site_uuid": "6681452d_9cae_4ff4_8ae8_0d504774265e", + "latitude": -6.2263304, + "longitude": 106.4247322, + "label": "CPI Balaraja Agent Office", + "created_at": "2026-07-14T04:03:09.294Z", + "updated_at": "2026-07-14T04:03:09.294Z", + "__v": 0 + }, + { + "_id": "6a584fdb36539224fa4cbfda", + "agent_uuid": "2F-TF-1D-GK", + "site_uuid": "6681452d_9cae_4ff4_8ae8_0d504774265e", + "latitude": -6.3763318, + "longitude": 106.8983017, + "label": "JRP Cibubur Agent", + "created_at": "2026-07-14T04:03:09.303Z", + "updated_at": "2026-07-14T04:57:22.288Z", + "__v": 0 + }, + { + "_id": "6a584fdb36539224fa4cbfdb", + "agent_uuid": "8A-V3-PB-85", + "site_uuid": "6681452d_9cae_4ff4_8ae8_0d504774265e", + "latitude": -6.2253265, + "longitude": 106.8061484, + "label": "IFG LT.18 Agent HQ", + "created_at": "2026-07-14T04:03:09.322Z", + "updated_at": "2026-07-14T04:03:09.322Z", + "__v": 0 + } +] \ No newline at end of file diff --git a/migration-temp/tenantconfigs.json b/migration-temp/tenantconfigs.json new file mode 100644 index 0000000..99d89f2 --- /dev/null +++ b/migration-temp/tenantconfigs.json @@ -0,0 +1,35 @@ +[ + { + "_id": "6a584fdb36539224fa4cbfd6", + "site_uuid": "default", + "brand_name": "BackOne", + "brand_logo": "/backone-logo.png", + "footer_copyright": "PT. Data Bisnis Solusi", + "primary_color": "#E11D48", + "created_at": "2026-07-14T02:15:21.201Z", + "updated_at": "2026-07-27T01:03:28.716Z", + "__v": 0 + }, + { + "_id": "6a584fdb36539224fa4cbfd7", + "site_uuid": "6681452d_9cae_4ff4_8ae8_0d504774265e", + "brand_name": "SIAB", + "brand_logo": "/siab-logo.png", + "footer_copyright": "PT. Data Bisnis Solusi", + "primary_color": "#3B82F6", + "created_at": "2026-07-14T02:15:21.204Z", + "updated_at": "2026-07-27T01:03:28.796Z", + "__v": 0 + }, + { + "_id": "6a584fdb36539224fa4cbfd8", + "site_uuid": "d7902405_0dc2_458b_8584_ed4d24b64f24", + "brand_name": "Nexus", + "brand_logo": "/nexus-logo.png", + "footer_copyright": "PT. Nexus Solusi", + "primary_color": "#8B5CF6", + "created_at": "2026-07-14T02:15:21.206Z", + "updated_at": "2026-07-27T01:03:28.799Z", + "__v": 0 + } +] \ No newline at end of file diff --git a/migration-temp/users.json b/migration-temp/users.json new file mode 100644 index 0000000..5b06da8 --- /dev/null +++ b/migration-temp/users.json @@ -0,0 +1,217 @@ +[ + { + "_id": "6a509b014fa14ba76d96ed33", + "username": "admin", + "password_hash": "$2a$10$uaBO91aVN9kwWS3rhCBvmu3uV00QFzMjorwqPK/AkhsGKKaLoFJoG", + "account_name": "BackOne Administrator", + "role": "SUPER_ADMIN", + "site_uuid": "6681452d_9cae_4ff4_8ae8_0d504774265e", + "is_active": true, + "created_at": "2026-07-08T06:20:27.502Z", + "updated_at": "2026-07-21T06:57:52.516Z", + "profile_picture": "profile-1784254528937-270868858.png", + "__v": 0, + "agent_uuid": null, + "created_by": "admin", + "login_attempts": 0 + }, + { + "_id": "6a509b254fa14ba76d96ed35", + "username": "cibubur", + "password_hash": "$2a$10$OjvVNyBXRogLWcBS07GHUOkBVtBMZ6iue6U71PgWWlbMXDWe9kCu.", + "account_name": "JRP Cibubur Agent", + "role": "AGENT_VIEWER", + "site_uuid": "6681452d_9cae_4ff4_8ae8_0d504774265e", + "agent_uuid": "2F-TF-1D-GK", + "is_active": true, + "created_at": "2026-07-14T03:39:34.474Z", + "__v": 0, + "created_by": "admin", + "profile_picture": null, + "updated_at": "2026-07-17T13:57:02.823Z", + "login_attempts": 0 + }, + { + "_id": "6a509b2e4fa14ba76d96ed36", + "username": "ifg", + "password_hash": "$2a$10$MsoJJqgY98DmgGMGyQIUD.PRA5kdbpXWhvNHFRakeipuJGF2F/73q", + "account_name": "IFG LT.18 Agent", + "role": "AGENT_VIEWER", + "site_uuid": "6681452d_9cae_4ff4_8ae8_0d504774265e", + "agent_uuid": "8A-V3-PB-85", + "is_active": true, + "created_at": "2026-07-14T03:39:52.757Z", + "__v": 0, + "created_by": "admin", + "profile_picture": null, + "updated_at": "2026-07-14T03:40:22.272Z" + }, + { + "_id": "6a509b394fa14ba76d96ed37", + "username": "balaraja", + "password_hash": "$2a$10$sx7XNdylDOr1XCy4PjjEuOrCoIWhayMNYwNlsAjspHVnmrz0xmQ9a", + "account_name": "CPI Balaraja Agent", + "role": "AGENT_VIEWER", + "site_uuid": "6681452d_9cae_4ff4_8ae8_0d504774265e", + "agent_uuid": "F6-2V-DT-8A", + "is_active": true, + "created_at": "2026-07-14T03:40:14.386Z", + "__v": 0, + "created_by": "admin", + "profile_picture": null, + "updated_at": "2026-07-14T03:40:14.386Z" + }, + { + "_id": "6a509b424fa14ba76d96ed38", + "username": "007", + "password_hash": "$2a$10$CDc8GOc0aTQaqMm/jD8E5OvYTxr.lbTPdrRbC6O1D2MDRzClbgyA6", + "account_name": "Gateway 007 Agent", + "role": "AGENT_VIEWER", + "site_uuid": "6681452d_9cae_4ff4_8ae8_0d504774265e", + "agent_uuid": "YW-6I-61-LL", + "is_active": true, + "created_at": "2026-07-14T03:40:51.583Z", + "__v": 0, + "created_by": "admin", + "profile_picture": null, + "updated_at": "2026-07-17T09:10:21.716Z", + "login_attempts": 3, + "lockout_until": "2026-07-17T09:25:21.716Z" + }, + { + "_id": "6a54b314301004e28818f8e2", + "username": "bsd", + "password_hash": "$2a$10$IIZtN8coQVLfptktA0bO2eIzaCpy3yIGtr9EUWsSf9MdTUaztx8eW", + "account_name": "Fazza BSD", + "profile_picture": null, + "role": "AGENT_VIEWER", + "site_uuid": "d7902405_0dc2_458b_8584_ed4d24b64f24", + "agent_uuid": "1T-5Q-RC-AS", + "is_active": true, + "created_at": "2026-07-14T03:38:04.913Z", + "updated_at": "2026-07-14T03:38:04.913Z", + "__v": 0, + "created_by": "admin" + }, + { + "_id": "6a54b332301004e28818f8e8", + "username": "jkt", + "password_hash": "$2a$10$EE0G6vQ6qbN6MYj2BSjqWOdJN2q/LmBcYRLZ578higbfLjnOzRKuW", + "account_name": "Fazza JKT", + "profile_picture": null, + "role": "AGENT_VIEWER", + "site_uuid": "d7902405_0dc2_458b_8584_ed4d24b64f24", + "agent_uuid": "2N-ID-VQ-AL", + "is_active": true, + "created_at": "2026-07-14T03:38:25.721Z", + "updated_at": "2026-07-14T03:38:25.721Z", + "__v": 0, + "created_by": "admin" + }, + { + "_id": "6a56617fe7a6bc10808db750", + "username": "siab", + "__v": 0, + "account_name": "SIAB Administrator", + "agent_uuid": null, + "created_at": "2026-07-14T03:13:43.107Z", + "created_by": "admin", + "is_active": true, + "password_hash": "$2a$10$lGP.s16GBImnBWKlFCg9Ge7d0k0vwwhFGrlfExRs6KlhO/fW6yJE.", + "profile_picture": "profile-1784254601947-954448750.png", + "role": "TENANT_ADMIN", + "site_uuid": "6681452d_9cae_4ff4_8ae8_0d504774265e", + "updated_at": "2026-07-17T02:16:41.972Z" + }, + { + "_id": "6a56617fe7a6bc10808db751", + "username": "nexus", + "__v": 0, + "account_name": "Nexus Administrator", + "agent_uuid": null, + "created_at": "2026-07-14T03:23:28.022Z", + "created_by": "nexus", + "is_active": true, + "password_hash": "$2a$10$nwhAiFodTWGZChddy10uvOV7jjo1aNMoJqrr9yB58GqGJE9oixaSe", + "profile_picture": "profile-1784254553441-339825741.png", + "role": "TENANT_ADMIN", + "site_uuid": "d7902405_0dc2_458b_8584_ed4d24b64f24", + "updated_at": "2026-07-17T09:37:28.382Z", + "login_attempts": 0 + }, + { + "_id": "6a56617fe7a6bc10808db752", + "username": "sulist", + "__v": 0, + "account_name": "BackOne Super SOC Analyst", + "agent_uuid": null, + "created_at": "2026-07-14T03:41:18.852Z", + "created_by": "admin", + "is_active": true, + "password_hash": "$2a$10$jNV0a9uQrtnvNJwkHVe2b.m0NBOXFSbGN/6cku/wCdeuV9p9gpmSq", + "profile_picture": "profile-1784254618510-383483774.png", + "role": "SOC_ANALYST", + "site_uuid": null, + "updated_at": "2026-07-17T02:16:58.532Z" + }, + { + "_id": "6a56617fe7a6bc10808db753", + "username": "nelis", + "__v": 0, + "account_name": "Nexus SOC Analyst", + "agent_uuid": null, + "created_at": "2026-07-14T03:42:02.608Z", + "created_by": "nexus", + "is_active": true, + "password_hash": "$2a$10$tKdI9yz1e9V2mSW4H/gj.udLtAtSrHJy0QxMbq6hN3mym5XxJpH.W", + "profile_picture": "profile-1784254567483-97901195.png", + "role": "SOC_ANALYST", + "site_uuid": "d7902405_0dc2_458b_8584_ed4d24b64f24", + "updated_at": "2026-07-17T02:16:07.500Z" + }, + { + "_id": "6a56617fe7a6bc10808db754", + "username": "nener", + "__v": 0, + "account_name": "Nexus Engineer", + "agent_uuid": null, + "created_at": "2026-07-14T03:44:55.970Z", + "created_by": "nexus", + "is_active": true, + "password_hash": "$2a$10$OoaKeuEeSHkqYMy1W50tvegaQm7u3qpP1YWuBcfmyJ45aH1kwL.Oq", + "profile_picture": "profile-1784254581808-854860134.png", + "role": "ENGINEER", + "site_uuid": "d7902405_0dc2_458b_8584_ed4d24b64f24", + "updated_at": "2026-07-17T02:16:21.824Z" + }, + { + "_id": "6a56617fe7a6bc10808db755", + "username": "silis", + "__v": 0, + "account_name": "SIAB SOC Analyst", + "agent_uuid": null, + "created_at": "2026-07-14T03:51:30.034Z", + "created_by": "siab", + "is_active": true, + "password_hash": "$2a$10$LrDCN9PzBjBV5uKKDIkcjOZcfq3WADJM408.VBrwlQmeqO/PbZtoG", + "profile_picture": "profile-1784254634906-830642874.png", + "role": "SOC_ANALYST", + "site_uuid": "6681452d_9cae_4ff4_8ae8_0d504774265e", + "updated_at": "2026-07-17T02:17:14.925Z" + }, + { + "_id": "6a56617fe7a6bc10808db756", + "username": "siner", + "__v": 0, + "account_name": "SIAB Engineer", + "agent_uuid": null, + "created_at": "2026-07-14T03:51:50.884Z", + "created_by": "siab", + "is_active": true, + "password_hash": "$2a$10$3CISy5oSUYnC23Whfwf36OSE3y7DHYBXDXRvJwZBldyQD0ehc5Nlm", + "profile_picture": "profile-1784254648483-456467829.png", + "role": "ENGINEER", + "site_uuid": "6681452d_9cae_4ff4_8ae8_0d504774265e", + "updated_at": "2026-07-17T02:17:28.503Z" + } +] \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 43935a7..8c7c359 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,17 +2,16 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", - serverExternalPackages: ["mongoose"], experimental: { serverActions: { - allowedOrigins: ["demoplace.my.id", "www.demoplace.my.id"], + allowedOrigins: ["dev.demoplace.my.id", "www.dev.demoplace.my.id"], }, }, async rewrites() { return [ { source: '/api/:path*', - destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3001'}/api/:path*`, + destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3011'}/api/:path*`, }, ]; }, diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf index ba3f186..7a283a1 100644 --- a/nginx/conf.d/default.conf +++ b/nginx/conf.d/default.conf @@ -1,8 +1,14 @@ +limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; +limit_req_zone $binary_remote_addr zone=uploads:10m rate=5r/s; + server { listen 80; - server_name demoplace.my.id; - - server_tokens off; # Hide NGINX version + server_name demoplace.my.id www.demoplace.my.id; + + server_tokens off; + + # Allow large file uploads for profile pictures (max 10MB) + client_max_body_size 10m; # Security Headers add_header X-Frame-Options "SAMEORIGIN" always; @@ -10,12 +16,12 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval';" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - # Rate Limiting zone configuration should be in nginx.conf (http block), but we can configure basic protection - # We will pass everything to the frontend container - - location / { - proxy_pass http://frontend:3000; + # Rate limiting on API endpoints + location /api/auth/ { + limit_req zone=api burst=20 nodelay; + proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -24,8 +30,37 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_hide_header X-Powered-By; + proxy_read_timeout 30s; + } - # Hide internal technologies from being sent back to the client + # Profile picture uploads — rate limited more strictly + location /api/auth/upload-profile-picture { + limit_req zone=uploads burst=5 nodelay; + client_max_body_size 10m; + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; proxy_hide_header X-Powered-By; } + + # All other traffic goes to Next.js frontend + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_hide_header X-Powered-By; + proxy_read_timeout 30s; + proxy_connect_timeout 10s; + } } diff --git a/package-lock.json b/package-lock.json index 8c2ce9b..216c77c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { - "name": "netify-app", - "version": "0.1.0", + "name": "backone-dpi", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "netify-app", - "version": "0.1.0", + "name": "backone-dpi", + "version": "1.0.0", "dependencies": { + "@react-pdf/renderer": "^4.5.1", "@types/leaflet": "^1.9.21", "axios": "^1.18.1", "bcryptjs": "^3.0.3", @@ -21,6 +22,7 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "framer-motion": "^12.42.2", + "isomorphic-git": "^1.40.0", "jsonwebtoken": "^9.0.3", "leaflet": "^1.9.4", "lucide-react": "^1.21.0", @@ -30,6 +32,7 @@ "next": "16.2.9", "next-themes": "^0.4.6", "node-cron": "^4.6.0", + "node-fetch": "^3.3.2", "react": "19.2.4", "react-dom": "19.2.4", "react-globe.gl": "^2.38.0", @@ -57,6 +60,9 @@ "ssh2-sftp-client": "^12.1.1", "tailwindcss": "^4", "typescript": "^5" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@alloc/quick-lru": { @@ -1259,6 +1265,30 @@ "node": ">= 10" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1307,6 +1337,183 @@ "node": ">=12.4.0" } }, + "node_modules/@react-pdf/fns": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz", + "integrity": "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==", + "license": "MIT" + }, + "node_modules/@react-pdf/font": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.8.tgz", + "integrity": "sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==", + "license": "MIT", + "dependencies": { + "@react-pdf/pdfkit": "^5.1.1", + "@react-pdf/types": "^2.11.1", + "fontkit": "^2.0.2", + "is-url": "^1.2.4" + } + }, + "node_modules/@react-pdf/image": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.1.0.tgz", + "integrity": "sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==", + "license": "MIT", + "dependencies": { + "@react-pdf/svg": "^1.1.0", + "jay-peg": "^1.1.1", + "png-js": "^2.0.0" + } + }, + "node_modules/@react-pdf/layout": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.6.1.tgz", + "integrity": "sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.3", + "@react-pdf/image": "^3.1.0", + "@react-pdf/primitives": "^4.3.0", + "@react-pdf/stylesheet": "^6.2.1", + "@react-pdf/textkit": "^6.3.0", + "@react-pdf/types": "^2.11.1", + "emoji-regex-xs": "^1.0.0", + "queue": "^6.0.1", + "yoga-layout": "^3.2.1" + } + }, + "node_modules/@react-pdf/pdfkit": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-5.1.1.tgz", + "integrity": "sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "browserify-zlib": "^0.2.0", + "fontkit": "^2.0.2", + "jay-peg": "^1.1.1", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^2.0.0", + "vite-compatible-readable-stream": "^3.6.1" + } + }, + "node_modules/@react-pdf/primitives": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.3.0.tgz", + "integrity": "sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==", + "license": "MIT" + }, + "node_modules/@react-pdf/reconciler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz", + "integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "scheduler": "0.25.0-rc-603e6108-20241029" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-pdf/reconciler/node_modules/scheduler": { + "version": "0.25.0-rc-603e6108-20241029", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz", + "integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==", + "license": "MIT" + }, + "node_modules/@react-pdf/render": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.5.1.tgz", + "integrity": "sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.1.3", + "@react-pdf/primitives": "^4.3.0", + "@react-pdf/textkit": "^6.3.0", + "@react-pdf/types": "^2.11.1", + "abs-svg-path": "^0.1.1", + "color-string": "^2.1.4", + "normalize-svg-path": "^1.1.0", + "parse-svg-path": "^0.1.2", + "svg-arc-to-cubic-bezier": "^3.2.0" + } + }, + "node_modules/@react-pdf/renderer": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.5.1.tgz", + "integrity": "sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.1.3", + "@react-pdf/font": "^4.0.8", + "@react-pdf/layout": "^4.6.1", + "@react-pdf/pdfkit": "^5.1.1", + "@react-pdf/primitives": "^4.3.0", + "@react-pdf/reconciler": "^2.0.0", + "@react-pdf/render": "^4.5.1", + "@react-pdf/types": "^2.11.1", + "events": "^3.3.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "queue": "^6.0.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-pdf/stylesheet": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.2.1.tgz", + "integrity": "sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.3", + "@react-pdf/types": "^2.11.1", + "color-string": "^2.1.4", + "hsl-to-hex": "^1.0.0", + "media-engine": "^1.0.3", + "postcss-value-parser": "^4.1.0" + } + }, + "node_modules/@react-pdf/svg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@react-pdf/svg/-/svg-1.1.0.tgz", + "integrity": "sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==", + "license": "MIT", + "dependencies": { + "@react-pdf/primitives": "^4.3.0" + } + }, + "node_modules/@react-pdf/textkit": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.3.0.tgz", + "integrity": "sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==", + "license": "MIT", + "dependencies": { + "@react-pdf/fns": "3.1.3", + "bidi-js": "^1.0.2", + "hyphen": "^1.6.4", + "unicode-properties": "^1.4.1" + } + }, + "node_modules/@react-pdf/types": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.11.1.tgz", + "integrity": "sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==", + "license": "MIT", + "dependencies": { + "@react-pdf/font": "^4.0.8", + "@react-pdf/primitives": "^4.3.0", + "@react-pdf/stylesheet": "^6.2.1" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", @@ -2601,7 +2808,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, "license": "MIT", "dependencies": { "event-target-shim": "^5.0.0" @@ -2610,6 +2816,12 @@ "node": ">=6.5" } }, + "node_modules/abs-svg-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", + "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3005,6 +3217,12 @@ "node": ">= 0.4" } }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "license": "MIT" + }, "node_modules/async-mutex": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", @@ -3024,7 +3242,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -3246,6 +3463,15 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -3327,6 +3553,24 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -3450,7 +3694,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3559,6 +3802,12 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/clean-git-ref": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", + "integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==", + "license": "Apache-2.0" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -3579,6 +3828,15 @@ "node": ">=20" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3608,6 +3866,27 @@ "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3883,7 +4162,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "dev": true, "license": "Apache-2.0", "bin": { "crc32": "bin/crc32.njs" @@ -4298,6 +4576,15 @@ "node": ">=12" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4410,7 +4697,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -4487,6 +4773,18 @@ "node": ">=8" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, + "node_modules/diff3": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", + "integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4561,6 +4859,12 @@ "dev": true, "license": "MIT" }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -5245,7 +5549,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5261,7 +5564,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -5341,7 +5643,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -5404,11 +5705,33 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fflate": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { @@ -5553,11 +5876,27 @@ } } }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -5606,6 +5945,18 @@ "node": ">= 0.6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5941,7 +6292,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -6022,6 +6372,21 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hsl-to-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz", + "integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==", + "license": "MIT", + "dependencies": { + "hsl-to-rgb-for-reals": "^1.1.0" + } + }, + "node_modules/hsl-to-rgb-for-reals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz", + "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==", + "license": "ISC" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -6055,6 +6420,12 @@ "node": ">= 6" } }, + "node_modules/hyphen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz", + "integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==", + "license": "ISC" + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -6095,7 +6466,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -6290,7 +6660,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6584,7 +6953,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -6596,6 +6964,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -6646,7 +7020,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, "license": "MIT" }, "node_modules/isexe": { @@ -6656,6 +7029,71 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic-git": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.40.0.tgz", + "integrity": "sha512-/CbnxwZqIm17y3c/z0INbkgEKSvFerXtO/NGgaRxZ8nvL3eoMtbjuAS7f4Pj7lZzj8HaultvDD1ClJTBVDl89g==", + "license": "MIT", + "dependencies": { + "async-lock": "^1.4.1", + "clean-git-ref": "^2.0.1", + "crc-32": "^1.2.0", + "diff3": "0.0.3", + "ignore": "^5.1.4", + "minimisted": "^2.0.0", + "pako": "^1.0.10", + "pify": "^4.0.1", + "readable-stream": "^4.0.0", + "sha.js": "^2.4.12", + "simple-get": "^4.0.1" + }, + "bin": { + "isogit": "cli.cjs" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/isomorphic-git/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/isomorphic-git/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -6674,6 +7112,15 @@ "node": ">= 0.4" } }, + "node_modules/jay-peg": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz", + "integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==", + "license": "MIT", + "dependencies": { + "restructure": "^3.0.0" + } + }, "node_modules/jerrypick": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", @@ -6693,6 +7140,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7225,6 +7678,25 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7361,6 +7833,12 @@ "node": ">= 0.4" } }, + "node_modules/media-engine": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz", + "integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==", + "license": "MIT" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -7478,6 +7956,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimisted": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz", + "integrity": "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -7949,6 +8436,26 @@ "node": ">=20" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-exports-info": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", @@ -7968,6 +8475,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-releases": { "version": "2.0.50", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", @@ -7988,6 +8513,15 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-svg-path": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", + "license": "MIT", + "dependencies": { + "svg-arc-to-cubic-bezier": "^3.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8207,6 +8741,12 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8220,6 +8760,12 @@ "node": ">=6" } }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -8290,6 +8836,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -8354,6 +8909,14 @@ "node": ">=8" } }, + "node_modules/png-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz", + "integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==", + "dependencies": { + "fflate": "^0.8.2" + } + }, "node_modules/point-in-polygon-hao": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz", @@ -8379,7 +8942,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8414,6 +8976,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/preact": { "version": "10.29.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.3.tgz", @@ -8465,7 +9033,6 @@ "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -8546,6 +9113,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8876,6 +9452,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", @@ -8926,6 +9511,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -9137,7 +9728,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -9188,6 +9778,26 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -9746,6 +10356,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, "node_modules/swr": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.2.tgz", @@ -9959,6 +10575,12 @@ "three": ">=0.154" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -10019,6 +10641,20 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10188,7 +10824,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -10332,6 +10967,32 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -10466,6 +11127,29 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite-compatible-readable-stream": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz", + "integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -10575,7 +11259,6 @@ "version": "1.1.22", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -10705,6 +11388,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zip-stream": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", diff --git a/package.json b/package.json index b2ae3b7..e32a97f 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,17 @@ { - "name": "netify-app", - "version": "0.1.0", + "name": "backone-dpi", + "version": "1.0.0", "private": true, + "engines": { + "node": ">=18.0.0" + }, "scripts": { - "dev": "concurrently --names \"NEXT,BACKEND,PROXY\" --prefix-colors \"cyan,green,yellow\" \"next dev\" \"node backend/server.js\" \"node proxy/index.js\"", - "dev:next": "next dev", + "dev": "concurrently --names \"MONGO,NEXT,BACKEND,PROXY\" --prefix-colors \"magenta,cyan,green,yellow\" \"node scripts/start-mongo.js\" \"next dev -p 3010\" \"node backend/server.js\" \"node proxy/index.js\"", + "dev:central": "concurrently --names \"NEXT,BACKEND\" --prefix-colors \"cyan,green\" \"next dev -p 3010\" \"node backend/server.js\"", + "dev:next": "next dev -p 3010", "dev:backend": "node backend/server.js", "dev:proxy": "node proxy/index.js", - "kill:ports": "powershell -Command \"@(3000,3001,4000) | ForEach-Object { $port = $_; $pids = netstat -ano | Select-String \\\":$port\\s+.+LISTENING\\\" | ForEach-Object { ($_ -split '\\s+')[-1] }; $pids | Where-Object { $_ -match '^\\d+$' } | ForEach-Object { taskkill /PID $_ /F 2>$null } }; Write-Host 'Ports 3000/3001/4000 cleared.'\"", + "kill:ports": "powershell -Command \"@(3010,3011,4010) | ForEach-Object { $port = $_; $pids = netstat -ano | Select-String \\\":$port\\s+.+LISTENING\\\" | ForEach-Object { ($_ -split '\\s+')[-1] }; $pids | Where-Object { $_ -match '^\\d+$' } | ForEach-Object { taskkill /PID $_ /F 2>$null } }; Write-Host 'Ports 3010/3011/4010 cleared.'\"", "build": "next build", "start": "next start", "start:prod": "concurrently \"next start\" \"node backend/server.js\" \"node proxy/index.js\"", @@ -15,9 +19,12 @@ "backend": "node backend/server.js", "proxy": "node proxy/index.js", "proxy:bun": "bun proxy/index.js", - "install:all": "npm install && cd backend && npm install && cd ../proxy && npm install && cd .." + "install:all": "npm install && cd backend && npm install && cd ../proxy && npm install && cd ..", + "deploy": "npm run build && node scripts/deploy-sftp.js", + "deploy:sftp": "node scripts/deploy-sftp.js" }, "dependencies": { + "@react-pdf/renderer": "^4.5.1", "@types/leaflet": "^1.9.21", "axios": "^1.18.1", "bcryptjs": "^3.0.3", @@ -31,6 +38,7 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "framer-motion": "^12.42.2", + "isomorphic-git": "^1.40.0", "jsonwebtoken": "^9.0.3", "leaflet": "^1.9.4", "lucide-react": "^1.21.0", @@ -40,6 +48,7 @@ "next": "16.2.9", "next-themes": "^0.4.6", "node-cron": "^4.6.0", + "node-fetch": "^3.3.2", "react": "19.2.4", "react-dom": "19.2.4", "react-globe.gl": "^2.38.0", diff --git a/postcss.config.mjs b/postcss.config.mjs index 61e3684..bc52b4a 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,7 +1,7 @@ -const config = { - plugins: { - "@tailwindcss/postcss": {}, - }, -}; - -export default config; +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/proxy/Dockerfile.bun b/proxy/Dockerfile.bun index 80a56ad..1f3fbd3 100644 --- a/proxy/Dockerfile.bun +++ b/proxy/Dockerfile.bun @@ -1,20 +1,20 @@ -FROM oven/bun:1-alpine - -WORKDIR /app - -# Install dependencies first (layer caching) -# bun install is compatible with npm package.json / package-lock.json -COPY package*.json ./ -RUN bun install --production - -# Copy application source -COPY . . - -# Expose proxy REST API port -EXPOSE 4000 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ - CMD bun -e "require('http').get('http://localhost:4000/health', r => r.statusCode === 200 ? process.exit(0) : process.exit(1)).on('error', () => process.exit(1))" - -CMD ["bun", "run", "index.js"] +FROM oven/bun:1-alpine + +WORKDIR /app + +# Install dependencies first (layer caching) +# bun install is compatible with npm package.json / package-lock.json +COPY package*.json ./ +RUN bun install --production + +# Copy application source +COPY . . + +# Expose proxy REST API port +EXPOSE 4000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD bun -e "require('http').get('http://localhost:4000/health', r => r.statusCode === 200 ? process.exit(0) : process.exit(1)).on('error', () => process.exit(1))" + +CMD ["bun", "run", "index.js"] diff --git a/proxy/INFO.md b/proxy/INFO.md index e3ab5db..b94cff9 100644 --- a/proxy/INFO.md +++ b/proxy/INFO.md @@ -1,108 +1,108 @@ -# BackOne DPI Proxy — Deployment Reference - -Standalone Docker image for collecting Netify DPI data and writing to MongoDB. - ---- - -## Environment Variables - -### Required - -| Variable | Description | -|---|---| -| `NETIFY_SITE_UUIDS` | Comma-separated Netify site UUIDs (or single `NETIFY_SITE_UUID`) | -| `NETIFY_TOKEN` | Netify JWT token (or `NETIFY_JWT_TOKEN` / `NETIFY_API_KEY`) | - -### MongoDB - -| Variable | Default | Description | -|---|---|---| -| `MONGODB_URI` | `mongodb://127.0.0.1:27017/backone_dpi` | MongoDB connection string | - -### Collection Mode - -| Variable | Default | Description | -|---|---|---| -| `PROXY_COLLECT_MODE` | `all` | `all` = all agents, `agent` = single agent, `agents` = list of agents | -| `PROXY_AGENT_UUID` | _(none)_ | Single agent UUID (required if `mode=agent`) | -| `PROXY_AGENT_UUIDS` | _(none)_ | Comma-separated agent UUIDs (required if `mode=agents`) | -| `PROXY_AGENT_DELAY_MS` | `5000` | Delay (ms) between each agent collection to avoid rate-limiting | - -### Scheduling - -| Variable | Default | Description | -|---|---|---| -| `PROXY_CRON_SCHEDULE` | `*/5 * * * *` | Cron expression for collection interval | -| `PROXY_CAPACITY_LOG_INTERVAL_MS` | `86400000` | How often to log DB capacity usage (default: 24h) | - -### Limits - -| Variable | Default | Description | -|---|---|---| -| `PROXY_FLOW_LIMIT` | `1000000` | Max flows to fetch per agent per cycle | -| `PROXY_PORT` | `4000` | REST API listen port | - -### Netify API - -| Variable | Default | Description | -|---|---|---| -| `NETIFY_INFORMATICS_BASE_URL` | `https://informatics.netify.ai/api/v1` | Netify API base URL | - ---- - -## Docker Run Example - -```bash -docker run -d \ - --name backone_proxy \ - -p 4000:4000 \ - -e MONGODB_URI=mongodb://host.docker.internal:27017/backone_dpi \ - -e NETIFY_SITE_UUIDS=site-uuid-1,site-uuid-2 \ - -e NETIFY_TOKEN=your-jwt-token \ - -e PROXY_COLLECT_MODE=agents \ - -e PROXY_AGENT_UUIDS=agent-uuid-1,agent-uuid-2,agent-uuid-3 \ - backone-proxy -``` - -## Docker Compose Example - -```yaml -services: - proxy: - build: ./proxy - container_name: backone_proxy - restart: always - ports: - - "4000:4000" - environment: - - MONGODB_URI=mongodb://mongodb:27017/backone_dpi - - PROXY_PORT=4000 - - PROXY_COLLECT_MODE=all - - PROXY_COLLECT_MODE=${PROXY_COLLECT_MODE:-all} - - PROXY_AGENT_UUID=${PROXY_AGENT_UUID:-} - - PROXY_AGENT_UUIDS=${PROXY_AGENT_UUIDS:-} - - PROXY_AGENT_DELAY_MS=${PROXY_AGENT_DELAY_MS:-5000} - - PROXY_CRON_SCHEDULE=${PROXY_CRON_SCHEDULE:-*/5 * * * *} - - NETIFY_SITE_UUIDS=${NETIFY_SITE_UUIDS} - - NETIFY_TOKEN=${NETIFY_TOKEN} - - NETIFY_INFORMATICS_BASE_URL=${NETIFY_INFORMATICS_BASE_URL:-https://informatics.netify.ai/api/v1} -``` - -## REST API Endpoints - -| Method | Endpoint | Description | -|---|---|---| -| `GET` | `/health` | Liveness check (MongoDB status) | -| `GET` | `/status` | Scheduler status, mode, last run | -| `GET` | `/agents` | List agent UUIDs in MongoDB | -| `POST` | `/collect/all` | Manual trigger — all agents | -| `POST` | `/collect/:uuid` | Manual trigger — single agent | -| `POST` | `/collect/agents` | Manual trigger — multiple agents `{"uuids":[...], "delay_ms":5000}` | -| `GET` | `/latest` | Latest data from all collections (debug) | -| `GET` | `/domain-details?domain=...` | IP/MAC details for a domain | - -## Health Check - -```bash -curl http://localhost:4000/health -``` +# BackOne DPI Proxy — Deployment Reference + +Standalone Docker image for collecting Netify DPI data and writing to MongoDB. + +--- + +## Environment Variables + +### Required + +| Variable | Description | +|---|---| +| `NETIFY_SITE_UUIDS` | Comma-separated Netify site UUIDs (or single `NETIFY_SITE_UUID`) | +| `NETIFY_TOKEN` | Netify JWT token (or `NETIFY_JWT_TOKEN` / `NETIFY_API_KEY`) | + +### MongoDB + +| Variable | Default | Description | +|---|---|---| +| `MONGODB_URI` | `mongodb://127.0.0.1:27017/backone_dpi` | MongoDB connection string | + +### Collection Mode + +| Variable | Default | Description | +|---|---|---| +| `PROXY_COLLECT_MODE` | `all` | `all` = all agents, `agent` = single agent, `agents` = list of agents | +| `PROXY_AGENT_UUID` | _(none)_ | Single agent UUID (required if `mode=agent`) | +| `PROXY_AGENT_UUIDS` | _(none)_ | Comma-separated agent UUIDs (required if `mode=agents`) | +| `PROXY_AGENT_DELAY_MS` | `5000` | Delay (ms) between each agent collection to avoid rate-limiting | + +### Scheduling + +| Variable | Default | Description | +|---|---|---| +| `PROXY_CRON_SCHEDULE` | `*/5 * * * *` | Cron expression for collection interval | +| `PROXY_CAPACITY_LOG_INTERVAL_MS` | `86400000` | How often to log DB capacity usage (default: 24h) | + +### Limits + +| Variable | Default | Description | +|---|---|---| +| `PROXY_FLOW_LIMIT` | `1000000` | Max flows to fetch per agent per cycle | +| `PROXY_PORT` | `4000` | REST API listen port | + +### Netify API + +| Variable | Default | Description | +|---|---|---| +| `NETIFY_INFORMATICS_BASE_URL` | `https://informatics.netify.ai/api/v1` | Netify API base URL | + +--- + +## Docker Run Example + +```bash +docker run -d \ + --name backone_proxy \ + -p 4000:4000 \ + -e MONGODB_URI=mongodb://host.docker.internal:27017/backone_dpi \ + -e NETIFY_SITE_UUIDS=site-uuid-1,site-uuid-2 \ + -e NETIFY_TOKEN=your-jwt-token \ + -e PROXY_COLLECT_MODE=agents \ + -e PROXY_AGENT_UUIDS=agent-uuid-1,agent-uuid-2,agent-uuid-3 \ + backone-proxy +``` + +## Docker Compose Example + +```yaml +services: + proxy: + build: ./proxy + container_name: backone_proxy + restart: always + ports: + - "4000:4000" + environment: + - MONGODB_URI=mongodb://mongodb:27017/backone_dpi + - PROXY_PORT=4000 + - PROXY_COLLECT_MODE=all + - PROXY_COLLECT_MODE=${PROXY_COLLECT_MODE:-all} + - PROXY_AGENT_UUID=${PROXY_AGENT_UUID:-} + - PROXY_AGENT_UUIDS=${PROXY_AGENT_UUIDS:-} + - PROXY_AGENT_DELAY_MS=${PROXY_AGENT_DELAY_MS:-5000} + - PROXY_CRON_SCHEDULE=${PROXY_CRON_SCHEDULE:-*/5 * * * *} + - NETIFY_SITE_UUIDS=${NETIFY_SITE_UUIDS} + - NETIFY_TOKEN=${NETIFY_TOKEN} + - NETIFY_INFORMATICS_BASE_URL=${NETIFY_INFORMATICS_BASE_URL:-https://informatics.netify.ai/api/v1} +``` + +## REST API Endpoints + +| Method | Endpoint | Description | +|---|---|---| +| `GET` | `/health` | Liveness check (MongoDB status) | +| `GET` | `/status` | Scheduler status, mode, last run | +| `GET` | `/agents` | List agent UUIDs in MongoDB | +| `POST` | `/collect/all` | Manual trigger — all agents | +| `POST` | `/collect/:uuid` | Manual trigger — single agent | +| `POST` | `/collect/agents` | Manual trigger — multiple agents `{"uuids":[...], "delay_ms":5000}` | +| `GET` | `/latest` | Latest data from all collections (debug) | +| `GET` | `/domain-details?domain=...` | IP/MAC details for a domain | + +## Health Check + +```bash +curl http://localhost:4000/health +``` diff --git a/proxy/check_device.js b/proxy/check_device.js new file mode 100644 index 0000000..7e33bd1 --- /dev/null +++ b/proxy/check_device.js @@ -0,0 +1,34 @@ +// check_device.js - Detailed check of 10.6.10.44 records +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') }); +const mongoose = require('mongoose'); + +async function run() { + await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/backone'); + const db = mongoose.connection.db; + + // Get all records for 10.6.10.44 + const docs = await db.collection('devicestats') + .find({ ip_address: '10.6.10.44' }) + .sort({ timestamp: 1 }) + .toArray(); + + console.log(`Total docs for 10.6.10.44: ${docs.length}`); + docs.forEach((d, i) => { + console.log(`\n--- Doc ${i + 1} ---`); + console.log(' _id: ', d._id); + console.log(' agent_uuid: ', d.agent_uuid); + console.log(' timestamp: ', d.timestamp); + console.log(' created_at: ', d.created_at); + console.log(' updated_at: ', d.updated_at); + console.log(' download: ', d.download); + console.log(' device_label:', d.device_label); + }); + + // Check if there are different agent_uuids + const agents = [...new Set(docs.map(d => d.agent_uuid))]; + console.log('\nDistinct agent_uuids for this IP:', agents); + + await mongoose.disconnect(); +} +run().catch(err => { console.error(err.message); process.exit(1); }); diff --git a/proxy/check_summary_data.js b/proxy/check_summary_data.js new file mode 100644 index 0000000..620d7e4 --- /dev/null +++ b/proxy/check_summary_data.js @@ -0,0 +1,63 @@ +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') }); +const mongoose = require('mongoose'); + +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi'; +const SIAB = '6681452d_9cae_4ff4_8ae8_0d504774265e'; + +mongoose.connect(MONGODB_URI).then(async () => { + const db = mongoose.connection.db; + const since24h = new Date(Date.now() - 24 * 3600000); + const since7d = new Date(Date.now() - 7 * 24 * 3600000); + + // Count site-level summary docs + const count24h = await db.collection('summaries').countDocuments({ + site_uuid: SIAB, agent_uuid: null, timestamp: { $gte: since24h } + }); + const countAll = await db.collection('summaries').countDocuments({ + site_uuid: SIAB, agent_uuid: null + }); + + // Sum bandwidth for last 24h (site-level, agent_uuid: null) + const agg24h = await db.collection('summaries').aggregate([ + { $match: { site_uuid: SIAB, agent_uuid: null, timestamp: { $gte: since24h } } }, + { $group: { _id: null, totalDown: { $sum: '$bandwidth_down' }, totalUp: { $sum: '$bandwidth_up' }, count: { $sum: 1 } } } + ]).toArray(); + + // Sum bandwidth ALL time (site-level) + const aggAll = await db.collection('summaries').aggregate([ + { $match: { site_uuid: SIAB, agent_uuid: null } }, + { $group: { _id: null, totalDown: { $sum: '$bandwidth_down' }, totalUp: { $sum: '$bandwidth_up' }, count: { $sum: 1 } } } + ]).toArray(); + + // Oldest and newest + const oldest = await db.collection('summaries').findOne({ site_uuid: SIAB, agent_uuid: null }, { sort: { timestamp: 1 }, projection: { timestamp: 1 } }); + const newest = await db.collection('summaries').findOne({ site_uuid: SIAB, agent_uuid: null }, { sort: { timestamp: -1 }, projection: { timestamp: 1, bandwidth_down: 1, bandwidth_up: 1 } }); + + console.log('\n=== MongoDB Summary Check (SIAB site) ==='); + console.log('Total site-level docs:', countAll); + console.log('Site-level docs in last 24h:', count24h); + console.log('\nBandwidth SUM (last 24h):'); + console.log(' Down:', agg24h[0] ? (agg24h[0].totalDown / 1024 / 1024).toFixed(2) + ' MB' : '0 MB'); + console.log(' Up :', agg24h[0] ? (agg24h[0].totalUp / 1024 / 1024).toFixed(2) + ' MB' : '0 MB'); + console.log('\nBandwidth SUM (ALL time):'); + console.log(' Down:', aggAll[0] ? (aggAll[0].totalDown / 1024 / 1024).toFixed(2) + ' MB' : '0 MB'); + console.log(' Up :', aggAll[0] ? (aggAll[0].totalUp / 1024 / 1024).toFixed(2) + ' MB' : '0 MB'); + console.log('\nOldest entry :', oldest?.timestamp); + console.log('Newest entry :', newest?.timestamp); + console.log('Latest bandwidth_down per 5min:', newest ? (newest.bandwidth_down / 1024 / 1024).toFixed(4) + ' MB' : 'N/A'); + console.log('Latest bandwidth_up per 5min :', newest ? (newest.bandwidth_up / 1024 / 1024).toFixed(4) + ' MB' : 'N/A'); + + // Also check flow data for comparison + const flowAgg = await db.collection('flows').aggregate([ + { $match: { site_uuid: SIAB, timestamp: { $gte: since24h } } }, + { $group: { _id: null, totalDown: { $sum: '$download' }, totalUp: { $sum: '$upload' }, count: { $sum: 1 } } } + ]).toArray(); + console.log('\nFlow-level bandwidth (last 24h from flows collection):'); + console.log(' Down:', flowAgg[0] ? (flowAgg[0].totalDown / 1024 / 1024).toFixed(2) + ' MB' : '0 MB'); + console.log(' Up :', flowAgg[0] ? (flowAgg[0].totalUp / 1024 / 1024).toFixed(2) + ' MB' : '0 MB'); + console.log(' Flow count:', flowAgg[0]?.count || 0); + console.log('=====================================\n'); + + process.exit(0); +}).catch(e => { console.error(e.message); process.exit(1); }); diff --git a/proxy/clean_devicestat_duplicates.js b/proxy/clean_devicestat_duplicates.js new file mode 100644 index 0000000..974598c --- /dev/null +++ b/proxy/clean_devicestat_duplicates.js @@ -0,0 +1,73 @@ +// proxy/clean_devicestat_duplicates.js +// ───────────────────────────────────────────────────────────────────────────── +// One-time cleanup script to deduplicate historical DeviceStat records. +// Keeps only the LATEST document per (agent_uuid, ip_address) pair, +// removing all older duplicates accumulated before the upsert fix. +// +// Usage: node proxy/clean_devicestat_duplicates.js +// ───────────────────────────────────────────────────────────────────────────── + +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') }); + +const mongoose = require('mongoose'); +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/backone'; + +const DeviceStatSchema = new mongoose.Schema({ + timestamp: { type: Date }, + agent_uuid: { type: String }, + site_uuid: { type: String }, + ip_address: { type: String }, + mac_address: { type: String }, + device_label: String, + device_type: String, + os_label: String, + manufacturer: String, + download: Number, + upload: Number, + flows: Number, + last_seen: String, +}, { timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } }); + +const DeviceStat = mongoose.model('DeviceStat', DeviceStatSchema); + +async function run() { + console.log('[Cleanup] Connecting to MongoDB...'); + await mongoose.connect(MONGODB_URI); + console.log('[Cleanup] Connected.'); + + // Find all unique (agent_uuid, ip_address) combinations + const groups = await DeviceStat.aggregate([ + { $group: { + _id: { agent_uuid: '$agent_uuid', ip_address: '$ip_address' }, + ids: { $push: '$_id' }, + timestamps: { $push: '$timestamp' }, + count: { $sum: 1 }, + }}, + { $match: { count: { $gt: 1 } } }, + ]); + + console.log(`[Cleanup] Found ${groups.length} (agent_uuid, ip_address) pairs with duplicates.`); + let totalDeleted = 0; + + for (const group of groups) { + // Sort the ids by matching timestamps - keep the latest + const paired = group.ids.map((id, i) => ({ id, ts: group.timestamps[i] })); + paired.sort((a, b) => new Date(b.ts) - new Date(a.ts)); + + // Keep the first (newest), delete the rest + const toDelete = paired.slice(1).map(p => p.id); + const result = await DeviceStat.deleteMany({ _id: { $in: toDelete } }); + totalDeleted += result.deletedCount; + } + + const remaining = await DeviceStat.countDocuments(); + console.log(`[Cleanup] Done. Deleted ${totalDeleted} duplicate DeviceStat records.`); + console.log(`[Cleanup] Remaining DeviceStat documents: ${remaining}`); + await mongoose.disconnect(); +} + +run().catch(err => { + console.error('[Cleanup] Fatal error:', err.message); + process.exit(1); +}); diff --git a/proxy/cleanup_duplicate_agents.js b/proxy/cleanup_duplicate_agents.js index ae5d73b..644fef0 100644 --- a/proxy/cleanup_duplicate_agents.js +++ b/proxy/cleanup_duplicate_agents.js @@ -8,8 +8,8 @@ const mongoose = require('mongoose'); const path = require('path'); require('dotenv').config({ path: path.join(__dirname, '../../../..', '.env.local') }); -const SIAB_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e'; -const NEXUS_UUID = 'd7902405_0dc2_458b_8584_ed4d24b64f24'; +const SIAB_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e'; +const OFFICE_UUID = '1959bb55_045b_47c7_bbdd_f33b7db197b9'; // Definitive SIAB agent list (from most recent collector run) const SIAB_AGENTS = ['F6-2V-DT-8A', 'YW-6I-61-LL', '2F-TF-1D-GK', '1R-79-J9-YE', '8A-V3-PB-85']; @@ -27,13 +27,13 @@ async function cleanup() { for (const colName of collections) { const col = db.collection(colName); - // 1. Delete SIAB agents that are stored under NEXUS site_uuid + // 1. Delete SIAB agents that are stored under OFFICE site_uuid const r1 = await col.deleteMany({ - site_uuid: NEXUS_UUID, + site_uuid: OFFICE_UUID, agent_uuid: { $in: SIAB_AGENTS } }); if (r1.deletedCount > 0) { - console.log(`[${colName}] Removed ${r1.deletedCount} docs (SIAB agents from NEXUS)`); + console.log(`[${colName}] Removed ${r1.deletedCount} docs (SIAB agents from OFFICE)`); totalDeleted += r1.deletedCount; } diff --git a/proxy/collector.js b/proxy/collector.js index 5017b69..6d60171 100644 --- a/proxy/collector.js +++ b/proxy/collector.js @@ -1,262 +1,263 @@ -// proxy/collector.js -// ───────────────────────────────────────────────────────────────────────────── -// Core data collection logic for the BackOne Proxy Server -// Supports 2 modes: ALL Agents and ONE Agent by UUID -// All data is stored in MongoDB, tagged with agent_uuid + site_uuid. -// ───────────────────────────────────────────────────────────────────────────── - -const path = require('path'); -require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') }); - -const netify = require('./netifyClient'); -const { collectSecondaryTelemetry } = require('./collectorHelper'); -const { - collectDevicesAndApps, - collectFlows, - collectThreats, - collectEvents -} = require('./collectorHelperDpi2'); - -const { Summary, AppStat } = require('./models/Schemas'); -const { pruneOldData } = require('./dataRetention'); - -const SITE_UUIDS_STR = process.env.NETIFY_SITE_UUIDS || process.env.NETIFY_SITE_UUID; -const SITE_UUIDS = SITE_UUIDS_STR ? SITE_UUIDS_STR.split(',').map(s => s.trim()).filter(Boolean) : []; - -async function collectForAgent(agentUuid, timestamp, siteUuid) { - const label = agentUuid || 'GLOBAL'; - console.log(`[Collector] → Fetching data for Agent: ${label}`); - - try { - // 1. Summary / Bandwidth (including derived speed, packet_drops, and peak_flow_rate) - const summary = await netify.fetchBandwidthSummary(1440, agentUuid, siteUuid); - if (summary) { - let download_speed = 0; - let upload_speed = 0; - let packet_drops = 0; - let peak_flow_rate = summary.active_flows || 0; - - try { - const prev = await Summary.findOne({ agent_uuid: agentUuid }).sort({ timestamp: -1 }).lean(); - if (prev && prev.timestamp) { - const timeDiffSec = (timestamp.getTime() - new Date(prev.timestamp).getTime()) / 1000; - if (timeDiffSec > 0) { - const bytesDiffDown = Math.max(0, summary.bandwidth_down - (prev.bandwidth_down || 0)); - const bytesDiffUp = Math.max(0, summary.bandwidth_up - (prev.bandwidth_up || 0)); - download_speed = bytesDiffDown / timeDiffSec; - upload_speed = bytesDiffUp / timeDiffSec; - } - } - } catch (err) { - console.error('[Collector] Error calculating summary speeds:', err.message); - } - - packet_drops = Math.floor((summary.active_flows || 0) * 0.015); - peak_flow_rate = Math.floor((summary.active_flows || 0) * 1.18); - - const activeFlows = summary.active_flows || 0; - const totalBandwidth = (summary.bandwidth_down || 0) + (summary.bandwidth_up || 0); - - const cpu_usage = Math.min(98, Math.max(1.2, parseFloat((2.5 + (activeFlows * 0.04) + (totalBandwidth / 10000000)).toFixed(2)))); - const memory_usage = Math.min(99, Math.max(10.5, parseFloat((15.4 + (activeFlows * 0.02) + (totalBandwidth / 25000000)).toFixed(2)))); - const queue_depth = Math.max(0, Math.floor((activeFlows * 0.15) + (totalBandwidth / 5000000))); - - await new Summary({ - timestamp, - agent_uuid: agentUuid, - site_uuid: siteUuid, - ...summary, - download_speed, - upload_speed, - packet_drops, - peak_flow_rate, - cpu_usage, - memory_usage, - queue_depth - }).save(); - console.log(`[Collector] ✓ Summary saved for ${label}`); - } - - // 2. Top Apps - const apps = await netify.fetchTopApps(1440, 200, agentUuid, siteUuid); - if (apps && apps.length > 0) { - const appDocs = apps.map(app => ({ - timestamp, agent_uuid: agentUuid, site_uuid: siteUuid, - app_label: app.application?.label || 'Unknown', - download: app.download || 0, upload: app.upload || 0, flows: app.flows || 0, - })); - await AppStat.insertMany(appDocs); - console.log(`[Collector] ✓ ${appDocs.length} apps saved for ${label}`); - } - - // Collect Secondary Telemetry (categories, TLS, countries, DHCP, User Agents, BitTorrent) - await collectSecondaryTelemetry(agentUuid, timestamp, siteUuid, netify, label); - - // 2. Devices & App Records - const ipToMacMap = await collectDevicesAndApps(agentUuid, timestamp, siteUuid, netify, label); - - // 3. Flows - await collectFlows(agentUuid, timestamp, siteUuid, netify, label, ipToMacMap); - - // 5. Threats - await collectThreats(agentUuid, timestamp, siteUuid, netify, label); - - // 6. Events - await collectEvents(agentUuid, timestamp, siteUuid, netify, label); - - return { success: true, agent_uuid: agentUuid }; - } catch (err) { - console.error(`[Collector] ✗ Error collecting for ${label}:`, err.message); - return { success: false, agent_uuid: agentUuid, error: err.message }; - } -} - -async function collectAllAgents() { - const timestamp = new Date(); - const timeString = timestamp.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' }) + ' WIB'; - console.log(`[Collector] === MODE: ALL AGENTS === Started at ${timeString}`); - - const results = []; - let totalAgents = 0; - - if (SITE_UUIDS.length === 0) { - console.warn('[Collector] No NETIFY_SITE_UUIDS configured.'); - return { success: false, mode: 'all', message: 'No sites configured', results: [] }; - } - - // Track agent UUIDs already assigned to a site to prevent cross-site duplication. - // The Netify /data/stats/top/agent/download endpoint is org-level and can return - // the same agent for multiple site queries. Each agent must belong to exactly one site. - const processedAgentUuids = new Set(); - - for (const siteUuid of SITE_UUIDS) { - console.log(`[Collector] Fetching agents for Site: ${siteUuid}`); - const rawAgents = await netify.fetchAgents(siteUuid); - if (!rawAgents || rawAgents.length === 0) { - console.warn(`[Collector] No agents found for site ${siteUuid}.`); - continue; - } - - // Deduplicate: only keep agents not yet seen in a previous site this cycle - const agents = rawAgents.filter(a => { - if (processedAgentUuids.has(a.uuid)) { - console.log(`[Collector] Skipping agent ${a.uuid} — already assigned to another site.`); - return false; - } - return true; - }); - - if (agents.length === 0) { - console.warn(`[Collector] No unique agents for site ${siteUuid} (all were already assigned). Skipping.`); - continue; - } - - // Register these agents as belonging to this site - for (const agent of agents) processedAgentUuids.add(agent.uuid); - - totalAgents += agents.length; - console.log(`[Collector] Processing ${agents.length} agents for site ${siteUuid}: ${agents.map(a => a.uuid).join(', ')}`); - - // ── Site-Level Summary (Pilihan A) ───────────────────────────────────── - // Collect bandwidth at site level (no agentUuid filter) so numbers match - // Netify portal exactly and avoid double-counting across agents. - try { - console.log(`[Collector] → Fetching site-level summary for site: ${siteUuid}`); - const siteSummary = await netify.fetchBandwidthSummary(1440, null, siteUuid); - if (siteSummary) { - let download_speed = 0; - let upload_speed = 0; - - try { - const prev = await Summary.findOne({ agent_uuid: null, site_uuid: siteUuid }).sort({ timestamp: -1 }).lean(); - if (prev && prev.timestamp) { - const timeDiffSec = (timestamp.getTime() - new Date(prev.timestamp).getTime()) / 1000; - if (timeDiffSec > 0) { - const bytesDiffDown = Math.max(0, siteSummary.bandwidth_down - (prev.bandwidth_down || 0)); - const bytesDiffUp = Math.max(0, siteSummary.bandwidth_up - (prev.bandwidth_up || 0)); - download_speed = bytesDiffDown / timeDiffSec; - upload_speed = bytesDiffUp / timeDiffSec; - } - } - } catch (err) { - console.error('[Collector] Error calculating site summary speeds:', err.message); - } - - const activeFlows = siteSummary.active_flows || 0; - const totalBandwidth = (siteSummary.bandwidth_down || 0) + (siteSummary.bandwidth_up || 0); - const packet_drops = Math.floor(activeFlows * 0.015); - const peak_flow_rate = Math.floor(activeFlows * 1.18); - const cpu_usage = Math.min(98, Math.max(1.2, parseFloat((2.5 + (activeFlows * 0.04) + (totalBandwidth / 10000000)).toFixed(2)))); - const memory_usage = Math.min(99, Math.max(10.5, parseFloat((15.4 + (activeFlows * 0.02) + (totalBandwidth / 25000000)).toFixed(2)))); - const queue_depth = Math.max(0, Math.floor((activeFlows * 0.15) + (totalBandwidth / 5000000))); - - await new Summary({ - timestamp, - agent_uuid: null, // null = site-level aggregate (bukan per-agent) - site_uuid: siteUuid, - ...siteSummary, - download_speed, - upload_speed, - packet_drops, - peak_flow_rate, - cpu_usage, - memory_usage, - queue_depth - }).save(); - console.log(`[Collector] ✓ Site-level summary saved for site: ${siteUuid} | Down: ${(siteSummary.bandwidth_down / 1e9).toFixed(2)} GB | Up: ${(siteSummary.bandwidth_up / 1e9).toFixed(2)} GB | Flows: ${siteSummary.active_flows?.toLocaleString()}`); - } - } catch (err) { - console.error(`[Collector] ✗ Failed to save site-level summary for ${siteUuid}:`, err.message); - } - - for (const agent of agents) { - const result = await collectForAgent(agent.uuid, timestamp, siteUuid); - results.push({ ...result, agent_label: agent.label, site_uuid: siteUuid }); - } - } - - const successful = results.filter(r => r.success).length; - console.log(`[Collector] === ALL AGENTS DONE === ${successful}/${totalAgents} successful across ${SITE_UUIDS.length} sites`); - - await pruneOldData().catch(err => console.error('[Collector] [Retention] error:', err.message)); - - return { success: true, mode: 'all', agents_count: totalAgents, successful }; -} - -async function collectSpecificAgent(agentUuid, siteUuid = SITE_UUIDS[0]) { - const timestamp = new Date(); - const timeString = timestamp.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' }) + ' WIB'; - console.log(`[Collector] === MODE: SPECIFIC AGENT ${agentUuid} === Started at ${timeString}`); - const result = await collectForAgent(agentUuid, timestamp, siteUuid); - - await pruneOldData().catch(err => console.error('[Collector] [Retention] error:', err.message)); - - return { success: result.success, mode: 'specific', agent_uuid: agentUuid }; -} - -async function collectSpecificAgents(agentUuids, siteUuid = SITE_UUIDS[0], delayMs = 5000) { - const timestamp = new Date(); - const timeString = timestamp.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' }) + ' WIB'; - console.log(`[Collector] === MODE: SPECIFIC AGENTS [${agentUuids.join(', ')}] === Started at ${timeString}`); - const results = []; - for (let i = 0; i < agentUuids.length; i++) { - if (i > 0) { - console.log(`[Collector] Waiting ${delayMs}ms before next agent...`); - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - const result = await collectForAgent(agentUuids[i], timestamp, siteUuid); - results.push(result); - } - const successful = results.filter(r => r.success).length; - console.log(`[Collector] === SPECIFIC AGENTS DONE === ${successful}/${agentUuids.length} successful`); - - await pruneOldData().catch(err => console.error('[Collector] [Retention] error:', err.message)); - - return { success: true, mode: 'specific_agents', agents_count: agentUuids.length, successful, results }; -} - -module.exports = { - collectAllAgents, - collectSpecificAgent, - collectSpecificAgents -}; +// proxy/collector.js +// ───────────────────────────────────────────────────────────────────────────── +// Core data collection logic for the BackOne Proxy Server +// Supports 2 modes: ALL Agents and ONE Agent by UUID +// All data is stored in MongoDB, tagged with agent_uuid + site_uuid. +// ───────────────────────────────────────────────────────────────────────────── + +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') }); + +const netify = require('./netifyClient'); +const { collectSecondaryTelemetry } = require('./collectorHelper'); +const { collectDevicesAndApps, collectFlows } = require('./collectorHelperDpi2'); +const { collectThreats, collectEvents } = require('./collectorHelperDpi3'); + +const { Summary, AppStat, AgentRegistry } = require('./models/Schemas'); +const { pruneOldData } = require('./dataRetention'); + +const SITE_UUIDS_STR = process.env.NETIFY_SITE_UUIDS || process.env.NETIFY_SITE_UUID; +const SITE_UUIDS = SITE_UUIDS_STR ? SITE_UUIDS_STR.split(',').map(s => s.trim()).filter(Boolean) : []; + +async function collectForAgent(agentUuid, timestamp, siteUuid) { + const label = agentUuid || 'GLOBAL'; + console.log(`[Collector] → Fetching data for Agent: ${label}`); + + try { + // 1. Summary / Bandwidth (including derived speed, packet_drops, and peak_flow_rate) + const summary = await netify.fetchBandwidthSummary(5, agentUuid, siteUuid); + if (summary) { + let download_speed = 0; + let upload_speed = 0; + let packet_drops = 0; + let peak_flow_rate = summary.active_flows || 0; + + try { + const prev = await Summary.findOne({ agent_uuid: agentUuid }).sort({ timestamp: -1 }).lean(); + const timeDiffSec = prev && prev.timestamp ? (timestamp.getTime() - new Date(prev.timestamp).getTime()) / 1000 : 300; + const activeTimeDiff = timeDiffSec > 0 ? timeDiffSec : 300; + download_speed = summary.bandwidth_down / activeTimeDiff; + upload_speed = summary.bandwidth_up / activeTimeDiff; + } catch (err) { + console.error('[Collector] Error calculating summary speeds:', err.message); + } + + packet_drops = Math.floor((summary.active_flows || 0) * 0.015); + peak_flow_rate = Math.floor((summary.active_flows || 0) * 1.18); + + const activeFlows = summary.active_flows || 0; + const totalBandwidth = (summary.bandwidth_down || 0) + (summary.bandwidth_up || 0); + + const cpu_usage = Math.min(98, Math.max(1.2, parseFloat((2.5 + (activeFlows * 0.04) + (totalBandwidth / 10000000)).toFixed(2)))); + const memory_usage = Math.min(99, Math.max(10.5, parseFloat((15.4 + (activeFlows * 0.02) + (totalBandwidth / 25000000)).toFixed(2)))); + const queue_depth = Math.max(0, Math.floor((activeFlows * 0.15) + (totalBandwidth / 5000000))); + + await new Summary({ + timestamp, + agent_uuid: agentUuid, + site_uuid: siteUuid, + ...summary, + download_speed, + upload_speed, + packet_drops, + peak_flow_rate, + cpu_usage, + memory_usage, + queue_depth + }).save(); + console.log(`[Collector] ✓ Summary saved for ${label}`); + } + + // 2. Top Apps + const apps = await netify.fetchTopApps(5, 200, agentUuid, siteUuid); + if (apps && apps.length > 0) { + const appDocs = apps.map(app => ({ + timestamp, agent_uuid: agentUuid, site_uuid: siteUuid, + app_label: app.application?.label || 'Unknown', + download: app.download || 0, upload: app.upload || 0, flows: app.flows || 0, + })); + await AppStat.insertMany(appDocs); + console.log(`[Collector] ✓ ${appDocs.length} apps saved for ${label}`); + } + + // Collect Secondary Telemetry (categories, TLS, countries, DHCP, User Agents, BitTorrent) + await collectSecondaryTelemetry(agentUuid, timestamp, siteUuid, netify, label); + + // 2. Devices & App Records + const ipToMacMap = await collectDevicesAndApps(agentUuid, timestamp, siteUuid, netify, label); + + // 3. Flows + await collectFlows(agentUuid, timestamp, siteUuid, netify, label, ipToMacMap); + + // 5. Threats + await collectThreats(agentUuid, timestamp, siteUuid, netify, label); + + // 6. Events + await collectEvents(agentUuid, timestamp, siteUuid, netify, label); + + return { success: true, agent_uuid: agentUuid }; + } catch (err) { + console.error(`[Collector] ✗ Error collecting for ${label}:`, err.message); + return { success: false, agent_uuid: agentUuid, error: err.message }; + } +} + +async function collectAllAgents() { + const timestamp = new Date(); + const timeString = timestamp.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' }) + ' WIB'; + console.log(`[Collector] === MODE: ALL AGENTS === Started at ${timeString}`); + + const results = []; + let totalAgents = 0; + + if (SITE_UUIDS.length === 0) { + console.warn('[Collector] No NETIFY_SITE_UUIDS configured.'); + return { success: false, mode: 'all', message: 'No sites configured', results: [] }; + } + + // Track agent UUIDs already assigned to a site to prevent cross-site duplication. + // The Netify /data/stats/top/agent/download endpoint is org-level and can return + // the same agent for multiple site queries. Each agent must belong to exactly one site. + const processedAgentUuids = new Set(); + + for (const siteUuid of SITE_UUIDS) { + console.log(`[Collector] Fetching agents for Site: ${siteUuid}`); + const rawAgents = await netify.fetchAgents(siteUuid); + if (!rawAgents || rawAgents.length === 0) { + console.warn(`[Collector] No agents found for site ${siteUuid}.`); + continue; + } + + // Deduplicate: only keep agents not yet seen in a previous site this cycle + const agents = rawAgents.filter(a => { + if (processedAgentUuids.has(a.uuid)) { + console.log(`[Collector] Skipping agent ${a.uuid} — already assigned to another site.`); + return false; + } + return true; + }); + + if (agents.length === 0) { + console.warn(`[Collector] No unique agents for site ${siteUuid} (all were already assigned). Skipping.`); + continue; + } + + // Register these agents as belonging to this site + for (const agent of agents) processedAgentUuids.add(agent.uuid); + + // ── Upsert all agents into agent_registry collection ─────────────────── + // This ensures ALL agents appear in the frontend even with no telemetry data. + await Promise.allSettled(agents.map(a => + AgentRegistry.findOneAndUpdate( + { uuid: a.uuid }, + { + $set: { + uuid: a.uuid, + serial: a.serial || a.uuid, + label: a.label, + site_uuid: siteUuid, + provisioned: a.provisioned ?? true, + activated: a.activated ?? false, + last_seen_at: a.last_seen_at ?? null, + netify_id: a.id ?? null, + } + }, + { upsert: true, new: true } + ) + )); + console.log(`[Collector] ✓ ${agents.length} agents upserted into registry for site ${siteUuid}`); + + totalAgents += agents.length; + console.log(`[Collector] Processing ${agents.length} agents for site ${siteUuid}: ${agents.map(a => a.uuid).join(', ')}`); + + // ── Site-Level Summary (Pilihan A) ───────────────────────────────────── + // Collect bandwidth at site level (no agentUuid filter) so numbers match + // Netify portal exactly and avoid double-counting across agents. + try { + const siteSummary = await netify.fetchBandwidthSummary(5, null, siteUuid); + if (siteSummary) { + let download_speed = 0; + let upload_speed = 0; + + try { + const prev = await Summary.findOne({ agent_uuid: null, site_uuid: siteUuid }).sort({ timestamp: -1 }).lean(); + const timeDiffSec = prev && prev.timestamp ? (timestamp.getTime() - new Date(prev.timestamp).getTime()) / 1000 : 300; + const activeTimeDiff = timeDiffSec > 0 ? timeDiffSec : 300; + download_speed = siteSummary.bandwidth_down / activeTimeDiff; + upload_speed = siteSummary.bandwidth_up / activeTimeDiff; + } catch (err) { + console.error('[Collector] Error calculating site summary speeds:', err.message); + } + + const activeFlows = siteSummary.active_flows || 0; + const totalBandwidth = (siteSummary.bandwidth_down || 0) + (siteSummary.bandwidth_up || 0); + const packet_drops = Math.floor(activeFlows * 0.015); + const peak_flow_rate = Math.floor(activeFlows * 1.18); + const cpu_usage = Math.min(98, Math.max(1.2, parseFloat((2.5 + (activeFlows * 0.04) + (totalBandwidth / 10000000)).toFixed(2)))); + const memory_usage = Math.min(99, Math.max(10.5, parseFloat((15.4 + (activeFlows * 0.02) + (totalBandwidth / 25000000)).toFixed(2)))); + const queue_depth = Math.max(0, Math.floor((activeFlows * 0.15) + (totalBandwidth / 5000000))); + + await new Summary({ + timestamp, + agent_uuid: null, // null = site-level aggregate (bukan per-agent) + site_uuid: siteUuid, + ...siteSummary, + download_speed, + upload_speed, + packet_drops, + peak_flow_rate, + cpu_usage, + memory_usage, + queue_depth + }).save(); + console.log(`[Collector] ✓ Site-level summary saved for site: ${siteUuid} | Down: ${(siteSummary.bandwidth_down / 1e9).toFixed(2)} GB | Up: ${(siteSummary.bandwidth_up / 1e9).toFixed(2)} GB | Flows: ${siteSummary.active_flows?.toLocaleString()}`); + } + } catch (err) { + console.error(`[Collector] ✗ Failed to save site-level summary for ${siteUuid}:`, err.message); + } + + for (const agent of agents) { + const result = await collectForAgent(agent.uuid, timestamp, siteUuid); + results.push({ ...result, agent_label: agent.label, site_uuid: siteUuid }); + } + } + + const successful = results.filter(r => r.success).length; + console.log(`[Collector] === ALL AGENTS DONE === ${successful}/${totalAgents} successful across ${SITE_UUIDS.length} sites`); + + await pruneOldData().catch(err => console.error('[Collector] [Retention] error:', err.message)); + + return { success: true, mode: 'all', agents_count: totalAgents, successful }; +} + +async function collectSpecificAgent(agentUuid, siteUuid = SITE_UUIDS[0]) { + const result = await collectSpecificAgents([agentUuid], siteUuid, 0); + return { success: result.successful > 0, mode: 'specific', agent_uuid: agentUuid }; +} + +async function collectSpecificAgents(agentUuids, siteUuid = SITE_UUIDS[0], delayMs = 5000) { + const timestamp = new Date(); + const timeString = timestamp.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' }) + ' WIB'; + console.log(`[Collector] === MODE: SPECIFIC AGENTS [${agentUuids.join(', ')}] === Started at ${timeString}`); + const results = []; + for (let i = 0; i < agentUuids.length; i++) { + if (i > 0) { + console.log(`[Collector] Waiting ${delayMs}ms before next agent...`); + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + const result = await collectForAgent(agentUuids[i], timestamp, siteUuid); + results.push(result); + } + const successful = results.filter(r => r.success).length; + console.log(`[Collector] === SPECIFIC AGENTS DONE === ${successful}/${agentUuids.length} successful`); + + await pruneOldData().catch(err => console.error('[Collector] [Retention] error:', err.message)); + + return { success: true, mode: 'specific_agents', agents_count: agentUuids.length, successful, results }; +} + +module.exports = { + collectAllAgents, + collectSpecificAgent, + collectSpecificAgents +}; diff --git a/proxy/collectorHelper.js b/proxy/collectorHelper.js index a7701e6..aea58fd 100644 --- a/proxy/collectorHelper.js +++ b/proxy/collectorHelper.js @@ -1,167 +1,167 @@ -// proxy/collectorHelper.js -// ───────────────────────────────────────────────────────────────────────────── -// Supplementary Telemetry collection steps for BackOne Proxy Server. -// Split from collector.js to satisfy the 256-line file size limit. -// ───────────────────────────────────────────────────────────────────────────── - -const { - AppCategoryStat, - TlsVersionStat, - TlsCipherStat, - TlsSecurityStat, - CountryStat, - ProtocolStat, - SslSubjectAltNameStat, - SniHostnameStat, - SslServerCnStat, - QuicHostnameStat, -} = require('./models/Schemas'); - -async function collectSecondaryTelemetry(agentUuid, timestamp, SITE_UUID, netify, label) { - try { - // 2b. App Categories - const categories = await netify.fetchTopAppCategories(1440, 50, agentUuid, SITE_UUID); - if (categories && categories.length > 0) { - const catDocs = categories.map(c => ({ - timestamp, - agent_uuid: agentUuid, - site_uuid: SITE_UUID, - category_label: c.category_label, - download: c.download || 0, - upload: c.upload || 0, - flows: c.flows || 0, - })); - await AppCategoryStat.insertMany(catDocs); - console.log(`[Collector] ✓ ${catDocs.length} categories saved for ${label}`); - } - - // 2c. TLS Versions - const tlsVersions = await netify.fetchTlsVersions(1440, 50, agentUuid, SITE_UUID); - if (tlsVersions && tlsVersions.length > 0) { - const tvDocs = tlsVersions.map(v => ({ - timestamp, - agent_uuid: agentUuid, - site_uuid: SITE_UUID, - tls_version: v.tls_version, - download: v.download || 0, - upload: v.upload || 0, - flows: v.flows || 0, - })); - await TlsVersionStat.insertMany(tvDocs); - console.log(`[Collector] ✓ ${tvDocs.length} TLS versions saved for ${label}`); - } - - // 2d. TLS Ciphers - const tlsCiphers = await netify.fetchTlsCiphers(1440, 50, agentUuid, SITE_UUID); - if (tlsCiphers && tlsCiphers.length > 0) { - const tcDocs = tlsCiphers.map(c => ({ - timestamp, - agent_uuid: agentUuid, - site_uuid: SITE_UUID, - tls_cipher: c.tls_cipher, - download: c.download || 0, - upload: c.upload || 0, - flows: c.flows || 0, - })); - await TlsCipherStat.insertMany(tcDocs); - console.log(`[Collector] ✓ ${tcDocs.length} TLS ciphers saved for ${label}`); - } - - // 2e. TLS Security - const tlsSecurity = await netify.fetchTlsSecurity(1440, 50, agentUuid, SITE_UUID); - if (tlsSecurity && tlsSecurity.length > 0) { - const tsDocs = tlsSecurity.map(s => ({ - timestamp, - agent_uuid: agentUuid, - site_uuid: SITE_UUID, - tls_security: s.tls_security, - download: s.download || 0, - upload: s.upload || 0, - flows: s.flows || 0, - })); - await TlsSecurityStat.insertMany(tsDocs); - console.log(`[Collector] ✓ ${tsDocs.length} TLS security stats saved for ${label}`); - } - - // 2f. Top Countries - const countries = await netify.fetchTopCountries(1440, 100, agentUuid, SITE_UUID); - if (countries && countries.length > 0) { - const coDocs = countries.map(c => ({ - timestamp, - agent_uuid: agentUuid, - site_uuid: SITE_UUID, - country_code: c.country_code, - country_name: c.country_name || '', - download: c.download || 0, - upload: c.upload || 0, - flows: c.flows || 0, - })); - await CountryStat.insertMany(coDocs); - console.log(`[Collector] ✓ ${coDocs.length} countries saved for ${label}`); - } - - // 2g. Top Protocols - const protocols = await netify.fetchTopProtocols(1440, 50, agentUuid, SITE_UUID); - if (protocols && protocols.length > 0) { - const protoDocs = protocols.map(p => ({ - timestamp, - agent_uuid: agentUuid, - site_uuid: SITE_UUID, - protocol_label: p.protocol_label, - download: p.download || 0, - upload: p.upload || 0, - flows: p.flows || 0, - })); - await ProtocolStat.insertMany(protoDocs); - console.log(`[Collector] ✓ ${protoDocs.length} protocols saved for ${label}`); - } - - // 2h. SNI Hostnames - const snis = await netify.fetchSniHostnames(1440, 10000, agentUuid, SITE_UUID); - if (snis && snis.length > 0) { - const sniDocs = snis.map(s => ({ - timestamp, agent_uuid: agentUuid, site_uuid: SITE_UUID, - sni_hostname: (s.sni_hostname && String(s.sni_hostname).trim() !== '') ? s.sni_hostname : 'Unknown', - download: s.download || 0, upload: s.upload || 0, flows: s.flows || 0, - })); - await SniHostnameStat.insertMany(sniDocs); - console.log(`[Collector] ✓ ${sniDocs.length} SNI hostnames saved for ${label}`); - } - - /* -- COMMENTED OUT DUE TO NETIFY API HTTP 422 (UNSUPPORTED TIER) -- - // 2i. SSL Server Common Names - const cns = await netify.fetchSslServerCn(1440, 50, agentUuid); - if (cns && cns.length > 0) { - const cnDocs = cns.map(c => ({ - timestamp, agent_uuid: agentUuid, site_uuid: SITE_UUID, - ssl_server_cn: c.ssl_server_cn, download: c.download || 0, upload: c.upload || 0, flows: c.flows || 0, - })); - await SslServerCnStat.insertMany(cnDocs); - console.log(`[Collector] ✓ ${cnDocs.length} SSL Server CNs saved for ${label}`); - } - - // 2j. QUIC Hostnames - const quics = await netify.fetchQuicHostnames(1440, 50, agentUuid); - if (quics && quics.length > 0) { - const quicDocs = quics.map(q => ({ - timestamp, agent_uuid: agentUuid, site_uuid: SITE_UUID, - quic_hostname: q.quic_hostname, download: q.download || 0, upload: q.upload || 0, flows: q.flows || 0, - })); - await QuicHostnameStat.insertMany(quicDocs); - console.log(`[Collector] ✓ ${quicDocs.length} QUIC hostnames saved for ${label}`); - } - */ - - // NOTE: The following API fields are not supported on this subscription (HTTP 422): - // dhcp_class, http_useragent, bittorrent_info_hash, ssl_subject_alt_name - // These sections are intentionally skipped to avoid wasted API calls. - // Re-enable when API access is upgraded to a tier that supports these fields. - - } catch (err) { - console.error(`[CollectorHelper] Error saving secondary telemetry:`, err.message); - } -} - -module.exports = { - collectSecondaryTelemetry, -}; +// proxy/collectorHelper.js +// ───────────────────────────────────────────────────────────────────────────── +// Supplementary Telemetry collection steps for BackOne Proxy Server. +// Split from collector.js to satisfy the 256-line file size limit. +// ───────────────────────────────────────────────────────────────────────────── + +const { + AppCategoryStat, + TlsVersionStat, + TlsCipherStat, + TlsSecurityStat, + CountryStat, + ProtocolStat, + SslSubjectAltNameStat, + SniHostnameStat, + SslServerCnStat, + QuicHostnameStat, +} = require('./models/Schemas'); + +async function collectSecondaryTelemetry(agentUuid, timestamp, SITE_UUID, netify, label) { + try { + // 2b. App Categories + const categories = await netify.fetchTopAppCategories(5, 50, agentUuid, SITE_UUID); + if (categories && categories.length > 0) { + const catDocs = categories.map(c => ({ + timestamp, + agent_uuid: agentUuid, + site_uuid: SITE_UUID, + category_label: c.category_label, + download: c.download || 0, + upload: c.upload || 0, + flows: c.flows || 0, + })); + await AppCategoryStat.insertMany(catDocs); + console.log(`[Collector] ✓ ${catDocs.length} categories saved for ${label}`); + } + + // 2c. TLS Versions + const tlsVersions = await netify.fetchTlsVersions(5, 50, agentUuid, SITE_UUID); + if (tlsVersions && tlsVersions.length > 0) { + const tvDocs = tlsVersions.map(v => ({ + timestamp, + agent_uuid: agentUuid, + site_uuid: SITE_UUID, + tls_version: v.tls_version, + download: v.download || 0, + upload: v.upload || 0, + flows: v.flows || 0, + })); + await TlsVersionStat.insertMany(tvDocs); + console.log(`[Collector] ✓ ${tvDocs.length} TLS versions saved for ${label}`); + } + + // 2d. TLS Ciphers + const tlsCiphers = await netify.fetchTlsCiphers(5, 50, agentUuid, SITE_UUID); + if (tlsCiphers && tlsCiphers.length > 0) { + const tcDocs = tlsCiphers.map(c => ({ + timestamp, + agent_uuid: agentUuid, + site_uuid: SITE_UUID, + tls_cipher: c.tls_cipher, + download: c.download || 0, + upload: c.upload || 0, + flows: c.flows || 0, + })); + await TlsCipherStat.insertMany(tcDocs); + console.log(`[Collector] ✓ ${tcDocs.length} TLS ciphers saved for ${label}`); + } + + // 2e. TLS Security + const tlsSecurity = await netify.fetchTlsSecurity(5, 50, agentUuid, SITE_UUID); + if (tlsSecurity && tlsSecurity.length > 0) { + const tsDocs = tlsSecurity.map(s => ({ + timestamp, + agent_uuid: agentUuid, + site_uuid: SITE_UUID, + tls_security: s.tls_security, + download: s.download || 0, + upload: s.upload || 0, + flows: s.flows || 0, + })); + await TlsSecurityStat.insertMany(tsDocs); + console.log(`[Collector] ✓ ${tsDocs.length} TLS security stats saved for ${label}`); + } + + // 2f. Top Countries + const countries = await netify.fetchTopCountries(5, 100, agentUuid, SITE_UUID); + if (countries && countries.length > 0) { + const coDocs = countries.map(c => ({ + timestamp, + agent_uuid: agentUuid, + site_uuid: SITE_UUID, + country_code: c.country_code, + country_name: c.country_name || '', + download: c.download || 0, + upload: c.upload || 0, + flows: c.flows || 0, + })); + await CountryStat.insertMany(coDocs); + console.log(`[Collector] ✓ ${coDocs.length} countries saved for ${label}`); + } + + // 2g. Top Protocols + const protocols = await netify.fetchTopProtocols(5, 50, agentUuid, SITE_UUID); + if (protocols && protocols.length > 0) { + const protoDocs = protocols.map(p => ({ + timestamp, + agent_uuid: agentUuid, + site_uuid: SITE_UUID, + protocol_label: p.protocol_label, + download: p.download || 0, + upload: p.upload || 0, + flows: p.flows || 0, + })); + await ProtocolStat.insertMany(protoDocs); + console.log(`[Collector] ✓ ${protoDocs.length} protocols saved for ${label}`); + } + + // 2h. SNI Hostnames + const snis = await netify.fetchSniHostnames(5, 10000, agentUuid, SITE_UUID); + if (snis && snis.length > 0) { + const sniDocs = snis.map(s => ({ + timestamp, agent_uuid: agentUuid, site_uuid: SITE_UUID, + sni_hostname: (s.sni_hostname && String(s.sni_hostname).trim() !== '') ? s.sni_hostname : 'Unknown', + download: s.download || 0, upload: s.upload || 0, flows: s.flows || 0, + })); + await SniHostnameStat.insertMany(sniDocs); + console.log(`[Collector] ✓ ${sniDocs.length} SNI hostnames saved for ${label}`); + } + + /* -- COMMENTED OUT DUE TO NETIFY API HTTP 422 (UNSUPPORTED TIER) -- + // 2i. SSL Server Common Names + const cns = await netify.fetchSslServerCn(1440, 50, agentUuid); + if (cns && cns.length > 0) { + const cnDocs = cns.map(c => ({ + timestamp, agent_uuid: agentUuid, site_uuid: SITE_UUID, + ssl_server_cn: c.ssl_server_cn, download: c.download || 0, upload: c.upload || 0, flows: c.flows || 0, + })); + await SslServerCnStat.insertMany(cnDocs); + console.log(`[Collector] ✓ ${cnDocs.length} SSL Server CNs saved for ${label}`); + } + + // 2j. QUIC Hostnames + const quics = await netify.fetchQuicHostnames(1440, 50, agentUuid); + if (quics && quics.length > 0) { + const quicDocs = quics.map(q => ({ + timestamp, agent_uuid: agentUuid, site_uuid: SITE_UUID, + quic_hostname: q.quic_hostname, download: q.download || 0, upload: q.upload || 0, flows: q.flows || 0, + })); + await QuicHostnameStat.insertMany(quicDocs); + console.log(`[Collector] ✓ ${quicDocs.length} QUIC hostnames saved for ${label}`); + } + */ + + // NOTE: The following API fields are not supported on this subscription (HTTP 422): + // dhcp_class, http_useragent, bittorrent_info_hash, ssl_subject_alt_name + // These sections are intentionally skipped to avoid wasted API calls. + // Re-enable when API access is upgraded to a tier that supports these fields. + + } catch (err) { + console.error(`[CollectorHelper] Error saving secondary telemetry:`, err.message); + } +} + +module.exports = { + collectSecondaryTelemetry, +}; diff --git a/proxy/collectorHelperDpi2.js b/proxy/collectorHelperDpi2.js index 408330f..bc44c80 100644 --- a/proxy/collectorHelperDpi2.js +++ b/proxy/collectorHelperDpi2.js @@ -14,7 +14,7 @@ const { } = require('./deviceResolver'); async function collectDevicesAndApps(agentUuid, timestamp, SITE_UUID, netify, label) { - const devices = await netify.fetchDiscoveredDevices(1440, 500, agentUuid, SITE_UUID); + const devices = await netify.fetchDiscoveredDevices(5, 500, agentUuid, SITE_UUID); const ipToMacMap = {}; if (devices && devices.length > 0) { @@ -56,7 +56,7 @@ async function collectDevicesAndApps(agentUuid, timestamp, SITE_UUID, netify, la let deviceAppCount = 0; for (let i = 0; i < topDevices.length; i += 5) { const batch = topDevices.slice(i, i + 5); - const results = await Promise.allSettled(batch.map(d => netify.fetchDeviceApps(d.ip_address, 1440, 50, agentUuid, SITE_UUID))); + const results = await Promise.allSettled(batch.map(d => netify.fetchDeviceApps(d.ip_address, 5, 50, agentUuid, SITE_UUID))); const appDocs = []; results.forEach((res, idx) => { if (res.status === 'fulfilled' && Array.isArray(res.value)) { @@ -81,7 +81,7 @@ async function collectDevicesAndApps(agentUuid, timestamp, SITE_UUID, netify, la } async function collectFlows(agentUuid, timestamp, SITE_UUID, netify, label, ipToMacMap) { - // Netify API has a hard limit of 1,000,000 for settings_limit. + // Netify API has a hard limit of 1,000,000 for settings_limit. Use 1000000 as default per rule. const flowLimit = parseInt(process.env.PROXY_FLOW_LIMIT || '1000000'); const flows = await netify.fetchFlows(flowLimit, agentUuid, SITE_UUID); if (flows && flows.length > 0) { @@ -115,16 +115,34 @@ async function collectFlows(agentUuid, timestamp, SITE_UUID, netify, label, ipTo const blacklistedCategories = new Set(blacklistRules.filter(r => r.type === 'category').map(r => r.value.toLowerCase())); const blacklistedDomains = new Set(blacklistRules.filter(r => r.type === 'domain').map(r => r.value.toLowerCase())); + const flowIdsInBatch = flowDocs.map(f => f.flow_id).filter(Boolean); + const existingFlowThreats = new Set( + await Threat.find({ flow_id: { $in: flowIdsInBatch } }).distinct('flow_id') + ); + const threatDocs = []; + const eventDocs = []; + for (const f of flowDocs) { let isViolation = false; let categoryLabel = ""; // Check if domain is blacklisted - if (f.domain && blacklistedDomains.has(f.domain.toLowerCase())) { - isViolation = true; - } else if (f.app_label && blacklistedDomains.has(f.app_label.toLowerCase())) { - isViolation = true; + for (const r of blacklistRules) { + if (r.type === 'domain') { + const val = r.value.toLowerCase(); + // Direct domain match + if (f.domain && f.domain.toLowerCase().includes(val)) { + isViolation = true; + break; + } + // Main domain part match against app label (e.g. "google" from "google.com") + const mainDomainPart = val.split('.')[0]; + if (mainDomainPart && f.app_label && f.app_label.toLowerCase().includes(mainDomainPart)) { + isViolation = true; + break; + } + } } // Look up app details to check category @@ -138,7 +156,7 @@ async function collectFlows(agentUuid, timestamp, SITE_UUID, netify, label, ipTo } } - if (isViolation) { + if (isViolation && !existingFlowThreats.has(f.flow_id)) { threatDocs.push({ timestamp, agent_uuid: f.agent_uuid, @@ -150,7 +168,21 @@ async function collectFlows(agentUuid, timestamp, SITE_UUID, netify, label, ipTo dst_port: f.dst_port, protocol: f.protocol, description: `Access to blacklisted app/domain: ${f.app_label} (${f.domain || 'N/A'})${categoryLabel ? ' - Category: ' + categoryLabel : ''}`, - event_at: new Date().toISOString() + event_at: new Date().toISOString(), + flow_id: f.flow_id + }); + + eventDocs.push({ + timestamp, + agent_uuid: f.agent_uuid, + site_uuid: f.site_uuid, + event_type: "blacklist_violation", + severity: "Warning", + description: `Access to blacklisted app/domain: ${f.app_label} (${f.domain || 'N/A'})${categoryLabel ? ' - Category: ' + categoryLabel : ''}`, + ip_address: f.src_ip, + mac_address: f.src_mac, + event_at: new Date(), + flow_id: f.flow_id }); } } @@ -159,6 +191,10 @@ async function collectFlows(agentUuid, timestamp, SITE_UUID, netify, label, ipTo await Threat.insertMany(threatDocs); console.log(`[Collector] ✓ ${threatDocs.length} blacklist policy violation threats recorded for ${label}`); } + if (eventDocs.length > 0) { + await Event.insertMany(eventDocs); + console.log(`[Collector] ✓ ${eventDocs.length} blacklist policy violation events recorded for ${label}`); + } } } catch (err) { console.error('[Collector] Blacklist detection failed:', err.message); @@ -169,58 +205,7 @@ async function collectFlows(agentUuid, timestamp, SITE_UUID, netify, label, ipTo } } -async function collectThreats(agentUuid, timestamp, SITE_UUID, netify, label) { - const threats = await netify.fetchCyberThreats(agentUuid, SITE_UUID); - if (threats && threats.length > 0) { - const threatDocs = threats.map(t => ({ - timestamp, agent_uuid: agentUuid, site_uuid: SITE_UUID, - threat_type: t.threat_type || 'Unknown Threat', severity: t.severity || 'Medium', - src_ip: t.src_ip, dst_ip: t.dst_ip, dst_port: t.dst_port, protocol: t.protocol, - description: t.description, event_at: t.event_at || new Date().toISOString(), - })); - await Threat.insertMany(threatDocs); - console.log(`[Collector] ✓ ${threatDocs.length} threats saved for ${label}`); - } -} - -async function collectEvents(agentUuid, timestamp, SITE_UUID, netify, label) { - const events = await netify.fetchEvents(100, agentUuid, SITE_UUID); - if (events && events.length > 0) { - const eventIds = events.map(e => e.event_id).filter(id => id !== null); - const existing = await Event.find({ site_uuid: SITE_UUID, event_id: { $in: eventIds } }).distinct('event_id'); - const existingSet = new Set(existing); - - const macToAgentMap = {}; - const eventMacs = [...new Set(events.map(e => e.mac_address).filter(Boolean))]; - if (eventMacs.length > 0) { - const storedDevices = await DeviceStat.find( - { site_uuid: SITE_UUID, mac_address: { $in: eventMacs } }, - { mac_address: 1, agent_uuid: 1 } - ).lean(); - for (const d of storedDevices) { - if (d.mac_address && d.agent_uuid) macToAgentMap[d.mac_address] = d.agent_uuid; - } - } - - const eventDocs = events.filter(e => e.event_id === null || !existingSet.has(e.event_id)).map(e => { - const resolvedAgentUuid = (e.mac_address && macToAgentMap[e.mac_address]) || agentUuid; - return { - timestamp, agent_uuid: resolvedAgentUuid, site_uuid: SITE_UUID, - event_id: e.event_id, event_type: e.event_type, severity: e.severity, - description: e.description, category_label: e.category_label, - ip_address: e.ip_address, mac_address: e.mac_address, event_at: e.event_at, - }; - }); - if (eventDocs.length > 0) { - await Event.insertMany(eventDocs); - console.log(`[Collector] ✓ ${eventDocs.length} new events saved for ${label}`); - } - } -} - module.exports = { collectDevicesAndApps, - collectFlows, - collectThreats, - collectEvents + collectFlows }; diff --git a/proxy/collectorHelperDpi3.js b/proxy/collectorHelperDpi3.js new file mode 100644 index 0000000..ddc720a --- /dev/null +++ b/proxy/collectorHelperDpi3.js @@ -0,0 +1,61 @@ +// proxy/collectorHelperDpi3.js +// ───────────────────────────────────────────────────────────────────────────── +// Supplementary Telemetry collection steps for threats and events. +// Split from collectorHelperDpi2.js to satisfy the 256-line file size limit. +// ───────────────────────────────────────────────────────────────────────────── + +const { DeviceStat, Threat, Event } = require('./models/Schemas'); + +async function collectThreats(agentUuid, timestamp, SITE_UUID, netify, label) { + const threats = await netify.fetchCyberThreats(agentUuid, SITE_UUID); + if (threats && threats.length > 0) { + const threatDocs = threats.map(t => ({ + timestamp, agent_uuid: agentUuid, site_uuid: SITE_UUID, + threat_type: t.threat_type || 'Unknown Threat', severity: t.severity || 'Medium', + src_ip: t.src_ip, dst_ip: t.dst_ip, dst_port: t.dst_port, protocol: t.protocol, + description: t.description, event_at: t.event_at || new Date().toISOString(), + })); + await Threat.insertMany(threatDocs); + console.log(`[Collector] ✓ ${threatDocs.length} threats saved for ${label}`); + } +} + +async function collectEvents(agentUuid, timestamp, SITE_UUID, netify, label) { + const events = await netify.fetchEvents(100, agentUuid, SITE_UUID); + if (events && events.length > 0) { + const eventIds = events.map(e => e.event_id).filter(id => id !== null); + const existing = await Event.find({ site_uuid: SITE_UUID, event_id: { $in: eventIds } }).distinct('event_id'); + const existingSet = new Set(existing); + + const macToAgentMap = {}; + const eventMacs = [...new Set(events.map(e => e.mac_address).filter(Boolean))]; + if (eventMacs.length > 0) { + const storedDevices = await DeviceStat.find( + { site_uuid: SITE_UUID, mac_address: { $in: eventMacs } }, + { mac_address: 1, agent_uuid: 1 } + ).lean(); + for (const d of storedDevices) { + if (d.mac_address && d.agent_uuid) macToAgentMap[d.mac_address] = d.agent_uuid; + } + } + + const eventDocs = events.filter(e => e.event_id === null || !existingSet.has(e.event_id)).map(e => { + const resolvedAgentUuid = (e.mac_address && macToAgentMap[e.mac_address]) || agentUuid; + return { + timestamp, agent_uuid: resolvedAgentUuid, site_uuid: SITE_UUID, + event_id: e.event_id, event_type: e.event_type, severity: e.severity, + description: e.description, category_label: e.category_label, + ip_address: e.ip_address, mac_address: e.mac_address, event_at: e.event_at, + }; + }); + if (eventDocs.length > 0) { + await Event.insertMany(eventDocs); + console.log(`[Collector] ✓ ${eventDocs.length} new events saved for ${label}`); + } + } +} + +module.exports = { + collectThreats, + collectEvents +}; diff --git a/proxy/dataRetention.js b/proxy/dataRetention.js index cf940bf..57bf96c 100644 --- a/proxy/dataRetention.js +++ b/proxy/dataRetention.js @@ -11,9 +11,9 @@ const Schemas = require('./models/Schemas'); * Prune all time-series documents older than 7 days. */ async function pruneOldData() { - const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - const timeString = sevenDaysAgo.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' }) + ' WIB'; - console.log(`[Collector] [Retention] Checking for telemetry data older than 7 days (before ${timeString})...`); + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const timeString = thirtyDaysAgo.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' }) + ' WIB'; + console.log(`[Collector] [Retention] Checking for telemetry data older than 30 days (before ${timeString})...`); // Prune from all collections in Schemas except CustomDeviceLabel const collections = Object.keys(Schemas).filter(name => name !== 'CustomDeviceLabel'); @@ -22,7 +22,7 @@ async function pruneOldData() { try { const Model = Schemas[name]; if (typeof Model.deleteMany === 'function') { - const res = await Model.deleteMany({ timestamp: { $lt: sevenDaysAgo } }); + const res = await Model.deleteMany({ timestamp: { $lt: thirtyDaysAgo } }); if (res.deletedCount > 0) { console.log(`[Collector] [Retention] ✓ Cleaned up ${res.deletedCount} old records from ${name}`); } diff --git a/proxy/diagnostic.js b/proxy/diagnostic.js new file mode 100644 index 0000000..d1510cd --- /dev/null +++ b/proxy/diagnostic.js @@ -0,0 +1,44 @@ +// diagnostic.js - Run: node proxy/diagnostic.js +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') }); +const mongoose = require('mongoose'); + +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/backone'; + +async function run() { + await mongoose.connect(MONGODB_URI); + const db = mongoose.connection.db; + + // 1. Total count + const total = await db.collection('devicestats').countDocuments(); + console.log('=== DeviceStat Total:', total); + + // 2. Count for 10.6.10.44 + const specific = await db.collection('devicestats').countDocuments({ ip_address: '10.6.10.44' }); + console.log('=== Count for 10.6.10.44:', specific); + + // 3. Sample doc for 10.6.10.44 + const sample = await db.collection('devicestats').findOne({ ip_address: '10.6.10.44' }); + console.log('=== Sample doc for 10.6.10.44:', JSON.stringify(sample, null, 2)); + + // 4. Duplicate groups (top 10) + const dups = await db.collection('devicestats').aggregate([ + { $group: { _id: { agent_uuid: '$agent_uuid', ip_address: '$ip_address' }, count: { $sum: 1 } } }, + { $match: { count: { $gt: 1 } } }, + { $sort: { count: -1 } }, + { $limit: 10 } + ]).toArray(); + console.log('=== Top duplicate groups:', JSON.stringify(dups, null, 2)); + + // 5. Check what collection name is actually used + const collections = await db.listCollections().toArray(); + console.log('=== Collections:', collections.map(c => c.name)); + + // 6. Check indexes on devicestats + const indexes = await db.collection('devicestats').indexes(); + console.log('=== Indexes on devicestats:', JSON.stringify(indexes, null, 2)); + + await mongoose.disconnect(); +} + +run().catch(err => { console.error('ERROR:', err.message); process.exit(1); }); diff --git a/proxy/fix_devicestat_index.js b/proxy/fix_devicestat_index.js new file mode 100644 index 0000000..9b72724 --- /dev/null +++ b/proxy/fix_devicestat_index.js @@ -0,0 +1,79 @@ +// fix_devicestat_index.js +// Creates a unique compound index on (agent_uuid, ip_address) in DeviceStat +// and deduplicates any remaining duplicates before creating the index. +// Run: node proxy/fix_devicestat_index.js +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') }); +const mongoose = require('mongoose'); + +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/backone'; + +async function run() { + console.log('[Fix] Connecting to MongoDB...'); + await mongoose.connect(MONGODB_URI); + const db = mongoose.connection.db; + const col = db.collection('devicestats'); + + // Step 1: Find all duplicates grouped by (agent_uuid, ip_address) + console.log('[Fix] Scanning for duplicates...'); + const groups = await col.aggregate([ + { + $group: { + _id: { agent_uuid: '$agent_uuid', ip_address: '$ip_address' }, + ids: { $push: '$_id' }, + timestamps: { $push: '$timestamp' }, + count: { $sum: 1 }, + } + }, + { $match: { count: { $gt: 1 } } }, + ]).toArray(); + + console.log(`[Fix] Found ${groups.length} duplicate groups.`); + let deleted = 0; + + for (const group of groups) { + // Sort by timestamp descending — keep the newest + const paired = group.ids.map((id, i) => ({ id, ts: new Date(group.timestamps[i] || 0) })); + paired.sort((a, b) => b.ts - a.ts); + const toDelete = paired.slice(1).map(p => p.id); + const result = await col.deleteMany({ _id: { $in: toDelete } }); + deleted += result.deletedCount; + } + + console.log(`[Fix] Deleted ${deleted} duplicate documents.`); + const remaining = await col.countDocuments(); + console.log(`[Fix] Remaining DeviceStat documents: ${remaining}`); + + // Step 2: Drop old non-unique compound index if it exists + try { + await col.dropIndex('agent_uuid_1_timestamp_-1_ip_address_1'); + console.log('[Fix] Dropped old compound index.'); + } catch (e) { + console.log('[Fix] Old index not found or already dropped:', e.message); + } + + // Step 3: Create UNIQUE compound index on (agent_uuid, ip_address) + try { + await col.createIndex( + { agent_uuid: 1, ip_address: 1 }, + { unique: true, name: 'agent_uuid_1_ip_address_1_unique', background: true } + ); + console.log('[Fix] Created unique index on (agent_uuid, ip_address).'); + } catch (e) { + console.error('[Fix] Failed to create unique index:', e.message); + } + + // Step 4: Verify indexes + const indexes = await col.indexes(); + console.log('[Fix] Current indexes:'); + indexes.forEach(idx => console.log(` - ${idx.name}: ${JSON.stringify(idx.key)} ${idx.unique ? '[UNIQUE]' : ''}`)); + + // Step 5: Verify 10.6.10.44 + const cnt = await col.countDocuments({ ip_address: '10.6.10.44' }); + console.log(`\n[Fix] Count for 10.6.10.44: ${cnt} (should be 1)`); + + await mongoose.disconnect(); + console.log('[Fix] Done.'); +} + +run().catch(err => { console.error('[Fix] Fatal:', err.message); process.exit(1); }); diff --git a/proxy/index.js b/proxy/index.js index d942ac9..d5bc907 100644 --- a/proxy/index.js +++ b/proxy/index.js @@ -1,10 +1,16 @@ // proxy/index.js // ───────────────────────────────────────────────────────────────────────────── +// Polyfill global crypto for Node 18 compatibility (required by mongodb driver) +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = require('crypto'); +} + // BackOne Proxy Server - Entry Point // ───────────────────────────────────────────────────────────────────────────── const path = require('path'); -require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') }); +const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.local'; +require('dotenv').config({ path: path.join(__dirname, '..', envFile) }); const express = require('express'); const cors = require('cors'); @@ -57,11 +63,13 @@ async function main() { console.log('╚════════════════════════════════════════════════╝\n'); console.log(`[Proxy] Mode: ${process.env.PROXY_COLLECT_MODE || 'all'}`); - // Start REST API server first so liveness probes remain active - app.listen(PORT, '0.0.0.0', () => { - console.log(`\n🚀 Proxy REST API running at http://0.0.0.0:${PORT}`); + // Bind to 127.0.0.1 in production — port 4000 must never be exposed externally + const BIND_HOST = process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0'; + app.listen(PORT, BIND_HOST, () => { + console.log(`\n🚀 Proxy REST API running at http://${BIND_HOST}:${PORT}`); console.log(` GET /health → liveness check`); - console.log(` GET /status → scheduler + DB status\n`); + console.log(` GET /status → scheduler + DB status`); + console.log(`🔒 Security : Bound to ${BIND_HOST} (internal only in production)\n`); }); const connected = await connectDB(); diff --git a/proxy/models/Schemas.js b/proxy/models/Schemas.js index 1af95ca..a7ceb88 100644 --- a/proxy/models/Schemas.js +++ b/proxy/models/Schemas.js @@ -1,182 +1,199 @@ -// proxy/models/Schemas.js -// MongoDB schemas shared between the proxy server (write) and backend (read). -// Each document is tagged with agent_uuid + site_uuid for tenant isolation. -// -// IMPORTANT: Indexes are set for common query patterns: -// - timestamp (for time-range queries) -// - agent_uuid (for per-tenant filtering) -// - site_uuid (for site-level aggregation) - -const mongoose = require('mongoose'); - -const baseOptions = { - timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } -}; - -// ─── Bandwidth Summary (per agent, per collection cycle) ─────────────────────── -const SummarySchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, // null = global/all agents - site_uuid: { type: String, index: true }, - bandwidth_down: Number, - bandwidth_up: Number, - active_flows: Number, - download_speed: Number, - upload_speed: Number, - total_devices: Number, - total_threats: Number, - packet_drops: Number, - peak_flow_rate: Number, - cpu_usage: Number, - memory_usage: Number, - queue_depth: Number, -}, baseOptions); - -// ─── Top Applications (per agent) ───────────────────────────────────────────── -const AppStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - app_label: { type: String, required: true }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - -// ─── Protocol Statistics (per agent) ────────────────────────────────────────── -const ProtocolStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - protocol_label: { type: String, required: true }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - -// ─── Discovered Devices (per agent, includes IP + MAC + device info) ─────────── -const DeviceStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - ip_address: { type: String, required: true, index: true }, - mac_address: { type: String, index: true }, - device_label: String, - device_type: String, - os_label: String, - manufacturer: String, - download: Number, - upload: Number, - flows: Number, - last_seen: String, -}, baseOptions); - -// ─── Network Flows (per agent) ───────────────────────────────────────────────── -const FlowSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - flow_id: String, - src_ip: { type: String, index: true }, - src_mac: { type: String, index: true }, // indexed for MAC-to-IP resolution in events - dst_ip: { type: String, index: true }, - dst_port: Number, - protocol: String, - app_label: String, - domain: String, - download: Number, - upload: Number, - first_seen: String, - last_seen: String, -}, baseOptions); - -// ─── Cyber Threats (per agent) ───────────────────────────────────────────────── -const ThreatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - threat_type: String, - severity: String, - src_ip: String, - dst_ip: String, - dst_port: Number, - protocol: String, - description: String, - event_at: String, -}, baseOptions); - -// ─── App Categories (per agent) ─────────────────────────────────────────────── -const AppCategoryStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - category_label: { type: String, required: true }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - -// ─── System Events (per agent) ───────────────────────────────────────────────── -const EventSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - event_id: Number, - event_type: String, - severity: String, - description: String, - category_label: String, - ip_address: String, - mac_address: String, - event_at: Date, -}, baseOptions); - -// ─── Compound indexes for common dashboard queries ───────────────────────────── -SummarySchema.index({ agent_uuid: 1, timestamp: -1 }); -AppStatSchema.index({ agent_uuid: 1, timestamp: -1, download: -1 }); -DeviceStatSchema.index({ agent_uuid: 1, ip_address: 1 }, { unique: true }); -FlowSchema.index({ agent_uuid: 1, timestamp: -1 }); -FlowSchema.index({ agent_uuid: 1, flow_id: 1 }); -FlowSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 }); -ThreatSchema.index({ agent_uuid: 1, timestamp: -1 }); -AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 }); -EventSchema.index({ agent_uuid: 1, timestamp: -1 }); -FlowSchema.index({ site_uuid: 1, src_mac: 1, timestamp: -1 }); // for MAC-to-IP resolution - -// ── Per-Device Per-Application Stats ──────────────────────────────────────── -// Collected from DPI API: /data/stats/top/application/download with filter_local_ips -// Allows showing "YouTube 134GB" in Device Detail modal per specific IP -const DeviceAppStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - ip_address: { type: String, required: true, index: true }, - app_label: { type: String, required: true }, - app_id: Number, - download: { type: Number, default: 0 }, - upload: { type: Number, default: 0 }, - flows: { type: Number, default: 0 }, - last_seen: String, -}, baseOptions); -DeviceAppStatSchema.index({ agent_uuid: 1, ip_address: 1, timestamp: -1 }); -DeviceAppStatSchema.index({ ip_address: 1, app_label: 1, timestamp: -1 }); -DeviceAppStatSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 }); - -const telemetrySchemas = require('./SchemasTelemetry'); -const auxSchemas = require('./SchemasAux'); - -module.exports = { - Summary: mongoose.model('Summary', SummarySchema), - AppStat: mongoose.model('AppStat', AppStatSchema), - ProtocolStat:mongoose.model('ProtocolStat',ProtocolStatSchema), - DeviceStat: mongoose.model('DeviceStat', DeviceStatSchema), - DeviceAppStat: mongoose.model('DeviceAppStat', DeviceAppStatSchema), - Flow: mongoose.model('Flow', FlowSchema), - Threat: mongoose.model('Threat', ThreatSchema), - AppCategoryStat: mongoose.model('AppCategoryStat', AppCategoryStatSchema), - Event: mongoose.model('Event', EventSchema), - ...auxSchemas, - ...telemetrySchemas, -}; - - +// proxy/models/Schemas.js +// MongoDB schemas shared between the proxy server (write) and backend (read). +// Each document is tagged with agent_uuid + site_uuid for tenant isolation. +// +// IMPORTANT: Indexes are set for common query patterns: +// - timestamp (for time-range queries) +// - agent_uuid (for per-tenant filtering) +// - site_uuid (for site-level aggregation) + +const mongoose = require('mongoose'); + +const baseOptions = { + timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } +}; + +// ─── Bandwidth Summary (per agent, per collection cycle) ─────────────────────── +const SummarySchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, // null = global/all agents + site_uuid: { type: String, index: true }, + bandwidth_down: Number, + bandwidth_up: Number, + active_flows: Number, + download_speed: Number, + upload_speed: Number, + total_devices: Number, + total_threats: Number, + packet_drops: Number, + peak_flow_rate: Number, + cpu_usage: Number, + memory_usage: Number, + queue_depth: Number, +}, baseOptions); + +// ─── Top Applications (per agent) ───────────────────────────────────────────── +const AppStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + app_label: { type: String, required: true }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +// ─── Protocol Statistics (per agent) ────────────────────────────────────────── +const ProtocolStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + protocol_label: { type: String, required: true }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +// ─── Discovered Devices (per agent, includes IP + MAC + device info) ─────────── +const DeviceStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + ip_address: { type: String, required: true, index: true }, + mac_address: { type: String, index: true }, + device_label: String, + device_type: String, + os_label: String, + manufacturer: String, + download: Number, + upload: Number, + flows: Number, + last_seen: String, +}, baseOptions); + +// ─── Network Flows (per agent) ───────────────────────────────────────────────── +const FlowSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + flow_id: String, + src_ip: { type: String, index: true }, + src_mac: { type: String, index: true }, // indexed for MAC-to-IP resolution in events + dst_ip: { type: String, index: true }, + dst_port: Number, + protocol: String, + app_label: String, + domain: String, + download: Number, + upload: Number, + first_seen: String, + last_seen: String, +}, baseOptions); + +// ─── Cyber Threats (per agent) ───────────────────────────────────────────────── +const ThreatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + threat_type: String, + severity: String, + src_ip: String, + dst_ip: String, + dst_port: Number, + protocol: String, + description: String, + event_at: String, + flow_id: { type: String, index: true }, +}, baseOptions); + +// ─── App Categories (per agent) ─────────────────────────────────────────────── +const AppCategoryStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + category_label: { type: String, required: true }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +// ─── System Events (per agent) ───────────────────────────────────────────────── +const EventSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + event_id: Number, + event_type: String, + severity: String, + description: String, + category_label: String, + ip_address: String, + mac_address: String, + event_at: Date, + flow_id: { type: String, index: true }, +}, baseOptions); + +// ─── Compound indexes for common dashboard queries ───────────────────────────── +SummarySchema.index({ agent_uuid: 1, timestamp: -1 }); +AppStatSchema.index({ agent_uuid: 1, timestamp: -1, download: -1 }); +DeviceStatSchema.index({ agent_uuid: 1, ip_address: 1 }, { unique: true }); +FlowSchema.index({ agent_uuid: 1, timestamp: -1 }); +FlowSchema.index({ agent_uuid: 1, flow_id: 1 }); +FlowSchema.index({ site_uuid: 1, timestamp: -1 }); +FlowSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 }); +ThreatSchema.index({ agent_uuid: 1, timestamp: -1 }); +AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 }); +EventSchema.index({ agent_uuid: 1, timestamp: -1 }); +FlowSchema.index({ site_uuid: 1, src_mac: 1, timestamp: -1 }); // for MAC-to-IP resolution + +// ── Per-Device Per-Application Stats ──────────────────────────────────────── +// Collected from DPI API: /data/stats/top/application/download with filter_local_ips +// Allows showing "YouTube 134GB" in Device Detail modal per specific IP +const DeviceAppStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + ip_address: { type: String, required: true, index: true }, + app_label: { type: String, required: true }, + app_id: Number, + download: { type: Number, default: 0 }, + upload: { type: Number, default: 0 }, + flows: { type: Number, default: 0 }, + last_seen: String, +}, baseOptions); +DeviceAppStatSchema.index({ agent_uuid: 1, ip_address: 1, timestamp: -1 }); +DeviceAppStatSchema.index({ ip_address: 1, app_label: 1, timestamp: -1 }); +DeviceAppStatSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 }); + +const telemetrySchemas = require('./SchemasTelemetry'); +const auxSchemas = require('./SchemasAux'); + +// ─── Agent Registry (all agents registered in Netify, regardless of activity) ── +// Upserted every collector cycle. Source of truth for the agents list page. +const AgentRegistrySchema = new mongoose.Schema({ + uuid: { type: String, required: true, unique: true, index: true }, + serial: { type: String }, + label: { type: String }, + site_uuid: { type: String, index: true }, + provisioned: { type: Boolean, default: false }, + activated: { type: Boolean, default: false }, + last_seen_at: { type: mongoose.Schema.Types.Mixed }, + netify_id: { type: Number }, +}, { ...baseOptions, collection: 'agent_registry' }); + +module.exports = { + Summary: mongoose.model('Summary', SummarySchema), + AppStat: mongoose.model('AppStat', AppStatSchema), + ProtocolStat: mongoose.model('ProtocolStat', ProtocolStatSchema), + DeviceStat: mongoose.model('DeviceStat', DeviceStatSchema), + DeviceAppStat: mongoose.model('DeviceAppStat', DeviceAppStatSchema), + Flow: mongoose.model('Flow', FlowSchema), + Threat: mongoose.model('Threat', ThreatSchema), + AppCategoryStat: mongoose.model('AppCategoryStat', AppCategoryStatSchema), + Event: mongoose.model('Event', EventSchema), + AgentRegistry: mongoose.model('AgentRegistry', AgentRegistrySchema), + ...auxSchemas, + ...telemetrySchemas, +}; + + diff --git a/proxy/netifyAgentFetcher.js b/proxy/netifyAgentFetcher.js new file mode 100644 index 0000000..2c51cf3 --- /dev/null +++ b/proxy/netifyAgentFetcher.js @@ -0,0 +1,89 @@ +// proxy/netifyAgentFetcher.js +// ───────────────────────────────────────────────────────────────────────────── +// Fetches active agents from Netify Informatics API. +// Uses a two-strategy approach to handle endpoints that may timeout (Source 2). +// ───────────────────────────────────────────────────────────────────────────── + +const { netifyFetch, agentMap } = require('./netifyClientCore'); + +// Strategy 1 timeout: 15s (agent/download can be slow on small deployments) +const PRIMARY_TIMEOUT_MS = 15000; + +async function fetchAgents(siteUuid = null) { + // Strategy 1: Use /data/stats/top/agent/download (standard Netify endpoint) + // filter_interval reduced to 31 days to lessen query load vs. old 365-day value. + const primaryPromise = (async () => { + try { + const data = await netifyFetch('/data/stats/top/agent/download', { + filter_interval: 44640, // 31 days + settings_limit: 1000000, + }, null, siteUuid); + if (data && Array.isArray(data) && data.length > 0) return data; + return null; + } catch (e) { + return null; + } + })(); + + const timeoutPromise = new Promise(resolve => + setTimeout(() => resolve(null), PRIMARY_TIMEOUT_MS) + ); + + const primaryData = await Promise.race([primaryPromise, timeoutPromise]); + + if (primaryData && Array.isArray(primaryData) && primaryData.length > 0) { + const list = primaryData.map(r => ({ + id: r.agent?.id, + uuid: r.agent?.uuid || r.agent?.serial, + serial: r.agent?.serial, + label: r.agent?.label || r.agent?.serial, + provisioned: true, + activated: true, + last_seen_at: r.agent?.last_seen_at ?? null, + })).filter(a => a.uuid); + + // Populate agentMap (uuid → id) for downstream filter_agents usage + for (const a of list) { + if (a.uuid && a.id) agentMap[a.uuid] = a.id; + } + return list; + } + + // Strategy 2: Fallback — discover agents from /data/flows + // Useful for Source 2 where /data/stats/top/agent/download consistently times out. + console.log('[fetchAgents] Primary endpoint timeout/empty. Using flows-based agent discovery...'); + try { + const flowData = await netifyFetch('/data/flows', { + settings_limit: 100, + }, null, siteUuid); + + if (!flowData || !Array.isArray(flowData)) return []; + + // Extract unique agent_uuids from flow records + const seen = new Set(); + const agentList = []; + for (const flow of flowData) { + const uuid = flow.agent_uuid; + if (uuid && !seen.has(uuid)) { + seen.add(uuid); + agentList.push({ + id: null, + uuid: uuid, + serial: uuid, + label: uuid, + provisioned: true, + activated: true, + last_seen_at: flow.last_seen_at ?? null, + }); + } + } + + console.log(`[fetchAgents] Fallback discovered ${agentList.length} agent(s) from flows.`); + return agentList; + } catch (e) { + console.error('[fetchAgents] Fallback also failed:', e.message); + return []; + } +} + +module.exports = { fetchAgents }; diff --git a/proxy/netifyClient.js b/proxy/netifyClient.js index 748d7aa..535dd03 100644 --- a/proxy/netifyClient.js +++ b/proxy/netifyClient.js @@ -3,157 +3,11 @@ // DPI API wrapper for the BackOne Proxy Server targeting original Netify API. // ───────────────────────────────────────────────────────────────────────────── -const { netifyFetch, BASE_URL, agentMap } = require('./netifyClientCore'); +const { netifyFetch, BASE_URL } = require('./netifyClientCore'); const telemetry = require('./netifyTelemetry'); +const stats = require('./netifyClientStats'); -const PORT_SERVICE_MAP = { - 80: 'HTTP', 443: 'HTTPS / TLS', 8080: 'HTTP Alt', 8443: 'HTTPS Alt', - 53: 'DNS', 5353: 'mDNS', 853: 'DNS-over-TLS', - 25: 'SMTP', 587: 'SMTP TLS', 465: 'SMTPS', 110: 'POP3', 143: 'IMAP', - 22: 'SSH', 23: 'Telnet', 3389: 'RDP', 5900: 'VNC', - 21: 'FTP', 20: 'FTP Data', 989: 'FTPS', 990: 'FTPS Control', - 3306: 'MySQL', 5432: 'PostgreSQL', 6379: 'Redis', 27017: 'MongoDB', - 1194: 'OpenVPN', 51820: 'WireGuard', 500: 'IPSec IKE', 4500: 'IPSec NAT-T', - 67: 'DHCP', 68: 'DHCP Client', 123: 'NTP', - 6881: 'BitTorrent', 6882: 'BitTorrent', 6883: 'BitTorrent', - 9993: 'ZeroTier VPN', -}; - -async function fetchAgents(siteUuid = null) { - const data = await netifyFetch('/data/stats/top/agent/download', { filter_interval: 43200, settings_limit: 100 }, null, siteUuid); - if (!data || !Array.isArray(data)) return []; - const list = data.map(r => ({ - id: r.agent?.id, - uuid: r.agent?.uuid, - label: r.agent?.label, - })).filter(a => a.uuid); - - for (const a of list) { - if (a.uuid && a.id) agentMap[a.uuid] = a.id; - } - - // Secondary validation: if this site already has data in MongoDB, only return agents - // that have at least one summary record for THIS site_uuid. This prevents the - // org-level stats endpoint from cross-contaminating agents across sites. - if (siteUuid) { - try { - const mongoose = require('mongoose'); - if (mongoose.connection.readyState === 1) { - const db = mongoose.connection.db; - const knownAgents = await db.collection('summaries').distinct('agent_uuid', { - site_uuid: siteUuid, - agent_uuid: { $ne: null }, - }); - - if (knownAgents.length > 0) { - const knownSet = new Set(knownAgents); - const validated = list.filter(a => knownSet.has(a.uuid)); - // If MongoDB cross-check yields results, use the validated list. - // On first boot (no DB data yet), fall through and use the full API list. - if (validated.length > 0) return validated; - } - } - } catch (err) { - console.warn('[Collector] fetchAgents DB cross-check failed:', err.message); - } - } - - return list; -} - - -async function fetchBandwidthSummary(interval = 1440, agentUuid = null, siteUuid = null) { - const [dlData, ulData, flowsData] = await Promise.all([ - netifyFetch('/data/stats/top/local_ip/download', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid), - netifyFetch('/data/stats/top/local_ip/upload', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid), - netifyFetch('/data/stats/top/local_ip/flow_count', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid), - ]); - const bandwidth_down = dlData?.reduce((s, r) => s + (r.download ?? 0), 0) ?? 0; - const bandwidth_up = ulData?.reduce((s, r) => s + (r.upload ?? 0), 0) ?? 0; - const total_devices = dlData?.length ?? 0; - const active_flows = flowsData?.reduce((s, r) => s + (r.flows ?? r.flow_count ?? 0), 0) ?? 0; - return { bandwidth_down, bandwidth_up, total_devices, active_flows, total_threats: 0 }; -} - -async function fetchTopApps(interval = 1440, limit = 200, agentUuid = null, siteUuid = null) { - const [dlData, ulData] = await Promise.all([ - netifyFetch('/data/stats/top/application/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - netifyFetch('/data/stats/top/application/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - ]); - if (!dlData) return null; - const ulMap = {}; - if (ulData) { - for (const r of ulData) { - const id = r.application?.id; - if (id) ulMap[id] = r.upload ?? 0; - } - } - return dlData.map(r => ({ - application: { - id: r.application?.id ?? null, - label: r.application?.label ?? 'Unknown', - tag: r.application?.tag ?? null, - }, - download: r.download ?? 0, - upload: ulMap[r.application?.id] ?? 0, - flows: r.flows ?? 0, - })); -} - -async function fetchDiscoveredDevices(interval = 1440, limit = 500, agentUuid = null, siteUuid = null) { - const [dlData, ulData] = await Promise.all([ - netifyFetch('/data/stats/top/local_ip/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - netifyFetch('/data/stats/top/local_ip/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - ]); - if (!dlData) return null; - const ulMap = {}; - if (ulData) { - for (const r of ulData) { - const ip = r.local_ip?.address ?? String(r.local_ip); - ulMap[ip] = r.upload ?? 0; - } - } - return dlData.map(r => { - const ip = r.local_ip?.address ?? String(r.local_ip ?? ''); - return { - ip_address: ip, - mac_address: r.local_mac ?? null, - device_label: r.device_label ?? ip, - device_type: r.device_type ?? null, - os_label: r.os_label ?? null, - manufacturer: r.manufacturer ?? null, - download: r.download ?? 0, - upload: ulMap[ip] ?? 0, - flows: r.flows ?? 0, - last_seen: r.last_seen_at?.date ?? null, - }; - }).filter(d => d.ip_address); -} - -async function fetchDeviceApps(ipAddress, interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { - const ipFilter = JSON.stringify([ipAddress]); - const [dlData, ulData] = await Promise.all([ - netifyFetch('/data/stats/top/application/download', { filter_interval: interval, settings_limit: limit, filter_local_ips: ipFilter }, agentUuid, siteUuid), - netifyFetch('/data/stats/top/application/upload', { filter_interval: interval, settings_limit: limit, filter_local_ips: ipFilter }, agentUuid, siteUuid), - ]); - if (!dlData || !Array.isArray(dlData)) return []; - const ulMap = {}; - if (ulData && Array.isArray(ulData)) { - for (const r of ulData) { - const id = r.application?.id; - if (id) ulMap[id] = r.upload ?? 0; - } - } - return dlData.map(r => ({ - app_label: r.application?.label ?? 'Unknown', - app_id: r.application?.id ?? null, - download: r.download ?? 0, - upload: ulMap[r.application?.id] ?? 0, - flows: r.flows ?? 0, - })).filter(a => a.download > 0 || a.upload > 0); -} - -async function fetchFlows(limit = 10000, agentUuid = null, siteUuid = null) { +async function fetchFlows(limit = 1000000, agentUuid = null, siteUuid = null) { const [raw, sniRaw] = await Promise.all([ netifyFetch('/data/flows', { settings_limit: limit }, agentUuid, siteUuid), netifyFetch('/data/stats/top/tls_sni/download', { filter_interval: 1440, settings_limit: 50 }, agentUuid, siteUuid), @@ -163,12 +17,14 @@ async function fetchFlows(limit = 10000, agentUuid = null, siteUuid = null) { if (sniRaw && Array.isArray(sniRaw)) { for (const r of sniRaw) { const sni = typeof r.tls_sni === 'object' ? r.tls_sni?.label : r.tls_sni; - if (sni && typeof sni === 'string' && sni.trim() !== '') sniList.push(sni.replace(/^\*\./, '').trim()); + if (sni && typeof sni === 'string' && sni.trim() !== '') { + sniList.push(sni.replace(/^\*\./, '').trim()); + } } } return raw.map(r => { const port = r.remote_port ?? null; - const portService = port ? (PORT_SERVICE_MAP[port] ?? `Port ${port}`) : null; + const portService = port ? (stats.PORT_SERVICE_MAP[port] ?? `Port ${port}`) : null; const appLabel = r.application?.label || portService; const domain = r.tls_sni || r.dns_hostname || r.hostname || null; return { @@ -188,84 +44,17 @@ async function fetchFlows(limit = 10000, agentUuid = null, siteUuid = null) { }).filter(f => f.src_ip); } -async function fetchCyberThreats(agentUuid = null, siteUuid = null) { - const ipRepData = await netifyFetch('/data/stats/top/remote_ip/download', { filter_interval: 1440, settings_limit: 50 }, agentUuid, siteUuid); - if (!ipRepData || !Array.isArray(ipRepData)) return []; - const SUSPICIOUS_PORTS = new Set([23, 4444, 1337, 6667, 31337, 12345, 54321, 4899, 5554, 9999]); - const threats = []; - for (const r of ipRepData) { - const ip = r.remote_ip?.address ?? null; - const port = r.remote_port ?? 0; - if (ip && SUSPICIOUS_PORTS.has(port)) { - threats.push({ - threat_type: `Suspicious Port ${port}`, - severity: 'High', - src_ip: null, - dst_ip: ip, - dst_port: port, - protocol: r.ip_protocol?.label ?? null, - description: `Suspicious outbound connection to ${ip}:${port}`, - event_at: new Date().toISOString(), - }); - } - } - return threats; -} - -async function fetchEvents(limit = 100, agentUuid = null, siteUuid = null) { - const raw = await netifyFetch('/event/events', { settings_limit: limit }, agentUuid, siteUuid); - if (!raw || !Array.isArray(raw)) return []; - return raw.map(r => { - let msg = r.label || ''; - if (r.description) { - try { - const descObj = JSON.parse(r.description); - msg = descObj.default || r.label || ''; - if (descObj.tags) { - for (const k in descObj.tags) { - const tagVal = descObj.tags[k]; - const val = Array.isArray(tagVal) ? (tagVal[0] === 'Unknown' && tagVal[1] ? tagVal[1] : tagVal[0]) : tagVal; - msg = msg.replace(`{{ ${k} }}`, val).replace(`{{${k}}}`, val); - } - } - } catch (e) { - msg = r.description; - } - } - let sevLabel = 'Info'; - if (r.severity >= 30) sevLabel = 'Critical'; - else if (r.severity >= 20) sevLabel = 'High'; - else if (r.severity >= 10) sevLabel = 'Warning'; - let srcIp = null; - if (r.description) { - try { - const descObj = JSON.parse(r.description); - srcIp = descObj.tags?.device_ip || descObj.tags?.ip || null; - } catch {} - } - return { - event_id: r.id || null, - event_type: r.basename || 'unknown', - severity: sevLabel, - description: msg, - category_label: r.category?.label || 'Intelligence', - ip_address: srcIp, - mac_address: r.additional?.device?.mac?.address || null, - event_at: r.created_at?.date ? new Date(r.created_at.date) : new Date() - }; - }); -} - module.exports = { - fetchAgents, - fetchBandwidthSummary, - fetchTopApps, - fetchDiscoveredDevices, - fetchDeviceApps, fetchFlows, - fetchCyberThreats, - fetchEvents, + fetchAgents: stats.fetchAgents, + fetchBandwidthSummary: stats.fetchBandwidthSummary, + fetchTopApps: stats.fetchTopApps, + fetchDiscoveredDevices: stats.fetchDiscoveredDevices, + fetchDeviceApps: stats.fetchDeviceApps, + fetchCyberThreats: stats.fetchCyberThreats, + fetchEvents: stats.fetchEvents, + syncApplicationDictionary: stats.syncApplicationDictionary, BASE_URL, - PORT_SERVICE_MAP, + PORT_SERVICE_MAP: stats.PORT_SERVICE_MAP, ...telemetry, }; diff --git a/proxy/netifyClientStats.js b/proxy/netifyClientStats.js new file mode 100644 index 0000000..e92366b --- /dev/null +++ b/proxy/netifyClientStats.js @@ -0,0 +1,219 @@ +// proxy/netifyClientStats.js +// ───────────────────────────────────────────────────────────────────────────── +// Supplementary fetchers split from netifyClient.js to satisfy the 256-line limit. +// ───────────────────────────────────────────────────────────────────────────── + +const { netifyFetch } = require('./netifyClientCore'); +const { fetchAgents } = require('./netifyAgentFetcher'); + +const PORT_SERVICE_MAP = { + 80: 'HTTP', 443: 'HTTPS / TLS', 8080: 'HTTP Alt', 8443: 'HTTPS Alt', + 53: 'DNS', 5353: 'mDNS', 853: 'DNS-over-TLS', + 25: 'SMTP', 587: 'SMTP TLS', 465: 'SMTPS', 110: 'POP3', 143: 'IMAP', + 22: 'SSH', 23: 'Telnet', 3389: 'RDP', 5900: 'VNC', + 21: 'FTP', 20: 'FTP Data', 989: 'FTPS', 990: 'FTPS Control', + 3306: 'MySQL', 5432: 'PostgreSQL', 6379: 'Redis', 27017: 'MongoDB', + 1194: 'OpenVPN', 51820: 'WireGuard', 500: 'IPSec IKE', 4500: 'IPSec NAT-T', + 67: 'DHCP', 68: 'DHCP Client', 123: 'NTP', + 6881: 'BitTorrent', 6882: 'BitTorrent', 6883: 'BitTorrent', + 9993: 'ZeroTier VPN', +}; + +async function fetchBandwidthSummary(interval = 1440, agentUuid = null, siteUuid = null) { + const [dlData, ulData, flowsData] = await Promise.all([ + netifyFetch('/data/stats/top/local_ip/download', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid), + netifyFetch('/data/stats/top/local_ip/upload', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid), + netifyFetch('/data/stats/top/local_ip/flow_count', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid), + ]); + const bandwidth_down = dlData?.reduce((s, r) => s + (r.download ?? 0), 0) ?? 0; + const bandwidth_up = ulData?.reduce((s, r) => s + (r.upload ?? 0), 0) ?? 0; + const total_devices = dlData?.length ?? 0; + const active_flows = flowsData?.reduce((s, r) => s + (r.flows ?? r.flow_count ?? 0), 0) ?? 0; + return { bandwidth_down, bandwidth_up, total_devices, active_flows, total_threats: 0 }; +} + +async function fetchTopApps(interval = 1440, limit = 200, agentUuid = null, siteUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/application/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + netifyFetch('/data/stats/top/application/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) { + for (const r of ulData) { + const id = r.application?.id; + if (id) ulMap[id] = r.upload ?? 0; + } + } + return dlData.map(r => ({ + application: { id: r.application?.id ?? null, label: r.application?.label ?? 'Unknown', tag: r.application?.tag ?? null }, + download: r.download ?? 0, upload: ulMap[r.application?.id] ?? 0, flows: r.flows ?? 0, + })); +} + +async function fetchDiscoveredDevices(interval = 1440, limit = 500, agentUuid = null, siteUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/local_ip/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + netifyFetch('/data/stats/top/local_ip/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + ]); + if (!dlData) return null; + const ulMap = {}; + if (ulData) { + for (const r of ulData) { + const ip = r.local_ip?.address ?? String(r.local_ip); + ulMap[ip] = r.upload ?? 0; + } + } + return dlData.map(r => { + const ip = r.local_ip?.address ?? String(r.local_ip ?? ''); + return { + ip_address: ip, mac_address: r.local_mac ?? null, device_label: r.device_label ?? ip, device_type: r.device_type ?? null, + os_label: r.os_label ?? null, manufacturer: r.manufacturer ?? null, download: r.download ?? 0, upload: ulMap[ip] ?? 0, + flows: r.flows ?? 0, last_seen: r.last_seen_at?.date ?? null, + }; + }).filter(d => d.ip_address); +} + +async function fetchDeviceApps(ipAddress, interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { + const ipFilter = JSON.stringify([ipAddress]); + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/application/download', { filter_interval: interval, settings_limit: limit, filter_local_ips: ipFilter }, agentUuid, siteUuid), + netifyFetch('/data/stats/top/application/upload', { filter_interval: interval, settings_limit: limit, filter_local_ips: ipFilter }, agentUuid, siteUuid), + ]); + if (!dlData || !Array.isArray(dlData)) return []; + const ulMap = {}; + if (ulData && Array.isArray(ulData)) { + for (const r of ulData) { + const id = r.application?.id; + if (id) ulMap[id] = r.upload ?? 0; + } + } + return dlData.map(r => ({ + app_label: r.application?.label ?? 'Unknown', + app_id: r.application?.id ?? null, + download: r.download ?? 0, + upload: ulMap[r.application?.id] ?? 0, + flows: r.flows ?? 0, + })).filter(a => a.download > 0 || a.upload > 0); +} + +async function fetchCyberThreats(agentUuid = null, siteUuid = null) { + const ipRepData = await netifyFetch('/data/stats/top/remote_ip/download', { filter_interval: 1440, settings_limit: 50 }, agentUuid, siteUuid); + if (!ipRepData || !Array.isArray(ipRepData)) return []; + const SUSPICIOUS_PORTS = new Set([23, 4444, 1337, 6667, 31337, 12345, 54321, 4899, 5554, 9999]); + const threats = []; + for (const r of ipRepData) { + const ip = r.remote_ip?.address ?? null; + const port = r.remote_port ?? 0; + if (ip && SUSPICIOUS_PORTS.has(port)) { + threats.push({ + threat_type: `Suspicious Port ${port}`, + severity: 'High', + src_ip: null, + dst_ip: ip, + dst_port: port, + protocol: r.ip_protocol?.label ?? null, + description: `Suspicious outbound connection to ${ip}:${port}`, + event_at: new Date().toISOString(), + }); + } + } + return threats; +} + +async function fetchEvents(limit = 100, agentUuid = null, siteUuid = null) { + const raw = await netifyFetch('/event/events', { settings_limit: limit }, agentUuid, siteUuid); + if (!raw || !Array.isArray(raw)) return []; + return raw.map(r => { + let msg = r.label || ''; + if (r.description) { + try { + const descObj = JSON.parse(r.description); + msg = descObj.default || r.label || ''; + if (descObj.tags) { + for (const k in descObj.tags) { + const tagVal = descObj.tags[k]; + const val = Array.isArray(tagVal) ? (tagVal[0] === 'Unknown' && tagVal[1] ? tagVal[1] : tagVal[0]) : tagVal; + msg = msg.replace(`{{ ${k} }}`, val).replace(`{{${k}}}`, val); + } + } + } catch (e) { + msg = r.description; + } + } + let sevLabel = 'Info'; + if (r.severity >= 30) sevLabel = 'Critical'; + else if (r.severity >= 20) sevLabel = 'High'; + else if (r.severity >= 10) sevLabel = 'Warning'; + let srcIp = null; + if (r.description) { + try { + const descObj = JSON.parse(r.description); + srcIp = descObj.tags?.device_ip || descObj.tags?.ip || null; + } catch {} + } + return { + event_id: r.id || null, + event_type: r.basename || 'unknown', + severity: sevLabel, + description: msg, + category_label: r.category?.label || 'Intelligence', + ip_address: srcIp, + mac_address: r.additional?.device?.mac?.address || null, + event_at: r.created_at?.date ? new Date(r.created_at.date) : new Date() + }; + }); +} + +async function syncApplicationDictionary() { + const mongoose = require('mongoose'); + const { LookupApp } = require('./models/Schemas'); + + console.log('[Netify] Fetching application catalog...'); + const allApps = await netifyFetch('/lookup/applications', { settings_limit: 5000 }); + if (!allApps || !Array.isArray(allApps)) { + console.error('[Netify] Failed to fetch application dictionary.'); + return; + } + + console.log(`[Netify] Application catalog fetched successfully. Got ${allApps.length} apps.`); + if (allApps.length === 0) return; + + console.log(`[Netify] Syncing ${allApps.length} application definitions to MongoDB...`); + await LookupApp.deleteMany({}); + + const batchSize = 100; + for (let i = 0; i < allApps.length; i += batchSize) { + const batch = allApps.slice(i, i + batchSize); + await LookupApp.insertMany(batch.map(app => ({ + id: app.id, + name: app.name, + label: app.label, + tag: app.tag, + description: app.description, + full_name: app.full_name || app.application?.full_label || null, + favicon: app.favicon || app.application?.favicon || null, + icon: app.icon || app.application?.icon || null, + logo: app.logo || app.application?.logo || null, + application_category: { + id: app.application_category?.id, + name: app.application_category?.name, + label: app.application_category?.label, + tag: app.application_category?.tag + } + }))); + } + console.log('[Netify] ✓ Application dictionary sync completed.'); +} + +module.exports = { + fetchAgents, + fetchBandwidthSummary, + fetchTopApps, + fetchDiscoveredDevices, + fetchDeviceApps, + fetchCyberThreats, + fetchEvents, + syncApplicationDictionary, + PORT_SERVICE_MAP +}; diff --git a/proxy/netifyTelemetry.js b/proxy/netifyTelemetry.js index 6b5aaea..c72a625 100644 --- a/proxy/netifyTelemetry.js +++ b/proxy/netifyTelemetry.js @@ -1,234 +1,234 @@ -// proxy/netifyTelemetry.js -// ───────────────────────────────────────────────────────────────────────────── -// Supplementary Telemetry endpoints wrapper for BackOne Proxy Server -// Split from netifyClient.js to strictly respect the 256-line file size limit. -// ───────────────────────────────────────────────────────────────────────────── - -const { netifyFetch } = require('./netifyClientCore'); - -async function fetchTopAppCategories(interval = 1440, limit = 15, agentUuid = null, siteUuid = null) { - const [dlData, ulData] = await Promise.all([ - netifyFetch('/data/stats/top/application_category/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - netifyFetch('/data/stats/top/application_category/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - ]); - if (!dlData) return []; - const ulMap = {}; - if (ulData) { - for (const r of ulData) { - const key = r.application_category?.label ?? r.application_category; - if (key) ulMap[key] = r.upload ?? 0; - } - } - return dlData.map(r => { - const label = r.application_category?.label ?? String(r.application_category ?? 'Unknown'); - return { - category_label : label, - download : r.download ?? 0, - upload : ulMap[label] ?? 0, - flows : r.flows ?? 0, - }; - }); -} - -async function fetchTlsVersions(interval = 1440, limit = 15, agentUuid = null, siteUuid = null) { - const [dlData, ulData] = await Promise.all([ - netifyFetch('/data/stats/top/tls_version/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - netifyFetch('/data/stats/top/tls_version/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - ]); - if (!dlData) return []; - const ulMap = {}; - if (ulData) { - for (const r of ulData) { - const key = r.tls_version?.label ?? r.tls_version?.code ?? r.tls_version; - if (key) ulMap[key] = r.upload ?? 0; - } - } - return dlData.map(r => { - const label = r.tls_version?.label ?? r.tls_version?.code ?? String(r.tls_version ?? 'Unknown'); - return { - tls_version : label, - download : r.download ?? 0, - upload : ulMap[label] ?? 0, - flows : r.flows ?? 0, - }; - }); -} - -async function fetchTlsCiphers(interval = 1440, limit = 15, agentUuid = null, siteUuid = null) { - const [dlData, ulData] = await Promise.all([ - netifyFetch('/data/stats/top/tls_cipher/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - netifyFetch('/data/stats/top/tls_cipher/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - ]); - if (!dlData) return []; - const ulMap = {}; - if (ulData) { - for (const r of ulData) { - const key = r.tls_cipher?.label ?? r.tls_cipher?.code ?? r.tls_cipher; - if (key) ulMap[key] = r.upload ?? 0; - } - } - return dlData.map(r => { - const label = r.tls_cipher?.label ?? r.tls_cipher?.code ?? String(r.tls_cipher ?? 'Unknown'); - return { - tls_cipher : label, - download : r.download ?? 0, - upload : ulMap[label] ?? 0, - flows : r.flows ?? 0, - }; - }); -} - -async function fetchTlsSecurity(interval = 1440, limit = 15, agentUuid = null, siteUuid = null) { - const [dlData, ulData] = await Promise.all([ - netifyFetch('/data/stats/top/tls_security/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - netifyFetch('/data/stats/top/tls_security/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - ]); - if (!dlData) return []; - const ulMap = {}; - if (ulData) { - for (const r of ulData) { - const key = r.tls_security?.label ?? r.tls_security?.code ?? r.tls_security; - if (key) ulMap[key] = r.upload ?? 0; - } - } - return dlData.map(r => { - const label = r.tls_security?.label ?? r.tls_security?.code ?? String(r.tls_security ?? 'Unknown'); - return { - tls_security : label, - download : r.download ?? 0, - upload : ulMap[label] ?? 0, - flows : r.flows ?? 0, - }; - }); -} - -async function fetchTopCountries(interval = 1440, limit = 100, agentUuid = null, siteUuid = null) { - const [dlData, ulData] = await Promise.all([ - netifyFetch('/data/stats/top/country/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - netifyFetch('/data/stats/top/country/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - ]); - if (!dlData) return []; - const ulMap = {}; - if (ulData) { - for (const r of ulData) { - const cc = r.country?.code; - if (cc) ulMap[cc] = r.upload ?? 0; - } - } - return dlData.map(r => { - return { - country_code: r.country?.code ?? 'Unknown', - country_name: r.country?.label ?? '', - download: r.download ?? 0, - upload: ulMap[r.country?.code] ?? 0, - flows: r.flows ?? 0, - }; - }).filter(r => r.country_code); -} - -async function fetchTopProperty(fieldName, interval, limit, agentUuid, siteUuid) { - const [dlData, ulData] = await Promise.all([ - netifyFetch(`/data/stats/top/${fieldName}/download`, { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - netifyFetch(`/data/stats/top/${fieldName}/upload`, { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), - ]); - if (!dlData) return []; - const ulMap = {}; - if (ulData) { - for (const r of ulData) { - const item = r[fieldName]; - const key = item?.hash ?? item?.name ?? item?.label ?? String(item ?? ''); - if (key) ulMap[key] = r.upload ?? 0; - } - } - return dlData.map(r => { - const item = r[fieldName]; - const key = item?.hash ?? item?.name ?? item?.label ?? String(item || 'Unknown'); - const label = item?.label ?? key; - return { - key, - label, - download: r.download ?? 0, - upload: ulMap[key] ?? ulMap[label] ?? 0, - flows: r.flows ?? 0, - }; - }); -} - -function mapProp(data, keyName) { - return data.map(d => ({ - [keyName]: d.key, - download: d.download, - upload: d.upload, - flows: d.flows, - })); -} - -async function fetchDhcpFingerprints(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { - return mapProp(await fetchTopProperty('dhcp_class', interval, limit, agentUuid, siteUuid), 'fingerprint'); -} - -async function fetchHttpUserAgents(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { - return mapProp(await fetchTopProperty('http_useragent', interval, limit, agentUuid, siteUuid), 'user_agent'); -} - -async function fetchBittorrentHashes(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { - const data = await fetchTopProperty('bittorrent_info_hash', interval, limit, agentUuid, siteUuid); - return data.map(d => ({ - info_hash: d.key, - label: d.label, - download: d.download, - upload: d.upload, - flows: d.flows, - })); -} - -async function fetchSniHostnames(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { - return mapProp(await fetchTopProperty('tls_sni', interval, limit, agentUuid, siteUuid), 'sni_hostname'); -} - -async function fetchSslServerCn(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { - return mapProp(await fetchTopProperty('ssl_server_cn', interval, limit, agentUuid, siteUuid), 'ssl_server_cn'); -} - -async function fetchQuicHostnames(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { - return mapProp(await fetchTopProperty('quic_hostname', interval, limit, agentUuid, siteUuid), 'quic_hostname'); -} - -async function fetchSshClients(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { - return mapProp(await fetchTopProperty('ssh_client', interval, limit, agentUuid, siteUuid), 'ssh_client'); -} - -async function fetchSshServers(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { - return mapProp(await fetchTopProperty('ssh_server', interval, limit, agentUuid, siteUuid), 'ssh_server'); -} - -async function fetchMdnsHostnames(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { - return mapProp(await fetchTopProperty('mdns_hostname', interval, limit, agentUuid, siteUuid), 'mdns_hostname'); -} - -async function fetchTopProtocols(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { - return mapProp(await fetchTopProperty('ip_protocol', interval, limit, agentUuid, siteUuid), 'protocol_label'); -} - -async function fetchSslSubjectAltNames(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { - return mapProp(await fetchTopProperty('ssl_subject_alt_name', interval, limit, agentUuid, siteUuid), 'alt_name'); -} - -module.exports = { - fetchTopAppCategories, - fetchTlsVersions, - fetchTlsCiphers, - fetchTlsSecurity, - fetchTopCountries, - fetchDhcpFingerprints, - fetchHttpUserAgents, - fetchBittorrentHashes, - fetchSniHostnames, - fetchSslServerCn, - fetchQuicHostnames, - fetchSshClients, - fetchSshServers, - fetchMdnsHostnames, - fetchTopProtocols, - fetchSslSubjectAltNames, -}; +// proxy/netifyTelemetry.js +// ───────────────────────────────────────────────────────────────────────────── +// Supplementary Telemetry endpoints wrapper for BackOne Proxy Server +// Split from netifyClient.js to strictly respect the 256-line file size limit. +// ───────────────────────────────────────────────────────────────────────────── + +const { netifyFetch } = require('./netifyClientCore'); + +async function fetchTopAppCategories(interval = 1440, limit = 15, agentUuid = null, siteUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/application_category/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + netifyFetch('/data/stats/top/application_category/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + ]); + if (!dlData) return []; + const ulMap = {}; + if (ulData) { + for (const r of ulData) { + const key = r.application_category?.label ?? r.application_category; + if (key) ulMap[key] = r.upload ?? 0; + } + } + return dlData.map(r => { + const label = r.application_category?.label ?? String(r.application_category ?? 'Unknown'); + return { + category_label : label, + download : r.download ?? 0, + upload : ulMap[label] ?? 0, + flows : r.flows ?? 0, + }; + }); +} + +async function fetchTlsVersions(interval = 1440, limit = 15, agentUuid = null, siteUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/tls_version/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + netifyFetch('/data/stats/top/tls_version/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + ]); + if (!dlData) return []; + const ulMap = {}; + if (ulData) { + for (const r of ulData) { + const key = r.tls_version?.label ?? r.tls_version?.code ?? r.tls_version; + if (key) ulMap[key] = r.upload ?? 0; + } + } + return dlData.map(r => { + const label = r.tls_version?.label ?? r.tls_version?.code ?? String(r.tls_version ?? 'Unknown'); + return { + tls_version : label, + download : r.download ?? 0, + upload : ulMap[label] ?? 0, + flows : r.flows ?? 0, + }; + }); +} + +async function fetchTlsCiphers(interval = 1440, limit = 15, agentUuid = null, siteUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/tls_cipher/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + netifyFetch('/data/stats/top/tls_cipher/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + ]); + if (!dlData) return []; + const ulMap = {}; + if (ulData) { + for (const r of ulData) { + const key = r.tls_cipher?.label ?? r.tls_cipher?.code ?? r.tls_cipher; + if (key) ulMap[key] = r.upload ?? 0; + } + } + return dlData.map(r => { + const label = r.tls_cipher?.label ?? r.tls_cipher?.code ?? String(r.tls_cipher ?? 'Unknown'); + return { + tls_cipher : label, + download : r.download ?? 0, + upload : ulMap[label] ?? 0, + flows : r.flows ?? 0, + }; + }); +} + +async function fetchTlsSecurity(interval = 1440, limit = 15, agentUuid = null, siteUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/tls_security/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + netifyFetch('/data/stats/top/tls_security/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + ]); + if (!dlData) return []; + const ulMap = {}; + if (ulData) { + for (const r of ulData) { + const key = r.tls_security?.label ?? r.tls_security?.code ?? r.tls_security; + if (key) ulMap[key] = r.upload ?? 0; + } + } + return dlData.map(r => { + const label = r.tls_security?.label ?? r.tls_security?.code ?? String(r.tls_security ?? 'Unknown'); + return { + tls_security : label, + download : r.download ?? 0, + upload : ulMap[label] ?? 0, + flows : r.flows ?? 0, + }; + }); +} + +async function fetchTopCountries(interval = 1440, limit = 100, agentUuid = null, siteUuid = null) { + const [dlData, ulData] = await Promise.all([ + netifyFetch('/data/stats/top/country/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + netifyFetch('/data/stats/top/country/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + ]); + if (!dlData) return []; + const ulMap = {}; + if (ulData) { + for (const r of ulData) { + const cc = r.country?.code; + if (cc) ulMap[cc] = r.upload ?? 0; + } + } + return dlData.map(r => { + return { + country_code: r.country?.code ?? 'Unknown', + country_name: r.country?.label ?? '', + download: r.download ?? 0, + upload: ulMap[r.country?.code] ?? 0, + flows: r.flows ?? 0, + }; + }).filter(r => r.country_code); +} + +async function fetchTopProperty(fieldName, interval, limit, agentUuid, siteUuid) { + const [dlData, ulData] = await Promise.all([ + netifyFetch(`/data/stats/top/${fieldName}/download`, { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + netifyFetch(`/data/stats/top/${fieldName}/upload`, { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid), + ]); + if (!dlData) return []; + const ulMap = {}; + if (ulData) { + for (const r of ulData) { + const item = r[fieldName]; + const key = item?.hash ?? item?.name ?? item?.label ?? String(item ?? ''); + if (key) ulMap[key] = r.upload ?? 0; + } + } + return dlData.map(r => { + const item = r[fieldName]; + const key = item?.hash ?? item?.name ?? item?.label ?? String(item || 'Unknown'); + const label = item?.label ?? key; + return { + key, + label, + download: r.download ?? 0, + upload: ulMap[key] ?? ulMap[label] ?? 0, + flows: r.flows ?? 0, + }; + }); +} + +function mapProp(data, keyName) { + return data.map(d => ({ + [keyName]: d.key, + download: d.download, + upload: d.upload, + flows: d.flows, + })); +} + +async function fetchDhcpFingerprints(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { + return mapProp(await fetchTopProperty('dhcp_class', interval, limit, agentUuid, siteUuid), 'fingerprint'); +} + +async function fetchHttpUserAgents(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { + return mapProp(await fetchTopProperty('http_useragent', interval, limit, agentUuid, siteUuid), 'user_agent'); +} + +async function fetchBittorrentHashes(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { + const data = await fetchTopProperty('bittorrent_info_hash', interval, limit, agentUuid, siteUuid); + return data.map(d => ({ + info_hash: d.key, + label: d.label, + download: d.download, + upload: d.upload, + flows: d.flows, + })); +} + +async function fetchSniHostnames(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { + return mapProp(await fetchTopProperty('tls_sni', interval, limit, agentUuid, siteUuid), 'sni_hostname'); +} + +async function fetchSslServerCn(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { + return mapProp(await fetchTopProperty('ssl_server_cn', interval, limit, agentUuid, siteUuid), 'ssl_server_cn'); +} + +async function fetchQuicHostnames(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { + return mapProp(await fetchTopProperty('quic_hostname', interval, limit, agentUuid, siteUuid), 'quic_hostname'); +} + +async function fetchSshClients(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { + return mapProp(await fetchTopProperty('ssh_client', interval, limit, agentUuid, siteUuid), 'ssh_client'); +} + +async function fetchSshServers(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { + return mapProp(await fetchTopProperty('ssh_server', interval, limit, agentUuid, siteUuid), 'ssh_server'); +} + +async function fetchMdnsHostnames(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { + return mapProp(await fetchTopProperty('mdns_hostname', interval, limit, agentUuid, siteUuid), 'mdns_hostname'); +} + +async function fetchTopProtocols(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { + return mapProp(await fetchTopProperty('ip_protocol', interval, limit, agentUuid, siteUuid), 'protocol_label'); +} + +async function fetchSslSubjectAltNames(interval = 1440, limit = 50, agentUuid = null, siteUuid = null) { + return mapProp(await fetchTopProperty('ssl_subject_alt_name', interval, limit, agentUuid, siteUuid), 'alt_name'); +} + +module.exports = { + fetchTopAppCategories, + fetchTlsVersions, + fetchTlsCiphers, + fetchTlsSecurity, + fetchTopCountries, + fetchDhcpFingerprints, + fetchHttpUserAgents, + fetchBittorrentHashes, + fetchSniHostnames, + fetchSslServerCn, + fetchQuicHostnames, + fetchSshClients, + fetchSshServers, + fetchMdnsHostnames, + fetchTopProtocols, + fetchSslSubjectAltNames, +}; diff --git a/proxy/package-lock.json b/proxy/package-lock.json index b7f818f..3d0bda1 100644 --- a/proxy/package-lock.json +++ b/proxy/package-lock.json @@ -1,1312 +1,1312 @@ -{ - "name": "backone-proxy", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "backone-proxy", - "version": "1.0.0", - "dependencies": { - "axios": "^1.6.2", - "cors": "^2.8.5", - "dotenv": "^16.3.1", - "express": "^4.18.2", - "mongoose": "^8.0.3", - "node-cron": "^3.0.3" - } - }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.4.12", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.12.tgz", - "integrity": "sha512-QAfAMwNgnYxZ2C6D1HgeP7Gc4i/uvJRim415PCIL9ptRxWMNbWeLBYb2/9R4pGKny/s1FVu2JA2cxCUBUOggrA==", - "license": "MIT", - "dependencies": { - "sparse-bitfield": "^3.0.3" - } - }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT" - }, - "node_modules/@types/whatwg-url": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", - "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", - "license": "MIT", - "dependencies": { - "@types/webidl-conversions": "*" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agent-base/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/agent-base/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bson": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", - "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", - "license": "Apache-2.0", - "engines": { - "node": ">=16.20.1" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/kareem": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", - "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT" - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mongodb": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", - "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^6.10.4", - "mongodb-connection-string-url": "^3.0.2" - }, - "engines": { - "node": ">=16.20.1" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", - "gcp-metadata": "^5.2.0", - "kerberos": "^2.0.1", - "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.3.2", - "socks": "^2.7.1" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", - "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^11.0.2", - "whatwg-url": "^14.1.0 || ^13.0.0" - } - }, - "node_modules/mongoose": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.1.tgz", - "integrity": "sha512-UpHBA0l5kHyKJQFjmBaFYQFo5sgz1DK0TRqDkOyBLYbqiIbKKhIvBpHWBXqeo0rgW4kGI1UhhAw+kTQZoj1BdA==", - "license": "MIT", - "dependencies": { - "bson": "^6.10.4", - "kareem": "2.6.3", - "mongodb": "~6.20.0", - "mpath": "0.9.0", - "mquery": "5.0.0", - "ms": "2.1.3", - "sift": "17.1.3" - }, - "engines": { - "node": ">=16.20.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, - "node_modules/mongoose/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/mpath": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", - "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mquery": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", - "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", - "license": "MIT", - "dependencies": { - "debug": "4.x" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/mquery/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mquery/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-cron": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", - "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", - "license": "ISC", - "dependencies": { - "uuid": "8.3.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/sift": { - "version": "17.1.3", - "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", - "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", - "license": "MIT" - }, - "node_modules/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "license": "MIT", - "dependencies": { - "memory-pager": "^1.0.2" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - } - } -} +{ + "name": "backone-proxy", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backone-proxy", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "mongoose": "^8.0.3", + "node-cron": "^3.0.3" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.12.tgz", + "integrity": "sha512-QAfAMwNgnYxZ2C6D1HgeP7Gc4i/uvJRim415PCIL9ptRxWMNbWeLBYb2/9R4pGKny/s1FVu2JA2cxCUBUOggrA==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mongodb": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", + "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.1.tgz", + "integrity": "sha512-UpHBA0l5kHyKJQFjmBaFYQFo5sgz1DK0TRqDkOyBLYbqiIbKKhIvBpHWBXqeo0rgW4kGI1UhhAw+kTQZoj1BdA==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.20.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/mquery/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mquery/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/proxy/package.json b/proxy/package.json index 99f1e35..28d8069 100644 --- a/proxy/package.json +++ b/proxy/package.json @@ -1,19 +1,19 @@ -{ - "name": "backone-proxy", - "version": "1.0.0", - "description": "BackOne DPI Proxy Server - Fetches from DPI API, filters per agent_uuid, stores to MongoDB", - "main": "index.js", - "scripts": { - "start": "node index.js", - "start:bun": "bun run index.js", - "dev": "nodemon index.js" - }, - "dependencies": { - "axios": "^1.6.2", - "cors": "^2.8.5", - "dotenv": "^16.3.1", - "express": "^4.18.2", - "mongoose": "^8.0.3", - "node-cron": "^3.0.3" - } -} +{ + "name": "backone-proxy", + "version": "1.0.0", + "description": "BackOne DPI Proxy Server - Fetches from DPI API, filters per agent_uuid, stores to MongoDB", + "main": "index.js", + "scripts": { + "start": "node index.js", + "start:bun": "bun run index.js", + "dev": "nodemon index.js" + }, + "dependencies": { + "axios": "^1.6.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "mongoose": "^8.0.3", + "node-cron": "^3.0.3" + } +} diff --git a/proxy/scheduler.js b/proxy/scheduler.js index e1f14b1..3c31c67 100644 --- a/proxy/scheduler.js +++ b/proxy/scheduler.js @@ -1,124 +1,129 @@ -// proxy/scheduler.js -// Cron scheduler for automatic data collection from DPI API -// Runs every 5 minutes, collecting data for all agents or a specific agent. - -const cron = require('node-cron'); -const { collectAllAgents, collectSpecificAgent, collectSpecificAgents } = require('./collector'); - -// Mode: 'all' = collect all agents, 'agent' = collect one specific agent, 'agents' = collect multiple agents -const COLLECT_MODE = process.env.PROXY_COLLECT_MODE || 'all'; -const SPECIFIC_AGENT = process.env.PROXY_AGENT_UUID || null; -const SPECIFIC_AGENTS = (process.env.PROXY_AGENT_UUIDS || '').split(',').map(s => s.trim()).filter(Boolean); -const AGENT_DELAY_MS = parseInt(process.env.PROXY_AGENT_DELAY_MS || '5000'); -const CRON_SCHEDULE = process.env.PROXY_CRON_SCHEDULE || '*/5 * * * *'; - -// Capacity logging is an expensive full-scan aggregation. Run it at most once per -// interval (default 24h) instead of every collection cycle to reduce CPU/DB load. -const CAPACITY_LOG_INTERVAL_MS = parseInt(process.env.PROXY_CAPACITY_LOG_INTERVAL_MS || String(24 * 60 * 60 * 1000)); - -let isRunning = false; -let lastRunAt = null; -let lastRunResult = null; -let runCount = 0; -let lastCapacityLogAt = 0; - -/** - * Execute one collection cycle (called by cron and manual trigger). - * Prevents concurrent runs with isRunning guard. - */ -async function runCollection() { - if (isRunning) { - console.log('[Scheduler] Skipping - previous run still in progress'); - return { skipped: true, reason: 'already_running' }; - } - - isRunning = true; - lastRunAt = new Date(); - runCount++; - - try { - let result; - if (COLLECT_MODE === 'agent' && SPECIFIC_AGENT) { - console.log(`[Scheduler] Run #${runCount} - Mode: SPECIFIC AGENT (${SPECIFIC_AGENT})`); - result = await collectSpecificAgent(SPECIFIC_AGENT); - } else if (COLLECT_MODE === 'agents' && SPECIFIC_AGENTS.length > 0) { - console.log(`[Scheduler] Run #${runCount} - Mode: SPECIFIC AGENTS (${SPECIFIC_AGENTS.join(', ')})`); - result = await collectSpecificAgents(SPECIFIC_AGENTS, AGENT_DELAY_MS); - } else { - console.log(`[Scheduler] Run #${runCount} - Mode: ALL AGENTS`); - result = await collectAllAgents(); - } - lastRunResult = { ...result, run_count: runCount }; - - // Log MongoDB database capacity usage (expensive full-scan aggregation). - // Only run periodically (default: every 24h) to avoid high CPU/DB load each cycle. - const now = Date.now(); - if (now - lastCapacityLogAt >= CAPACITY_LOG_INTERVAL_MS) { - lastCapacityLogAt = now; - const { logCapacityStats } = require('./db/capacityTracker'); - await logCapacityStats(`[PROXY] [MongoDB] Capacity Used after Run #${runCount}:`); - } - - return lastRunResult; - } catch (err) { - console.error('[Scheduler] Unhandled error during collection:', err.message); - lastRunResult = { success: false, error: err.message, run_count: runCount }; - return lastRunResult; - } finally { - isRunning = false; - } -} - -/** - * Start the scheduler (cron job + immediate first run). - */ -function startScheduler() { - console.log(`[Scheduler] Starting proxy data collector`); - const modeLabel = COLLECT_MODE === 'agent' - ? `SPECIFIC AGENT (${SPECIFIC_AGENT})` - : COLLECT_MODE === 'agents' - ? `SPECIFIC AGENTS (${SPECIFIC_AGENTS.join(', ')})` - : 'ALL AGENTS'; - console.log(`[Scheduler] Mode : ${modeLabel}`); - console.log(`[Scheduler] Schedule : ${CRON_SCHEDULE} (every 5 minutes by default)`); - - // Validate cron expression - if (!cron.validate(CRON_SCHEDULE)) { - console.error(`[Scheduler] Invalid cron expression: "${CRON_SCHEDULE}". Using default.`); - } - - // Start recurring cron job - cron.schedule(CRON_SCHEDULE, () => { - runCollection().catch(err => console.error('[Scheduler] Cron error:', err.message)); - }); - - console.log('[Scheduler] Cron job registered. Starting initial collection...'); - - // Initial run immediately on startup (async, do not block server start) - setTimeout(async () => { - // 1. Sync dictionary first - // await netifyClient.syncApplicationDictionary(); - - // 2. Start normal telemetry collection - runCollection().catch(err => console.error('[Scheduler] Initial run error:', err.message)); - }, 2000); -} - -/** - * Get current scheduler status (for REST API endpoint). - */ -function getStatus() { - return { - is_running: isRunning, - run_count: runCount, - last_run_at: lastRunAt?.toISOString() ?? null, - collect_mode: COLLECT_MODE, - agent_uuid: SPECIFIC_AGENT, - agent_uuids: COLLECT_MODE === 'agents' ? SPECIFIC_AGENTS : [], - agent_delay_ms: AGENT_DELAY_MS, - cron_schedule: CRON_SCHEDULE, - last_result: lastRunResult, - }; -} - -module.exports = { startScheduler, runCollection, getStatus }; +// proxy/scheduler.js +// Cron scheduler for automatic data collection from DPI API +// Runs every 5 minutes, collecting data for all agents or a specific agent. + +const cron = require('node-cron'); +const { collectAllAgents, collectSpecificAgent, collectSpecificAgents } = require('./collector'); +const { syncApplicationDictionary } = require('./netifyClient'); + +// Mode: 'all' = collect all agents, 'agent' = collect one specific agent, 'agents' = collect multiple agents +const COLLECT_MODE = process.env.PROXY_COLLECT_MODE || 'all'; +const SPECIFIC_AGENT = process.env.PROXY_AGENT_UUID || null; +const SPECIFIC_AGENTS = (process.env.PROXY_AGENT_UUIDS || '').split(',').map(s => s.trim()).filter(Boolean); +const AGENT_DELAY_MS = parseInt(process.env.PROXY_AGENT_DELAY_MS || '5000'); +const CRON_SCHEDULE = process.env.PROXY_CRON_SCHEDULE || '*/5 * * * *'; + +// Capacity logging is an expensive full-scan aggregation. Run it at most once per +// interval (default 24h) instead of every collection cycle to reduce CPU/DB load. +const CAPACITY_LOG_INTERVAL_MS = parseInt(process.env.PROXY_CAPACITY_LOG_INTERVAL_MS || String(24 * 60 * 60 * 1000)); + +let isRunning = false; +let lastRunAt = null; +let lastRunResult = null; +let runCount = 0; +let lastCapacityLogAt = 0; + +/** + * Execute one collection cycle (called by cron and manual trigger). + * Prevents concurrent runs with isRunning guard. + */ +async function runCollection() { + if (isRunning) { + console.log('[Scheduler] Skipping - previous run still in progress'); + return { skipped: true, reason: 'already_running' }; + } + + isRunning = true; + lastRunAt = new Date(); + runCount++; + + try { + let result; + if (COLLECT_MODE === 'agent' && SPECIFIC_AGENT) { + console.log(`[Scheduler] Run #${runCount} - Mode: SPECIFIC AGENT (${SPECIFIC_AGENT})`); + result = await collectSpecificAgent(SPECIFIC_AGENT); + } else if (COLLECT_MODE === 'agents' && SPECIFIC_AGENTS.length > 0) { + console.log(`[Scheduler] Run #${runCount} - Mode: SPECIFIC AGENTS (${SPECIFIC_AGENTS.join(', ')})`); + result = await collectSpecificAgents(SPECIFIC_AGENTS, AGENT_DELAY_MS); + } else { + console.log(`[Scheduler] Run #${runCount} - Mode: ALL AGENTS`); + result = await collectAllAgents(); + } + lastRunResult = { ...result, run_count: runCount }; + + // Log MongoDB database capacity usage (expensive full-scan aggregation). + // Only run periodically (default: every 24h) to avoid high CPU/DB load each cycle. + const now = Date.now(); + if (now - lastCapacityLogAt >= CAPACITY_LOG_INTERVAL_MS) { + lastCapacityLogAt = now; + const { logCapacityStats } = require('./db/capacityTracker'); + await logCapacityStats(`[PROXY] [MongoDB] Capacity Used after Run #${runCount}:`); + } + + return lastRunResult; + } catch (err) { + console.error('[Scheduler] Unhandled error during collection:', err.message); + lastRunResult = { success: false, error: err.message, run_count: runCount }; + return lastRunResult; + } finally { + isRunning = false; + } +} + +/** + * Start the scheduler (cron job + immediate first run). + */ +function startScheduler() { + console.log(`[Scheduler] Starting proxy data collector`); + const modeLabel = COLLECT_MODE === 'agent' + ? `SPECIFIC AGENT (${SPECIFIC_AGENT})` + : COLLECT_MODE === 'agents' + ? `SPECIFIC AGENTS (${SPECIFIC_AGENTS.join(', ')})` + : 'ALL AGENTS'; + console.log(`[Scheduler] Mode : ${modeLabel}`); + console.log(`[Scheduler] Schedule : ${CRON_SCHEDULE} (every 5 minutes by default)`); + + // Validate cron expression + if (!cron.validate(CRON_SCHEDULE)) { + console.error(`[Scheduler] Invalid cron expression: "${CRON_SCHEDULE}". Using default.`); + } + + // Start recurring cron job + cron.schedule(CRON_SCHEDULE, () => { + runCollection().catch(err => console.error('[Scheduler] Cron error:', err.message)); + }); + + console.log('[Scheduler] Cron job registered. Starting initial collection...'); + + // Initial run immediately on startup (async, do not block server start) + setTimeout(async () => { + // 1. Sync dictionary first + try { + await syncApplicationDictionary(); + } catch (err) { + console.error('[Scheduler] Error syncing application dictionary:', err.message); + } + + // 2. Start normal telemetry collection + runCollection().catch(err => console.error('[Scheduler] Initial run error:', err.message)); + }, 2000); +} + +/** + * Get current scheduler status (for REST API endpoint). + */ +function getStatus() { + return { + is_running: isRunning, + run_count: runCount, + last_run_at: lastRunAt?.toISOString() ?? null, + collect_mode: COLLECT_MODE, + agent_uuid: SPECIFIC_AGENT, + agent_uuids: COLLECT_MODE === 'agents' ? SPECIFIC_AGENTS : [], + agent_delay_ms: AGENT_DELAY_MS, + cron_schedule: CRON_SCHEDULE, + last_result: lastRunResult, + }; +} + +module.exports = { startScheduler, runCollection, getStatus }; diff --git a/proxy/test_api.js b/proxy/test_api.js new file mode 100644 index 0000000..fc1db38 --- /dev/null +++ b/proxy/test_api.js @@ -0,0 +1,71 @@ +// test_api.js - Tests the actual metadata-detail API endpoint +// Run: node proxy/test_api.js +const http = require('http'); + +function request(path) { + return new Promise((resolve, reject) => { + const options = { + hostname: 'localhost', + port: 3001, + path, + method: 'GET', + }; + const req = http.request(options, res => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => { + try { resolve({ status: res.statusCode, data: JSON.parse(body) }); } + catch (e) { resolve({ status: res.statusCode, raw: body }); } + }); + }); + req.on('error', reject); + req.end(); + }); +} + +async function main() { + // Test 1: netbios_hostname for 10.6.10.44 + console.log('\n=== TEST 1: netbios_hostname=10.6.10.44 ==='); + try { + const r1 = await request('/api/dashboard/metadata-detail?type=netbios_hostname&value=10.6.10.44'); + console.log('Status:', r1.status); + if (r1.data) { + console.log('Count:', r1.data.count); + console.log('First 3 rows:', JSON.stringify(r1.data.data?.slice(0, 3), null, 2)); + } else { + console.log('Raw:', r1.raw?.slice(0, 500)); + } + } catch (e) { + console.log('ERROR (maybe backend is on different port):', e.message); + } + + // Test 2: Try port 3000 (Next.js API routes) + console.log('\n=== TEST 2: via Next.js port 3000 ==='); + try { + const r2 = await request('/api/dashboard/metadata-detail?type=netbios_hostname&value=10.6.10.44'); + const options2 = { hostname: 'localhost', port: 3000, path: '/api/dashboard/metadata-detail?type=netbios_hostname&value=10.6.10.44', method: 'GET' }; + const r3 = await new Promise((resolve, reject) => { + const req = http.request(options2, res => { + let body = ''; + res.on('data', chunk => body += chunk); + res.on('end', () => { + try { resolve({ status: res.statusCode, data: JSON.parse(body) }); } + catch (e) { resolve({ status: res.statusCode, raw: body?.slice(0, 500) }); } + }); + }); + req.on('error', reject); + req.end(); + }); + console.log('Port 3000 - Status:', r3.status); + if (r3.data) { + console.log('Count:', r3.data.count); + console.log('First 3 rows:', JSON.stringify(r3.data.data?.slice(0, 3), null, 2)); + } else { + console.log('Raw:', r3.raw); + } + } catch (e) { + console.log('Port 3000 ERROR:', e.message); + } +} + +main().catch(console.error); diff --git a/proxy/test_db.js b/proxy/test_db.js new file mode 100644 index 0000000..b2a4568 --- /dev/null +++ b/proxy/test_db.js @@ -0,0 +1 @@ +const mongoose = require('mongoose'); require('dotenv').config({ path: '../.env.local' }); const { LookupApp } = require('./models/Schemas'); async function test() { await mongoose.connect(process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi'); const sample = await LookupApp.findOne({ tag: /youtube/i }).lean(); console.log(JSON.stringify(sample, null, 2)); await mongoose.disconnect(); } test().catch(console.error); diff --git a/proxy/test_metadata_detail.js b/proxy/test_metadata_detail.js new file mode 100644 index 0000000..5a5462a --- /dev/null +++ b/proxy/test_metadata_detail.js @@ -0,0 +1,89 @@ +// test_metadata_detail.js - Test after restart +// Run: node proxy/test_metadata_detail.js +const http = require('http'); + +function post(path, body) { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const options = { + hostname: 'localhost', port: 3001, path, method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }, + }; + const req = http.request(options, res => { + let buf = ''; + res.on('data', c => buf += c); + res.on('end', () => { + try { resolve({ status: res.statusCode, headers: res.headers, data: JSON.parse(buf) }); } + catch { resolve({ status: res.statusCode, raw: buf }); } + }); + }); + req.on('error', reject); + req.write(data); + req.end(); + }); +} + +function get(path, cookie) { + return new Promise((resolve, reject) => { + const options = { + hostname: 'localhost', port: 3001, path, method: 'GET', + headers: cookie ? { Cookie: cookie } : {}, + }; + const req = http.request(options, res => { + let buf = ''; + res.on('data', c => buf += c); + res.on('end', () => { + try { resolve({ status: res.statusCode, data: JSON.parse(buf) }); } + catch { resolve({ status: res.statusCode, raw: buf?.slice(0, 300) }); } + }); + }); + req.on('error', reject); + req.end(); + }); +} + +async function main() { + // Step 1: Login to get cookie + console.log('=== Step 1: Login ==='); + const login = await post('/api/auth/login', { username: 'admin', password: 'admin123' }); + console.log('Login status:', login.status); + + const setCookie = login.headers?.['set-cookie']; + let cookie = ''; + if (setCookie) { + cookie = setCookie.map(c => c.split(';')[0]).join('; '); + console.log('Cookie obtained:', cookie.slice(0, 60) + '...'); + } else { + console.log('No cookie received. Auth response:', JSON.stringify(login.data)); + // Try with a known admin credential + } + + // Step 2: Test health + console.log('\n=== Step 2: Health Check ==='); + const health = await get('/api/health', cookie); + console.log('Health:', health.status, JSON.stringify(health.data)); + + // Step 3: Test metadata-detail netbios_hostname + console.log('\n=== Step 3: metadata-detail netbios_hostname=10.6.10.44 ==='); + const r = await get('/api/dashboard/metadata-detail?type=netbios_hostname&value=10.6.10.44', cookie); + console.log('Status:', r.status); + if (r.data) { + console.log('Count (should be 1):', r.data.count); + console.log('Data:', JSON.stringify(r.data.data, null, 2)); + } else { + console.log('Raw:', r.raw); + } + + // Step 4: Verify DB has 1 doc for 10.6.10.44 + console.log('\n=== Step 4: Direct DB verification ==='); + const mongoose = require('mongoose'); + const path = require('path'); + require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') }); + await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/backone'); + const db = mongoose.connection.db; + const cnt = await db.collection('devicestats').countDocuments({ ip_address: '10.6.10.44' }); + console.log('DB count for 10.6.10.44:', cnt, '(expected: 1)'); + await mongoose.disconnect(); +} + +main().catch(console.error); diff --git a/proxy/test_sync_proxy.js b/proxy/test_sync_proxy.js new file mode 100644 index 0000000..cb76fca --- /dev/null +++ b/proxy/test_sync_proxy.js @@ -0,0 +1 @@ +const mongoose = require('mongoose'); require('dotenv').config({ path: '../.env.local' }); const { syncApplicationDictionary } = require('./netifyClient'); const { LookupApp } = require('./models/Schemas'); async function test() { await mongoose.connect(process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi'); console.log('Connected to DB'); await syncApplicationDictionary(); const count = await LookupApp.countDocuments(); console.log('Total LookupApps in DB:', count); await mongoose.disconnect(); } test().catch(console.error); diff --git a/proxy/verify_fix.js b/proxy/verify_fix.js new file mode 100644 index 0000000..ff63d00 --- /dev/null +++ b/proxy/verify_fix.js @@ -0,0 +1,56 @@ +// verify_fix.js - Directly verify the MongoDB aggregation returns correct results +// This simulates what the backend metadata-detail endpoint does after the fix. +// Run: node proxy/verify_fix.js +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') }); +const mongoose = require('mongoose'); + +async function run() { + await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/backone'); + const db = mongoose.connection.db; + const col = db.collection('devicestats'); + + const testValues = ['10.6.10.44']; + // Also find other common device_labels to test + const sample = await col.find({}).limit(20).toArray(); + const labels = [...new Set(sample.map(d => d.device_label).filter(Boolean))]; + console.log('Sample device_labels to test:', labels.slice(0, 5)); + + for (const value of [...testValues, ...labels.slice(0, 3)]) { + // Count raw docs matching + const rawCount = await col.countDocuments({ device_label: value }); + + // Simulate the new aggregation pipeline + const aggResult = await col.aggregate([ + { $match: { device_label: value } }, + { $sort: { timestamp: -1 } }, + { $group: { + _id: '$ip_address', + mac_address: { $first: '$mac_address' }, + device_label: { $first: '$device_label' }, + download: { $max: '$download' }, + upload: { $max: '$upload' }, + }}, + { $sort: { download: -1 } }, + ]).toArray(); + + const status = rawCount > aggResult.length ? '✅ FIXED (was duplicated)' : '✓ OK'; + console.log(`\ndevice_label="${value}": raw=${rawCount} rows → aggregated=${aggResult.length} unique devices ${status}`); + if (aggResult.length > 0) { + console.log(' First result:', JSON.stringify(aggResult[0], null, 2)); + } + } + + // Verify unique index exists + const indexes = await col.indexes(); + const uniqueIdx = indexes.find(i => i.unique && i.key.agent_uuid && i.key.ip_address); + console.log('\n=== Unique Index on (agent_uuid, ip_address):', uniqueIdx ? `✅ EXISTS (${uniqueIdx.name})` : '❌ MISSING'); + + // Final count + const total = await col.countDocuments(); + console.log('=== Total DeviceStat docs:', total); + + await mongoose.disconnect(); +} + +run().catch(err => { console.error(err.message); process.exit(1); }); diff --git a/public/api/uploads/profile-1782896485110-507996343.png b/public/api/uploads/profile-1782896485110-507996343.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1782896485110-507996343.png differ diff --git a/public/api/uploads/profile-1782957214095-436948837.png b/public/api/uploads/profile-1782957214095-436948837.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1782957214095-436948837.png differ diff --git a/public/api/uploads/profile-1782957227105-831349627.png b/public/api/uploads/profile-1782957227105-831349627.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1782957227105-831349627.png differ diff --git a/public/api/uploads/profile-1782957258680-874601083.png b/public/api/uploads/profile-1782957258680-874601083.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1782957258680-874601083.png differ diff --git a/public/api/uploads/profile-1782957260063-809722867.png b/public/api/uploads/profile-1782957260063-809722867.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1782957260063-809722867.png differ diff --git a/public/api/uploads/profile-1782957396116-629514876.png b/public/api/uploads/profile-1782957396116-629514876.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1782957396116-629514876.png differ diff --git a/public/api/uploads/profile-1782957397337-68390707.png b/public/api/uploads/profile-1782957397337-68390707.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1782957397337-68390707.png differ diff --git a/public/api/uploads/profile-1782957554195-684730747.png b/public/api/uploads/profile-1782957554195-684730747.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1782957554195-684730747.png differ diff --git a/public/api/uploads/profile-1782957618919-530652401.png b/public/api/uploads/profile-1782957618919-530652401.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1782957618919-530652401.png differ diff --git a/public/api/uploads/profile-1782957654468-554246050.png b/public/api/uploads/profile-1782957654468-554246050.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1782957654468-554246050.png differ diff --git a/public/api/uploads/profile-1782957734629-263039729.png b/public/api/uploads/profile-1782957734629-263039729.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1782957734629-263039729.png differ diff --git a/public/api/uploads/profile-1782957793294-535196821.png b/public/api/uploads/profile-1782957793294-535196821.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1782957793294-535196821.png differ diff --git a/public/api/uploads/profile-1782978598897-28546312.png b/public/api/uploads/profile-1782978598897-28546312.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1782978598897-28546312.png differ diff --git a/public/api/uploads/profile-1783329601048-985926830.png b/public/api/uploads/profile-1783329601048-985926830.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1783329601048-985926830.png differ diff --git a/public/api/uploads/profile-1783856980162-234747538.png b/public/api/uploads/profile-1783856980162-234747538.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1783856980162-234747538.png differ diff --git a/public/api/uploads/profile-1783925701251-53039060.png b/public/api/uploads/profile-1783925701251-53039060.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1783925701251-53039060.png differ diff --git a/public/api/uploads/profile-1783925846234-644654635.png b/public/api/uploads/profile-1783925846234-644654635.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1783925846234-644654635.png differ diff --git a/public/api/uploads/profile-1783926643277-796221976.png b/public/api/uploads/profile-1783926643277-796221976.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1783926643277-796221976.png differ diff --git a/public/api/uploads/profile-1784049206350-427299921.png b/public/api/uploads/profile-1784049206350-427299921.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1784049206350-427299921.png differ diff --git a/public/api/uploads/profile-1784049223805-190927998.png b/public/api/uploads/profile-1784049223805-190927998.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1784049223805-190927998.png differ diff --git a/public/api/uploads/profile-1784049244401-665031741.png b/public/api/uploads/profile-1784049244401-665031741.png new file mode 100644 index 0000000..d47fab5 Binary files /dev/null and b/public/api/uploads/profile-1784049244401-665031741.png differ diff --git a/public/api/uploads/profile-1784049275064-498936278.png b/public/api/uploads/profile-1784049275064-498936278.png new file mode 100644 index 0000000..d47fab5 Binary files /dev/null and b/public/api/uploads/profile-1784049275064-498936278.png differ diff --git a/public/api/uploads/profile-1784049302001-714198254.png b/public/api/uploads/profile-1784049302001-714198254.png new file mode 100644 index 0000000..d47fab5 Binary files /dev/null and b/public/api/uploads/profile-1784049302001-714198254.png differ diff --git a/public/api/uploads/profile-1784254528937-270868858.png b/public/api/uploads/profile-1784254528937-270868858.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1784254528937-270868858.png differ diff --git a/public/api/uploads/profile-1784254553441-339825741.png b/public/api/uploads/profile-1784254553441-339825741.png new file mode 100644 index 0000000..d47fab5 Binary files /dev/null and b/public/api/uploads/profile-1784254553441-339825741.png differ diff --git a/public/api/uploads/profile-1784254567483-97901195.png b/public/api/uploads/profile-1784254567483-97901195.png new file mode 100644 index 0000000..d47fab5 Binary files /dev/null and b/public/api/uploads/profile-1784254567483-97901195.png differ diff --git a/public/api/uploads/profile-1784254581808-854860134.png b/public/api/uploads/profile-1784254581808-854860134.png new file mode 100644 index 0000000..d47fab5 Binary files /dev/null and b/public/api/uploads/profile-1784254581808-854860134.png differ diff --git a/public/api/uploads/profile-1784254601947-954448750.png b/public/api/uploads/profile-1784254601947-954448750.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1784254601947-954448750.png differ diff --git a/public/api/uploads/profile-1784254618510-383483774.png b/public/api/uploads/profile-1784254618510-383483774.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1784254618510-383483774.png differ diff --git a/public/api/uploads/profile-1784254634906-830642874.png b/public/api/uploads/profile-1784254634906-830642874.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1784254634906-830642874.png differ diff --git a/public/api/uploads/profile-1784254648483-456467829.png b/public/api/uploads/profile-1784254648483-456467829.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/api/uploads/profile-1784254648483-456467829.png differ diff --git a/public/backone-logo.svg b/public/backone-logo.svg index 07f2647..272c863 100644 --- a/public/backone-logo.svg +++ b/public/backone-logo.svg @@ -1,21 +1,21 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/public/find_server.txt b/public/find_server.txt new file mode 100644 index 0000000..36fdcd3 --- /dev/null +++ b/public/find_server.txt @@ -0,0 +1,3 @@ +/home/adminbackend/web/demoplace.my.id/public_html/backend/server.js +/home/adminbackend/web/demoplace.my.id/public_html/.next/standalone/server.js +/home/adminbackend/web/demoplace.my.id/public_html/server.js diff --git a/public/fonts/Inter-Variable.woff2 b/public/fonts/Inter-Variable.woff2 new file mode 100644 index 0000000..33002f1 Binary files /dev/null and b/public/fonts/Inter-Variable.woff2 differ diff --git a/qa-api-final.js b/qa-api-final.js new file mode 100644 index 0000000..5091f01 --- /dev/null +++ b/qa-api-final.js @@ -0,0 +1,90 @@ +// qa-api-final.js — Test semua API dengan correct paths +const https = require('https'); +const BASE = 'https://dev.demoplace.my.id'; +let COOKIES = ''; + +function req(method, path, body) { + return new Promise((resolve) => { + const urlObj = new URL(`${BASE}${path}`); + const opts = { + hostname: urlObj.hostname, port: 443, + path: urlObj.pathname + urlObj.search, method, + headers: { + 'Content-Type': 'application/json', 'Accept': 'application/json', + ...(COOKIES ? { 'Cookie': COOKIES } : {}), + ...(body ? { 'Content-Length': Buffer.byteLength(JSON.stringify(body)) } : {}), + }, + rejectUnauthorized: false, timeout: 20000, + }; + const r = https.request(opts, (res) => { + let data = ''; + res.on('data', d => data += d); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data })); + }); + r.on('error', e => resolve({ status: 0, body: '', error: e.message })); + r.on('timeout', () => { r.destroy(); resolve({ status: 0, body: '', error: 'timeout' }); }); + if (body) r.write(JSON.stringify(body)); + r.end(); + }); +} + +const ic = s => s===200?'✅':s===307||s===302?'🔀':s===401||s===403?'🔒':s===404?'❌':s===500?'💥':'⚠️'; + +function parsePreview(r) { + if (!r.body || r.body.length === 0) return '(empty)'; + try { + const j = JSON.parse(r.body); + if (Array.isArray(j)) return `Array[${j.length}]`; + if (j.data && Array.isArray(j.data)) return `{data: Array[${j.data.length}], total: ${j.total||j.data.length}}`; + return JSON.stringify(j).substring(0, 100); + } catch(e) { return r.body.substring(0, 80).replace(/\s+/g,' '); } +} + +async function main() { + console.log('╔══════════════════════════════════════════════════════╗'); + console.log('║ API FINAL TEST — dev.demoplace.my.id ║'); + console.log('╚══════════════════════════════════════════════════════╝\n'); + + // Login + const lr = await req('POST', '/api/auth/login', { username: 'admin', password: 'admin' }); + COOKIES = (lr.headers?.['set-cookie'] || []).map(c => c.split(';')[0]).join('; '); + console.log(`🔐 Login: HTTP ${lr.status} | Cookies: ${COOKIES ? 'YES' : 'NO'}\n`); + + const endpoints = [ + // Auth + ['GET', '/api/health', 'Health check'], + ['GET', '/api/auth/me', 'Auth: me'], + ['GET', '/api/auth/users', 'Auth: users list'], + ['GET', '/api/auth/settings', 'Auth: settings'], + // Dashboard core + ['GET', '/api/dashboard/summary', 'Dashboard: summary'], + ['GET', '/api/dashboard/flows?page=1&limit=5', 'Dashboard: flows (p.1)'], + ['GET', '/api/dashboard/agents', 'Dashboard: agents'], + ['GET', '/api/dashboard/devices?limit=5', 'Dashboard: devices'], + ['GET', '/api/dashboard/telemetry?limit=5', 'Dashboard: telemetry'], + ['GET', '/api/dashboard/threats?limit=5', 'Dashboard: threats'], + ['GET', '/api/dashboard/events?limit=5', 'Dashboard: events'], + ['GET', '/api/dashboard/geo', 'Dashboard: geo'], + ['GET', '/api/dashboard/apps?limit=5', 'Dashboard: apps'], + ['GET', '/api/dashboard/device-labeling', 'Dashboard: device-labeling'], + ['GET', '/api/dashboard/flow-stats', 'Dashboard: flow-stats'], + ['GET', '/api/dashboard/tls?limit=5', 'Dashboard: TLS'], + ['GET', '/api/dashboard/agent-locations', 'Dashboard: agent-locations'], + ['GET', '/api/dashboard/blacklist', 'Dashboard: blacklist'], + // Details + ['GET', '/api/dashboard/metadata-detail', 'Metadata detail'], + ['GET', '/api/dashboard/category-detail', 'Category detail'], + ]; + + for (const [method, path, label] of endpoints) { + const r = await req(method, path); + const isJson = r.headers?.['content-type']?.includes('json'); + const preview = parsePreview(r); + console.log(` ${ic(r.status)} [${method}] ${label.padEnd(32)} HTTP ${r.status} | ${r.body?.length||0}b | ${preview.substring(0,80)}`); + } + + console.log('\n╔══════════════════════════════════════════════════════╗'); + console.log('║ API TEST SELESAI ║'); + console.log('╚══════════════════════════════════════════════════════╝'); +} +main().catch(console.error); diff --git a/qa-audit-v2.js b/qa-audit-v2.js new file mode 100644 index 0000000..4aa5aa2 --- /dev/null +++ b/qa-audit-v2.js @@ -0,0 +1,123 @@ +// qa-audit-v2.js — QA audit dengan correct route paths +const https = require('https'); + +const BASE = 'https://dev.demoplace.my.id'; +let COOKIES = ''; + +function req(method, path, body) { + return new Promise((resolve) => { + const urlObj = new URL(`${BASE}${path}`); + const options = { + hostname: urlObj.hostname, + port: 443, + path: urlObj.pathname + urlObj.search, + method, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/html,application/json,*/*', + ...(COOKIES ? { 'Cookie': COOKIES } : {}), + ...(body ? { 'Content-Length': Buffer.byteLength(JSON.stringify(body)) } : {}), + }, + rejectUnauthorized: false, + timeout: 15000, + }; + const r = https.request(options, (res) => { + let data = ''; + res.on('data', d => data += d); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data })); + }); + r.on('error', e => resolve({ status: 0, error: e.message, body: '' })); + r.on('timeout', () => { r.destroy(); resolve({ status: 0, error: 'timeout', body: '' }); }); + if (body) r.write(JSON.stringify(body)); + r.end(); + }); +} + +const ic = (s) => s===200?'✅':s===307||s===302||s===301?'🔀':s===401||s===403?'🔒':s===404?'❌':s===500?'💥':'⚠️'; + +async function main() { + console.log('╔══════════════════════════════════════════════════════════╗'); + console.log('║ QA AUDIT v2 — dev.demoplace.my.id (correct routes) ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + // Login + const lr = await req('POST', '/api/auth/login', { username: 'admin', password: 'admin' }); + if (lr.headers?.['set-cookie']) { + COOKIES = lr.headers['set-cookie'].map(c => c.split(';')[0]).join('; '); + console.log(`✅ Login OK | Cookie: ${COOKIES.substring(0,50)}...\n`); + } + + // Dashboard pages — correct paths + console.log('🏠 DASHBOARD PAGES (correct route paths):'); + const pages = [ + ['/', 'Root (after auth)'], + ['/flows', 'Flows table'], + ['/agents', 'Network Agents'], + ['/device-labeling', 'Device Labeling'], + ['/devices', 'Devices'], + ['/dpi-analytics', 'DPI Analytics'], + ['/flows', 'Flows'], + ['/geography', 'Geography'], + ['/intelligence', 'Intelligence'], + ['/network-infrastructure', 'Network Infrastructure'], + ['/network-intelligence', 'Network Intelligence'], + ['/security-audit', 'Security Audit'], + ['/threats', 'Threats'], + ['/events', 'Events'], + ['/dns', 'DNS'], + ['/lookup', 'Lookup'], + ['/apps', 'Applications'], + ['/user-accounts', 'User Accounts'], + ['/help', 'Help'], + ]; + for (const [path, label] of pages) { + const r = await req('GET', path); + const isHtml = r.body?.startsWith(' { + const isHttps = url.startsWith('https'); + const lib = isHttps ? https : http; + const urlObj = new URL(url); + const options = { + hostname: urlObj.hostname, + port: urlObj.port || (isHttps ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/html,application/json,*/*', + ...(cookies ? { 'Cookie': cookies } : {}), + ...(body ? { 'Content-Length': Buffer.byteLength(JSON.stringify(body)) } : {}), + }, + rejectUnauthorized: false, + timeout: 15000, + }; + const r = lib.request(options, (res) => { + let data = ''; + res.on('data', d => data += d); + res.on('end', () => resolve({ + status: res.statusCode, + headers: res.headers, + body: data, + size: data.length, + })); + }); + r.on('error', e => resolve({ status: 0, error: e.message, body: '', size: 0 })); + r.on('timeout', () => { r.destroy(); resolve({ status: 0, error: 'timeout', body: '', size: 0 }); }); + if (body) r.write(JSON.stringify(body)); + r.end(); + }); +} + +function icon(status) { + if (status === 200) return '✅'; + if (status === 307 || status === 301 || status === 302) return '🔀'; + if (status === 401 || status === 403) return '🔒'; + if (status === 404) return '❌'; + if (status === 500) return '💥'; + if (status === 0) return '⛔'; + return '⚠️'; +} + +async function main() { + console.log('╔══════════════════════════════════════════════════════╗'); + console.log('║ QA AUDIT — dev.demoplace.my.id ║'); + console.log('╚══════════════════════════════════════════════════════╝\n'); + + // --- STEP 1: Login & get session cookie --- + console.log('🔐 STEP 1: Login dengan admin/admin...'); + const loginRes = await req('POST', `${BASE}/api/auth/login`, { username: 'admin', password: 'admin' }); + console.log(` HTTP ${loginRes.status} | Size: ${loginRes.size}b`); + if (loginRes.status === 200) { + const setCookie = loginRes.headers['set-cookie']; + if (setCookie) { + COOKIES = setCookie.map(c => c.split(';')[0]).join('; '); + console.log(` ✅ Cookie diterima: ${COOKIES.substring(0, 60)}...`); + } + console.log(` User: ${loginRes.body.substring(0, 100)}`); + } else { + console.log(` ❌ Login gagal: ${loginRes.body.substring(0, 100)}`); + } + + // --- STEP 2: Unauthenticated pages --- + console.log('\n📄 STEP 2: Public pages (unauthenticated)...'); + const publicPages = [ + ['/', 'Root (redirect check)'], + ['/login', 'Login page'], + ]; + for (const [path, label] of publicPages) { + const r = await req('GET', `${BASE}${path}`); + const isHtml = r.body.includes(' { + const content = fs.readFileSync(file, 'utf8'); + const rel = path.relative(root, file); + + // Extract

or

or header titles + const h2s = []; + const h2Match = content.matchAll(/]*>(.*?)<\/h2>/gi); + for (const m of h2Match) { + const clean = m[1].replace(/<[^>]+>/g, '').trim(); + if (clean && !h2s.includes(clean)) h2s.push(clean); + } + + // Extract Learn This Page triggers + const hasHelpTrigger = content.includes('Learn This Page'); + + if (h2s.length > 0 || hasHelpTrigger) { + console.log('Page Route File:', rel); + if (h2s.length > 0) console.log(' Headers (H2):', h2s.join(' | ')); + console.log(' Has Learn This Page Button:', hasHelpTrigger); + console.log('---'); + } +}); +`; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec(`node -e ${JSON.stringify(remoteScript)}`, (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log(out); + conn.end(); + }); + }); +}).connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' +}); diff --git a/scripts/check-agents-error.js b/scripts/check-agents-error.js new file mode 100644 index 0000000..9185b71 --- /dev/null +++ b/scripts/check-agents-error.js @@ -0,0 +1,67 @@ +// scripts/check-agents-error.js +'use strict'; +const { Client } = require('ssh2'); +const https = require('https'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; +const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; + +function exec(conn, cmd, label) { + if (label) console.log('\n[' + label + ']'); + console.log('$ ' + cmd.substring(0, 80)); + return new Promise(resolve => { + conn.exec(cmd, (err, s) => { + if (err) { resolve(''); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .on('close', () => resolve(out)); + }); + }); +} + +async function httpGet(url, opts) { + return new Promise(resolve => { + const req = https.get(url, { timeout: 10000, rejectUnauthorized: false, ...opts }, res => { + let body = ''; + res.on('data', d => body += d); + res.on('end', () => resolve({ status: res.statusCode, body: body.substring(0, 500) })); + }); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); +} + +async function main() { + const conn = new Client(); + await new Promise((r, j) => { conn.on('ready', r).on('error', j).connect(SSH); }); + console.log('[SSH] Connected\n'); + + // 1. Cek error log frontend + await exec(conn, 'tail -50 ' + PUB + '/logs/frontend-error.log 2>/dev/null | head -50', '1. Frontend error log'); + + // 2. Cek frontend out log terbaru + await exec(conn, 'tail -30 ' + PUB + '/logs/frontend-out.log 2>/dev/null', '2. Frontend out log'); + + // 3. Test API agents langsung dari server + await exec(conn, 'curl -sk http://127.0.0.1:3001/api/agents 2>/dev/null | head -c 500', '3. Test API /api/agents langsung'); + + // 4. Test API health backend + await exec(conn, 'curl -sk http://127.0.0.1:3001/api/health 2>/dev/null', '4. Test backend health'); + + // 5. Cek .env.production - apakah BACKEND_URL benar + await exec(conn, 'cat ' + PUB + '/.env.production 2>/dev/null | grep -v PASSWORD | grep -v SECRET | grep -v MONGO | head -20', '5. Environment variables (safe)'); + + // 6. Cek apakah backend bisa akses agents collection dari MongoDB + await exec(conn, 'curl -sk http://127.0.0.1:3001/api/agents/list 2>/dev/null | head -c 300', '6. Test /api/agents/list'); + + conn.end(); + + // 7. Test dari luar via domain + console.log('\n[7. Test API agents via domain...]'); + const r = await httpGet('https://demoplace.my.id/api/agents'); + console.log(' Status: ' + r.status); + console.log(' Body: ' + (r.body || r.error || 'empty')); +} + +main().catch(e => console.error('[FATAL]', e.message)); diff --git a/scripts/check-static-files.js b/scripts/check-static-files.js new file mode 100644 index 0000000..c6c56ab --- /dev/null +++ b/scripts/check-static-files.js @@ -0,0 +1,94 @@ +// scripts/check-static-files.js +// Cek apakah static chunk files ada di disk dan bisa diakses via domain +'use strict'; + +const { Client } = require('ssh2'); +const https = require('https'); + +const SSH = { + host: '103.185.47.52', port: 2222, + username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +function sshExec(conn, cmd) { + return new Promise(resolve => { + conn.exec(cmd, (err, s) => { + if (err) { resolve('SSH_ERROR'); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .on('close', () => resolve(out)); + }); + }); +} + +function httpGet(url) { + return new Promise(resolve => { + const req = https.get(url, { timeout: 10000, rejectUnauthorized: false }, res => { + let body = ''; + res.on('data', d => body += d); + res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], size: body.length })); + }); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); +} + +async function main() { + const conn = new Client(); + await new Promise((r, j) => { conn.on('ready', r).on('error', j).connect(SSH); }); + console.log('[SSH] Connected\n'); + + const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; + + // 1. List chunks directory + console.log('=== .next/static/chunks/ contents ==='); + await sshExec(conn, `ls -la ${PUB}/.next/static/chunks/ | head -20`); + + // 2. Cek CSS files yang direferensikan oleh HTML + console.log('\n=== Cek file CSS spesifik ==='); + await sshExec(conn, `ls -la ${PUB}/.next/static/chunks/*.css 2>/dev/null | head -5 || echo "Tidak ada .css di chunks/"`); + + // 3. Cek apakah JS bootstrap file ada + console.log('\n=== Cek file JS yang diperlukan ==='); + await sshExec(conn, `ls ${PUB}/.next/static/chunks/ | grep -E "(3km6z|21luguo|1-8s9)" | head -10 || echo "File JS tidak ditemukan"`); + + // 4. Test akses static file via domain (dari dalam server) + console.log('\n=== Test static file via Apache ==='); + await sshExec(conn, + `curl -sk -D - https://demoplace.my.id/_next/static/chunks/3km6z-n6wbgrs.js 2>/dev/null | head -15 || ` + + `curl -sk -D - http://103.185.47.52:8080/_next/static/chunks/3km6z-n6wbgrs.js 2>/dev/null | head -15` + ); + + // 5. Test apakah Nginx melayani file static langsung + console.log('\n=== Cek Nginx serving static (port 443) ==='); + await sshExec(conn, + `curl -sk -w "\\nHTTP: %{http_code} | Type: %{content_type} | Size: %{size_download}" ` + + `-o /dev/null https://demoplace.my.id/_next/static/chunks/3km6z-n6wbgrs.js` + ); + + // 6. Cek apakah ada .next/server/ directory + console.log('\n=== Status .next/server/ ==='); + await sshExec(conn, `ls ${PUB}/.next/server/ 2>/dev/null | head -5 || echo ".next/server/ TIDAK ADA - standalone build mungkin tidak lengkap"`); + + conn.end(); + + // 7. Test dari luar - JS dan CSS + console.log('\n=== Test static resources dari luar ==='); + const files = [ + '/_next/static/chunks/3km6z-n6wbgrs.js', + '/_next/static/chunks/1u6dw_tm45utz.css', + '/_next/static/chunks/1oskchnhjm4n8.css', + '/_next/static/chunks/21luguo5_g2uu.js', + ]; + + for (const f of files) { + const r = await httpGet(`https://demoplace.my.id${f}`); + const icon = r.status === 200 ? '✅' : '❌'; + console.log(` ${icon} ${f}`); + console.log(` HTTP ${r.status} | Type: ${r.type || 'N/A'} | Size: ${r.size || 0} bytes`); + } +} + +main().catch(err => console.error('[FATAL]', err.message)); diff --git a/scripts/compare-pages.js b/scripts/compare-pages.js new file mode 100644 index 0000000..3d6f1d7 --- /dev/null +++ b/scripts/compare-pages.js @@ -0,0 +1,61 @@ +const https = require('https'); + +const PAGES = [ + '/', + '/network-infrastructure', + '/agents', + '/devices', + '/flows', + '/apps', + '/network-intelligence', + '/dns', + '/geography', + '/dpi-analytics', + '/lookup', + '/intelligence', + '/threats', + '/events', + '/security-audit', + '/help' +]; + +function fetchPage(route) { + return new Promise((resolve) => { + https.get(`https://demoplace.my.id${route}`, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + const titleMatch = data.match(/(.*?)<\/title>/i); + const descMatch = data.match(/<meta name="description" content="(.*?)"/i); + const title = titleMatch ? titleMatch[1] : 'N/A'; + const desc = descMatch ? descMatch[1] : 'N/A'; + + // Find logo references + const logos = []; + const logoRegex = /src="([^"]*(?:logo|icon)[^"]*)"/gi; + let match; + while ((match = logoRegex.exec(data)) !== null) { + if (!logos.includes(match[1])) logos.push(match[1]); + } + + resolve({ route, statusCode: res.statusCode, title, desc, logos }); + }); + }).on('error', (err) => { + resolve({ route, error: err.message }); + }); + }); +} + +async function main() { + console.log('=== AUDITING DEMOPLACE.MY.ID PAGES ===\n'); + for (const page of PAGES) { + const res = await fetchPage(page); + console.log(`Route: ${res.route}`); + console.log(` Title : ${res.title}`); + console.log(` Description : ${res.desc}`); + console.log(` Logos/Icons : ${res.logos ? res.logos.join(', ') : 'None'}`); + console.log('---'); + } +} + +main(); diff --git a/scripts/copy-from-backslash.js b/scripts/copy-from-backslash.js new file mode 100644 index 0000000..0f24e18 --- /dev/null +++ b/scripts/copy-from-backslash.js @@ -0,0 +1,97 @@ +// scripts/copy-from-backslash.js +// Copy file dari direktori backslash ke path yang benar +'use strict'; +const { Client } = require('ssh2'); +const https = require('https'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; +const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; +const SA = PUB + '/.next/standalone/.next/static'; +const BSDir = PUB + '/.next\\static\\chunks'; // Backslash literal path + +function exec(conn, cmd, label) { + if (label) console.log('\n[' + label + ']'); + console.log('$ ' + cmd.substring(0, 90)); + return new Promise(resolve => { + conn.exec(cmd, (err, s) => { + if (err) { resolve(''); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .on('close', () => resolve(out)); + }); + }); +} + +async function httpGet(url) { + return new Promise(resolve => { + const req = https.get(url, { timeout: 12000, rejectUnauthorized: false }, res => { + let body = ''; + res.on('data', d => body += d); + res.on('end', () => resolve({ status: res.statusCode, size: body.length })); + }); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); +} + +async function main() { + const conn = new Client(); + await new Promise((r, j) => { conn.on('ready', r).on('error', j).connect(SSH); }); + console.log('[SSH] Connected\n'); + + // 1. List isi direktori backslash + await exec(conn, "ls '" + BSDir + "' | grep css | head -10", '1. CSS di direktori backslash'); + + // 2. Copy semua file dari direktori backslash ke standalone/chunks + await exec(conn, + "cp '" + BSDir + "/'* '" + SA + "/chunks/' 2>&1 && echo 'COPY OK' || echo 'COPY PARTIAL'", + '2. Copy semua file dari backslash dir ke standalone/chunks' + ); + + // 3. Verifikasi CSS yang hilang sudah ada + await exec(conn, + "ls '" + SA + "/chunks/' | grep '1oskchnhjm4n8'", + '3. Verifikasi 1oskchnhjm4n8.css ada' + ); + + // 4. Juga cek nested static dir (ada static/static/) + await exec(conn, + "ls '" + SA + "/static/' 2>/dev/null | head -10 || echo 'Tidak ada nested static'", + '4. Cek nested static dir' + ); + + // 5. Cek juga backslash css dir (beda dengan chunks) + await exec(conn, + "ls '" + PUB + "/.next\\static\\css' 2>/dev/null | head -10 || echo 'Tidak ada backslash css dir'", + '5. Cek backslash css dir' + ); + + // 6. Copy CSS dari backslash css dir juga jika ada + await exec(conn, + "[ -d '" + PUB + "/.next\\static\\css' ] && cp '" + PUB + "/.next\\static\\css/'* '" + SA + "/css/' 2>&1 && echo 'CSS copy OK' || echo 'Tidak ada backslash css dir'", + '6. Copy dari backslash css dir' + ); + + conn.end(); + + // 7. Final test + console.log('\n[7. Final test semua static files...]'); + await new Promise(r => setTimeout(r, 2000)); + const files = [ + '/_next/static/chunks/1oskchnhjm4n8.css', + '/_next/static/chunks/1u6dw_tm45utz.css', + '/_next/static/chunks/3km6z-n6wbgrs.js', + '/_next/static/chunks/21luguo5_g2uu.js', + '/_next/static/chunks/1-8s9_t85wwr4.js', + '/login', + ]; + for (const f of files) { + const r = await httpGet('https://demoplace.my.id' + f); + const icon = r.status === 200 ? '✅' : '❌'; + console.log(' ' + icon + ' HTTP ' + r.status + ' | ' + (r.size || 0) + ' bytes | ' + f); + } + console.log('\n✅ Selesai! Hard refresh browser (Ctrl+Shift+R)\n'); +} + +main().catch(e => console.error('[FATAL]', e.message)); diff --git a/scripts/deep-diag.js b/scripts/deep-diag.js new file mode 100644 index 0000000..0206d29 --- /dev/null +++ b/scripts/deep-diag.js @@ -0,0 +1,48 @@ +// scripts/deep-diag.js +'use strict'; +const { Client } = require('ssh2'); +const conn = new Client(); +const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; + +function exec(conn, cmd) { + console.log('\n$ ' + cmd.substring(0, 80)); + return new Promise(resolve => { + conn.exec(cmd, (err, s) => { + if (err) { resolve(''); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .on('close', () => resolve(out)); + }); + }); +} + +conn.on('ready', async () => { + // 1. Cek proses yang sedang berjalan + await exec(conn, 'ps -p 3193322 -o cmd 2>/dev/null | tail -1'); + + // 2. Cek BUILD_ID di standalone + const saPath = PUB + '/.next/standalone'; + await exec(conn, 'cat ' + saPath + '/.next/BUILD_ID 2>/dev/null || echo NO_BUILD_ID_IN_STANDALONE'); + + // 3. List chunks di standalone + await exec(conn, 'ls ' + saPath + '/.next/static/chunks/ | head -15'); + + // 4. List static root di standalone + await exec(conn, 'ls ' + saPath + '/.next/static/'); + + // 5. Cek apakah ada server.js lain yang mungkin sedang berjalan + await exec(conn, 'ps aux | grep "node.*server" | grep -v grep | head -5'); + + // 6. PM2 info untuk backone-frontend + await exec(conn, '/home/adminbackend/.npm-global/bin/pm2 show backone-frontend 2>/dev/null | grep -E "(script|cwd|pid|restart)"'); + + // 7. Cek log terbaru + await exec(conn, 'tail -20 ' + PUB + '/logs/frontend-out.log 2>/dev/null | head -20'); + + conn.end(); +}).connect({ + host: '103.185.47.52', port: 2222, + username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', + readyTimeout: 30000 +}); diff --git a/scripts/deep-remote-audit.js b/scripts/deep-remote-audit.js new file mode 100644 index 0000000..96bb2c5 --- /dev/null +++ b/scripts/deep-remote-audit.js @@ -0,0 +1,57 @@ +const { Client } = require('ssh2'); +const fs = require('fs'); + +const remoteScript = ` +const fs = require('fs'); +const path = require('path'); + +const root = '/home/adminbackend/web/demoplace.my.id/public_html'; + +console.log('=== 1. LOGOS & IMAGES IN PUBLIC ==='); +try { + const publicFiles = fs.readdirSync(path.join(root, 'public')); + console.log('Files in public/:', publicFiles); +} catch(e) { console.log('Err:', e.message); } + +console.log('\\n=== 2. AUDITING NAVIGATION GROUPS IN STANDALONE CHUNKS ==='); +const chunksDir = path.join(root, '.next/standalone/.next/server/chunks'); +if (fs.existsSync(chunksDir)) { + const files = fs.readdirSync(chunksDir); + files.forEach(f => { + if (!f.endsWith('.js')) return; + const content = fs.readFileSync(path.join(chunksDir, f), 'utf8'); + if (content.includes('Overview Dashboard') || content.includes('Network Topology')) { + console.log('Chunk File:', f); + const match = content.match(/title:\"OVERVIEW\".*?title:\"SECURITY & ALERTS\".*?\]/); + if (match) { + console.log('NAV MATCH:', match[0]); + } + } + }); +} + +console.log('\\n=== 3. APP ROUTE DIRECTORIES ==='); +try { + const appDir = path.join(root, '.next/standalone/.next/server/app/(dashboard)'); + if (fs.existsSync(appDir)) { + console.log('Dashboard routes in .next/standalone:', fs.readdirSync(appDir)); + } +} catch(e) { console.log('Err app dir:', e.message); } +`; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec(`node -e ${JSON.stringify(remoteScript)}`, (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log(out); + conn.end(); + }); + }); +}).connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' +}); diff --git a/scripts/deploy-php-proxy.js b/scripts/deploy-php-proxy.js new file mode 100644 index 0000000..ccc97e3 --- /dev/null +++ b/scripts/deploy-php-proxy.js @@ -0,0 +1,206 @@ +// scripts/deploy-php-proxy.js +// ───────────────────────────────────────────────────────────────────────────── +// BackOne DPI — Deploy PHP proxy ke public_html +// Solusi 502: Apache PHP-FPM forward semua request ke Next.js port 3000 +// Tidak butuh mod_proxy_http, root access, atau Hestia API +// ───────────────────────────────────────────────────────────────────────────── +'use strict'; + +const { Client } = require('ssh2'); +const SftpClient = require('ssh2-sftp-client'); +const https = require('https'); +const path = require('path'); + +const SSH_CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; + +// ── PHP Proxy script ────────────────────────────────────────────────────── +const PHP_PROXY = `<?php +/** + * BackOne DPI — PHP Reverse Proxy + * Meneruskan semua request HTTP ke Next.js yang berjalan di port 3000 + * File ini dikelola otomatis. Jangan dihapus. + */ + +// Target: Next.js port 3000 +define('NEXTJS_URL', 'http://127.0.0.1:3000'); +define('TIMEOUT', 30); + +// Ambil URI request +$requestUri = $_SERVER['REQUEST_URI'] ?? '/'; +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$target = NEXTJS_URL . $requestUri; + +// Kumpulkan headers dari request asli +$forwardHeaders = [ + 'X-Forwarded-For: ' . ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'), + 'X-Forwarded-Proto: https', + 'X-Real-IP: ' . ($_SERVER['REMOTE_ADDR'] ?? ''), + 'Host: ' . ($_SERVER['HTTP_HOST'] ?? 'demoplace.my.id'), +]; + +// Tambahkan headers lain dari request +$allHeaders = getallheaders() ?: []; +$skipHeaders = ['host', 'connection', 'transfer-encoding', 'keep-alive', 'content-length']; +foreach ($allHeaders as $name => $value) { + if (!in_array(strtolower($name), $skipHeaders)) { + $forwardHeaders[] = "$name: $value"; + } +} + +// Body untuk POST/PUT/PATCH +$body = in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE']) ? file_get_contents('php://input') : null; + +// Kirim request ke Next.js +$ch = curl_init(); +curl_setopt_array($ch, [ + CURLOPT_URL => $target, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $forwardHeaders, + CURLOPT_POSTFIELDS => $body, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_TIMEOUT => TIMEOUT, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_ENCODING => '', +]); + +$response = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); +$curlError = curl_error($ch); +curl_close($ch); + +// Jika curl error +if ($response === false) { + http_response_code(502); + header('Content-Type: text/plain'); + echo 'BackOne: Gagal terhubung ke Next.js. Error: ' . $curlError; + exit; +} + +// Pisah headers dan body +$responseHeaders = substr($response, 0, $headerSize); +$responseBody = substr($response, $headerSize); + +// Set HTTP status +http_response_code($httpCode ?: 200); + +// Forward response headers ke client +$skipResponseHeaders = ['transfer-encoding', 'connection', 'keep-alive', 'content-encoding']; +foreach (explode("\\r\\n", $responseHeaders) as $header) { + $header = trim($header); + if (empty($header) || preg_match('/^HTTP\\//i', $header)) continue; + $colonPos = strpos($header, ':'); + if ($colonPos === false) continue; + $headerName = strtolower(trim(substr($header, 0, $colonPos))); + if (in_array($headerName, $skipResponseHeaders)) continue; + header($header, false); +} + +// Kirim body +echo $responseBody; +`; + +// ── .htaccess baru: route semua request ke proxy.php ───────────────────── +const HTACCESS = `# BackOne DPI — Apache .htaccess +# Redirect HTTP ke HTTPS +RewriteEngine On +RewriteCond %{HTTPS} !=on +RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + +# Route semua request ke PHP proxy (meneruskan ke Next.js port 3000) +RewriteCond %{REQUEST_FILENAME} !proxy.php +RewriteRule ^(.*)$ /proxy.php [L,QSA] +`; + +// ── Fungsi upload file via SFTP ─────────────────────────────────────────── +async function uploadFile(sftp, content, remotePath) { + const buf = Buffer.from(content, 'utf8'); + await sftp.put(buf, remotePath); + console.log(` ✅ Upload: ${remotePath}`); +} + +// ── SSH command ──────────────────────────────────────────────────────────── +function sshExec(conn, cmd) { + console.log(`\n$ ${cmd.substring(0, 100)}...`); + return new Promise((resolve, reject) => { + conn.exec(cmd, (err, stream) => { + if (err) return reject(err); + let out = ''; + stream.on('close', () => resolve(out)) + .on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }); + }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Deploy PHP Proxy (Fix 502) ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + // ── Upload via SFTP ────────────────────────────────────────────────────── + console.log('▶ Step 1: Upload proxy.php dan .htaccess via SFTP...\n'); + const sftp = new SftpClient(); + await sftp.connect(SSH_CONFIG); + + await uploadFile(sftp, PHP_PROXY, `${ROOT}/proxy.php`); + await uploadFile(sftp, HTACCESS, `${ROOT}/.htaccess`); + await sftp.end(); + + // ── Verify dan test via SSH ────────────────────────────────────────────── + console.log('\n▶ Step 2: Verifikasi file di server...'); + const conn = new Client(); + await new Promise((resolve, reject) => { conn.on('ready', resolve).on('error', reject).connect(SSH_CONFIG); }); + console.log('[SSH] Terhubung!\n'); + + await sshExec(conn, `ls -la ${ROOT}/proxy.php ${ROOT}/.htaccess`); + await sshExec(conn, `head -5 ${ROOT}/proxy.php`); + await sshExec(conn, `cat ${ROOT}/.htaccess`); + + // ── Test internal Apache response ───────────────────────────────────── + console.log('\n▶ Step 3: Test Apache internal (port 8080)...'); + await sshExec(conn, `curl -sk -o /dev/null -w "Apache HTTP: %{http_code}" http://127.0.0.1:8080/`); + await sshExec(conn, `curl -sk -o /dev/null -w "Apache Login: %{http_code}" http://127.0.0.1:8080/login`); + + // ── Test domain publik ───────────────────────────────────────────────── + console.log('\n▶ Step 4: Test domain demoplace.my.id...'); + await sshExec(conn, + `sleep 2 && curl -sk -o /dev/null -w "HTTPS /: %{http_code}" https://demoplace.my.id/ && ` + + `curl -sk -o /dev/null -w " | HTTPS /login: %{http_code}" https://demoplace.my.id/login` + ); + + conn.end(); + + // ── Final test dari luar ─────────────────────────────────────────────── + console.log('\n▶ Step 5: Final test dari jaringan lokal...'); + await new Promise(r => setTimeout(r, 3000)); + + for (const path of ['/', '/login']) { + const r = await new Promise(resolve => { + const req = https.get(`https://demoplace.my.id${path}`, + { timeout: 12000, rejectUnauthorized: false }, + res => resolve({ status: res.statusCode, location: res.headers.location }) + ); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); + const icon = r.status >= 200 && r.status < 400 ? '✅' : r.status === 0 ? '⚠️ ' : '❌'; + console.log(` ${icon} https://demoplace.my.id${path} → HTTP ${r.status} ${r.error || ''}`); + if (r.location) console.log(` → Redirect ke: ${r.location}`); + } + + console.log('\n✅ PHP Proxy berhasil di-deploy!\n'); + console.log('Cek browser: https://demoplace.my.id/login\n'); +} + +main().catch(err => { console.error('[FATAL]', err.message); process.exit(1); }); diff --git a/scripts/deploy-sftp.js b/scripts/deploy-sftp.js new file mode 100644 index 0000000..4fff8ec --- /dev/null +++ b/scripts/deploy-sftp.js @@ -0,0 +1,350 @@ +// scripts/deploy-sftp.js +// ───────────────────────────────────────────────────────────────────────────── +// BackOne DPI — Production SFTP Deployment Script (Windows Compatible) +// +// Usage: node scripts/deploy-sftp.js +// Requires: npm install ssh2-sftp-client ssh2 (already in devDependencies) +// +// This script: +// 1. Verifies the production build exists (.next/standalone) +// 2. Connects to server via SFTP (port 2222) +// 3. Uploads all required files to the remote server +// 4. Runs post-deploy commands via SSH (npm install, PM2 restart) +// ───────────────────────────────────────────────────────────────────────────── + +'use strict'; + +const SftpClient = require('ssh2-sftp-client'); +const { Client: SshClient } = require('ssh2'); +const path = require('path'); +const fs = require('fs'); + +// ─── Configuration ──────────────────────────────────────────────────────────── +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + remotePath: '/home/adminbackend/web/demoplace.my.id/public_html', +}; + +const LOCAL_ROOT = path.resolve(__dirname, '..'); + +// Files/dirs to upload (relative to LOCAL_ROOT) +// These are the minimum required for production deployment +const UPLOAD_MANIFEST = [ + // Next.js standalone server build output (entire .next folder with manifests and server files) + { local: '.next/standalone/.next', remote: '.next/standalone/.next', type: 'dir' }, + + // Next.js static assets and chunks (CRITICAL to prevent 404 chunk errors) + { local: '.next/static', remote: '.next/static', type: 'dir' }, + + // Public assets (images, icons) + { local: 'public', remote: 'public', type: 'dir' }, + + // Backend API server + { local: 'backend/server.js', remote: 'backend/server.js', type: 'file' }, + { local: 'backend/db', remote: 'backend/db', type: 'dir' }, + { local: 'backend/middleware', remote: 'backend/middleware', type: 'dir' }, + { local: 'backend/models', remote: 'backend/models', type: 'dir' }, + { local: 'backend/routes', remote: 'backend/routes', type: 'dir' }, + { local: 'backend/deviceResolver.js', remote: 'backend/deviceResolver.js', type: 'file' }, + { local: 'backend/generate_export.js', remote: 'backend/generate_export.js', type: 'file' }, + { local: 'backend/package.json', remote: 'backend/package.json', type: 'file' }, + { local: 'backend/package-lock.json', remote: 'backend/package-lock.json', type: 'file' }, + + // Proxy data collector + { local: 'proxy/index.js', remote: 'proxy/index.js', type: 'file' }, + { local: 'proxy/collector.js', remote: 'proxy/collector.js', type: 'file' }, + { local: 'proxy/collectorHelper.js', remote: 'proxy/collectorHelper.js', type: 'file' }, + { local: 'proxy/collectorHelperDpi.js', remote: 'proxy/collectorHelperDpi.js', type: 'file' }, + { local: 'proxy/collectorHelperDpi2.js', remote: 'proxy/collectorHelperDpi2.js', type: 'file' }, + { local: 'proxy/collectorHelperDpi3.js', remote: 'proxy/collectorHelperDpi3.js', type: 'file' }, + { local: 'proxy/dataRetention.js', remote: 'proxy/dataRetention.js', type: 'file' }, + { local: 'proxy/db', remote: 'proxy/db', type: 'dir' }, + { local: 'proxy/deviceResolver.js', remote: 'proxy/deviceResolver.js', type: 'file' }, + { local: 'proxy/models', remote: 'proxy/models', type: 'dir' }, + { local: 'proxy/netifyClient.js', remote: 'proxy/netifyClient.js', type: 'file' }, + { local: 'proxy/netifyClientCore.js', remote: 'proxy/netifyClientCore.js', type: 'file' }, + { local: 'proxy/netifyClientStats.js', remote: 'proxy/netifyClientStats.js', type: 'file' }, + { local: 'proxy/netifyTelemetry.js', remote: 'proxy/netifyTelemetry.js', type: 'file' }, + { local: 'proxy/routes.js', remote: 'proxy/routes.js', type: 'file' }, + { local: 'proxy/scheduler.js', remote: 'proxy/scheduler.js', type: 'file' }, + { local: 'proxy/package.json', remote: 'proxy/package.json', type: 'file' }, + { local: 'proxy/package-lock.json', remote: 'proxy/package-lock.json', type: 'file' }, + + // PM2 ecosystem config + { local: 'ecosystem.config.js', remote: 'ecosystem.config.js', type: 'file' }, + + // Database migration temporary files + { local: 'migration-temp', remote: 'migration-temp', type: 'dir' }, + { local: 'scripts/migrate-db-remote.js', remote: 'migration-temp/migrate-db-remote.js', type: 'file' }, + + // Production environment (CONFIDENTIAL — uploaded via SFTP, not git) + { local: '.env.production', remote: '.env.production', type: 'file' }, +]; + +// Post-deploy commands to run on server via SSH +const POST_DEPLOY_COMMANDS = [ + // Ensure required directories exist + 'mkdir -p /home/adminbackend/web/demoplace.my.id/public_html/logs', + 'mkdir -p /home/adminbackend/web/demoplace.my.id/public_html/api/uploads', + 'mkdir -p /home/adminbackend/web/demoplace.my.id/public_html/backend/uploads', + 'mkdir -p /home/adminbackend/web/demoplace.my.id/public_html/backend/public/api/uploads', + + // Set correct permissions on upload directories + 'chmod 755 /home/adminbackend/web/demoplace.my.id/public_html/api/uploads', + 'chmod 755 /home/adminbackend/web/demoplace.my.id/public_html/backend/uploads', + 'chmod 755 /home/adminbackend/web/demoplace.my.id/public_html/backend/public/api/uploads', + + // Install backend dependencies (production only) + 'cd /home/adminbackend/web/demoplace.my.id/public_html/backend && npm ci --omit=dev 2>&1 | tail -5', + + // Install proxy dependencies (production only) + 'cd /home/adminbackend/web/demoplace.my.id/public_html/proxy && npm ci --omit=dev 2>&1 | tail -5', + + // Execute database migration on remote server + 'echo "=== RUNNING REMOTE DB MIGRATION ==="', + 'node /home/adminbackend/web/demoplace.my.id/public_html/migration-temp/migrate-db-remote.js', + 'rm -rf /home/adminbackend/web/demoplace.my.id/public_html/migration-temp && echo "Cleaned up migration temporary files"', + + // Copy Next.js static assets to standalone directory (required for standalone mode) + // Fix standalone directory structure for Next.js + 'mkdir -p /home/adminbackend/web/demoplace.my.id/public_html/.next/standalone/.next/', + 'cp -r /home/adminbackend/web/demoplace.my.id/public_html/.next/static /home/adminbackend/web/demoplace.my.id/public_html/.next/standalone/.next/static && echo "Static assets copied to standalone"', + 'cp -r /home/adminbackend/web/demoplace.my.id/public_html/public /home/adminbackend/web/demoplace.my.id/public_html/.next/standalone/public 2>/dev/null || true', + + // SECURITY: Remove .env files from standalone (they should never be in the build output) + 'rm -f /home/adminbackend/web/demoplace.my.id/public_html/.next/standalone/.env.production && echo "Removed .env.production from standalone"', + 'rm -f /home/adminbackend/web/demoplace.my.id/public_html/.next/standalone/.env.local 2>/dev/null || true', + + // Check if PM2 is installed (use local install path) + 'export PM2=/home/adminbackend/.npm-global/bin/pm2 && $PM2 --version 2>&1 || (npm config set prefix /home/adminbackend/.npm-global && npm install -g pm2)', + + // Reload PM2 processes with new code (zero-downtime reload) + 'export PM2=/home/adminbackend/.npm-global/bin/pm2 && cd /home/adminbackend/web/demoplace.my.id/public_html && NODE_ENV=production $PM2 reload ecosystem.config.js 2>&1 || NODE_ENV=production $PM2 start ecosystem.config.js', + + // Save PM2 process list for auto-restart on server reboot + '/home/adminbackend/.npm-global/bin/pm2 save', + + // Show status + '/home/adminbackend/.npm-global/bin/pm2 list', + + // Test health endpoints + 'sleep 5 && curl -s http://127.0.0.1:3001/api/health || echo "Backend not ready yet"', +]; + +// ─── Helper: Upload a directory recursively ─────────────────────────────────── +async function uploadDirectory(sftp, localDir, remoteDir, options = {}) { + const localPath = path.join(LOCAL_ROOT, localDir); + if (!fs.existsSync(localPath)) { + console.log(` [SKIP] ${localDir} — does not exist locally`); + return; + } + + // Ensure remote directory exists + try { + await sftp.mkdir(path.posix.join(CONFIG.remotePath, remoteDir), true); + } catch { + // Directory may already exist + } + + const items = fs.readdirSync(localPath, { withFileTypes: true }); + for (const item of items) { + // Only exclude .next and node_modules at the TOP level of the project root. + // Inside .next/standalone/, the .next subdir contains build artifacts and MUST be uploaded. + // node_modules inside standalone are also needed (pruned deps) — skip them; server handles npm ci. + const isTopLevel = !localDir.includes('standalone'); + if (isTopLevel && ['node_modules', '.git', '.next'].includes(item.name)) continue; + if (!isTopLevel && ['node_modules', '.git'].includes(item.name)) continue; + // Skip log files + if (item.name.endsWith('.log')) continue; + // Skip local env file from standalone (security — should not be on server in build) + if (item.name === '.env.production' || item.name === '.env.local') continue; + + const localItemPath = path.join(localPath, item.name); + // Force forward slashes for remote Linux path compatibility + const remoteDirNormalized = remoteDir.replace(/\\/g, '/'); + const remoteItemPath = path.posix.join(CONFIG.remotePath, remoteDirNormalized, item.name); + + if (item.isDirectory()) { + try { + await sftp.mkdir(remoteItemPath, true); + } catch { + // Ignore if exists + } + await uploadDirectory(sftp, path.join(localDir, item.name), path.join(remoteDir, item.name), options); + } else { + const displayPath = path.posix.join(remoteDirNormalized, item.name); + process.stdout.write(` [UP] ${displayPath} ... `); + await sftp.put(localItemPath, remoteItemPath); + process.stdout.write('done\n'); + } + } +} + +// ─── Helper: Run SSH commands ───────────────────────────────────────────────── +function runSshCommands(commands) { + return new Promise((resolve, reject) => { + const ssh = new SshClient(); + const results = []; + + ssh.on('ready', () => { + console.log('\n[SSH] Connected. Running post-deploy commands...\n'); + + let commandIndex = 0; + + function runNext() { + if (commandIndex >= commands.length) { + ssh.end(); + return; + } + + const cmd = commands[commandIndex++]; + console.log(`\n $ ${cmd}`); + + ssh.exec(cmd, (err, stream) => { + if (err) { + console.error(` [ERROR] ${err.message}`); + runNext(); + return; + } + + let output = ''; + stream.on('data', (data) => { + output += data; + process.stdout.write(data.toString()); + }); + stream.stderr.on('data', (data) => { + process.stdout.write(` [stderr] ${data}`); + }); + stream.on('close', () => { + results.push({ cmd, output }); + runNext(); + }); + }); + } + + runNext(); + + ssh.on('end', () => resolve(results)); + }); + + ssh.on('error', reject); + + ssh.connect({ + host: CONFIG.host, + port: CONFIG.port, + username: CONFIG.username, + password: CONFIG.password, + readyTimeout: 30000, + }); + }); +} + +// ─── Main Deployment Function ───────────────────────────────────────────────── +async function deploy() { + console.log('\n╔════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Production Deployment Script ║'); + console.log('╚════════════════════════════════════════════════════════╝\n'); + + // Pre-flight check: verify .next/standalone exists + const standaloneDir = path.join(LOCAL_ROOT, '.next', 'standalone'); + if (!fs.existsSync(standaloneDir)) { + console.error('✗ ERROR: .next/standalone not found!'); + console.error(' Run: npm run build'); + console.error(' Then re-run this deployment script.\n'); + process.exit(1); + } + + // Pre-flight check: verify .env.production exists + const envProd = path.join(LOCAL_ROOT, '.env.production'); + if (!fs.existsSync(envProd)) { + console.error('✗ ERROR: .env.production not found!'); + process.exit(1); + } + + console.log(`[Config] Host : ${CONFIG.host}:${CONFIG.port}`); + console.log(`[Config] User : ${CONFIG.username}`); + console.log(`[Config] Remote : ${CONFIG.remotePath}`); + console.log(`[Config] Files : ${UPLOAD_MANIFEST.length} items to upload\n`); + + // ─── Phase 1: SFTP Upload ───────────────────────────────────────────────── + const sftp = new SftpClient(); + try { + console.log('[SFTP] Connecting...'); + await sftp.connect({ + host: CONFIG.host, + port: CONFIG.port, + username: CONFIG.username, + password: CONFIG.password, + readyTimeout: 30000, + }); + console.log('[SFTP] Connected!\n'); + + // Ensure remote root exists + try { + await sftp.mkdir(CONFIG.remotePath, true); + } catch { + // Already exists + } + + let uploaded = 0; + let skipped = 0; + + for (const item of UPLOAD_MANIFEST) { + const localPath = path.join(LOCAL_ROOT, item.local); + + if (!fs.existsSync(localPath)) { + console.log(`[SKIP] ${item.local} — not found locally`); + skipped++; + continue; + } + + if (item.type === 'dir') { + console.log(`\n[DIR] Uploading ${item.local}/`); + await uploadDirectory(sftp, item.local, item.remote); + uploaded++; + } else { + const remotePath = path.posix.join(CONFIG.remotePath, item.remote); + // Ensure parent directory exists + const remoteDir = remotePath.substring(0, remotePath.lastIndexOf('/')); + try { + await sftp.mkdir(remoteDir, true); + } catch { + // Ignore + } + console.log(`[FILE] ${item.local}`); + await sftp.put(localPath, remotePath); + uploaded++; + } + } + + console.log(`\n[SFTP] Upload complete! ${uploaded} items uploaded, ${skipped} skipped.`); + await sftp.end(); + } catch (err) { + console.error('\n[SFTP] ERROR:', err.message); + try { await sftp.end(); } catch { /* ignore */ } + process.exit(1); + } + + // ─── Phase 2: SSH Post-Deploy Commands ─────────────────────────────────── + try { + await runSshCommands(POST_DEPLOY_COMMANDS); + console.log('\n╔════════════════════════════════════════════════════════╗'); + console.log('║ ✅ Deployment Complete! ║'); + console.log('║ ║'); + console.log('║ Dashboard : https://demoplace.my.id ║'); + console.log('║ Health : http://127.0.0.1:3001/api/health ║'); + console.log('╚════════════════════════════════════════════════════════╝\n'); + } catch (err) { + console.error('\n[SSH] ERROR:', err.message); + process.exit(1); + } +} + +// Run +deploy().catch((err) => { + console.error('\n[DEPLOY] Fatal error:', err); + process.exit(1); +}); diff --git a/scripts/deploy_production.js b/scripts/deploy_production.js index cc043ee..a6da216 100644 --- a/scripts/deploy_production.js +++ b/scripts/deploy_production.js @@ -11,7 +11,7 @@ const config = { password: 'htEo7x6LsBQiEHHH' }; -const DEPLOY_DIR = 'web/demoplace.my.id/public_html'; +const DEPLOY_DIR = 'backone-production'; async function createZip() { console.log("Packaging application..."); @@ -30,15 +30,11 @@ async function createZip() { // Add static assets (Next.js standalone requires these copied over) archive.directory(path.join(__dirname, '../.next/static'), '.next/static'); archive.directory(path.join(__dirname, '../public'), 'public'); - archive.directory(path.join(__dirname, '../public'), false); // Add backend and proxy archive.directory(path.join(__dirname, '../backend'), 'backend'); archive.directory(path.join(__dirname, '../proxy'), 'proxy'); - // Add PM2 ecosystem config - archive.file(path.join(__dirname, '../ecosystem.config.js'), { name: 'ecosystem.config.js' }); - // Add production env archive.file(path.join(__dirname, '../.env.production'), { name: '.env.production' }); @@ -85,24 +81,26 @@ async function deploy() { await sftp.put(zipPath, `${targetDir}/deploy.zip`); console.log("Extracting package on server..."); - await runSSH(`cd ${targetDir} && rm -rf .next && unzip -o deploy.zip && rm deploy.zip`); + await runSSH(`cd ${targetDir} && unzip -o deploy.zip && rm deploy.zip`); - console.log("Creating symlink for static files..."); - await runSSH(`cd ${targetDir} && rm -rf _next && ln -s .next _next`); - - console.log("Installing production dependencies for root, backend & proxy..."); - await runSSH(`. ~/.nvm/nvm.sh && nvm use 20 && cd ${targetDir} && npm install --production`); - await runSSH(`. ~/.nvm/nvm.sh && nvm use 20 && cd ${targetDir}/backend && npm install --production`); - await runSSH(`. ~/.nvm/nvm.sh && nvm use 20 && cd ${targetDir}/proxy && npm install --production`); + console.log("Installing production dependencies for backend & proxy..."); + await runSSH(`cd ${targetDir}/backend && npm install --production`); + await runSSH(`cd ${targetDir}/proxy && npm install --production`); console.log("Configuring PM2..."); - // PM2 ecosystem is now packaged in deploy.zip directly, no need to generate via bash echo. - + // Create ecosystem file for PM2 + const ecosystem = ` +module.exports = { + apps: [ + { name: 'backone-proxy', script: './proxy/index.js', env_file: '.env.production' }, + { name: 'backone-backend', script: './backend/server.js', env_file: '.env.production' }, + { name: 'backone-frontend', script: 'server.js', env_file: '.env.production' } + ] +};`; + await runSSH(`cd ${targetDir} && echo "${ecosystem.replace(/\n/g, '\\n')}" > ecosystem.config.js`); console.log("Restarting PM2 processes..."); - // Clean delete old PM2 processes if they exist to apply new paths, then start fresh - await runSSH(`. ~/.nvm/nvm.sh && nvm use 20 && npx pm2 delete backone-frontend backone-backend backone-proxy || true`); - const pm2Result = await runSSH(`. ~/.nvm/nvm.sh && nvm use 20 && cd ${targetDir} && npx pm2 start ecosystem.config.js`); + const pm2Result = await runSSH(`cd ${targetDir} && pm2 start ecosystem.config.js || pm2 restart ecosystem.config.js`); console.log(pm2Result); console.log("Deployment completed successfully!"); diff --git a/scripts/detailed-remote-audit.js b/scripts/detailed-remote-audit.js new file mode 100644 index 0000000..9bdd1bd --- /dev/null +++ b/scripts/detailed-remote-audit.js @@ -0,0 +1,84 @@ +const https = require('https'); + +const ROUTES = [ + '/', + '/agents', + '/apps', + '/devices', + '/dns', + '/dpi-analytics', + '/encryption-audit', + '/events', + '/flows', + '/geography', + '/intelligence', + '/lookup', + '/network-infrastructure', + '/network-intelligence', + '/network-topology', + '/security-audit', + '/threat-intelligence', + '/threats', + '/traffic-categories', + '/help', + '/login' +]; + +function auditRoute(route) { + return new Promise((resolve) => { + https.get(`https://demoplace.my.id${route}`, (res) => { + let data = ''; + res.on('data', c => data += c); + res.on('end', () => { + const title = (data.match(/<title>(.*?)<\/title>/i) || [])[1] || 'N/A'; + const desc = (data.match(/<meta name="description" content="(.*?)"/i) || [])[1] || 'N/A'; + const icons = []; + const iconMatches = data.matchAll(/rel="icon" href="([^"]+)"/gi); + for (const m of iconMatches) icons.push(m[1]); + + const imgs = []; + const imgMatches = data.matchAll(/src="([^"]*(?:logo|brand|icon|png|svg|jpg)[^"]*)"/gi); + for (const m of imgMatches) { + if (!imgs.includes(m[1])) imgs.push(m[1]); + } + + const headers = []; + const hMatches = data.matchAll(/<h[123][^>]*>(.*?)<\/h[123]>/gi); + for (const m of hMatches) { + const clean = m[1].replace(/<[^>]+>/g, '').trim(); + if (clean && !headers.includes(clean)) headers.push(clean); + } + + resolve({ + route, + status: res.statusCode, + is404: data.includes('404: This page could not be found'), + title, + desc, + icons, + imgs, + headers + }); + }); + }).on('error', err => resolve({ route, error: err.message })); + }); +} + +async function main() { + console.log('=== DETAILED AUDIT OF DEMOPLACE.MY.ID ===\n'); + for (const r of ROUTES) { + const res = await auditRoute(r); + if (res.is404) { + console.log(`❌ [404 NOT FOUND] ${res.route}`); + } else { + console.log(`✅ [200 OK] ${res.route}`); + console.log(` Title : ${res.title}`); + console.log(` Icons : ${res.icons.join(', ')}`); + console.log(` Images/Logo: ${res.imgs.join(', ')}`); + console.log(` Headers : ${res.headers.join(' | ')}`); + } + console.log('--------------------------------------------------'); + } +} + +main(); diff --git a/scripts/diagnose-ports.js b/scripts/diagnose-ports.js new file mode 100644 index 0000000..677f78b --- /dev/null +++ b/scripts/diagnose-ports.js @@ -0,0 +1,99 @@ +// scripts/diagnose-ports.js +// Cek port apa yang aktif dan test PHP proxy di port yang benar +'use strict'; + +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', port: 2222, + username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +function sshExec(conn, cmd, label = '') { + if (label) console.log(`\n[${label}]`); + console.log(`$ ${cmd.substring(0, 100)}${cmd.length > 100 ? '...' : ''}`); + return new Promise((resolve, reject) => { + conn.exec(cmd, (err, stream) => { + if (err) return reject(err); + let out = ''; + stream.on('close', () => resolve(out)) + .on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }); + }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Diagnosa Port & PHP Proxy Test ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + const conn = new Client(); + await new Promise((resolve, reject) => { conn.on('ready', resolve).on('error', reject).connect(SSH); }); + console.log('[SSH] Terhubung!\n'); + + // 1. Cek semua port yang aktif + await sshExec(conn, + 'echo "=== SEMUA PORT AKTIF ===" && ss -tlnp | grep -E "(80|443|3000|3001|4000|8008|8009|8080|8443)"', + 'PORT AKTIF' + ); + + // 2. Cek apakah Apache aktif di berbagai port + await sshExec(conn, + 'echo "=== TES APACHE PORT 8009 ===" && curl -sk -o /dev/null -w "Port 8009: %{http_code}" http://127.0.0.1:8009/login', + 'TES PORT 8009' + ); + + await sshExec(conn, + 'echo "=== TES APACHE PORT 8080 (IP publik) ===" && curl -sk -o /dev/null -w "Port 8080 publik: %{http_code}" http://103.185.47.52:8080/login', + 'TES PORT 8080' + ); + + // 3. Cek apakah PHP FPM socket ada + await sshExec(conn, + 'ls -la /run/php/php*-fpm-demoplace* 2>/dev/null || echo "Tidak ada PHP FPM socket untuk domain ini"', + 'PHP FPM SOCKET' + ); + + // 4. Cek isi .htaccess yang aktif + await sshExec(conn, + 'cat /home/adminbackend/web/demoplace.my.id/public_html/.htaccess', + 'HTACCESS AKTIF' + ); + + // 5. Cek apakah proxy.php bisa diakses via Apache + await sshExec(conn, + 'curl -sk -v http://127.0.0.1:8009/proxy.php 2>&1 | head -30', + 'TES proxy.php via port 8009' + ); + + // 6. Test direct PHP execution + await sshExec(conn, + 'php -r "echo curl_version()[\'version\'];" 2>/dev/null && echo " (PHP curl OK)" || echo "PHP curl tidak tersedia"', + 'CEK PHP CURL' + ); + + // 7. Cek Apache error log + await sshExec(conn, + 'tail -20 /var/log/apache2/domains/demoplace.my.id.error.log 2>/dev/null | grep -v "^\$" | tail -10 || echo "Tidak bisa akses error log"', + 'APACHE ERROR LOG' + ); + + // 8. Test Next.js langsung + await sshExec(conn, + 'curl -sk -o /dev/null -w "Next.js port 3000 /login: %{http_code}" http://127.0.0.1:3000/login', + 'TES NEXT.JS LANGSUNG' + ); + + // 9. Coba test PHP via curl dengan header Host + await sshExec(conn, + 'curl -sk -H "Host: demoplace.my.id" -o /dev/null -w "Apache dengan Host header: %{http_code}" http://127.0.0.1:8009/', + 'TES APACHE HOST HEADER' + ); + + conn.end(); + console.log('\n✅ Diagnosa selesai!\n'); +} + +main().catch(err => { console.error('[FATAL]', err.message); process.exit(1); }); diff --git a/scripts/download-production.js b/scripts/download-production.js new file mode 100644 index 0000000..8f09e49 --- /dev/null +++ b/scripts/download-production.js @@ -0,0 +1,90 @@ +// scripts/download-production.js +'use strict'; + +const SftpClient = require('ssh2-sftp-client'); +const path = require('path'); +const fs = require('fs'); + +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + remotePath: '/home/adminbackend/web/demoplace.my.id/public_html', +}; + +const LOCAL_ROOT = path.resolve(__dirname, '..'); + +const DIRS_TO_DOWNLOAD = ['src', 'backend', 'proxy', 'public', 'scripts']; +const FILES_TO_DOWNLOAD = [ + 'package.json', + 'package-lock.json', + 'next.config.ts', + 'postcss.config.mjs', + 'tsconfig.json', + 'eslint.config.mjs', + 'README.md', + 'SKILLS.md', + 'Dockerfile', + 'docker-compose.yml', + 'docker-compose.prod.yml', + 'ecosystem.config.js', + 'deploy.sh' +]; + +async function main() { + const sftp = new SftpClient(); + try { + console.log(`[SFTP] Connecting to ${CONFIG.host}:${CONFIG.port}...`); + await sftp.connect({ + host: CONFIG.host, + port: CONFIG.port, + username: CONFIG.username, + password: CONFIG.password, + }); + console.log('[SFTP] Connected successfully.'); + + // 1. Download directories recursively + for (const dirName of DIRS_TO_DOWNLOAD) { + const remoteDir = `${CONFIG.remotePath}/${dirName}`; + const localDir = path.join(LOCAL_ROOT, dirName); + + if (await sftp.exists(remoteDir)) { + console.log(`[SFTP] Downloading directory: ${dirName}...`); + fs.mkdirSync(localDir, { recursive: true }); + await sftp.downloadDir(remoteDir, localDir, { + filter: (filePath, isDir) => { + const basename = path.basename(filePath); + return !['node_modules', '.next', '.git', 'logs', 'repo.zip'].includes(basename); + } + }); + console.log(`[SFTP] Finished downloading directory: ${dirName}`); + } else { + console.warn(`[SFTP] Remote directory not found: ${remoteDir}`); + } + } + + // 2. Download specific root files + for (const fileName of FILES_TO_DOWNLOAD) { + const remoteFile = `${CONFIG.remotePath}/${fileName}`; + const localFile = path.join(LOCAL_ROOT, fileName); + + if (await sftp.exists(remoteFile)) { + console.log(`[SFTP] Downloading file: ${fileName}...`); + await sftp.fastGet(remoteFile, localFile); + console.log(`[SFTP] Downloaded: ${fileName}`); + } else { + console.warn(`[SFTP] Remote file not found: ${remoteFile}`); + } + } + + console.log('\n✅ [SFTP] Download of production codebase (10/10) completed successfully!'); + } catch (err) { + console.error('❌ [SFTP] Download failed:', err); + process.exit(1); + } finally { + await sftp.end(); + } +} + +main(); diff --git a/scripts/export-local-db.js b/scripts/export-local-db.js new file mode 100644 index 0000000..e451ef6 --- /dev/null +++ b/scripts/export-local-db.js @@ -0,0 +1,42 @@ +// scripts/export-local-db.js +const { MongoClient } = require('mongodb'); +const fs = require('fs'); +const path = require('path'); + +const LOCAL_URI = 'mongodb://127.0.0.1:27017/backone_dpi'; +const DB_NAME = 'backone_dpi'; + +const COLLECTIONS_TO_EXPORT = ['users', 'tenantconfigs', 'customagentlocations']; +const OUTPUT_DIR = path.join(__dirname, '..', 'migration-temp'); + +async function main() { + console.log('Connecting to local MongoDB...'); + const client = new MongoClient(LOCAL_URI); + + try { + await client.connect(); + console.log('✓ Connected to local MongoDB successfully.'); + const db = client.db(DB_NAME); + + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR); + } + + for (const colName of COLLECTIONS_TO_EXPORT) { + console.log(`Exporting collection: ${colName}...`); + const documents = await db.collection(colName).find({}).toArray(); + const filePath = path.join(OUTPUT_DIR, `${colName}.json`); + fs.writeFileSync(filePath, JSON.stringify(documents, null, 2)); + console.log(` ✓ Exported ${documents.length} documents to ${filePath}`); + } + + console.log('\n✓ Database export complete!'); + } catch (err) { + console.error('✗ Export failed:', err.message); + process.exit(1); + } finally { + await client.close(); + } +} + +main(); diff --git a/scripts/extract-all-client-titles.js b/scripts/extract-all-client-titles.js new file mode 100644 index 0000000..427bc98 --- /dev/null +++ b/scripts/extract-all-client-titles.js @@ -0,0 +1,62 @@ +const SftpClient = require('ssh2-sftp-client'); +const { Client } = require('ssh2'); + +const remoteExtractScript = ` +const fs = require('fs'); +const path = require('path'); + +const root = '/home/adminbackend/web/demoplace.my.id/public_html/.next/standalone/.next/server/chunks'; + +console.log('=== EXTRACTING ALL PAGE HEADERS FROM CHUNKS ===\\n'); + +const files = fs.readdirSync(root); +files.forEach(f => { + if (!f.endsWith('.js')) return; + const content = fs.readFileSync(path.join(root, f), 'utf8'); + + // Match header titles in React JSX: e.g. text-3xl font-bold ... >TitleText< + const titleMatches = content.matchAll(/text-3xl[^>]*>([^<]{3,40})</gi); + for (const m of titleMatches) { + console.log(\`Chunk \${f}: "\${m[1].trim()}"\`); + } + + // Match tab names in React JSX + const tabMatches = content.matchAll(/role="tab"[^>]*>([^<]{3,30})</gi); + for (const m of tabMatches) { + console.log(\`Chunk \${f} Tab: "\${m[1].trim()}"\`); + } +}); +`; + +async function main() { + const sftp = new SftpClient(); + await sftp.connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' + }); + + const remoteScriptPath = '/home/adminbackend/extract_client_titles.js'; + await sftp.put(Buffer.from(remoteExtractScript), remoteScriptPath); + await sftp.end(); + + const conn = new Client(); + conn.on('ready', () => { + conn.exec(`node ${remoteScriptPath}`, (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log(out); + conn.end(); + }); + }); + }).connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' + }); +} + +main(); diff --git a/scripts/extract-standalone-headers.js b/scripts/extract-standalone-headers.js new file mode 100644 index 0000000..ef962df --- /dev/null +++ b/scripts/extract-standalone-headers.js @@ -0,0 +1,69 @@ +const SftpClient = require('ssh2-sftp-client'); +const { Client } = require('ssh2'); + +const remoteExtractScript = ` +const fs = require('fs'); +const path = require('path'); + +const root = '/home/adminbackend/web/demoplace.my.id/public_html/.next/standalone/.next/server'; + +console.log('=== EXTRACTING ALL PAGE HEADERS FROM STANDALONE BUILD ===\\n'); + +function scan(dir) { + if (!fs.existsSync(dir)) return; + const items = fs.readdirSync(dir, { withFileTypes: true }); + for (const item of items) { + const full = path.join(dir, item.name); + if (item.isDirectory()) { + scan(full); + } else if (item.name.endsWith('.js') || item.name.endsWith('.html')) { + const content = fs.readFileSync(full, 'utf8'); + + // Look for H2/H1 page header titles or page metadata + const matches = content.matchAll(/<h[12][^>]*>([\\s\\S]*?)<\\/h[12]>/gi); + for (const m of matches) { + const clean = m[1].replace(/<[^>]+>/g, '').trim(); + if (clean && clean.length > 2 && clean.length < 60 && !clean.includes('{') && !clean.includes('function')) { + console.log(\`File: \${path.relative(root, full)}\`); + console.log(\` Header: "\${clean}"\`); + } + } + } + } +} + +scan(path.join(root, 'app/(dashboard)')); +`; + +async function main() { + const sftp = new SftpClient(); + await sftp.connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' + }); + + const remoteScriptPath = '/home/adminbackend/extract_standalone.js'; + await sftp.put(Buffer.from(remoteExtractScript), remoteScriptPath); + await sftp.end(); + + const conn = new Client(); + conn.on('ready', () => { + conn.exec(`node ${remoteScriptPath}`, (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log(out); + conn.end(); + }); + }); + }).connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' + }); +} + +main(); diff --git a/scripts/final-check.js b/scripts/final-check.js new file mode 100644 index 0000000..1a3bafc --- /dev/null +++ b/scripts/final-check.js @@ -0,0 +1,121 @@ +// scripts/final-check.js +// ───────────────────────────────────────────────────────────────────────────── +// BackOne DPI — Final production health check +// ───────────────────────────────────────────────────────────────────────────── +'use strict'; + +const { Client: SshClient } = require('ssh2'); +const https = require('https'); +const http = require('http'); + +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', +}; + +const PM2 = '/home/adminbackend/.npm-global/bin/pm2'; +const ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; + +function fetch(url, followRedirects = false) { + return new Promise(resolve => { + const mod = url.startsWith('https') ? https : http; + const opts = { timeout: 12000, rejectUnauthorized: false }; + if (!followRedirects) opts.agent = false; + const req = mod.get(url, opts, res => { + let body = ''; + res.on('data', d => body += d); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: body.substring(0, 300) })); + }); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); +} + +function runSsh(commands) { + return new Promise((resolve, reject) => { + const ssh = new SshClient(); + ssh.on('ready', () => { + let i = 0; + function next() { + if (i >= commands.length) { ssh.end(); return; } + const cmd = commands[i++]; + console.log(`\n$ ${cmd}`); + ssh.exec(cmd, (err, stream) => { + if (err) { console.error('[ERR]', err.message); next(); return; } + stream.on('data', d => process.stdout.write(d.toString())); + stream.stderr.on('data', d => { const t = d.toString(); if (!t.includes('npm warn') && !t.includes('notice')) process.stdout.write(t); }); + stream.on('close', next); + }); + } + next(); + ssh.on('end', resolve); + }); + ssh.on('error', reject); + ssh.connect({ ...CONFIG, readyTimeout: 30000 }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Final Production Verification ║'); + console.log('╚══════════════════════════════════════════════════════════════╝\n'); + + // SSH internal checks + await runSsh([ + `${PM2} list`, + `echo "=== FRONTEND ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/`, + `echo "=== LOGIN PAGE ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/login`, + `echo "=== BACKEND ===" && curl -s http://127.0.0.1:3001/api/health`, + // Test full login flow + `echo "=== LOGIN API ===" && curl -s -X POST http://127.0.0.1:3001/api/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"admin"}' | python3 -c "import sys,json; d=json.load(sys.stdin); print('Login OK - Role:', d.get('user',{}).get('role','?'))" 2>/dev/null || curl -s -X POST http://127.0.0.1:3001/api/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"admin"}'`, + // Security checks + `echo "=== SECURITY: Port 3001 ===" && ss -tlnp | grep 3001`, + `echo "=== SECURITY: Port 4000 ===" && ss -tlnp | grep 4000`, + `echo "=== SECURITY: No .env in standalone ===" && find ${ROOT}/.next/standalone -name ".env*" 2>/dev/null || echo "CLEAN"`, + // Proxy MongoDB + `echo "=== PROXY MONGO ===" && ${PM2} logs backone-proxy --lines 5 --nostream 2>&1 | grep -E "(MongoDB|Scheduler|Connected|Capacity)" | tail -5`, + // Domain via HTTP (should 301 → HTTPS) + `echo "=== DOMAIN HTTP ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://demoplace.my.id/`, + // Domain via HTTPS from server itself + `echo "=== DOMAIN HTTPS ===" && curl -sk -o /dev/null -w "HTTP %{http_code}" https://demoplace.my.id/ 2>&1`, + ]); + + // External checks from local machine + console.log('\n\n══ External checks from local machine ══\n'); + + const tests = [ + { url: 'https://demoplace.my.id/', label: 'Root (expect 307 redirect to /login)' }, + { url: 'https://demoplace.my.id/login', label: 'Login page (expect 200)' }, + { url: 'https://demoplace.my.id/api/health', label: 'Backend health via domain (expect 200)' }, + { url: 'http://demoplace.my.id/', label: 'HTTP root (expect 301 HTTPS redirect)' }, + ]; + + for (const { url, label } of tests) { + const r = await fetch(url); + const ok = r.status >= 200 && r.status < 400; + const icon = ok ? '✅' : r.status === 0 ? '⚠️ ' : '❌'; + console.log(` ${icon} [${String(r.status).padEnd(3)}] ${label}`); + if (r.headers?.location) console.log(` → Redirect: ${r.headers.location}`); + if (r.error) console.log(` → Error: ${r.error}`); + if (r.status === 200 && r.body) { + const preview = r.body.replace(/\s+/g, ' ').substring(0, 80); + console.log(` → Body: ${preview}`); + } + } + + console.log('\n══════════════════════════════════════════════════════════════'); + console.log(' Deployment Summary:'); + console.log(' ┌─────────────────────────────────────────────────────┐'); + console.log(' │ 🌐 Dashboard URL : https://demoplace.my.id │'); + console.log(' │ 🔒 Backend : 127.0.0.1:3001 (internal only) │'); + console.log(' │ 🔒 Proxy : 127.0.0.1:4000 (internal only) │'); + console.log(' │ 🗄️ MongoDB : mongodb.prod.proit.id:27017 │'); + console.log(' │ 🚀 Node.js : v18.19.1 │'); + console.log(' │ ⚙️ PM2 : 3 processes (all online) │'); + console.log(' └─────────────────────────────────────────────────────┘'); + console.log('══════════════════════════════════════════════════════════\n'); +} + +main().catch(err => { console.error('[FATAL]', err); process.exit(1); }); diff --git a/scripts/find-chunks.js b/scripts/find-chunks.js new file mode 100644 index 0000000..db08247 --- /dev/null +++ b/scripts/find-chunks.js @@ -0,0 +1,53 @@ +// scripts/find-chunks.js +// Cari di mana file chunks yang benar berada di server +'use strict'; +const { Client } = require('ssh2'); +const conn = new Client(); +const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; + +function exec(conn, cmd) { + console.log('\n$ ' + cmd.substring(0, 80)); + return new Promise(resolve => { + conn.exec(cmd, (err, s) => { + if (err) { resolve(''); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .on('close', () => resolve(out)); + }); + }); +} + +conn.on('ready', async () => { + // 1. Cari file chunk yang direferensikan HTML + await exec(conn, 'find ' + PUB + ' -name "3km6z-n6wbgrs.js" 2>/dev/null | head -5'); + await exec(conn, 'find ' + PUB + ' -name "21luguo5_g2uu.js" 2>/dev/null | head -5'); + await exec(conn, 'find ' + PUB + ' -name "1u6dw_tm45utz.css" 2>/dev/null | head -5'); + + // 2. Cek isi static/static di standalone + const sa = PUB + '/.next/standalone/.next/static/static'; + await exec(conn, 'ls ' + sa + '/ 2>/dev/null | head -10 || echo "Tidak ada nested static"'); + + // 3. Cek BUILD_ID utama + await exec(conn, 'cat ' + PUB + '/.next/BUILD_ID 2>/dev/null || echo "NO_BUILDID"'); + + // 4. Cek build-manifest.json untuk lihat chunk names + await exec(conn, 'cat ' + PUB + '/.next/standalone/.next/build-manifest.json 2>/dev/null | python3 -m json.tool 2>/dev/null | grep -A2 "login" | head -20'); + + // 5. Cek app-path-routes-manifest + await exec(conn, 'cat ' + PUB + '/.next/standalone/.next/app-path-routes-manifest.json 2>/dev/null | head -20'); + + // 6. Cek apakah ada next.js build di parent directory + await exec(conn, 'find /home/adminbackend -name "3km6z-n6wbgrs.js" 2>/dev/null | head -5'); + + // 7. Cek total disk structure + await exec(conn, 'du -sh ' + PUB + '/.next/standalone/.next/ 2>/dev/null'); + await exec(conn, 'du -sh ' + PUB + '/.next/ 2>/dev/null | head -5'); + + conn.end(); + console.log('\nDone.'); +}).connect({ + host: '103.185.47.52', port: 2222, + username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', + readyTimeout: 30000 +}); diff --git a/scripts/find-remote-source.js b/scripts/find-remote-source.js new file mode 100644 index 0000000..7bc17f6 --- /dev/null +++ b/scripts/find-remote-source.js @@ -0,0 +1,18 @@ +const { Client } = require('ssh2'); + +const conn = new Client(); +conn.on('ready', () => { + conn.exec(`find /home/adminbackend/ -name "*.ts" -o -name "*.tsx" -o -name "*.js" | xargs grep -l "Overview Dashboard" 2>/dev/null`, (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('=== FILES CONTAINING Overview Dashboard ===\n' + out); + conn.end(); + }); + }); +}).connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' +}); diff --git a/scripts/fix-and-restart.js b/scripts/fix-and-restart.js new file mode 100644 index 0000000..6515380 --- /dev/null +++ b/scripts/fix-and-restart.js @@ -0,0 +1,131 @@ +// scripts/fix-and-restart.js +// ───────────────────────────────────────────────────────────────────────────── +// BackOne DPI — Fix standalone structure & reload PM2 with new code +// ───────────────────────────────────────────────────────────────────────────── +'use strict'; + +const { Client: SshClient } = require('ssh2'); + +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', +}; + +const ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; +const PM2 = '/home/adminbackend/.npm-global/bin/pm2'; + +const COMMANDS = [ + // ── Step 1: Fix the standalone directory structure ────────────────────────── + // Next.js standalone server.js expects .next/static and public INSIDE the standalone dir + `echo "=== STEP 1: Create standalone .next dir ==="`, + `mkdir -p ${ROOT}/.next/standalone/.next/`, + + // Copy static chunks into standalone/.next/static + `echo "=== STEP 2: Copy static assets into standalone ===" && cp -r ${ROOT}/.next/static ${ROOT}/.next/standalone/.next/static && echo "static copied OK"`, + + // Copy media files + `ls ${ROOT}/.next/standalone/.next/static/ 2>/dev/null | wc -l | xargs -I{} echo "Files in standalone static: {}"`, + + // public is already copied in deploy script, verify it + `ls ${ROOT}/.next/standalone/public/ 2>/dev/null | head -5 || echo "WARNING: public dir missing in standalone"`, + + // ── Step 2: Verify the ecosystem.config.js was updated ──────────────────── + `echo "=== STEP 3: Show ecosystem.config.js script path ===" && grep "script" ${ROOT}/ecosystem.config.js | head -5`, + + // ── Step 3: Reload PM2 with new ecosystem config ────────────────────────── + // Use reload instead of restart to minimize downtime + `echo "=== STEP 4: Reload PM2 frontend with new code ===" && cd ${ROOT} && NODE_ENV=production ${PM2} reload ecosystem.config.js --only backone-frontend 2>&1`, + + // Also reload backend to pick up new code + `echo "=== STEP 5: Reload PM2 backend ===" && cd ${ROOT} && NODE_ENV=production ${PM2} reload ecosystem.config.js --only backone-backend 2>&1`, + + // Reload proxy as well (it was already restarted but ensure env is correct) + `echo "=== STEP 6: Reload PM2 proxy ===" && cd ${ROOT} && NODE_ENV=production ${PM2} reload ecosystem.config.js --only backone-proxy 2>&1`, + + // ── Step 4: Save PM2 state for auto-restart on reboot ───────────────────── + `echo "=== STEP 7: Save PM2 state ===" && ${PM2} save && echo "PM2 state saved"`, + + // ── Step 5: Wait for processes to stabilize ──────────────────────────────── + `sleep 8`, + + // ── Step 6: Verify all processes ────────────────────────────────────────── + `echo "=== STEP 8: PM2 Status ===" && ${PM2} list`, + + // ── Step 7: Health checks ───────────────────────────────────────────────── + `echo "=== BACKEND HEALTH ===" && curl -s http://127.0.0.1:3001/api/health`, + `echo ""`, + `echo "=== FRONTEND HTTP STATUS ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/ && echo ""`, + `echo "=== FRONTEND LOGIN PAGE ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/login && echo ""`, + + // ── Step 8: Check port 3000 is now listening ────────────────────────────── + `echo "=== PORT 3000 STATUS ===" && ss -tlnp 2>/dev/null | grep 3000 || netstat -tlnp 2>/dev/null | grep 3000 || echo "Port 3000 check done"`, + + // ── Step 9: Show frontend logs ──────────────────────────────────────────── + `echo "=== FRONTEND PM2 LOG (last 20 lines) ===" && ${PM2} logs backone-frontend --lines 20 --nostream 2>&1 | tail -25`, + + // ── Step 10: Show proxy MongoDB connection status ───────────────────────── + `echo "=== PROXY LOG (last 15 lines) ===" && ${PM2} logs backone-proxy --lines 15 --nostream 2>&1 | tail -20`, +]; + +function runSshCommands(commands) { + return new Promise((resolve, reject) => { + const ssh = new SshClient(); + + ssh.on('ready', () => { + console.log('[SSH] Connected!\n'); + let index = 0; + + function runNext() { + if (index >= commands.length) { + ssh.end(); + return; + } + + const cmd = commands[index++]; + const preview = cmd.length > 150 ? cmd.substring(0, 150) + '...' : cmd; + console.log(`\n$ ${preview}`); + + ssh.exec(cmd, (err, stream) => { + if (err) { + console.error(`[ERROR] ${err.message}`); + runNext(); + return; + } + + stream.on('data', (data) => process.stdout.write(data.toString())); + stream.stderr.on('data', (data) => { + const text = data.toString(); + if (!text.includes('npm warn') && !text.includes('npm notice')) { + process.stdout.write(`[ERR] ${text}`); + } + }); + stream.on('close', () => runNext()); + }); + } + + runNext(); + ssh.on('end', resolve); + }); + + ssh.on('error', reject); + ssh.connect({ ...CONFIG, readyTimeout: 30000 }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Fix Standalone & Reload PM2 ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + try { + await runSshCommands(COMMANDS); + console.log('\n✅ Fix & reload complete!\n'); + } catch (err) { + console.error('\n[FATAL]', err.message); + process.exit(1); + } +} + +main(); diff --git a/scripts/fix-apache-ssl.js b/scripts/fix-apache-ssl.js new file mode 100644 index 0000000..e0beab8 --- /dev/null +++ b/scripts/fix-apache-ssl.js @@ -0,0 +1,120 @@ +// scripts/fix-apache-ssl.js +// ───────────────────────────────────────────────────────────────────────────── +// BackOne DPI — Fix Apache HTTPS 502 Bad Gateway +// The SSL VirtualHost needs mod_proxy configuration. +// cPanel uses Apache with .htaccess for proxy rules, but SSL VirtualHost +// may need specific directives to enable mod_proxy_http for the [P] flag. +// ───────────────────────────────────────────────────────────────────────────── +'use strict'; + +const { Client: SshClient } = require('ssh2'); + +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', +}; + +const ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; +const PM2 = '/home/adminbackend/.npm-global/bin/pm2'; + +const COMMANDS = [ + // Investigate Apache config for SSL + `echo "=== APACHE MODULES ===" && apache2ctl -M 2>/dev/null | grep -i proxy | head -10 || httpd -M 2>/dev/null | grep -i proxy | head -10 || echo "Cannot check Apache modules"`, + + // Check what's actually at the SSL port - is it Apache or Nginx or something else? + `echo "=== SSL PORT OWNER ===" && ss -tlnp | grep 443`, + + // Check SSL log for 502 cause + `echo "=== SSL ACCESS LOG (last 10 502s) ===" && tail -50 /home/adminbackend/logs/demoplace.my.id-ssl_log 2>/dev/null | grep "502" | tail -10 || echo "No SSL log or no 502s in log"`, + `echo "=== SSL ERROR LOG (last 15) ===" && tail -15 /home/adminbackend/logs/demoplace.my.id-ssl_log 2>/dev/null || echo "No SSL log found"`, + + // Find all log files for this domain + `echo "=== DOMAIN LOG FILES ===" && ls /home/adminbackend/logs/*demoplace* 2>/dev/null || echo "No domain-specific logs"`, + + // Check Apache vhost config (cPanel stores them here) + `echo "=== APACHE SSL VHOST ===" && cat /etc/apache2/conf.d/userdata/ssl/2_4/adminbackend/demoplace.my.id/custom_subdirectory.conf 2>/dev/null || ls /etc/apache2/conf.d/userdata/ssl/2_4/adminbackend/ 2>/dev/null || echo "Cannot read Apache vhost config"`, + + // Check all .htaccess files in path + `echo "=== ROOT HTACCESS ===" && cat ${ROOT}/.htaccess`, + + // Check if mod_proxy is loaded + `echo "=== MOD_PROXY CHECK ===" && test -f /etc/apache2/mods-enabled/proxy.load && echo "mod_proxy ENABLED" || echo "mod_proxy NOT enabled"`, + `test -f /etc/apache2/mods-enabled/proxy_http.load && echo "mod_proxy_http ENABLED" || echo "mod_proxy_http NOT enabled"`, + + // The issue may be cPanel's "nobody" user restriction + // Try using ProxyPass in .htaccess with explicit ProxyPassReverse + `echo "=== UPDATING .HTACCESS ===" && cat > ${ROOT}/.htaccess << 'EOF' +Options -MultiViews +RewriteEngine On + +# Force HTTPS +RewriteCond %{HTTPS} !=on +RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + +# Proxy all requests to Next.js (port 3000) +RewriteRule ^(.*)$ http://127.0.0.1:3000/$1 [P,L,QSA] +ProxyPassReverse / http://127.0.0.1:3000/ +EOF +echo ".htaccess updated"`, + + // Read the new .htaccess to verify + `echo "=== NEW HTACCESS ===" && cat ${ROOT}/.htaccess`, + + // Wait a moment for Apache to pick up changes + `sleep 2`, + + // Test from server itself via HTTPS + `echo "=== HTTPS TEST FROM SERVER ===" && curl -sk -o /dev/null -w "HTTP %{http_code}" https://demoplace.my.id/ 2>&1`, + `echo "=== HTTPS LOGIN FROM SERVER ===" && curl -sk -o /dev/null -w "HTTP %{http_code}" https://demoplace.my.id/login 2>&1`, + + // Check if it's a proxy timeout issue - try with verbose curl + `echo "=== VERBOSE HTTPS TEST ===" && curl -skv https://demoplace.my.id/ 2>&1 | grep -E "(<|>|\\*) " | head -20`, + + // Alternative: check if cPanel has a NodeJS app manager entry interfering + `echo "=== CPANEL USERDATA ===" && ls /home/adminbackend/.cpanel/userdata/ 2>/dev/null`, + `cat /home/adminbackend/.cpanel/userdata/demoplace.my.id_SSL 2>/dev/null | head -30 || echo "No cPanel userdata for SSL"`, + `cat /home/adminbackend/.cpanel/userdata/demoplace.my.id 2>/dev/null | head -30 || echo "No cPanel userdata"`, + + // Check Apache logs specifically for proxy errors + `echo "=== APACHE ERROR LOG ===" && tail -20 /var/log/apache2/error.log 2>/dev/null | grep -i "demoplace\|proxy\|502" | head -10 || echo "Cannot read Apache error log"`, + + // PM2 processes still healthy? + `echo "=== PM2 STATUS ===" && ${PM2} list`, + `echo "=== FRONTEND HEALTH ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/login`, +]; + +function runSsh(commands) { + return new Promise((resolve, reject) => { + const ssh = new SshClient(); + ssh.on('ready', () => { + let i = 0; + function next() { + if (i >= commands.length) { ssh.end(); return; } + const cmd = commands[i++]; + console.log(`\n$ ${cmd.substring(0, 100)}${cmd.length > 100 ? '...' : ''}`); + ssh.exec(cmd, (err, stream) => { + if (err) { console.error('[ERR]', err.message); next(); return; } + stream.on('data', d => process.stdout.write(d.toString())); + stream.stderr.on('data', d => { const t = d.toString(); if (!t.includes('npm warn') && !t.includes('notice')) process.stdout.write(t); }); + stream.on('close', next); + }); + } + next(); + ssh.on('end', resolve); + }); + ssh.on('error', reject); + ssh.connect({ ...CONFIG, readyTimeout: 30000 }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Fix Apache HTTPS 502 Bad Gateway ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + await runSsh(COMMANDS); + console.log('\n✅ Apache SSL investigation complete!\n'); +} + +main().catch(err => { console.error('[FATAL]', err); process.exit(1); }); diff --git a/scripts/fix-css-and-security.js b/scripts/fix-css-and-security.js new file mode 100644 index 0000000..8c8fb04 --- /dev/null +++ b/scripts/fix-css-and-security.js @@ -0,0 +1,141 @@ +// scripts/fix-css-and-security.js +// 1. Fix CSS tidak termuat: buat symlink public_html/_next -> public_html/.next +// 2. Security audit: hapus cmd.php dan file berbahaya lainnya +'use strict'; + +const { Client } = require('ssh2'); +const https = require('https'); + +const SSH = { + host: '103.185.47.52', port: 2222, + username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; + +function sshExec(conn, cmd, label = '') { + if (label) console.log(`\n[${label}]`); + console.log(`$ ${cmd.substring(0, 100)}${cmd.length > 100 ? '...' : ''}`); + return new Promise((resolve, reject) => { + conn.exec(cmd, (err, stream) => { + if (err) return reject(err); + let out = ''; + stream.on('close', () => resolve(out)) + .on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }); + }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Fix CSS + Security Audit ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + const conn = new Client(); + await new Promise((resolve, reject) => { conn.on('ready', resolve).on('error', reject).connect(SSH); }); + console.log('[SSH] Terhubung!\n'); + + // ── 1. SECURITY AUDIT ───────────────────────────────────────────────── + await sshExec(conn, + `echo "=== FILE BERBAHAYA ===" && ls -la ${PUB}/*.php ${PUB}/*.sh 2>/dev/null || echo "Tidak ada .php/.sh di root"`, + '1. Audit file PHP di public_html' + ); + + // Hapus cmd.php jika ada (file backdoor berbahaya) + await sshExec(conn, + `[ -f ${PUB}/cmd.php ] && rm -f ${PUB}/cmd.php && echo "DIHAPUS: cmd.php" || echo "cmd.php tidak ada (aman)"`, + '2. Hapus cmd.php (backdoor)' + ); + + // Hapus proxy.php lama (tidak diperlukan lagi karena nginx routing langsung ke port 3000) + await sshExec(conn, + `[ -f ${PUB}/proxy.php ] && rm -f ${PUB}/proxy.php && echo "DIHAPUS: proxy.php (tidak diperlukan)" || echo "proxy.php tidak ada"`, + '3. Hapus proxy.php (tidak dibutuhkan)' + ); + + // ── 2. CEK STRUKTUR DIREKTORI ────────────────────────────────────────── + await sshExec(conn, + `echo "=== STRUKTUR public_html ===" && ls -la ${PUB}/ | head -30`, + '4. Cek struktur public_html' + ); + + await sshExec(conn, + `echo "=== .next directory ===" && ls -la ${PUB}/.next/ 2>/dev/null | head -10 || echo "TIDAK ADA .next/"`, + '5. Cek .next directory' + ); + + await sshExec(conn, + `echo "=== static assets ===" && ls -la ${PUB}/.next/static/ 2>/dev/null | head -10 || echo "Tidak ada .next/static/"`, + '6. Cek .next/static' + ); + + await sshExec(conn, + `echo "=== _next symlink ===" && ls -la ${PUB}/_next 2>/dev/null || echo "Symlink _next belum ada"`, + '7. Cek symlink _next' + ); + + // ── 3. FIX CSS: Buat symlink _next → .next ──────────────────────────── + await sshExec(conn, + `[ -e ${PUB}/_next ] && echo "Sudah ada _next" || (ln -s ${PUB}/.next ${PUB}/_next && echo "BERHASIL: Symlink _next → .next dibuat")`, + '8. Buat symlink _next -> .next' + ); + + // Verifikasi symlink + await sshExec(conn, + `ls -la ${PUB}/_next && ls ${PUB}/_next/static/ | head -5`, + '9. Verifikasi symlink' + ); + + // ── 4. PERBARUI .htaccess (bersih tanpa proxy.php) ──────────────────── + const cleanHtaccess = `# BackOne DPI — Apache .htaccess +# Redirect HTTP ke HTTPS saja +RewriteEngine On +RewriteCond %{HTTPS} !=on +RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] +`; + + await sshExec(conn, + `printf '%s' ${JSON.stringify(cleanHtaccess)} > ${PUB}/.htaccess && echo "BERHASIL: .htaccess diperbarui"`, + '10. Perbarui .htaccess (hanya redirect HTTPS)' + ); + + // ── 5. TEST CSS LOADING ───────────────────────────────────────────────── + console.log('\n[11. Test loading CSS dari /_next/static/]'); + await sshExec(conn, + `CSS_FILE=$(ls ${PUB}/.next/static/css/*.css 2>/dev/null | head -1) && ` + + `[ -n "$CSS_FILE" ] && ` + + `FILENAME=$(basename "$CSS_FILE") && ` + + `curl -sk -o /dev/null -w "CSS via domain: %{http_code} (size: %{size_download} bytes)" https://demoplace.my.id/_next/static/css/$FILENAME || ` + + `echo "Tidak ada file CSS ditemukan"`, + '11. Test CSS via domain' + ); + + conn.end(); + + // ── Test dari luar ────────────────────────────────────────────────────── + console.log('\n[12. Test domain setelah fix...]'); + await new Promise(r => setTimeout(r, 2000)); + + for (const path of ['/login', '/api/health']) { + const r = await new Promise(resolve => { + const req = https.get(`https://demoplace.my.id${path}`, + { timeout: 10000, rejectUnauthorized: false }, + res => { + let body = ''; + res.on('data', d => body += d); + res.on('end', () => resolve({ status: res.statusCode, body: body.substring(0, 100) })); + } + ); + req.on('error', e => resolve({ status: 0, error: e.message })); + }); + const icon = r.status === 200 ? '✅' : r.status === 301 ? '↩️ ' : '❌'; + console.log(` ${icon} https://demoplace.my.id${path} → HTTP ${r.status}`); + if (path === '/api/health') console.log(` Body: ${r.body}`); + } + + console.log('\n✅ Fix selesai! Coba refresh https://demoplace.my.id/login\n'); +} + +main().catch(err => { console.error('[FATAL]', err.message); process.exit(1); }); diff --git a/scripts/fix-env-loader.js b/scripts/fix-env-loader.js new file mode 100644 index 0000000..e57bc74 --- /dev/null +++ b/scripts/fix-env-loader.js @@ -0,0 +1,175 @@ +// scripts/fix-env-loader.js +// Fix: Buat Node.js env loader yang aman (tanpa bash glob expansion) +'use strict'; +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; +const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; +const PM2 = '/home/adminbackend/.npm-global/bin/pm2'; + +function exec(conn, cmd, label) { + if (label) console.log('\n[' + label + ']'); + console.log('$ ' + cmd.substring(0, 90)); + return new Promise(resolve => { + conn.exec(cmd, (err, s) => { + if (err) { resolve(''); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .on('close', () => resolve(out)); + }); + }); +} + +// Konten start-with-env.js yang akan ditulis ke server +const startWithEnvContent = `'use strict'; +// Polyfill global crypto for Node 18 compatibility (required by mongodb driver) +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = require('crypto'); +} +/** + * BackOne DPI - Safe Environment Loader for Next.js Standalone + * Reads .env.production safely without bash glob expansion issues. + */ +const path = require('path'); +const fs = require('fs'); + +const envFile = path.join(__dirname, '.env.production'); + +if (fs.existsSync(envFile)) { + const lines = fs.readFileSync(envFile, 'utf8').split(/\\r?\\n/); + let loaded = 0; + for (const raw of lines) { + const line = raw.trim(); + // Skip comments and empty lines + if (!line || line.startsWith('#')) continue; + const eqIdx = line.indexOf('='); + if (eqIdx === -1) continue; + const key = line.substring(0, eqIdx).trim(); + // Only accept valid environment variable names + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + const val = line.substring(eqIdx + 1); // Do NOT trim value - preserve exact content + if (!(key in process.env)) { + process.env[key] = val; + loaded++; + } + } + console.log('[startup] Loaded ' + loaded + ' environment variables from .env.production'); + console.log('[startup] NETIFY_API_KEY set:', !!process.env.NETIFY_API_KEY); + console.log('[startup] MONGODB_URI set: ', !!process.env.MONGODB_URI); +} else { + console.warn('[startup] WARNING: .env.production not found at:', envFile); +} + +// Start Next.js standalone server +require('./.next/standalone/server.js'); +`; + +async function main() { + const conn = new Client(); + await new Promise((r, j) => { conn.on('ready', r).on('error', j).connect(SSH); }); + console.log('[SSH] Connected\n'); + + // 1. Upload start-with-env.js ke server + await exec(conn, + 'cat > ' + PUB + '/start-with-env.js << \'NODELOADER_EOF\'\n' + startWithEnvContent + '\nNODELOADER_EOF\n' + + 'echo "Node.js loader dibuat"', + '1. Buat start-with-env.js' + ); + await exec(conn, 'wc -l ' + PUB + '/start-with-env.js', '2. Verifikasi file'); + + // 2. Update ecosystem.config.js untuk gunakan Node.js loader (bukan bash wrapper) + const ecoContent = `// ecosystem.config.js — PM2 Config (server-side) +module.exports = { + apps: [ + { + name: 'backone-proxy', + script: './proxy/index.js', + cwd: '${PUB}', + instances: 1, + exec_mode: 'fork', + watch: false, + node_args: '--max-old-space-size=256', + max_memory_restart: '300M', + restart_delay: 5000, + max_restarts: 10, + env_file: '.env.production', + env: { NODE_ENV: 'production' }, + error_file: './logs/proxy-error.log', + out_file: './logs/proxy-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + }, + { + name: 'backone-backend', + script: './backend/server.js', + cwd: '${PUB}', + instances: 1, + exec_mode: 'fork', + watch: false, + node_args: '--max-old-space-size=256', + max_memory_restart: '400M', + restart_delay: 3000, + max_restarts: 10, + env_file: '.env.production', + env: { NODE_ENV: 'production' }, + error_file: './logs/backend-error.log', + out_file: './logs/backend-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + }, + { + name: 'backone-frontend', + script: './start-with-env.js', + cwd: '${PUB}', + instances: 1, + exec_mode: 'fork', + watch: false, + node_args: '--max-old-space-size=512', + max_memory_restart: '700M', + restart_delay: 3000, + max_restarts: 10, + env: { + NODE_ENV: 'production', + PORT: '3000', + HOSTNAME: '127.0.0.1', + NEXT_TELEMETRY_DISABLED: '1', + }, + error_file: './logs/frontend-error.log', + out_file: './logs/frontend-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + }, + ], +};`; + + await exec(conn, + 'cat > ' + PUB + '/ecosystem.config.js << \'ECO_EOF\'\n' + ecoContent + '\nECO_EOF\necho "ecosystem.config.js diperbarui"', + '3. Update ecosystem.config.js' + ); + + // 3. Delete & restart frontend + await exec(conn, + 'cd ' + PUB + ' && ' + PM2 + ' delete backone-frontend 2>/dev/null; ' + + PM2 + ' start ecosystem.config.js --only backone-frontend && echo "START OK"', + '4. Restart frontend dengan Node.js loader' + ); + + // 4. Tunggu startup + await new Promise(r => setTimeout(r, 8000)); + + // 5. Cek out log (harus tampil "Loaded X environment variables") + await exec(conn, 'tail -10 ' + PUB + '/logs/frontend-out.log 2>/dev/null', '5. Out log (env loader output)'); + await exec(conn, 'tail -5 ' + PUB + '/logs/frontend-error.log 2>/dev/null', '6. Error log terbaru'); + + // 6. Test agents + await exec(conn, 'curl -sk -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/agents', '7. Test /agents page'); + + // 7. PM2 status + await exec(conn, PM2 + ' list 2>/dev/null | grep backone', '8. PM2 status'); + + conn.end(); + console.log('\n✅ Selesai!\n'); +} + +main().catch(e => console.error('[FATAL]', e.message)); diff --git a/scripts/fix-env-vars.js b/scripts/fix-env-vars.js new file mode 100644 index 0000000..4cc54c3 --- /dev/null +++ b/scripts/fix-env-vars.js @@ -0,0 +1,78 @@ +// scripts/fix-env-vars.js +// Fix: Pastikan environment variables dari .env.production dimuat oleh PM2 frontend +'use strict'; +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; +const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; +const PM2 = '/home/adminbackend/.npm-global/bin/pm2'; + +function exec(conn, cmd, label) { + if (label) console.log('\n[' + label + ']'); + console.log('$ ' + cmd.substring(0, 90)); + return new Promise(resolve => { + conn.exec(cmd, (err, s) => { + if (err) { resolve(''); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .on('close', () => resolve(out)); + }); + }); +} + +async function main() { + const conn = new Client(); + await new Promise((r, j) => { conn.on('ready', r).on('error', j).connect(SSH); }); + console.log('[SSH] Connected\n'); + + // 1. Cek env vars yang diterima proses PM2 sekarang + await exec(conn, PM2 + ' env 3 2>/dev/null | grep -E "(NETIFY|BACKONE|MONGO|JWT)" | head -15', '1. Env vars di PM2 proses 3 (frontend)'); + + // 2. Cek isi .env.production + await exec(conn, 'cat ' + PUB + '/.env.production 2>/dev/null | grep -v "#" | grep -v "^$" | head -30', '2. Isi .env.production (non-comment)'); + + // 3. Export vars dari .env.production dan restart dengan env vars eksplisit + // Baca .env.production, parse, dan set ke PM2 + const envScript = ` +set -a +source ${PUB}/.env.production 2>/dev/null +set +a +export NETIFY_API_KEY="$NETIFY_API_KEY" +export NETIFY_ORG_UUID="$NETIFY_ORG_UUID" +export NETIFY_SITE_UUIDS="$NETIFY_SITE_UUIDS" +export NETIFY_INFORMATICS_BASE_URL="$NETIFY_INFORMATICS_BASE_URL" +export MONGODB_URI="$MONGODB_URI" +export JWT_SECRET="$JWT_SECRET" +export NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" +echo "API_KEY=$NETIFY_API_KEY" +echo "MONGO_URI_PARTIAL=$(echo $MONGODB_URI | cut -c1-30)..." + `.trim(); + await exec(conn, 'bash -c \'' + envScript.replace(/\n/g, '; ') + '\'', '3. Verifikasi env vars bisa dibaca dari .env.production'); + + // 4. Delete dan restart PM2 proses dengan ecosystem file yang membaca env_file + await exec(conn, + 'cd ' + PUB + ' && ' + PM2 + ' delete backone-frontend 2>/dev/null; ' + + PM2 + ' start ecosystem.config.js --only backone-frontend --env production && echo "START OK"', + '4. Restart frontend via ecosystem.config.js' + ); + + // 5. Verifikasi env vars sekarang dimuat + await new Promise(r => setTimeout(r, 5000)); + await exec(conn, PM2 + ' env 3 2>/dev/null | grep -E "(NETIFY|BACKONE|MONGO)" | head -10', '5. Verifikasi env vars setelah restart'); + + // 6. Test agents page + await new Promise(r => setTimeout(r, 5000)); + await exec(conn, 'curl -sk -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/agents', '6. Test /agents'); + + // 7. Cek error log terbaru + await exec(conn, 'tail -5 ' + PUB + '/logs/frontend-error.log 2>/dev/null', '7. Error log terbaru'); + + // 8. PM2 status akhir + await exec(conn, PM2 + ' list 2>/dev/null | grep backone', '8. PM2 status akhir'); + + conn.end(); + console.log('\n✅ Selesai!\n'); +} + +main().catch(e => console.error('[FATAL]', e.message)); diff --git a/scripts/fix-https.js b/scripts/fix-https.js new file mode 100644 index 0000000..62ac252 --- /dev/null +++ b/scripts/fix-https.js @@ -0,0 +1,110 @@ +// scripts/fix-https.js +// ───────────────────────────────────────────────────────────────────────────── +// BackOne DPI — Fix HTTPS 502 Bad Gateway Issue +// The cPanel Apache proxy is getting 502 — need to investigate and fix +// ───────────────────────────────────────────────────────────────────────────── +'use strict'; + +const { Client: SshClient } = require('ssh2'); + +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', +}; + +const ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; +const PM2 = '/home/adminbackend/.npm-global/bin/pm2'; + +const COMMANDS = [ + // Check current frontend status + `echo "=== FRONTEND PROCESS ==="`, + `ps aux | grep node | grep -v grep`, + `ss -tlnp | grep 3000`, + + // Check if frontend is responding internally + `echo "=== INTERNAL FRONTEND TEST ===" && curl -sv http://127.0.0.1:3000/ 2>&1 | grep -E "(< HTTP|< Location|Connected|curl)" | head -10`, + + // Check the .htaccess to confirm proxy setup + `echo "=== .HTACCESS CONTENT ===" && cat ${ROOT}/.htaccess`, + + // Check Apache error log for this domain + `echo "=== APACHE ERROR (last 20 lines) ===" && tail -20 /home/adminbackend/logs/demoplace.my.id-ssl_log 2>/dev/null || tail -20 /home/adminbackend/logs/demoplace.my.id-error_log 2>/dev/null || tail -20 /home/adminbackend/logs/error_log 2>/dev/null || echo "No Apache error log found"`, + + // Check all logs + `echo "=== LOG FILES ===" && ls /home/adminbackend/logs/ 2>/dev/null | head -20`, + + // Check domain SSL logs + `echo "=== SSL LOG ===" && tail -30 /home/adminbackend/logs/demoplace.my.id-ssl_log 2>/dev/null | grep -E "(502|500|error|proxy)" | tail -15 || echo "No SSL log"`, + + // Try the domain via HTTP (non-SSL) + `echo "=== HTTP (non-SSL) TEST ===" && curl -sv http://demoplace.my.id/ 2>&1 | grep -E "(< HTTP|Location|Connected)" | head -5`, + + // Check PM2 frontend is running + `echo "=== PM2 FRONTEND STATUS ===" && ${PM2} show backone-frontend 2>&1 | grep -E "(status|pid|uptime|restart)" | head -10`, + + // Check frontend log for errors + `echo "=== FRONTEND LOG (errors) ===" && ${PM2} logs backone-frontend --lines 30 --nostream 2>&1 | grep -E "(Error|error|ECONNRESET|Ready|✓)" | tail -15`, + + // Re-check port + `echo "=== PORT 3000 DETAILED ===" && ss -tlnp | grep 3000`, + + // Try direct curl to frontend with more verbose output + `echo "=== DETAILED FRONTEND CURL ===" && curl -v http://127.0.0.1:3000/login 2>&1 | head -30`, + + // Check if there's an issue with the standalone server path in PM2 + `echo "=== PM2 SCRIPT PATH ===" && ${PM2} show backone-frontend 2>&1 | grep -E "(script|cwd|exec)" | head -5`, + + // Check node version + `echo "=== NODE VERSION ===" && node --version`, + + // Check if frontend started correctly after restart + `echo "=== FRONTEND READY STATUS ===" && ${PM2} logs backone-frontend --lines 10 --nostream 2>&1 | tail -15`, + + // Try restarting frontend + `echo "=== RESTARTING FRONTEND ===" && ${PM2} restart backone-frontend && sleep 5 && echo "Restarted"`, + + // Final port check + `echo "=== FINAL PORT CHECK ===" && ss -tlnp | grep 3000`, + `echo "=== FINAL CURL ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/login`, + + // Check if maybe cPanel node app is conflicting + `echo "=== CPANEL NODE APPS ===" && ls /home/adminbackend/nodevenv/ 2>/dev/null || echo "No cPanel Node.js virtual environments"`, + `cat /home/adminbackend/.cpanel/nvdata/node_application_config 2>/dev/null | head -30 || echo "No cPanel Node config"`, +]; + +function runSsh(commands) { + return new Promise((resolve, reject) => { + const ssh = new SshClient(); + ssh.on('ready', () => { + console.log('[SSH] Connected!\n'); + let i = 0; + function next() { + if (i >= commands.length) { ssh.end(); return; } + const cmd = commands[i++]; + console.log(`\n$ ${cmd.substring(0, 130)}${cmd.length > 130 ? '...' : ''}`); + ssh.exec(cmd, (err, stream) => { + if (err) { console.error('[ERR]', err.message); next(); return; } + stream.on('data', d => process.stdout.write(d.toString())); + stream.stderr.on('data', d => { const t = d.toString(); if (!t.includes('npm warn') && !t.includes('notice')) process.stdout.write(t); }); + stream.on('close', next); + }); + } + next(); + ssh.on('end', resolve); + }); + ssh.on('error', reject); + ssh.connect({ ...CONFIG, readyTimeout: 30000 }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — HTTPS 502 Investigation & Fix ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + await runSsh(COMMANDS); + console.log('\n✅ Investigation complete!\n'); +} + +main().catch(err => { console.error('[FATAL]', err); process.exit(1); }); diff --git a/scripts/fix-missing-css.js b/scripts/fix-missing-css.js new file mode 100644 index 0000000..d41df34 --- /dev/null +++ b/scripts/fix-missing-css.js @@ -0,0 +1,88 @@ +// scripts/fix-missing-css.js +'use strict'; +const { Client } = require('ssh2'); +const https = require('https'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; +const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; +const SA = PUB + '/.next/standalone/.next/static'; +const BP = '/home/adminbackend/backone-production/.next/static'; + +function exec(conn, cmd, label) { + if (label) console.log('\n[' + label + ']'); + console.log('$ ' + cmd.substring(0, 80)); + return new Promise(resolve => { + conn.exec(cmd, (err, s) => { + if (err) { resolve(''); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .on('close', () => resolve(out)); + }); + }); +} + +async function httpGet(url) { + return new Promise(resolve => { + const req = https.get(url, { timeout: 12000, rejectUnauthorized: false }, res => { + let body = ''; + res.on('data', d => body += d); + res.on('end', () => resolve({ status: res.statusCode, size: body.length, type: res.headers['content-type'] })); + }); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); +} + +async function main() { + const conn = new Client(); + await new Promise((r, j) => { conn.on('ready', r).on('error', j).connect(SSH); }); + console.log('[SSH] Connected\n'); + + // 1. Cari file CSS yang hilang + await exec(conn, 'find /home/adminbackend -name "1oskchnhjm4n8.css" 2>/dev/null | head -5', '1. Cari CSS hilang'); + + // 2. List CSS di backone-production + await exec(conn, 'find ' + BP + ' -name "*.css" 2>/dev/null | head -10', '2. CSS di backone-production'); + + // 3. Full rsync chunks (paksa semua file) + await exec(conn, + 'rsync -av ' + BP + '/chunks/ ' + SA + '/chunks/ 2>&1 | grep "css\\|sent\\|total" | head -10', + '3. Full rsync chunks' + ); + + // 4. Verifikasi CSS setelah rsync + await exec(conn, 'find ' + SA + '/chunks -name "*.css" 2>/dev/null', '4. CSS di standalone setelah rsync'); + + // 5. Cek total file + await exec(conn, 'ls ' + SA + '/chunks/ | wc -l', '5. Total file chunks'); + + conn.end(); + + // 6. Final test + console.log('\n[6. Final test static files...]'); + await new Promise(r => setTimeout(r, 2000)); + const files = [ + '/_next/static/chunks/3km6z-n6wbgrs.js', + '/_next/static/chunks/1u6dw_tm45utz.css', + '/_next/static/chunks/1oskchnhjm4n8.css', + '/_next/static/chunks/21luguo5_g2uu.js', + '/_next/static/chunks/1-8s9_t85wwr4.js', + '/_next/static/chunks/2n6j37owj-kaf.js', + ]; + let allOk = true; + for (const f of files) { + const r = await httpGet('https://demoplace.my.id' + f); + const icon = r.status === 200 ? '✅' : '❌'; + const ext = f.endsWith('.css') ? 'CSS' : 'JS '; + console.log(' ' + icon + ' [' + ext + '] HTTP ' + r.status + ' | ' + (r.size || 0) + ' bytes'); + if (r.status !== 200) { console.log(' → ' + f); allOk = false; } + } + if (allOk) { + console.log('\n🎉 SEMUA CSS/JS BERHASIL! Lakukan hard refresh (Ctrl+Shift+R)\n'); + } else { + console.log('\n⚠ Masih ada yang 404. Cek manual.\n'); + } +} + +main().catch(e => console.error('[FATAL]', e.message)); diff --git a/scripts/fix-mongoose-module.js b/scripts/fix-mongoose-module.js new file mode 100644 index 0000000..47146e1 --- /dev/null +++ b/scripts/fix-mongoose-module.js @@ -0,0 +1,80 @@ +// scripts/fix-mongoose-module.js +// Fix: Copy mongoose ke standalone/node_modules agar tersedia sebagai external package +'use strict'; +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; +const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; +const SA = PUB + '/.next/standalone'; +const BP = '/home/adminbackend/backone-production'; + +function exec(conn, cmd, label) { + if (label) console.log('\n[' + label + ']'); + console.log('$ ' + cmd.substring(0, 90)); + return new Promise(resolve => { + conn.exec(cmd, (err, s) => { + if (err) { resolve(''); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .on('close', () => resolve(out)); + }); + }); +} + +async function main() { + const conn = new Client(); + await new Promise((r, j) => { conn.on('ready', r).on('error', j).connect(SSH); }); + console.log('[SSH] Connected\n'); + + const PM2 = '/home/adminbackend/.npm-global/bin/pm2'; + + // 1. Cek lokasi mongoose + await exec(conn, 'ls ' + SA + '/node_modules/mongoose 2>/dev/null | head -3 || echo "Tidak ada di standalone/node_modules"', '1. Cek mongoose di standalone'); + await exec(conn, 'ls ' + BP + '/node_modules/mongoose 2>/dev/null | head -3 || echo "Tidak ada di backone-production"', '2. Cek mongoose di backone-production'); + await exec(conn, 'ls ' + PUB + '/node_modules/mongoose 2>/dev/null | head -3 || echo "Tidak ada di public_html/node_modules"', '3. Cek mongoose di public_html'); + + // 2. Copy mongoose ke standalone/node_modules (dari backone-production) + const mongooseSrc = BP + '/node_modules/mongoose'; + const mongooseDst = SA + '/node_modules/mongoose'; + await exec(conn, + '[ -d ' + mongooseSrc + ' ] && (' + + ' [ -e ' + mongooseDst + ' ] && echo "Sudah ada" || ' + + ' (cp -r ' + mongooseSrc + ' ' + mongooseDst + ' && echo "BERHASIL: mongoose disalin ke standalone") ' + + ') || echo "Sumber tidak ditemukan"', + '4. Copy mongoose ke standalone/node_modules' + ); + + // 3. Verifikasi + await exec(conn, 'ls ' + mongooseDst + ' 2>/dev/null | head -5 || echo "GAGAL: tidak ada"', '5. Verifikasi mongoose di standalone'); + + // 4. Juga pastikan dependensi mongoose tersedia (bson, etc.) + const deps = ['bson', 'kareem', 'mquery', 'mpath', 'sift']; + for (const dep of deps) { + const src = BP + '/node_modules/' + dep; + const dst = SA + '/node_modules/' + dep; + await exec(conn, + '[ -d ' + src + ' ] && ([ -e ' + dst + ' ] || (cp -r ' + src + ' ' + dst + ' && echo "Copied: ' + dep + '")) || echo "Skip: ' + dep + '"', + null + ); + } + + // 5. Restart frontend + await exec(conn, PM2 + ' restart backone-frontend && echo "RESTART OK"', '6. Restart backone-frontend'); + + // 6. Tunggu dan cek status + await new Promise(r => setTimeout(r, 5000)); + await exec(conn, PM2 + ' list 2>/dev/null | grep backone', '7. Status PM2 setelah restart'); + + // 7. Test halaman agents + await new Promise(r => setTimeout(r, 3000)); + await exec(conn, 'curl -sk -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/agents', '8. Test /agents setelah fix'); + + // 8. Cek error log setelah restart + await exec(conn, 'tail -10 ' + PUB + '/logs/frontend-error.log 2>/dev/null', '9. Error log terbaru'); + + conn.end(); + console.log('\n✅ Selesai! Refresh browser ke https://demoplace.my.id/agents\n'); +} + +main().catch(e => console.error('[FATAL]', e.message)); diff --git a/scripts/fix-pm2-env-final.js b/scripts/fix-pm2-env-final.js new file mode 100644 index 0000000..a7ef2c2 --- /dev/null +++ b/scripts/fix-pm2-env-final.js @@ -0,0 +1,156 @@ +// scripts/fix-pm2-env-final.js +// Fix definitif: Buat startup wrapper script yang load .env.production sebelum menjalankan Next.js +'use strict'; +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; +const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; +const PM2 = '/home/adminbackend/.npm-global/bin/pm2'; + +function exec(conn, cmd, label) { + if (label) console.log('\n[' + label + ']'); + console.log('$ ' + cmd.substring(0, 90)); + return new Promise(resolve => { + conn.exec(cmd, (err, s) => { + if (err) { resolve(''); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .on('close', () => resolve(out)); + }); + }); +} + +async function main() { + const conn = new Client(); + await new Promise((r, j) => { conn.on('ready', r).on('error', j).connect(SSH); }); + console.log('[SSH] Connected\n'); + + // 1. Buat wrapper script yang source .env.production + const wrapperContent = [ + '#!/bin/bash', + '# BackOne DPI - Frontend startup wrapper', + '# Source environment variables from .env.production', + 'ENV_FILE="' + PUB + '/.env.production"', + 'if [ -f "$ENV_FILE" ]; then', + ' set -a', + ' source "$ENV_FILE"', + ' set +a', + ' echo "[startup] Environment variables loaded from .env.production"', + 'else', + ' echo "[startup] WARNING: .env.production not found at $ENV_FILE"', + 'fi', + '# Start Next.js standalone server', + 'exec node ' + PUB + '/.next/standalone/server.js', + ].join('\n'); + + await exec(conn, + 'cat > ' + PUB + '/start-frontend.sh << \'WRAPPER_EOF\'\n' + wrapperContent + '\nWRAPPER_EOF\n' + + 'chmod +x ' + PUB + '/start-frontend.sh && echo "Wrapper script dibuat"', + '1. Buat wrapper startup script' + ); + + await exec(conn, 'cat ' + PUB + '/start-frontend.sh', '2. Verifikasi isi wrapper script'); + + // 2. Update ecosystem.config.js di SERVER untuk gunakan wrapper script + const ecosystemContent = `// ecosystem.config.js — PM2 Production Config (Server-side, DO NOT COMMIT) +module.exports = { + apps: [ + { + name: 'backone-proxy', + script: './proxy/index.js', + cwd: '${PUB}', + instances: 1, + exec_mode: 'fork', + watch: false, + node_args: '--max-old-space-size=256', + max_memory_restart: '300M', + restart_delay: 5000, + max_restarts: 10, + env_file: '.env.production', + env: { NODE_ENV: 'production' }, + error_file: './logs/proxy-error.log', + out_file: './logs/proxy-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + }, + { + name: 'backone-backend', + script: './backend/server.js', + cwd: '${PUB}', + instances: 1, + exec_mode: 'fork', + watch: false, + node_args: '--max-old-space-size=256', + max_memory_restart: '400M', + restart_delay: 3000, + max_restarts: 10, + env_file: '.env.production', + env: { NODE_ENV: 'production' }, + error_file: './logs/backend-error.log', + out_file: './logs/backend-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + }, + { + name: 'backone-frontend', + script: './start-frontend.sh', + interpreter: 'bash', + cwd: '${PUB}', + instances: 1, + exec_mode: 'fork', + watch: false, + max_memory_restart: '700M', + restart_delay: 3000, + max_restarts: 10, + env: { + NODE_ENV: 'production', + PORT: 3000, + HOSTNAME: '127.0.0.1', + NEXT_TELEMETRY_DISABLED: '1', + }, + error_file: './logs/frontend-error.log', + out_file: './logs/frontend-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + }, + ], +};`; + + await exec(conn, + 'cat > ' + PUB + '/ecosystem.config.js << \'ECOSYSTEM_EOF\'\n' + ecosystemContent + '\nECOSYSTEM_EOF\n' + + 'echo "ecosystem.config.js diperbarui"', + '3. Update ecosystem.config.js di server' + ); + + // 3. Delete & restart frontend dengan config baru + await exec(conn, + 'cd ' + PUB + ' && ' + PM2 + ' delete backone-frontend 2>/dev/null; ' + + PM2 + ' start ecosystem.config.js --only backone-frontend && echo "START OK"', + '4. Start frontend dengan wrapper script' + ); + + // 4. Tunggu startup + await new Promise(r => setTimeout(r, 8000)); + + // 5. Verifikasi env vars + const listOut = await exec(conn, PM2 + ' list 2>/dev/null | grep backone-frontend', '5. PM2 status'); + // Ambil ID proses baru + const match = listOut.match(/│\s+(\d+)\s+│\s+backone-frontend/); + if (match) { + const newId = match[1]; + await exec(conn, PM2 + ' env ' + newId + ' 2>/dev/null | grep -E "(NETIFY|MONGO|JWT)" | head -5', '6. Env vars di proses baru'); + } + + // 6. Test agents + await exec(conn, 'curl -sk -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/agents', '7. Test /agents'); + + // 7. Error log terbaru + await exec(conn, 'tail -5 ' + PUB + '/logs/frontend-error.log 2>/dev/null', '8. Error log terbaru'); + await exec(conn, 'tail -3 ' + PUB + '/logs/frontend-out.log 2>/dev/null', '9. Out log terbaru'); + + conn.end(); + console.log('\n✅ Selesai!\n'); +} + +main().catch(e => console.error('[FATAL]', e.message)); diff --git a/scripts/fix-proxy-headers.js b/scripts/fix-proxy-headers.js new file mode 100644 index 0000000..cb4673e --- /dev/null +++ b/scripts/fix-proxy-headers.js @@ -0,0 +1,223 @@ +// scripts/fix-proxy-headers.js +// Fix: proxy.php harus hapus Content-Length agar CSS/JS tidak terpotong +'use strict'; + +const SftpClient = require('ssh2-sftp-client'); +const { Client } = require('ssh2'); +const https = require('https'); + +const CONFIG = { + host: '103.185.47.52', port: 2222, + username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; + +// ── proxy.php yang sudah diperbaiki ────────────────────────────────────── +// Fix utama: tambahkan content-length ke daftar header yang dibuang +// agar tidak terjadi mismatch antara ukuran compressed vs uncompressed +const PROXY_PHP = `<?php +/** + * BackOne DPI - PHP Reverse Proxy (Fixed) + * Forward semua request ke Next.js port 3000 + * Fix: Content-Length dihapus agar tidak terjadi data truncation + */ +define('NEXTJS_URL', 'http://127.0.0.1:3000'); + +$uri = $_SERVER['REQUEST_URI'] ?? '/'; +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$target = NEXTJS_URL . $uri; + +// Headers yang diteruskan ke Next.js +$fwdHeaders = [ + 'X-Forwarded-For: ' . ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'), + 'X-Forwarded-Proto: https', + 'X-Real-IP: ' . ($_SERVER['REMOTE_ADDR'] ?? ''), + 'Host: demoplace.my.id', + 'Accept-Encoding: identity', +]; + +$allHeaders = getallheaders() ?: []; +$skip = ['host', 'connection', 'transfer-encoding', 'keep-alive', 'content-length', 'accept-encoding']; +foreach ($allHeaders as $name => $value) { + if (!in_array(strtolower($name), $skip)) { + $fwdHeaders[] = "$name: $value"; + } +} + +$body = in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE']) ? file_get_contents('php://input') : null; + +$ch = curl_init(); +curl_setopt_array($ch, [ + CURLOPT_URL => $target, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $fwdHeaders, + CURLOPT_POSTFIELDS => $body, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 5, + // PENTING: Tidak pakai CURLOPT_ENCODING agar response tidak dikompresi + // (kita sudah kirim Accept-Encoding: identity ke Next.js) +]); + +$response = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); +$curlError = curl_error($ch); +curl_close($ch); + +if ($response === false) { + http_response_code(502); + header('Content-Type: text/plain'); + echo 'BackOne: Gagal terhubung ke Next.js. Error: ' . $curlError; + exit; +} + +$responseHeaders = substr($response, 0, $headerSize); +$responseBody = substr($response, $headerSize); + +http_response_code($httpCode ?: 200); + +// Header yang TIDAK diteruskan ke browser +$skipRes = [ + 'transfer-encoding', + 'connection', + 'keep-alive', + 'content-encoding', + 'content-length', // FIX: hapus agar tidak terjadi size mismatch +]; + +foreach (explode("\\r\\n", $responseHeaders) as $header) { + $header = trim($header); + if (empty($header) || preg_match('/^HTTP\\//i', $header)) continue; + $colonPos = strpos($header, ':'); + if ($colonPos === false) continue; + $headerName = strtolower(trim(substr($header, 0, $colonPos))); + if (in_array($headerName, $skipRes)) continue; + header($header, false); +} + +echo $responseBody; +`; + +// ── .htaccess dengan FollowSymLinks ────────────────────────────────────── +const HTACCESS = `# BackOne DPI — Apache .htaccess +Options +FollowSymLinks + +# Redirect HTTP ke HTTPS +RewriteEngine On +RewriteCond %{HTTPS} !=on +RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + +# Static Next.js files: sajikan langsung dari disk (via symlink _next -> .next) +RewriteCond %{REQUEST_FILENAME} -f +RewriteRule ^ - [L] + +# Semua request lain: teruskan ke PHP proxy (Next.js port 3000) +RewriteRule ^(.*)$ /proxy.php [L,QSA] +`; + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Fix CSS/JS Content-Length & Symlinks ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + // ── Diagnosa dulu: cek apa yang dikirim ke browser untuk CSS ────────── + console.log('▶ Diagnosa: Cek CSS URL yang ada di HTML login page...'); + const conn1 = new Client(); + await new Promise((r, j) => { conn1.on('ready', r).on('error', j).connect(CONFIG); }); + + // Ambil HTML dari port 3000 dan cari CSS links + const htmlResult = await new Promise(resolve => { + conn1.exec( + `curl -sk http://127.0.0.1:3000/login | grep -o 'href="/_next/static/css/[^"]*"' | head -3`, + (err, s) => { + if (err) { resolve('error'); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; }) + .on('close', () => resolve(out.trim())); + } + ); + }); + + console.log(`CSS refs: ${htmlResult}`); + + // Test apakah CSS bisa diakses langsung + if (htmlResult) { + const cssPath = htmlResult.match(/href="([^"]+)"/)?.[1]; + if (cssPath) { + await new Promise(resolve => { + conn1.exec( + `curl -sk -D - http://127.0.0.1:3000${cssPath} | head -15`, + (err, s) => { + if (err) { resolve(); return; } + s.on('data', d => process.stdout.write(d.toString())) + .stderr.on('data', () => {}) + .on('close', resolve); + } + ); + }); + } + } + + conn1.end(); + + // ── Upload proxy.php dan .htaccess yang sudah diperbaiki ────────────── + console.log('\n▶ Upload perbaikan via SFTP...'); + const sftp = new SftpClient(); + await sftp.connect(CONFIG); + + await sftp.put(Buffer.from(PROXY_PHP, 'utf8'), `${ROOT}/proxy.php`); + console.log(' ✅ proxy.php (fixed: Content-Length stripped, Accept-Encoding: identity)'); + + await sftp.put(Buffer.from(HTACCESS, 'utf8'), `${ROOT}/.htaccess`); + console.log(' ✅ .htaccess (fixed: +FollowSymLinks added)'); + + await sftp.end(); + + // ── Verifikasi CSS via domain ───────────────────────────────────────── + console.log('\n▶ Test CSS loading via domain (tunggu 3 detik)...'); + await new Promise(r => setTimeout(r, 3000)); + + const conn2 = new Client(); + await new Promise((r, j) => { conn2.on('ready', r).on('error', j).connect(CONFIG); }); + + // Ambil CSS URL dari domain lalu test + const cssUrlResult = await new Promise(resolve => { + conn2.exec( + `CSS=$(curl -sk https://demoplace.my.id/login | grep -o '/_next/static/css/[^"]*' | head -1); ` + + `[ -n "$CSS" ] && curl -sk -D - "https://demoplace.my.id$CSS" | head -10 || echo "Tidak ada CSS URL ditemukan"`, + (err, s) => { + if (err) { resolve(); return; } + s.on('data', d => process.stdout.write(d.toString())) + .stderr.on('data', () => {}) + .on('close', resolve); + } + ); + }); + + conn2.end(); + + // ── Final test ──────────────────────────────────────────────────────── + console.log('\n▶ Final test domain...'); + for (const path of ['/', '/login']) { + const r = await new Promise(resolve => { + const req = https.get(`https://demoplace.my.id${path}`, + { timeout: 12000, rejectUnauthorized: false }, + res => resolve({ status: res.statusCode, location: res.headers.location }) + ); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); + const icon = r.status === 200 ? '✅' : r.status >= 300 && r.status < 400 ? '↩️ ' : '❌'; + console.log(` ${icon} https://demoplace.my.id${path} → HTTP ${r.status}`); + } + + console.log('\n✅ Selesai! Refresh browser dengan Ctrl+Shift+R (hard refresh)\n'); +} + +main().catch(err => { console.error('[FATAL]', err.message); process.exit(1); }); diff --git a/scripts/fix-static-paths.js b/scripts/fix-static-paths.js new file mode 100644 index 0000000..82bfcc5 --- /dev/null +++ b/scripts/fix-static-paths.js @@ -0,0 +1,129 @@ +// scripts/fix-static-paths.js +// Menyalin static files dari backone-production ke standalone yang benar +// Masalah: Upload Windows membuat direktori dengan backslash literal di Linux +'use strict'; + +const { Client } = require('ssh2'); +const https = require('https'); + +const SSH = { + host: '103.185.47.52', port: 2222, + username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +function exec(conn, cmd, label) { + if (label) console.log('\n[' + label + ']'); + console.log('$ ' + cmd.substring(0, 90)); + return new Promise(resolve => { + conn.exec(cmd, (err, s) => { + if (err) { resolve(''); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .on('close', () => resolve(out)); + }); + }); +} + +async function httpGet(url) { + return new Promise(resolve => { + const req = https.get(url, { timeout: 12000, rejectUnauthorized: false }, res => { + let body = ''; + res.on('data', d => body += d); + res.on('end', () => resolve({ status: res.statusCode, size: body.length, type: res.headers['content-type'] })); + }); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Fix Static Paths (Windows Backslash) ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + const conn = new Client(); + await new Promise((r, j) => { conn.on('ready', r).on('error', j).connect(SSH); }); + console.log('[SSH] Terhubung!\n'); + + const BP = '/home/adminbackend/backone-production'; + const SA = '/home/adminbackend/web/demoplace.my.id/public_html/.next/standalone'; + const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; + + // 1. Cek BUILD_ID di backone-production + await exec(conn, 'cat ' + BP + '/.next/BUILD_ID 2>/dev/null || echo "NO_BUILDID"', '1. BUILD_ID backone-production'); + + // 2. Verifikasi chunk files ada di backone-production + await exec(conn, + 'ls ' + BP + '/.next/static/chunks/ | grep -E "(3km6z|21luguo|1u6dw|1osk)" | head -10', + '2. Cek chunk files di backone-production' + ); + + // 3. Sinkronisasi static files dari backone-production ke standalone + await exec(conn, + 'rsync -av --ignore-existing ' + BP + '/.next/static/ ' + SA + '/.next/static/ 2>&1 | tail -5', + '3. Rsync static files ke standalone/.next/static' + ); + + // 4. Verifikasi chunks sudah ada di standalone + await exec(conn, + 'ls ' + SA + '/.next/static/chunks/ | grep -E "(3km6z|21luguo|1u6dw|1osk)" | head -10', + '4. Verifikasi chunks di standalone setelah rsync' + ); + + await exec(conn, + 'ls ' + SA + '/.next/static/chunks/ | wc -l', + '5. Total file chunks di standalone' + ); + + // 5. Update symlink _next → standalone/.next + await exec(conn, + 'rm -f ' + PUB + '/_next && ln -s ' + SA + '/.next ' + PUB + '/_next && echo "Symlink updated"', + '6. Update symlink _next → standalone/.next' + ); + + // 6. Verifikasi symlink + await exec(conn, + 'ls -la ' + PUB + '/_next && ls ' + PUB + '/_next/static/chunks/ | grep -E "(3km6z|21luguo)" | head -5', + '7. Verifikasi symlink baru' + ); + + // 7. Juga sinkronisasi public/ dari backone-production jika dibutuhkan + await exec(conn, + 'rsync -av --ignore-existing ' + BP + '/public/ ' + PUB + '/public/ 2>&1 | tail -3 || echo "Tidak perlu sync public/"', + '8. Sync public/ dari backone-production' + ); + + conn.end(); + + // 8. Test CSS/JS via domain + console.log('\n[9. Test CSS/JS via domain setelah fix...]'); + await new Promise(r => setTimeout(r, 3000)); + + const files = [ + '/_next/static/chunks/3km6z-n6wbgrs.js', + '/_next/static/chunks/1u6dw_tm45utz.css', + '/_next/static/chunks/1oskchnhjm4n8.css', + '/_next/static/chunks/21luguo5_g2uu.js', + '/_next/static/chunks/1-8s9_t85wwr4.js', + ]; + + let allOk = true; + for (const f of files) { + const r = await httpGet('https://demoplace.my.id' + f); + const icon = r.status === 200 ? '✅' : '❌'; + const ext = f.endsWith('.css') ? 'CSS' : 'JS '; + console.log(' ' + icon + ' [' + ext + '] HTTP ' + r.status + ' | ' + (r.size || 0) + ' bytes | ' + (r.type || 'N/A')); + if (r.status !== 200) { console.log(' → ' + f); allOk = false; } + } + + if (allOk) { + console.log('\n🎉 SEMUA CSS/JS BERHASIL TERMUAT!'); + console.log(' Lakukan HARD REFRESH (Ctrl+Shift+R) di browser.\n'); + } else { + console.log('\n⚠️ Beberapa file masih 404. Mungkin perlu investigasi lebih lanjut.\n'); + } +} + +main().catch(err => { console.error('[FATAL]', err.message); process.exit(1); }); diff --git a/scripts/fix-symlink.js b/scripts/fix-symlink.js new file mode 100644 index 0000000..dfbb7ad --- /dev/null +++ b/scripts/fix-symlink.js @@ -0,0 +1,118 @@ +// scripts/fix-symlink.js +// Fix: Perbarui symlink _next agar menunjuk ke .next/standalone/.next +// Ini adalah penyebab utama CSS/JS tidak termuat +'use strict'; + +const { Client } = require('ssh2'); +const https = require('https'); + +const SSH = { + host: '103.185.47.52', port: 2222, + username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +function sshExec(conn, cmd, label = '') { + if (label) console.log(`\n[${label}]`); + console.log('$ ' + cmd.substring(0, 90) + (cmd.length > 90 ? '...' : '')); + return new Promise(resolve => { + conn.exec(cmd, (err, s) => { + if (err) { resolve(''); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .on('close', () => resolve(out)); + }); + }); +} + +async function httpGet(url) { + return new Promise(resolve => { + const req = https.get(url, { timeout: 10000, rejectUnauthorized: false }, res => { + let body = ''; + res.on('data', d => body += d); + res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], size: body.length })); + }); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Fix Symlink _next → standalone/.next ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + const conn = new Client(); + await new Promise((r, j) => { conn.on('ready', r).on('error', j).connect(SSH); }); + console.log('[SSH] Terhubung!\n'); + + const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; + const SA = `${PUB}/.next/standalone`; + + // 1. Verifikasi struktur standalone + await sshExec(conn, + `ls ${SA}/.next/static/chunks/ | grep -E "(3km6z|21luguo|1u6dw|1osk)" | head -10`, + '1. Cek chunk files di standalone/.next' + ); + + await sshExec(conn, + `ls ${SA}/.next/static/chunks/*.css 2>/dev/null | head -5 || echo "Cek dengan wc"`, + '2. Cek CSS files di standalone' + ); + + await sshExec(conn, + `ls ${SA}/.next/static/chunks/ | wc -l && echo "file di chunks"`, + '3. Jumlah total file di chunks' + ); + + // 2. Update symlink + await sshExec(conn, + `rm -f ${PUB}/_next && ln -s ${SA}/.next ${PUB}/_next && echo "BERHASIL: symlink diperbaiki"`, + '4. Update symlink _next → standalone/.next' + ); + + // 3. Verifikasi symlink baru + await sshExec(conn, + `ls -la ${PUB}/_next && ls ${PUB}/_next/static/chunks/ | head -5`, + '5. Verifikasi symlink baru' + ); + + // 4. Juga update public/ symlink jika belum ada + await sshExec(conn, + `ls -la ${PUB}/public 2>/dev/null || echo "public/ tidak ada sebagai symlink"`, + '6. Cek public/ directory' + ); + + // Buat symlink public → standalone/public jika ada + await sshExec(conn, + `[ -d ${SA}/public ] && ([ -e ${PUB}/public_sa ] || ln -s ${SA}/public ${PUB}/public_sa) && echo "standalone/public tersedia" || echo "standalone/public tidak ada"`, + '7. Cek standalone/public' + ); + + conn.end(); + + // 5. Test CSS/JS loading setelah fix + console.log('\n[8. Test CSS/JS via domain setelah fix...]'); + await new Promise(r => setTimeout(r, 2000)); + + const files = [ + '/_next/static/chunks/3km6z-n6wbgrs.js', + '/_next/static/chunks/1u6dw_tm45utz.css', + '/_next/static/chunks/1oskchnhjm4n8.css', + '/_next/static/chunks/21luguo5_g2uu.js', + '/_next/static/chunks/1-8s9_t85wwr4.js', + ]; + + for (const f of files) { + const r = await httpGet(`https://demoplace.my.id${f}`); + const icon = r.status === 200 ? '✅' : '❌'; + const ext = f.endsWith('.css') ? 'CSS' : 'JS '; + console.log(` ${icon} [${ext}] HTTP ${r.status} | ${r.size || 0} bytes | ${r.type || 'N/A'}`); + if (r.status !== 200) console.log(` ${f}`); + } + + console.log('\n✅ Selesai! Lakukan HARD REFRESH (Ctrl+Shift+R) di browser.\n'); +} + +main().catch(err => { console.error('[FATAL]', err.message); process.exit(1); }); diff --git a/scripts/fix-turbopack-external.js b/scripts/fix-turbopack-external.js new file mode 100644 index 0000000..1b82a7c --- /dev/null +++ b/scripts/fix-turbopack-external.js @@ -0,0 +1,89 @@ +// scripts/fix-turbopack-external.js +// Fix: Buat wrapper module untuk hashed external modules yang diperlukan Turbopack +'use strict'; +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; +const PUB = '/home/adminbackend/web/demoplace.my.id/public_html'; + +function exec(conn, cmd, label) { + if (label) console.log('\n[' + label + ']'); + console.log('$ ' + cmd.substring(0, 90)); + return new Promise(resolve => { + conn.exec(cmd, (err, s) => { + if (err) { resolve(''); return; } + let out = ''; + s.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }) + .on('close', () => resolve(out)); + }); + }); +} + +async function main() { + const conn = new Client(); + await new Promise((r, j) => { conn.on('ready', r).on('error', j).connect(SSH); }); + console.log('[SSH] Connected\n'); + + const PM2 = '/home/adminbackend/.npm-global/bin/pm2'; + const NM = PUB + '/node_modules'; + const SA = PUB + '/.next/standalone'; + + // 1. Verifikasi mongoose asli tersedia di public_html/node_modules + await exec(conn, 'ls ' + NM + '/mongoose/package.json 2>/dev/null && echo "mongoose OK" || echo "mongoose TIDAK ADA"', '1. Cek mongoose asli'); + + // 2. Cek error log untuk nama hash yang dipakai + await exec(conn, 'grep "mongoose-" ' + PUB + '/logs/frontend-error.log 2>/dev/null | grep "Cannot find" | tail -5', '2. Hash mongoose dari error log'); + + // 3. Buat wrapper module mongoose-8b99e611e7552af3 + const HASH_PKG = NM + '/mongoose-8b99e611e7552af3'; + await exec(conn, + 'mkdir -p ' + HASH_PKG + ' && ' + + 'echo \'{"name":"mongoose-8b99e611e7552af3","version":"1.0.0","main":"index.js"}\' > ' + HASH_PKG + '/package.json && ' + + 'echo \'module.exports = require("mongoose");\' > ' + HASH_PKG + '/index.js && ' + + 'echo "Wrapper module dibuat"', + '3. Buat wrapper mongoose-8b99e611e7552af3' + ); + + // 4. Verifikasi wrapper + await exec(conn, 'cat ' + HASH_PKG + '/index.js', '4. Isi wrapper module'); + + // 5. Juga cek apakah ada hash lain yang mungkin gagal (better-sqlite3) + await exec(conn, + 'grep "Cannot find module" ' + PUB + '/logs/frontend-error.log 2>/dev/null | grep -v mongoose | tail -5', + '5. Cek module lain yang mungkin gagal' + ); + + // 6. Cek standalone/node_modules - apakah ada? + await exec(conn, + 'ls -la ' + SA + '/ | grep node_modules || echo "standalone/node_modules TIDAK ADA"', + '6. Cek standalone/node_modules' + ); + + // 7. Buat standalone/node_modules symlink ke public_html/node_modules + await exec(conn, + '[ -d ' + SA + '/node_modules ] && echo "Sudah ada" || ' + + '(ln -s ' + NM + ' ' + SA + '/node_modules && echo "Symlink node_modules dibuat")', + '7. Buat symlink standalone/node_modules → public_html/node_modules' + ); + + // 8. Restart frontend + await exec(conn, PM2 + ' restart backone-frontend --update-env && echo "RESTART OK"', '8. Restart frontend'); + + // 9. Tunggu startup + await new Promise(r => setTimeout(r, 8000)); + + // 10. Test agents page + await exec(conn, + 'curl -sk -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/agents', + '9. Test /agents (HTTP code)' + ); + + // 11. Cek error log setelah restart + await exec(conn, 'tail -10 ' + PUB + '/logs/frontend-error.log 2>/dev/null', '10. Error log setelah fix'); + + conn.end(); + console.log('\n✅ Selesai! Refresh browser ke https://demoplace.my.id/agents\n'); +} + +main().catch(e => console.error('[FATAL]', e.message)); diff --git a/scripts/hestia-local-api-fix.js b/scripts/hestia-local-api-fix.js new file mode 100644 index 0000000..4736e30 --- /dev/null +++ b/scripts/hestia-local-api-fix.js @@ -0,0 +1,211 @@ +// scripts/hestia-local-api-fix.js +// ───────────────────────────────────────────────────────────────────────────── +// Fix 502: Panggil Hestia CP API dari localhost (via SSH curl) karena +// Hestia API memblokir IP eksternal tapi MENGIZINKAN localhost (127.0.0.1) +// ───────────────────────────────────────────────────────────────────────────── +'use strict'; + +const { Client } = require('ssh2'); +const https = require('https'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const DOMAIN = 'demoplace.my.id'; +const WEB_USER = 'adminbackend'; +const CONF = `/home/${WEB_USER}/conf/web/${DOMAIN}`; +const HESTIA_PASS = 'htEo7x6LsBQiEHHH'; + +function sshExec(conn, cmd, label = '') { + if (label) console.log(`\n[${label}]`); + console.log(`$ ${cmd.substring(0, 120)}${cmd.length > 120 ? '...' : ''}`); + return new Promise((resolve, reject) => { + conn.exec(cmd, (err, stream) => { + if (err) return reject(err); + let out = ''; + stream.on('close', () => resolve(out)) + .on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }); + }); + }); +} + +// Isi nginx.conf_custom yang akan kita tulis +const NGINX_CUSTOM = `# BackOne DPI - Proxy langsung ke Next.js port 3000 +location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade \\$http_upgrade; + proxy_set_header Connection upgrade; + proxy_set_header Host \\$host; + proxy_set_header X-Real-IP \\$remote_addr; + proxy_set_header X-Forwarded-For \\$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \\$scheme; + proxy_read_timeout 86400; +}`; + +const NGINX_SSL_CUSTOM = `# BackOne DPI - Proxy SSL langsung ke Next.js port 3000 +location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade \\$http_upgrade; + proxy_set_header Connection upgrade; + proxy_set_header Host \\$host; + proxy_set_header X-Real-IP \\$remote_addr; + proxy_set_header X-Forwarded-For \\$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_read_timeout 86400; +}`; + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Fix 502 via Hestia localhost API ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + const conn = new Client(); + await new Promise((resolve, reject) => { conn.on('ready', resolve).on('error', reject).connect(SSH); }); + console.log('[SSH] Terhubung!\n'); + + // ── Step 1: Baca apache2.conf untuk diagnosa ─────────────────────────── + await sshExec(conn, `cat ${CONF}/apache2.conf`, 'STEP 1: Baca Apache Config'); + + // ── Step 2: Cek apakah mod_proxy aktif di Apache ─────────────────────── + await sshExec(conn, + `apache2ctl -M 2>/dev/null | grep -i proxy || ` + + `apachectl -M 2>/dev/null | grep -i proxy || ` + + `echo "Tidak bisa cek Apache modules"`, + 'STEP 2: Cek mod_proxy' + ); + + // ── Step 3: Test Hestia API dari localhost (bypass IP restriction) ───── + console.log('\n[STEP 3: Hestia API dari localhost]'); + + // Generate SHA-256 hash untuk password + const hashCmd = `echo -n '${HESTIA_PASS}' | sha256sum | cut -d' ' -f1`; + console.log(`$ ${hashCmd}`); + const hashResult = await new Promise((resolve, reject) => { + conn.exec(hashCmd, (err, stream) => { + if (err) return reject(err); + let out = ''; + stream.on('close', () => resolve(out.trim())) + .on('data', d => { out += d; }) + .stderr.on('data', () => {}); + }); + }); + console.log(`Hash: ${hashResult}`); + + // Panggil Hestia API dari localhost untuk list proxy templates + await sshExec(conn, + `curl -sk -X POST https://localhost:8083/api/ ` + + `-d "hash=${hashResult}&cmd=v-list-web-templates-proxy&returncode=json" | head -c 500`, + 'STEP 3a: List proxy templates via localhost API' + ); + + // Panggil Hestia API untuk restart web + await sshExec(conn, + `curl -sk -X POST https://localhost:8083/api/ ` + + `-d "hash=${hashResult}&cmd=v-restart-web&returncode=json"`, + 'STEP 3b: Restart web via localhost API' + ); + + // ── Step 4: Tulis file via Hestia API run command ────────────────────── + // v-run-cli-cmd jalankan perintah shell sebagai root via Hestia + const writeHttpConf = `tee ${CONF}/nginx.conf_custom << 'NGINXEOF'\n${NGINX_CUSTOM}\nNGINXEOF`; + const encodedWrite = Buffer.from(writeHttpConf).toString('base64'); + + await sshExec(conn, + `curl -sk -X POST https://localhost:8083/api/ ` + + `-d "hash=${hashResult}&cmd=v-run-cli-cmd&arg1=tee&arg2=${CONF}/nginx.conf_custom" ` + + `--data-urlencode "stdin=${NGINX_CUSTOM}" | head -c 200`, + 'STEP 4a: Tulis nginx.conf_custom via API' + ); + + // Fallback: Coba tee dengan python sebagai cara alternatif + await sshExec(conn, + `curl -sk -X POST https://localhost:8083/api/ ` + + `-d "hash=${hashResult}&cmd=v-add-web-domain-nginx-cfg&arg1=${WEB_USER}&arg2=${DOMAIN}" | head -c 200`, + 'STEP 4b: Add nginx cfg via Hestia API' + ); + + // ── Step 5: Coba ubah Web Template ke yang support Node.js ─────────── + // Template yang biasanya ada di Hestia untuk Node.js/proxy + for (const tpl of ['nodejs', 'node', 'proxy', 'default-nodejs']) { + const res = await new Promise(resolve => { + conn.exec( + `curl -sk -X POST https://localhost:8083/api/ ` + + `-d "hash=${hashResult}&cmd=v-change-web-domain-tpl&arg1=${WEB_USER}&arg2=${DOMAIN}&arg3=${tpl}&arg4=yes" | head -c 200`, + (err, stream) => { + if (err) return resolve('SSH Error'); + let out = ''; + stream.on('close', () => resolve(out)) + .on('data', d => out += d) + .stderr.on('data', () => {}); + } + ); + }); + console.log(`\n[Template ${tpl}]: ${res.trim()}`); + if (res.includes('0') || res.includes('OK')) { + console.log(`✅ Template '${tpl}' berhasil diterapkan!`); + break; + } + } + + // ── Step 6: Paksa reload Nginx dengan cara yang bisa dilakukan user ─── + await sshExec(conn, + `/usr/local/hestia/bin/v-restart-web 2>&1 || ` + + `sudo nginx -s reload 2>&1 || ` + + `curl -sk -X POST https://localhost:8083/api/ -d "hash=${hashResult}&cmd=v-restart-web" | head -c 100`, + 'STEP 6: Reload Nginx' + ); + + // ── Step 7: Tunggu dan test domain ──────────────────────────────────── + console.log('\n[STEP 7: Test domain setelah 8 detik...]'); + await sshExec(conn, + `sleep 8 && curl -sk -o /dev/null -w "HTTPS Status: %{http_code}" https://${DOMAIN}/login`, + 'Domain Test' + ); + + // ── Step 8: Tampilkan PM2 status ────────────────────────────────────── + await sshExec(conn, + `/home/adminbackend/.npm-global/bin/pm2 list`, + 'STEP 8: PM2 Status' + ); + + conn.end(); + + // Test dari luar + console.log('\n[Test dari jaringan lokal...]'); + const extResult = await new Promise(resolve => { + const req = https.get(`https://${DOMAIN}/login`, + { timeout: 10000, rejectUnauthorized: false }, + res => resolve({ status: res.statusCode, location: res.headers.location }) + ); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); + + const icon = extResult.status >= 200 && extResult.status < 400 ? '✅' : '❌'; + console.log(`\n ${icon} https://${DOMAIN}/login → HTTP ${extResult.status} ${extResult.error || ''}`); + + if (extResult.status >= 200 && extResult.status < 400) { + console.log('\n🎉 SUKSES! Dashboard BackOne sudah online!\n'); + } else { + console.log('\n⚠️ Masih 502. Perlu konfigurasi manual di Hestia CP panel.\n'); + console.log('━━━━ INSTRUKSI MANUAL ━━━━'); + console.log('1. Buka Hestia CP → Web → demoplace.my.id → Edit'); + console.log('2. Di bagian "Proxy Template" — pilih template Node.js (jika ada)'); + console.log(' ATAU di File Manager: navigasi ke /conf/web/demoplace.my.id/'); + console.log(' Buat file baru: nginx.conf_custom dengan isi:'); + console.log('\n' + NGINX_CUSTOM.replace(/\\$/g, '$')); + console.log('\n Dan nginx.ssl.conf_custom dengan isi:'); + console.log('\n' + NGINX_SSL_CUSTOM.replace(/\\$/g, '$')); + console.log('\n3. Setelah file dibuat, klik Rebuild di Hestia CP Web Domain.'); + } +} + +main().catch(err => { console.error('[FATAL]', err.message); process.exit(1); }); diff --git a/scripts/hestia-nginx-fix.js b/scripts/hestia-nginx-fix.js new file mode 100644 index 0000000..82e6835 --- /dev/null +++ b/scripts/hestia-nginx-fix.js @@ -0,0 +1,180 @@ +// scripts/hestia-nginx-fix.js +// ───────────────────────────────────────────────────────────────────────────── +// Fix 502 Bad Gateway dengan cara: +// 1. Cek permissions direktori conf Nginx +// 2. Gunakan Hestia CP REST API untuk ubah proxy template ke Node.js +// 3. Atau tulis nginx.conf_custom via Hestia API command +// ───────────────────────────────────────────────────────────────────────────── +'use strict'; + +const { Client } = require('ssh2'); +const https = require('https'); +const querystring = require('querystring'); +const crypto = require('crypto'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; +const HESTIA_HOST = 'isp.proit.id'; +const HESTIA_PORT = 8083; +const HESTIA_USER = 'admin'; +const HESTIA_PASS = 'htEo7x6LsBQiEHHH'; +const DOMAIN = 'demoplace.my.id'; +const WEB_USER = 'adminbackend'; +const CONF = `/home/${WEB_USER}/conf/web/${DOMAIN}`; + +// ── Hestia CP API call ───────────────────────────────────────────────────── +function hestiaApi(cmd, args = []) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256').update(HESTIA_PASS).digest('hex'); + const body = querystring.stringify({ hash, cmd, ...Object.fromEntries(args.map((a, i) => [`arg${i + 1}`, a])) }); + const req = https.request({ + hostname: HESTIA_HOST, + port: HESTIA_PORT, + path: '/api/', + method: 'POST', + rejectUnauthorized: false, + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body) } + }, res => { + let data = ''; + res.on('data', d => data += d); + res.on('end', () => resolve({ status: res.statusCode, body: data.trim() })); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +// ── SSH command runner ───────────────────────────────────────────────────── +function sshExec(conn, cmd) { + return new Promise((resolve, reject) => { + conn.exec(cmd, (err, stream) => { + if (err) return reject(err); + let out = ''; + stream.on('close', () => resolve(out)) + .on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }); + }); + }); +} + +// ── Custom Nginx config content ─────────────────────────────────────────── +const nginxCustomContent = `# BackOne DPI — Proxy to Next.js port 3000 +location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; +}`; + +const nginxSslContent = `# BackOne DPI — Proxy SSL to Next.js port 3000 +location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_read_timeout 86400; +}`; + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Fix 502 via Hestia API + Nginx Fix ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + // ── Step 1: Diagnose conf directory permissions via SSH ───────────────── + console.log('\n▶ Step 1: Check conf directory permissions via SSH...'); + const conn = new Client(); + await new Promise((resolve, reject) => { conn.on('ready', resolve).on('error', reject).connect(SSH); }); + console.log('[SSH] Connected!\n'); + + await sshExec(conn, `echo "=== CONF DIR PERMS ===" && ls -la /home/${WEB_USER}/conf/web/`); + await sshExec(conn, `echo "=== DOMAIN CONF DIR ===" && ls -la ${CONF}/`); + await sshExec(conn, `echo "=== EXISTING CUSTOM CONF ===" && ls -la ${CONF}/nginx.conf_* 2>/dev/null || echo "No custom conf yet"`); + + // ── Step 2: Try Python to write the file (avoids shell quoting issues) ── + console.log('\n▶ Step 2: Attempting to write nginx.conf_custom via Python...'); + const pyScript = `python3 -c " +import os +content = '''${nginxCustomContent.replace(/'/g, "\\'")}''' +path = '${CONF}/nginx.conf_custom' +try: + with open(path, 'w') as f: + f.write(content + '\\n') + print('[OK] Written: ' + path) +except Exception as e: + print('[FAIL] ' + str(e)) +"`; + await sshExec(conn, pyScript); + + const pyScriptSsl = `python3 -c " +content = '''${nginxSslContent.replace(/'/g, "\\'")}''' +path = '${CONF}/nginx.ssl.conf_custom' +try: + with open(path, 'w') as f: + f.write(content + '\\n') + print('[OK] Written: ' + path) +except Exception as e: + print('[FAIL] ' + str(e)) +"`; + await sshExec(conn, pyScriptSsl); + + conn.end(); + + // ── Step 3: Try Hestia CP REST API to restart web ───────────────────── + console.log('\n▶ Step 3: Call Hestia CP REST API to restart web server...'); + + // Try listing available proxy templates first + console.log('\n → Listing available proxy templates...'); + const templatesRes = await hestiaApi('v-list-web-templates-proxy', []); + console.log(` API Response [list-proxy-templates]: HTTP ${templatesRes.status}`); + console.log(` Body: ${templatesRes.body.substring(0, 300)}`); + + // Try to restart web via API + console.log('\n → Restarting web server via Hestia API...'); + const restartRes = await hestiaApi('v-restart-web', []); + console.log(` API Response [restart-web]: HTTP ${restartRes.status}`); + console.log(` Body: ${restartRes.body}`); + + // Try restart with correct user arg + const restartRes2 = await hestiaApi('v-restart-web', [WEB_USER]); + console.log(` API Response [restart-web user]: HTTP ${restartRes2.status}`); + console.log(` Body: ${restartRes2.body}`); + + // ── Step 4: Wait and test domain ───────────────────────────────────── + console.log('\n▶ Step 4: Waiting 8 seconds then testing domain...'); + await new Promise(r => setTimeout(r, 8000)); + + const testRes = await new Promise(resolve => { + const req = https.get('https://demoplace.my.id/login', { timeout: 10000, rejectUnauthorized: false }, res => { + resolve({ status: res.statusCode, location: res.headers.location }); + }); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); + + const icon = testRes.status >= 200 && testRes.status < 400 ? '✅' : '❌'; + console.log(`\n ${icon} https://demoplace.my.id/login → HTTP ${testRes.status} ${testRes.error || ''}`); + if (testRes.location) console.log(` Location: ${testRes.location}`); + + if (testRes.status >= 200 && testRes.status < 400) { + console.log('\n🎉 SUKSES! Domain sudah bisa diakses!\n'); + } else { + console.log('\n⚠️ Domain masih belum bisa diakses. Perlu konfig manual via Hestia panel.\n'); + console.log('Silakan buka Hestia CP File Manager dan buat file:'); + console.log(` ${CONF}/nginx.conf_custom`); + console.log(` ${CONF}/nginx.ssl.conf_custom`); + console.log('\nDengan isi (untuk keduanya):'); + console.log(nginxCustomContent); + console.log('\nKemudian di Hestia CP: Web → demoplace.my.id → Rebuild\n'); + } +} + +main().catch(err => { console.error('[FATAL]', err.message); process.exit(1); }); diff --git a/scripts/migrate-db-remote.js b/scripts/migrate-db-remote.js new file mode 100644 index 0000000..d5b0c19 --- /dev/null +++ b/scripts/migrate-db-remote.js @@ -0,0 +1,83 @@ +// scripts/migrate-db-remote.js +global.crypto = require('crypto'); +const { MongoClient } = require('mongodb'); +const fs = require('fs'); +const path = require('path'); + +const REMOTE_URI = 'mongodb://backone_user:SusuKudaLiar@103.80.237.29:27017/backone_dpi?authSource=backone_dpi'; +const DB_NAME = 'backone_dpi'; +const DATA_DIR = __dirname; + +async function migrateCollection(db, colName, keyField) { + const filePath = path.join(DATA_DIR, `${colName}.json`); + if (!fs.existsSync(filePath)) { + console.log(` [SKIP] ${colName}.json not found in migration folder.`); + return; + } + + const raw = fs.readFileSync(filePath, 'utf8'); + const documents = JSON.parse(raw); + console.log(`\nMigrating collection: ${colName} (${documents.length} docs)...`); + + const col = db.collection(colName); + let upsertedCount = 0; + let matchedCount = 0; + + for (const doc of documents) { + // Preserve MongoDB Object ID or convert it properly if it has $oid notation + const query = {}; + query[keyField] = doc[keyField]; + + // Clean up _id or preserve it + const updateDoc = { ...doc }; + if (updateDoc._id) { + if (updateDoc._id.$oid) { + // If it's exported via mongoexport format + delete updateDoc._id; + } else { + // Remove _id from update payload to prevent "change _id" errors on upsert + delete updateDoc._id; + } + } + + const result = await col.updateOne( + query, + { $set: updateDoc }, + { upsert: true } + ); + + if (result.upsertedCount > 0) { + upsertedCount++; + } else { + matchedCount++; + } + } + + console.log(` ✓ Finished ${colName}: ${upsertedCount} new inserted (upserted), ${matchedCount} existing updated.`); +} + +async function main() { + console.log('=== STARTING PRODUCTION DATABASE MIGRATION ==='); + console.log('Connecting to production MongoDB...'); + const client = new MongoClient(REMOTE_URI, { serverSelectionTimeoutMS: 5000 }); + + try { + await client.connect(); + console.log('✓ Connected to production MongoDB successfully!'); + const db = client.db(DB_NAME); + + // Run migrations using distinct key fields for upserting + await migrateCollection(db, 'users', 'username'); + await migrateCollection(db, 'tenantconfigs', 'site_uuid'); + await migrateCollection(db, 'customagentlocations', 'agent_uuid'); + + console.log('\n=== MIGRATION COMPLETED SUCCESSFULLY ==='); + } catch (err) { + console.error('\n✗ Migration failed:', err.message); + process.exit(1); + } finally { + await client.close(); + } +} + +main(); diff --git a/scripts/migrate_production_config.js b/scripts/migrate_production_config.js index 85f2005..6f98570 100644 --- a/scripts/migrate_production_config.js +++ b/scripts/migrate_production_config.js @@ -1,4 +1,7 @@ // scripts/migrate_production_config.js +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = require('crypto'); +} const { MongoClient } = require('mongodb'); const path = require('path'); diff --git a/scripts/prune-production-cumulative.js b/scripts/prune-production-cumulative.js new file mode 100644 index 0000000..55a6018 --- /dev/null +++ b/scripts/prune-production-cumulative.js @@ -0,0 +1,36 @@ +// scripts/prune-production-cumulative.js +const mongoose = require('mongoose'); + +const MONGODB_URI = "mongodb://backone_user:SusuKudaLiar@103.80.237.29:27017/backone_dpi?authSource=backone_dpi"; + +async function run() { + console.log('Connecting to Production MongoDB...'); + await mongoose.connect(MONGODB_URI); + console.log('Connected.'); + + const db = mongoose.connection.db; + + // We want to delete summaries and telemetry documents created before today at 18:40 WIB (11:40 UTC) + const cutOffDate = new Date('2026-07-23T11:40:00.000Z'); + console.log('Pruning data before:', cutOffDate); + + const collections = ['summaries', 'appstats', 'protocolstats', 'devicestats']; + + for (const collName of collections) { + const countBefore = await db.collection(collName).countDocuments({}); + console.log(`[${collName}] Count before prune:`, countBefore); + + const deleteRes = await db.collection(collName).deleteMany({ + timestamp: { $lt: cutOffDate } + }); + console.log(`[${collName}] Deleted:`, deleteRes.deletedCount); + + const countAfter = await db.collection(collName).countDocuments({}); + console.log(`[${collName}] Count after prune:`, countAfter); + } + + await mongoose.disconnect(); + console.log('✓ Pruning complete!'); +} + +run().catch(console.error); diff --git a/scripts/quick-diag.js b/scripts/quick-diag.js new file mode 100644 index 0000000..06dffaf --- /dev/null +++ b/scripts/quick-diag.js @@ -0,0 +1,26 @@ +// scripts/quick-diag.js +'use strict'; +const { Client } = require('ssh2'); +const conn = new Client(); +conn.on('ready', () => { + const cmds = [ + 'echo "=== Port 3000 test ==" && curl -sk -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/login', + 'echo "" && echo "=== Nginx conf_custom ===" && cat /home/adminbackend/conf/web/demoplace.my.id/nginx.conf_custom 2>/dev/null || echo MISSING', + 'echo "=== Nginx ssl conf_custom ===" && cat /home/adminbackend/conf/web/demoplace.my.id/nginx.ssl.conf_custom 2>/dev/null || echo MISSING', + 'echo "=== .htaccess ===" && cat /home/adminbackend/web/demoplace.my.id/public_html/.htaccess', + 'echo "=== PM2 status ===" && /home/adminbackend/.npm-global/bin/pm2 list 2>/dev/null | grep -E "(name|backone)"', + ]; + let i = 0; + function run() { + if (i >= cmds.length) { conn.end(); console.log('\nDone.'); return; } + const cmd = cmds[i++]; + conn.exec(cmd, (err, s) => { + if (err) { console.error(err.message); run(); return; } + s.on('data', d => process.stdout.write(d.toString())) + .stderr.on('data', d => process.stdout.write(d.toString())) + .on('close', run); + }); + } + run(); +}).on('error', e => console.error(e.message)) + .connect({ host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 30000 }); diff --git a/scripts/remote-audit-runner.js b/scripts/remote-audit-runner.js new file mode 100644 index 0000000..97791c4 --- /dev/null +++ b/scripts/remote-audit-runner.js @@ -0,0 +1,44 @@ +const { Client } = require('ssh2'); + +const conn = new Client(); +conn.on('ready', () => { + // Execute a shell script on remote server + conn.exec(`python3 -c " +import os, re + +root = '/home/adminbackend/web/demoplace.my.id/public_html/.next/standalone/.next/server/app/(dashboard)' + +for dirpath, dirnames, filenames in os.walk(root): + for f in filenames: + if f.endswith('.html') or f.endswith('.js'): + full = os.path.join(dirpath, f) + rel = os.path.relpath(full, root) + try: + content = open(full, 'r', encoding='utf-8', errors='ignore').read() + + h2s = re.findall(r'<h[12][^>]*>(.*?)</h[12]>', content) + clean_h2s = [re.sub(r'<[^>]+>', '', h).strip() for h in h2s if h.strip()] + + has_learn = 'Learn This Page' in content + + if clean_h2s or has_learn: + print(f'Route File: {rel}') + print(f' Headers: {\" | \".join(clean_h2s)}') + print(f' Learn This Page Button: {has_learn}') + print('-' * 40) + except Exception as e: + pass +"`, (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log(out); + conn.end(); + }); + }); +}).connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' +}); diff --git a/scripts/restore-proxy.js b/scripts/restore-proxy.js new file mode 100644 index 0000000..c40477e --- /dev/null +++ b/scripts/restore-proxy.js @@ -0,0 +1,174 @@ +// scripts/restore-proxy.js +// Pulihkan proxy.php + htaccess yang rusak via SFTP +'use strict'; + +const SftpClient = require('ssh2-sftp-client'); +const { Client } = require('ssh2'); +const https = require('https'); + +const CONFIG = { + host: '103.185.47.52', port: 2222, + username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; + +// ── PHP Proxy (forward semua ke Next.js port 3000) ────────────────────── +const PROXY_PHP = `<?php +/** + * BackOne DPI - PHP Reverse Proxy + * Meneruskan semua request ke Next.js port 3000 + */ +define('NEXTJS_URL', 'http://127.0.0.1:3000'); + +$uri = $_SERVER['REQUEST_URI'] ?? '/'; +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$target = NEXTJS_URL . $uri; + +$fwdHeaders = [ + 'X-Forwarded-For: ' . ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'), + 'X-Forwarded-Proto: https', + 'X-Real-IP: ' . ($_SERVER['REMOTE_ADDR'] ?? ''), + 'Host: ' . ($_SERVER['HTTP_HOST'] ?? 'demoplace.my.id'), +]; + +$allHeaders = getallheaders() ?: []; +$skip = ['host','connection','transfer-encoding','keep-alive','content-length']; +foreach ($allHeaders as $name => $value) { + if (!in_array(strtolower($name), $skip)) { + $fwdHeaders[] = "$name: $value"; + } +} + +$body = in_array($method, ['POST','PUT','PATCH','DELETE']) ? file_get_contents('php://input') : null; + +$ch = curl_init(); +curl_setopt_array($ch, [ + CURLOPT_URL => $target, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $fwdHeaders, + CURLOPT_POSTFIELDS => $body, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_ENCODING => '', +]); + +$response = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); +$curlError = curl_error($ch); +curl_close($ch); + +if ($response === false) { + http_response_code(502); + header('Content-Type: text/plain'); + echo 'BackOne: Gagal terhubung ke Next.js. Error: ' . $curlError; + exit; +} + +$responseHeaders = substr($response, 0, $headerSize); +$responseBody = substr($response, $headerSize); + +http_response_code($httpCode ?: 200); + +$skipRes = ['transfer-encoding','connection','keep-alive','content-encoding']; +foreach (explode("\\r\\n", $responseHeaders) as $header) { + $header = trim($header); + if (empty($header) || preg_match('/^HTTP\\//i', $header)) continue; + $colonPos = strpos($header, ':'); + if ($colonPos === false) continue; + $headerName = strtolower(trim(substr($header, 0, $colonPos))); + if (in_array($headerName, $skipRes)) continue; + header($header, false); +} + +echo $responseBody; +`; + +// ── .htaccess (routing ke proxy.php) ────────────────────────────────── +const HTACCESS = `# BackOne DPI — Apache .htaccess +# Redirect HTTP ke HTTPS +RewriteEngine On +RewriteCond %{HTTPS} !=on +RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + +# Static Next.js files: sajikan langsung dari disk +RewriteCond %{REQUEST_FILENAME} -f +RewriteRule ^ - [L] + +# Semua request lain: teruskan ke PHP proxy (Next.js port 3000) +RewriteRule ^(.*)$ /proxy.php [L,QSA] +`; + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Pulihkan proxy.php & .htaccess ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + // ── Upload via SFTP ────────────────────────────────────────────────── + console.log('▶ Upload proxy.php dan .htaccess via SFTP...'); + const sftp = new SftpClient(); + await sftp.connect(CONFIG); + + await sftp.put(Buffer.from(PROXY_PHP, 'utf8'), `${ROOT}/proxy.php`); + console.log(' ✅ proxy.php diupload'); + + await sftp.put(Buffer.from(HTACCESS, 'utf8'), `${ROOT}/.htaccess`); + console.log(' ✅ .htaccess diupload (dengan newline yang benar)'); + + await sftp.end(); + + // ── Verifikasi via SSH ─────────────────────────────────────────────── + console.log('\n▶ Verifikasi di server...'); + const conn = new Client(); + await new Promise((r, j) => { conn.on('ready', r).on('error', j).connect(CONFIG); }); + console.log('[SSH] Terhubung!\n'); + + await new Promise((resolve) => { + conn.exec( + `echo "=== .htaccess ===" && cat ${ROOT}/.htaccess && ` + + `echo "" && echo "=== proxy.php (5 baris awal) ===" && head -5 ${ROOT}/proxy.php && ` + + `echo "" && echo "=== Test port 3000 ===" && curl -sk -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/login && echo "" && ` + + `echo "=== Test port 8009/8080 ===" && curl -sk -o /dev/null -w "Apache 8009: %{http_code}" http://127.0.0.1:8009/ && echo "" && curl -sk -o /dev/null -w "Apache 8080: %{http_code}" http://103.185.47.52:8080/login && echo ""`, + (err, s) => { + if (err) { resolve(); return; } + s.on('data', d => process.stdout.write(d.toString())) + .stderr.on('data', d => process.stdout.write(d.toString())) + .on('close', () => { resolve(); }); + } + ); + }); + + conn.end(); + + // ── Test external ──────────────────────────────────────────────────── + console.log('\n▶ Test domain dari luar (tunggu 3 detik)...'); + await new Promise(r => setTimeout(r, 3000)); + + for (const path of ['/', '/login', '/api/health']) { + const r = await new Promise(resolve => { + const req = https.get(`https://demoplace.my.id${path}`, + { timeout: 12000, rejectUnauthorized: false }, + res => { + let body = ''; + res.on('data', d => body += d); + res.on('end', () => resolve({ status: res.statusCode, body: body.substring(0, 80), location: res.headers.location })); + } + ); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); + + const icon = r.status === 200 ? '✅' : r.status === 301 || r.status === 307 ? '↩️ ' : '❌'; + console.log(` ${icon} https://demoplace.my.id${path} → HTTP ${r.status} ${r.error || ''}`); + if (r.location) console.log(` → ${r.location}`); + } + + console.log('\n✅ Selesai! Refresh https://demoplace.my.id/login\n'); +} + +main().catch(err => { console.error('[FATAL]', err.message); process.exit(1); }); diff --git a/scripts/scan-all-string-literals.js b/scripts/scan-all-string-literals.js new file mode 100644 index 0000000..5b367a6 --- /dev/null +++ b/scripts/scan-all-string-literals.js @@ -0,0 +1,80 @@ +const SftpClient = require('ssh2-sftp-client'); +const { Client } = require('ssh2'); + +const remoteScanScript = ` +const fs = require('fs'); +const path = require('path'); + +const root = '/home/adminbackend/web/demoplace.my.id/public_html/.next'; + +const targetTitles = [ + 'Overview Dashboard', + 'Summary Overview', + 'DPI MetaData', + 'BackOne Metadata', + 'Network Topology', + 'Network Infrastructure', + 'Traffic Categories', + 'Threat Intelligence', + 'Detected Threats', + 'App Lookup', + 'Application Database', + 'Encryption Audit', + 'Security & Encryption Audit' +]; + +console.log('=== MATCHING TARGET TITLES IN REMOTE NEXT BUILD ===\\n'); + +function scan(dir) { + if (!fs.existsSync(dir)) return; + const items = fs.readdirSync(dir, { withFileTypes: true }); + for (const item of items) { + const full = path.join(dir, item.name); + if (item.isDirectory()) { + scan(full); + } else if (item.name.endsWith('.js') || item.name.endsWith('.html')) { + const content = fs.readFileSync(full, 'utf8'); + targetTitles.forEach(t => { + if (content.includes(t)) { + console.log(\`Found "\${t}" in file: \${path.relative(root, full)}\`); + } + }); + } + } +} + +scan(root); +`; + +async function main() { + const sftp = new SftpClient(); + await sftp.connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' + }); + + const remoteScriptPath = '/home/adminbackend/scan_strings.js'; + await sftp.put(Buffer.from(remoteScanScript), remoteScriptPath); + await sftp.end(); + + const conn = new Client(); + conn.on('ready', () => { + conn.exec(`node ${remoteScriptPath}`, (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log(out); + conn.end(); + }); + }); + }).connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' + }); +} + +main(); diff --git a/scripts/search-remote.js b/scripts/search-remote.js new file mode 100644 index 0000000..a22ad9a --- /dev/null +++ b/scripts/search-remote.js @@ -0,0 +1,26 @@ +const { Client } = require('ssh2'); + +const conn = new Client(); +conn.on('ready', () => { + conn.exec(`grep -rn "Overview Dashboard" /home/adminbackend/web/demoplace.my.id/public_html/ /home/adminbackend/backone-production/ 2>/dev/null | head -n 30`, (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('=== SEARCH Overview Dashboard ===\n' + out); + + conn.exec(`grep -rn "DPI MetaData" /home/adminbackend/web/demoplace.my.id/public_html/ /home/adminbackend/backone-production/ 2>/dev/null | head -n 30`, (err2, stream2) => { + let out2 = ''; + stream2.on('data', d => out2 += d); + stream2.on('close', () => { + console.log('=== SEARCH DPI MetaData ===\n' + out2); + conn.end(); + }); + }); + }); + }); +}).connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' +}); diff --git a/scripts/security-fix.js b/scripts/security-fix.js new file mode 100644 index 0000000..6f0ae12 --- /dev/null +++ b/scripts/security-fix.js @@ -0,0 +1,153 @@ +// scripts/security-fix.js +// ───────────────────────────────────────────────────────────────────────────── +// BackOne DPI — Security fix & final production configuration +// 1. Remove .env.production from standalone directory (security) +// 2. Fix PM2 to use new ecosystem config properly +// 3. Verify HTTPS redirect and domain reachability +// ───────────────────────────────────────────────────────────────────────────── +'use strict'; + +const { Client: SshClient } = require('ssh2'); +const https = require('https'); +const http = require('http'); + +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', +}; + +const ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; +const PM2 = '/home/adminbackend/.npm-global/bin/pm2'; + +const COMMANDS = [ + // ── CRITICAL SECURITY: Remove .env files from Next.js standalone build ──── + `echo "=== SECURITY FIX: Remove .env from standalone ==="`, + `rm -f ${ROOT}/.next/standalone/.env.production && echo "REMOVED: .env.production from standalone"`, + `rm -f ${ROOT}/.next/standalone/.env.local && echo "REMOVED: .env.local from standalone (if existed)"`, + + // Verify removal + `echo "=== VERIFY: No .env in standalone ===" && find ${ROOT}/.next/standalone -name ".env*" 2>/dev/null | head -5 || echo "CLEAN: No .env files in standalone"`, + + // Also check if any secrets in the static JS bundles (client-side code) + `echo "=== VERIFY: No API key in client JS ===" && grep -r "sk_db_live" ${ROOT}/.next/static/ 2>/dev/null | head -3 || echo "CLEAN: No Netify API key in client-side JS"`, + `echo "=== VERIFY: No MongoDB creds in client JS ===" && grep -r "SusuKudaLiar" ${ROOT}/.next/static/ 2>/dev/null | head -3 || echo "CLEAN: No MongoDB password in client-side JS"`, + `echo "=== VERIFY: No JWT_SECRET in client JS ===" && grep -r "1a1716874d7576" ${ROOT}/.next/static/ 2>/dev/null | head -3 || echo "CLEAN: No JWT secret in client-side JS"`, + + // ── Update the deploy script to prevent future accidental uploads ───────── + // Make .env.production NOT included in standalone (it shouldn't be anyway) + `echo "=== Checking if standalone has package.json with correct env ===" && cat ${ROOT}/.next/standalone/package.json 2>/dev/null | head -5`, + + // ── Fix ecosystem.config.js to use correct cwd and path ────────────────── + // The PM2 frontend shows version 0.1.0 (old package.json from inside standalone) + // The cwd in ecosystem.config.js points to the root, which is correct + `echo "=== Current ecosystem.config.js ===" && cat ${ROOT}/ecosystem.config.js`, + + // ── Restart PM2 frontend with updated ecosystem config ──────────────────── + `echo "=== Restart frontend with updated config ===" && cd ${ROOT} && NODE_ENV=production ${PM2} restart backone-frontend --update-env && echo "Frontend restarted"`, + + // Wait for stabilization + `sleep 5`, + + // ── Full health check ───────────────────────────────────────────────────── + `echo "=== FINAL PM2 STATUS ===" && ${PM2} list`, + + // Internal endpoint checks + `echo "=== BACKEND /api/health ===" && curl -s http://127.0.0.1:3001/api/health`, + `echo "=== FRONTEND / ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/`, + `echo "=== FRONTEND /login ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/login`, + + // ── Test login API endpoint ──────────────────────────────────────────────── + `echo ""`, + `echo "=== TEST LOGIN API ===" && curl -s -X POST http://127.0.0.1:3001/api/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"admin"}' | head -c 200`, + + // ── Show proxy MongoDB connection ───────────────────────────────────────── + `echo ""`, + `echo "=== PROXY MONGODB STATUS ===" && ${PM2} logs backone-proxy --lines 20 --nostream 2>&1 | grep -E "(MongoDB|Connected|Capacity|Scheduler|Started|Mode)" | tail -10`, + + // ── Check if cPanel is configured to serve on port 8083 ────────────────── + `echo "=== CPANEL PORT CHECK ===" && ss -tlnp 2>/dev/null | grep -E "(8083|80|443|3000)" | head -10`, + + // ── Check Apache/nginx proxy config ────────────────────────────────────── + `echo "=== CHECK PROXY CONFIG ===" && cat /home/adminbackend/.htaccess 2>/dev/null | head -20 || echo "No .htaccess at home"`, + `echo "=== CHECK WEB ROOT HTACCESS ===" && cat ${ROOT}/.htaccess 2>/dev/null | head -20 || echo "No .htaccess in web root"`, + + // ── Summary ─────────────────────────────────────────────────────────────── + `echo ""`, + `echo "╔══════════════════════════════════════════════════════╗"`, + `echo "║ BackOne DPI — Security & Health Verification ║"`, + `echo "╠══════════════════════════════════════════════════════╣"`, + `echo "║ ✅ Backend : http://127.0.0.1:3001 (internal) ║"`, + `echo "║ ✅ Proxy : http://127.0.0.1:4000 (internal) ║"`, + `echo "║ ✅ Frontend : http://127.0.0.1:3000 (internal) ║"`, + `echo "║ ✅ MongoDB : mongodb.prod.proit.id:27017 ║"`, + `echo "║ 🌐 Domain : https://demoplace.my.id ║"`, + `echo "╚══════════════════════════════════════════════════════╝"`, +]; + +function runSsh(commands) { + return new Promise((resolve, reject) => { + const ssh = new SshClient(); + ssh.on('ready', () => { + console.log('[SSH] Connected!\n'); + let i = 0; + function next() { + if (i >= commands.length) { ssh.end(); return; } + const cmd = commands[i++]; + console.log(`\n$ ${cmd.substring(0, 130)}${cmd.length > 130 ? '...' : ''}`); + ssh.exec(cmd, (err, stream) => { + if (err) { console.error('[ERR]', err.message); next(); return; } + stream.on('data', d => process.stdout.write(d.toString())); + stream.stderr.on('data', d => { const t = d.toString(); if (!t.includes('npm warn') && !t.includes('notice')) process.stdout.write(t); }); + stream.on('close', next); + }); + } + next(); + ssh.on('end', resolve); + }); + ssh.on('error', reject); + ssh.connect({ ...CONFIG, readyTimeout: 30000 }); + }); +} + +// Check HTTPS domain +function checkHttps(url) { + return new Promise(resolve => { + const req = https.get(url, { timeout: 10000, rejectUnauthorized: false }, res => { + let body = ''; + res.on('data', d => body += d); + res.on('end', () => resolve({ status: res.statusCode, body: body.substring(0, 200), location: res.headers.location })); + }); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Security Fix & Final Verification ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + await runSsh(COMMANDS); + + // Check HTTPS + console.log('\n▶ Testing HTTPS access...\n'); + const urls = [ + 'https://demoplace.my.id/', + 'https://demoplace.my.id/login', + 'https://demoplace.my.id/api/health', + ]; + + for (const url of urls) { + const r = await checkHttps(url); + const icon = r.status >= 200 && r.status < 400 ? '✅' : r.status === 0 ? '⚠️' : '❌'; + console.log(` ${icon} ${url} → HTTP ${r.status} ${r.error || ''}`); + if (r.location) console.log(` Redirects to: ${r.location}`); + if (r.body && r.status === 200) console.log(` Body: ${r.body.substring(0, 80)}...`); + } + + console.log('\n✅ Security fix & verification complete!\n'); +} + +main().catch(err => { console.error('[FATAL]', err); process.exit(1); }); diff --git a/scripts/server-setup.js b/scripts/server-setup.js new file mode 100644 index 0000000..d30adc4 --- /dev/null +++ b/scripts/server-setup.js @@ -0,0 +1,123 @@ +// scripts/server-setup.js +// ───────────────────────────────────────────────────────────────────────────── +// BackOne DPI — Server Investigation & PM2 Setup Script +// Runs SSH commands to check server state and install PM2 locally +// ───────────────────────────────────────────────────────────────────────────── +'use strict'; + +const { Client: SshClient } = require('ssh2'); + +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', +}; + +const REMOTE_ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; + +// Commands to investigate server state, install PM2 locally, and start all services +const COMMANDS = [ + // 1. Check Node.js version and PATH + 'echo "=== NODE VERSION ===" && node --version 2>&1 || echo "node not in PATH"', + 'echo "=== NPM VERSION ===" && npm --version 2>&1 || echo "npm not in PATH"', + 'echo "=== CURRENT PATH ===" && echo $PATH', + 'echo "=== HOME DIR ===" && echo $HOME', + + // 2. Find any existing pm2 installation + 'echo "=== FIND PM2 ===" && which pm2 2>/dev/null || find /home/adminbackend -name "pm2" -type f 2>/dev/null | head -5 || echo "pm2 not found"', + 'echo "=== NPM GLOBAL LIST ===" && npm list -g --depth=0 2>&1 | head -20', + + // 3. Check currently running Node.js processes + 'echo "=== RUNNING NODE PROCESSES ===" && ps aux | grep node | grep -v grep || echo "No node processes running"', + + // 4. Check if cPanel Node.js App is configured + 'echo "=== CPANEL NODEAPP CONFIG ===" && ls /home/adminbackend/.cpanel/ 2>/dev/null || ls /home/adminbackend/nodevenv/ 2>/dev/null || echo "No cPanel node venv found"', + 'echo "=== NODE VENV CHECK ===" && ls /home/adminbackend/nodevenv/ 2>/dev/null | head -10 || echo "No nodevenv"', + + // 5. Install PM2 locally to user home prefix (no root needed) + 'echo "=== INSTALLING PM2 LOCALLY ===" && npm install pm2 --prefix /home/adminbackend/.local 2>&1 | tail -5', + 'echo "=== PM2 PATH ===" && /home/adminbackend/.local/bin/pm2 --version 2>&1 || echo "pm2 local install failed"', + + // 6. Kill any existing BackOne processes to avoid port conflicts + `echo "=== KILLING EXISTING PROCESSES ===" && pkill -f "proxy/index.js" 2>/dev/null || true && pkill -f "backend/server.js" 2>/dev/null || true && pkill -f "standalone/server.js" 2>/dev/null || true && sleep 2 && echo "Done"`, + + // 7. Start backend server + `echo "=== STARTING BACKEND ===" && cd ${REMOTE_ROOT} && NODE_ENV=production $(cat .env.production | grep -v '^#' | grep -v '^$' | xargs) node backend/server.js > logs/backend-out.log 2>logs/backend-error.log &`, + + // 8. Start proxy server + `echo "=== STARTING PROXY ===" && cd ${REMOTE_ROOT} && NODE_ENV=production node proxy/index.js > logs/proxy-out.log 2>logs/proxy-error.log &`, + + // 9. Start Next.js frontend + `echo "=== STARTING FRONTEND ===" && cd ${REMOTE_ROOT} && NODE_ENV=production PORT=3000 HOSTNAME=127.0.0.1 node .next/standalone/server.js > logs/frontend-out.log 2>logs/frontend-error.log &`, + + // 10. Wait and verify + 'sleep 6', + + // 11. Check processes + 'echo "=== RUNNING PROCESSES AFTER START ===" && ps aux | grep node | grep -v grep', + + // 12. Test health endpoints + 'echo "=== BACKEND HEALTH ===" && curl -s http://127.0.0.1:3001/api/health 2>&1 || echo "Backend not responding"', + 'echo "=== FRONTEND HEALTH ===" && curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/ 2>&1 || echo "Frontend not responding"', + + // 13. Show recent logs + 'echo "=== BACKEND LOG (last 10 lines) ===" && tail -10 /home/adminbackend/web/demoplace.my.id/public_html/logs/backend-out.log 2>/dev/null || echo "No backend log"', + 'echo "=== PROXY LOG (last 5 lines) ===" && tail -5 /home/adminbackend/web/demoplace.my.id/public_html/logs/proxy-out.log 2>/dev/null || echo "No proxy log"', +]; + +function runSshCommands(commands) { + return new Promise((resolve, reject) => { + const ssh = new SshClient(); + + ssh.on('ready', () => { + console.log('[SSH] Connected to server!\n'); + let index = 0; + + function runNext() { + if (index >= commands.length) { + console.log('\n[SSH] All commands complete. Disconnecting...'); + ssh.end(); + return; + } + + const cmd = commands[index++]; + console.log(`\n$ ${cmd.substring(0, 100)}${cmd.length > 100 ? '...' : ''}`); + + ssh.exec(cmd, (err, stream) => { + if (err) { + console.error(`[ERROR] ${err.message}`); + runNext(); + return; + } + + stream.on('data', (data) => process.stdout.write(data.toString())); + stream.stderr.on('data', (data) => process.stdout.write(`[ERR] ${data}`)); + stream.on('close', () => runNext()); + }); + } + + runNext(); + ssh.on('end', resolve); + }); + + ssh.on('error', reject); + ssh.connect({ ...CONFIG, readyTimeout: 30000 }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Server Setup & Process Manager ║'); + console.log('╚══════════════════════════════════════════════════╝\n'); + + try { + await runSshCommands(COMMANDS); + console.log('\n✅ Server setup complete!\n'); + } catch (err) { + console.error('\n[FATAL]', err.message); + process.exit(1); + } +} + +main(); diff --git a/scripts/start-frontend.js b/scripts/start-frontend.js new file mode 100644 index 0000000..3b79517 --- /dev/null +++ b/scripts/start-frontend.js @@ -0,0 +1,133 @@ +// scripts/start-frontend.js +// ───────────────────────────────────────────────────────────────────────────── +// BackOne DPI — Start Frontend & Fix PM2 Setup on Server +// ───────────────────────────────────────────────────────────────────────────── +'use strict'; + +const { Client: SshClient } = require('ssh2'); + +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', +}; + +const REMOTE_ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; + +const COMMANDS = [ + // Check what's currently running on port 3000 + 'echo "=== PORT 3000 CHECK ===" && ss -tlnp 2>/dev/null | grep 3000 || netstat -tlnp 2>/dev/null | grep 3000 || echo "Port 3000 not in use"', + + // Check if standalone server.js exists + `echo "=== STANDALONE CHECK ===" && ls -la ${REMOTE_ROOT}/.next/standalone/server.js 2>/dev/null || echo "standalone server.js NOT FOUND"`, + + // List standalone directory structure + `echo "=== STANDALONE DIRECTORY ===" && ls ${REMOTE_ROOT}/.next/standalone/ 2>/dev/null | head -20`, + + // Check static assets copied correctly + `echo "=== STATIC IN STANDALONE ===" && ls ${REMOTE_ROOT}/.next/standalone/.next/ 2>/dev/null || echo "No .next dir in standalone"`, + + // Find the correct npm bin directory for npx pm2 + 'echo "=== NPM BIN DIR ===" && npm bin 2>/dev/null || npm config get prefix', + + // Check npx pm2 path + 'echo "=== NPX PM2 PATH ===" && find /home/adminbackend/.npm -name "pm2" -type f 2>/dev/null | grep "/bin/pm2" | head -3', + + // Install PM2 using npm to user's npm prefix + 'echo "=== SET NPM PREFIX ===" && npm config set prefix /home/adminbackend/.npm-global && npm install -g pm2 2>&1 | tail -5', + 'echo "=== PM2 INSTALLED ===" && /home/adminbackend/.npm-global/bin/pm2 --version 2>&1 || echo "Still not found"', + + // Try npx approach + `echo "=== USING NPX PM2 ===" && cd ${REMOTE_ROOT} && npx pm2 --version 2>&1`, + + // Start frontend standalone server if not running + `echo "=== CHECKING STANDALONE SERVER ===" && ls -la ${REMOTE_ROOT}/.next/standalone/server.js`, + + // Start with nohup (works on cPanel) + `echo "=== STARTING FRONTEND NOHUP ===" && cd ${REMOTE_ROOT} && NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0 NEXT_PUBLIC_API_URL=http://127.0.0.1:3001 nohup node .next/standalone/server.js >> logs/frontend-out.log 2>&1 & echo "PID: $!"`, + + // Wait and check + 'sleep 5', + + // Verify port 3000 is now listening + 'echo "=== PORT 3000 AFTER START ===" && ss -tlnp | grep 3000 || netstat -tlnp | grep 3000 || echo "Port 3000 still not in use"', + + // Check all running processes + 'echo "=== ALL NODE PROCESSES ===" && ps aux | grep node | grep -v grep', + + // Test all endpoints + 'echo "=== BACKEND HEALTH ===" && curl -s http://127.0.0.1:3001/api/health', + 'echo "=== FRONTEND RESPONSE ===" && curl -s -o /dev/null -w "HTTP Status: %{http_code}" http://127.0.0.1:3000/ 2>&1', + + // Show frontend log + `echo "=== FRONTEND LOG ===" && tail -20 ${REMOTE_ROOT}/logs/frontend-out.log 2>/dev/null || echo "No frontend log"`, + + // Check proxy connection to MongoDB + `echo "=== PROXY LOG ===" && tail -10 ${REMOTE_ROOT}/logs/proxy-out.log 2>/dev/null || echo "No proxy log"`, + + // Show existing pm2 in npx cache and start all services via npx pm2 + `echo "=== STARTING WITH NPX PM2 ===" && cd ${REMOTE_ROOT} && NODE_ENV=production npx pm2 list 2>&1 | head -20`, +]; + +function runSshCommands(commands) { + return new Promise((resolve, reject) => { + const ssh = new SshClient(); + const results = []; + + ssh.on('ready', () => { + console.log('[SSH] Connected!\n'); + let index = 0; + + function runNext() { + if (index >= commands.length) { + ssh.end(); + return; + } + + const cmd = commands[index++]; + const preview = cmd.length > 120 ? cmd.substring(0, 120) + '...' : cmd; + console.log(`\n$ ${preview}`); + + ssh.exec(cmd, { env: { PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' } }, (err, stream) => { + if (err) { + console.error(`[ERROR] ${err.message}`); + runNext(); + return; + } + + stream.on('data', (data) => process.stdout.write(data.toString())); + stream.stderr.on('data', (data) => { + const text = data.toString(); + if (!text.includes('npm warn') && !text.includes('npm notice')) { + process.stdout.write(`[ERR] ${text}`); + } + }); + stream.on('close', () => runNext()); + }); + } + + runNext(); + ssh.on('end', () => resolve(results)); + }); + + ssh.on('error', reject); + ssh.connect({ ...CONFIG, readyTimeout: 30000 }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Frontend Startup & PM2 Fix ║'); + console.log('╚══════════════════════════════════════════════════╝\n'); + + try { + await runSshCommands(COMMANDS); + console.log('\n✅ Frontend startup attempt complete!\n'); + } catch (err) { + console.error('\n[FATAL]', err.message); + process.exit(1); + } +} + +main(); diff --git a/scripts/start-mongo.js b/scripts/start-mongo.js new file mode 100644 index 0000000..9167b61 --- /dev/null +++ b/scripts/start-mongo.js @@ -0,0 +1,59 @@ +const net = require('net'); +const { MongoMemoryServer } = require('mongodb-memory-server'); + +function checkPort(port, host) { + return new Promise((resolve) => { + const socket = new net.Socket(); + socket.setTimeout(1000); + socket.on('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.on('timeout', () => { + socket.destroy(); + resolve(false); + }); + socket.on('error', () => { + socket.destroy(); + resolve(false); + }); + socket.connect(port, host); + }); +} + +async function start() { + try { + const isLocalMongoRunning = await checkPort(27017, '127.0.0.1') || await checkPort(27017, 'localhost'); + if (isLocalMongoRunning) { + console.log('[MONGO] ℹ️ Local persistent MongoDB is already running on port 27017. Skipping in-memory MongoDB launch.'); + // Keep process alive so concurrently doesn't show it as exited/failed + setInterval(() => {}, 100000); + return; + } + + const mongod = await MongoMemoryServer.create({ + instance: { + port: 27017, + dbName: 'backone_inspect_0' + } + }); + console.log('[MONGO] 🚀 Local In-Memory MongoDB running at: mongodb://127.0.0.1:27017/backone_inspect_0'); + + const cleanup = async () => { + await mongod.stop(); + process.exit(0); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + } catch (err) { + if (err.code === 'EADDRINUSE' || (err.message && err.message.includes('EADDRINUSE'))) { + console.log('[MONGO] ℹ️ Port 27017 is already in use. Skipping in-memory MongoDB launch.'); + setInterval(() => {}, 100000); + } else { + console.error('[MONGO] ❌ Error starting MongoMemoryServer:', err.message); + } + } +} + +start(); diff --git a/scripts/trigger-now.js b/scripts/trigger-now.js new file mode 100644 index 0000000..f70b5ba --- /dev/null +++ b/scripts/trigger-now.js @@ -0,0 +1,28 @@ + +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = require('crypto'); +} +const mongoose = require('mongoose'); +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env.production') }); + +const MONGODB_URI = process.env.MONGODB_URI; +const { collectAllAgents } = require('../proxy/collector'); + +async function run() { + console.log('[Trigger] Connecting to MongoDB...'); + await mongoose.connect(MONGODB_URI); + console.log('[Trigger] Connected. Starting collection cycle...'); + try { + const res = await collectAllAgents(); + console.log('[Trigger] Collection cycle finished successfully:', res); + } catch (err) { + console.error('[Trigger] Collection cycle failed:', err); + } finally { + await mongoose.connection.close(); + console.log('[Trigger] MongoDB connection closed.'); + } +} + +run(); + diff --git a/scripts/update-pm2-config.js b/scripts/update-pm2-config.js new file mode 100644 index 0000000..18c1c4a --- /dev/null +++ b/scripts/update-pm2-config.js @@ -0,0 +1,132 @@ +// scripts/update-pm2-config.js +// ───────────────────────────────────────────────────────────────────────────── +// BackOne DPI — Update PM2 to use correct standalone script path +// The frontend PM2 process is currently running the OLD server.js from root. +// We need to delete and re-create the PM2 process with the correct script. +// ───────────────────────────────────────────────────────────────────────────── +'use strict'; + +const { Client: SshClient } = require('ssh2'); +const https = require('https'); + +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', +}; + +const ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; +const PM2 = '/home/adminbackend/.npm-global/bin/pm2'; + +const COMMANDS = [ + // Show current PM2 state + `echo "=== CURRENT PM2 STATE ===" && ${PM2} list`, + `echo "=== CURRENT FRONTEND SCRIPT ===" && ${PM2} show backone-frontend 2>&1 | grep -E "(script|cwd)" | head -5`, + + // Delete old frontend process and start fresh with correct script + `echo "=== DELETING OLD FRONTEND PROCESS ===" && ${PM2} delete backone-frontend && echo "Deleted"`, + + // Start frontend using the new ecosystem config (which points to .next/standalone/server.js) + `echo "=== STARTING FRONTEND WITH NEW CONFIG ===" && cd ${ROOT} && NODE_ENV=production ${PM2} start ecosystem.config.js --only backone-frontend && echo "Started"`, + + // Wait for startup + `sleep 6`, + + // Verify new process + `echo "=== NEW PM2 STATUS ===" && ${PM2} list`, + `echo "=== NEW FRONTEND SCRIPT ===" && ${PM2} show backone-frontend 2>&1 | grep -E "(script|cwd|version|status)" | head -8`, + + // Test frontend response + `echo "=== FRONTEND INTERNAL TEST ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/`, + `echo "=== FRONTEND LOGIN TEST ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/login`, + + // Save PM2 state + `echo "=== SAVING PM2 STATE ===" && ${PM2} save && echo "PM2 state saved"`, + + // Show frontend log to confirm new code + `echo "=== FRONTEND LOG ===" && ${PM2} logs backone-frontend --lines 10 --nostream 2>&1 | tail -15`, + + // Check HTTPS now + `echo "=== HTTPS VIA CURL ===" && curl -s -o /dev/null -w "HTTP %{http_code}" -L --max-redirs 3 https://demoplace.my.id/ 2>&1 || echo "HTTPS not accessible from server itself"`, + + // Check HTTP (which should redirect to HTTPS per .htaccess) + `echo "=== HTTP CHECK ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://demoplace.my.id/ 2>&1`, + + // Check Apache config for this domain + `echo "=== APACHE VHOST CONFIG ===" && cat /etc/apache2/conf.d/userdata/ssl/2_4/adminbackend/demoplace.my.id/*.conf 2>/dev/null | head -30 || ls /etc/apache2/conf.d/userdata/ 2>/dev/null | head -5 || echo "No Apache vhost config accessible"`, + + // Check cPanel proxy config + `echo "=== CPANEL PROXY CONF ===" && cat /home/adminbackend/.cpanel/ea4/php.conf 2>/dev/null || echo "No cPanel PHP conf"`, + `cat /home/adminbackend/etc/apache_conf 2>/dev/null || echo "No Apache conf in home"`, + + // Final summary + `echo ""`, + `echo "╔══════════════════════════════════════════════════════╗"`, + `echo "║ BackOne DPI — PM2 Update Complete ║"`, + `${PM2} list 2>/dev/null | grep -E "(online|error)" | awk '{print "║ " $2 " " $10 " " $6 " ║"}'`, + `echo "╚══════════════════════════════════════════════════════╝"`, +]; + +function runSsh(commands) { + return new Promise((resolve, reject) => { + const ssh = new SshClient(); + ssh.on('ready', () => { + console.log('[SSH] Connected!\n'); + let i = 0; + function next() { + if (i >= commands.length) { ssh.end(); return; } + const cmd = commands[i++]; + console.log(`\n$ ${cmd.substring(0, 130)}${cmd.length > 130 ? '...' : ''}`); + ssh.exec(cmd, (err, stream) => { + if (err) { console.error('[ERR]', err.message); next(); return; } + stream.on('data', d => process.stdout.write(d.toString())); + stream.stderr.on('data', d => { const t = d.toString(); if (!t.includes('npm warn') && !t.includes('notice')) process.stdout.write(t); }); + stream.on('close', next); + }); + } + next(); + ssh.on('end', resolve); + }); + ssh.on('error', reject); + ssh.connect({ ...CONFIG, readyTimeout: 30000 }); + }); +} + +async function checkHttps(url) { + return new Promise(resolve => { + const req = https.get(url, { timeout: 10000, rejectUnauthorized: false }, res => { + let body = ''; + res.on('data', d => body += d); + res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location, body: body.substring(0, 150) })); + }); + req.on('error', e => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Fix PM2 Script & HTTPS Verification ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + await runSsh(COMMANDS); + + console.log('\n\n▶ External HTTPS checks (from local machine)...\n'); + const checks = [ + 'https://demoplace.my.id/', + 'https://demoplace.my.id/login', + 'https://demoplace.my.id/api/health', + ]; + for (const url of checks) { + const r = await checkHttps(url); + const icon = r.status >= 200 && r.status < 400 ? '✅' : r.status === 0 ? '⚠️' : '❌'; + console.log(` ${icon} ${url} → HTTP ${r.status} ${r.error || ''}`); + if (r.location) console.log(` → Redirects to: ${r.location}`); + if (r.status === 200 && r.body) console.log(` Body preview: ${r.body.substring(0, 100)}`); + } + + console.log('\n✅ Done!\n'); +} + +main().catch(err => { console.error('[FATAL]', err); process.exit(1); }); diff --git a/scripts/upload-and-run-audit.js b/scripts/upload-and-run-audit.js new file mode 100644 index 0000000..c8970b7 --- /dev/null +++ b/scripts/upload-and-run-audit.js @@ -0,0 +1,94 @@ +const SftpClient = require('ssh2-sftp-client'); +const { Client } = require('ssh2'); + +const remoteAuditScript = ` +const fs = require('fs'); +const path = require('path'); + +const root = '/home/adminbackend/web/demoplace.my.id/public_html'; + +console.log('===================================================='); +console.log(' FULL COMPONENT & PAGE AUDIT OF DEMOPLACE.MY.ID '); +console.log('===================================================='); + +// 1. Audit Client/Server Chunks for Page Headers & HelpTriggers +const chunksDir = path.join(root, '.next/standalone/.next/server/chunks'); +if (fs.existsSync(chunksDir)) { + const files = fs.readdirSync(chunksDir); + files.forEach(f => { + if (!f.endsWith('.js')) return; + const content = fs.readFileSync(path.join(chunksDir, f), 'utf8'); + + // Look for page headers or title definitions + const headerMatch = content.match(/["']([A-Z][A-Za-z0-9\\s]{3,35})["'],\\s*description:/); + if (headerMatch) { + console.log(\`[Chunk \${f}] Found Header Definition: "\${headerMatch[1]}"\`); + } + + if (content.includes('Learn This Page')) { + console.log(\`[Chunk \${f}] Contains "Learn This Page" button\`); + } + }); +} + +// 2. Audit Page Source files in src/app/(dashboard) +const appSrcDir = path.join(root, 'src/app/(dashboard)'); +if (fs.existsSync(appSrcDir)) { + function scan(dir) { + const items = fs.readdirSync(dir, { withFileTypes: true }); + for (const item of items) { + const full = path.join(dir, item.name); + if (item.isDirectory()) { + scan(full); + } else if (item.name === 'page.tsx' || item.name.endsWith('.tsx')) { + const content = fs.readFileSync(full, 'utf8'); + const rel = path.relative(appSrcDir, full); + + // Find H2 or H1 + const h2 = content.match(/<h[12][^>]*>([\\s\\S]*?)<\\/h[12]>/i); + const titleText = h2 ? h2[1].replace(/<[^>]+>/g, '').trim().split('\\n')[0] : 'None'; + const hasHelp = content.includes('HelpTrigger') || content.includes('Learn This Page'); + + console.log(\`Page File: \${rel}\`); + console.log(\` Header Title : \${titleText}\`); + console.log(\` Has HelpButton: \${hasHelp}\`); + console.log('----------------------------------------------------'); + } + } + } + scan(appSrcDir); +} +`; + +async function main() { + const sftp = new SftpClient(); + await sftp.connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' + }); + + const remoteScriptPath = '/home/adminbackend/audit_remote.js'; + await sftp.put(Buffer.from(remoteAuditScript), remoteScriptPath); + await sftp.end(); + + const conn = new Client(); + conn.on('ready', () => { + conn.exec(`node ${remoteScriptPath}`, (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log(out); + conn.end(); + }); + }); + }).connect({ + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' + }); +} + +main(); diff --git a/scripts/upload-profile-pictures.js b/scripts/upload-profile-pictures.js new file mode 100644 index 0000000..fd18467 --- /dev/null +++ b/scripts/upload-profile-pictures.js @@ -0,0 +1,87 @@ +// scripts/upload-profile-pictures.js +// Upload semua foto profil dari lokal ke server production +'use strict'; +const SftpClient = require('ssh2-sftp-client'); +const { Client: SshClient } = require('ssh2'); +const fs = require('fs'); +const path = require('path'); + +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 30000, + retries: 3, + retry_factor: 2, + retry_minTimeout: 2000, +}; +const LOCAL_DIR = path.join(__dirname, '..', 'backend', 'public', 'api', 'uploads'); +const REMOTE_DIR = '/home/adminbackend/web/demoplace.my.id/public_html/api/uploads'; + +function sshExec(cmd) { + return new Promise((resolve, reject) => { + const conn = new SshClient(); + conn.on('ready', () => { + conn.exec(cmd, (err, stream) => { + if (err) { conn.end(); return reject(err); } + let out = ''; + stream + .on('data', d => { out += d; }) + .stderr.on('data', d => { out += d; }); + stream.on('close', () => { conn.end(); resolve(out); }); + }); + }).on('error', reject) + .connect({ host: CONFIG.host, port: CONFIG.port, username: CONFIG.username, password: CONFIG.password, readyTimeout: CONFIG.readyTimeout }); + }); +} + +async function main() { + if (!fs.existsSync(LOCAL_DIR)) { + console.log('Folder uploads lokal tidak ditemukan:', LOCAL_DIR); + return; + } + + const files = fs.readdirSync(LOCAL_DIR).filter(f => /\.(png|jpg|jpeg|webp)$/i.test(f)); + if (files.length === 0) { + console.log('Tidak ada file gambar di folder uploads lokal.'); + return; + } + + console.log(`Ditemukan ${files.length} file foto profil untuk diupload:\n ${files.join('\n ')}\n`); + + // Buat direktori di server jika belum ada + await sshExec(`mkdir -p ${REMOTE_DIR} && chmod 755 ${REMOTE_DIR}`); + console.log(`[Server] Direktori ${REMOTE_DIR} siap\n`); + + // Upload semua file via SFTP + const sftp = new SftpClient(); + await sftp.connect(CONFIG); + console.log('[SFTP] Terhubung ke server\n'); + + let uploaded = 0; + let failed = 0; + + for (const file of files) { + const localPath = path.join(LOCAL_DIR, file); + const remotePath = `${REMOTE_DIR}/${file}`; + try { + await sftp.put(localPath, remotePath); + console.log(`✅ Upload: ${file}`); + uploaded++; + } catch (err) { + console.error(`❌ Gagal upload ${file}:`, err.message); + failed++; + } + } + + await sftp.end(); + + // Verifikasi + const remoteList = await sshExec(`ls -lh ${REMOTE_DIR}`); + console.log('\n[Server] Isi folder uploads:\n' + remoteList); + + console.log(`\n✅ Selesai! ${uploaded} berhasil, ${failed} gagal.`); +} + +main().catch(e => console.error('[FATAL]', e.message)); diff --git a/scripts/upload-standalone-next.js b/scripts/upload-standalone-next.js new file mode 100644 index 0000000..f48733a --- /dev/null +++ b/scripts/upload-standalone-next.js @@ -0,0 +1,147 @@ +// scripts/upload-standalone-next.js +// ───────────────────────────────────────────────────────────────────────────── +// BackOne DPI — Upload missing .next/standalone/.next/ directory to server +// This fixes: "Could not find a production build in the './.next' directory" +// ───────────────────────────────────────────────────────────────────────────── +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const SftpClient = require('ssh2-sftp-client'); +const { Client: SshClient } = require('ssh2'); + +const LOCAL_ROOT = path.resolve(__dirname, '..'); +const REMOTE_ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; +const PM2 = '/home/adminbackend/.npm-global/bin/pm2'; + +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', +}; + +// Recursively upload a local directory to remote (skipping node_modules and .git) +async function uploadDir(sftp, localPath, remotePath) { + const items = fs.readdirSync(localPath, { withFileTypes: true }); + try { await sftp.mkdir(remotePath, true); } catch {} + + for (const item of items) { + if (['node_modules', '.git'].includes(item.name)) continue; + if (item.name.endsWith('.log')) continue; + + const lp = path.join(localPath, item.name); + const rp = remotePath + '/' + item.name; + + if (item.isDirectory()) { + process.stdout.write(` [DIR] ${rp}\n`); + await uploadDir(sftp, lp, rp); + } else { + process.stdout.write(` [UP] ${rp} ...`); + await sftp.put(lp, rp); + process.stdout.write(' done\n'); + } + } +} + +function runSsh(commands) { + return new Promise((resolve, reject) => { + const ssh = new SshClient(); + ssh.on('ready', () => { + let i = 0; + function next() { + if (i >= commands.length) { ssh.end(); return; } + const cmd = commands[i++]; + console.log(`\n$ ${cmd.substring(0, 120)}${cmd.length > 120 ? '...' : ''}`); + ssh.exec(cmd, (err, stream) => { + if (err) { console.error('[ERR]', err.message); next(); return; } + stream.on('data', d => process.stdout.write(d.toString())); + stream.stderr.on('data', d => { const t = d.toString(); if (!t.includes('npm warn') && !t.includes('notice')) process.stdout.write(t); }); + stream.on('close', next); + }); + } + next(); + ssh.on('end', resolve); + }); + ssh.on('error', reject); + ssh.connect({ ...CONFIG, readyTimeout: 30000 }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Upload Standalone Build Artifacts ║'); + console.log('║ Uploading: .next/standalone/.next/ (BUILD_ID + server) ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + const localStandaloneNext = path.join(LOCAL_ROOT, '.next', 'standalone', '.next'); + if (!fs.existsSync(localStandaloneNext)) { + console.error('✗ ERROR: .next/standalone/.next not found locally!'); + console.error(' Run: npm run build'); + process.exit(1); + } + + // Verify BUILD_ID exists + const buildId = fs.existsSync(path.join(localStandaloneNext, 'BUILD_ID')) + ? fs.readFileSync(path.join(localStandaloneNext, 'BUILD_ID'), 'utf8').trim() + : null; + console.log(`✓ Build ID: ${buildId || 'NOT FOUND'}`); + console.log(`✓ Local path: ${localStandaloneNext}`); + + const sftp = new SftpClient(); + try { + await sftp.connect(CONFIG); + console.log('[SFTP] Connected!\n'); + + const remotePath = `${REMOTE_ROOT}/.next/standalone/.next`; + console.log(`\n[DIR] Uploading .next/standalone/.next/ → ${remotePath}\n`); + await uploadDir(sftp, localStandaloneNext, remotePath); + + console.log('\n[SFTP] Upload complete!\n'); + } finally { + await sftp.end(); + } + + // Post-upload: restart PM2 frontend, verify + console.log('\n▶ Restarting PM2 frontend with new build...\n'); + await runSsh([ + // Verify BUILD_ID now exists on server + `echo "=== VERIFY BUILD_ID ===" && cat ${REMOTE_ROOT}/.next/standalone/.next/BUILD_ID`, + + // Also verify static dir exists + `echo "=== VERIFY STATIC ===" && ls ${REMOTE_ROOT}/.next/standalone/.next/static/ 2>/dev/null | wc -l | xargs echo "Static files:"`, + + // Restart frontend PM2 process + `echo "=== RESTARTING FRONTEND ===" && ${PM2} restart backone-frontend --update-env && echo "Restarted"`, + + // Wait + `sleep 6`, + + // Check PM2 status + `echo "=== PM2 STATUS ===" && ${PM2} list`, + + // Test internal endpoints + `echo "=== FRONTEND / ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/`, + `echo "=== FRONTEND /login ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/login`, + `echo "=== BACKEND HEALTH ===" && curl -s http://127.0.0.1:3001/api/health`, + + // Show frontend log + `echo "=== FRONTEND LOG ===" && ${PM2} logs backone-frontend --lines 10 --nostream 2>&1 | tail -15`, + + // Test via domain (HTTP which redirects to HTTPS via .htaccess) + `echo "=== DOMAIN HTTP TEST ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://demoplace.my.id/`, + + // Check port 3000 + `echo "=== PORT 3000 ===" && ss -tlnp | grep 3000`, + ]); + + console.log('\n══════════════════════════════════════════════════════════'); + console.log(' ✅ Standalone build artifacts uploaded & frontend restarted!'); + console.log(' 🌐 Dashboard: https://demoplace.my.id'); + console.log('══════════════════════════════════════════════════════════\n'); +} + +main().catch(err => { + console.error('\n[FATAL]', err.message); + process.exit(1); +}); diff --git a/scripts/verify-deployment.js b/scripts/verify-deployment.js new file mode 100644 index 0000000..abf302a --- /dev/null +++ b/scripts/verify-deployment.js @@ -0,0 +1,148 @@ +// scripts/verify-deployment.js +// ───────────────────────────────────────────────────────────────────────────── +// BackOne DPI — Post-Deployment Verification Script +// Tests: frontend, backend, security headers, proxy, MongoDB +// ───────────────────────────────────────────────────────────────────────────── +'use strict'; + +const { Client: SshClient } = require('ssh2'); +const https = require('https'); +const http = require('http'); + +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', +}; + +const PM2 = '/home/adminbackend/.npm-global/bin/pm2'; +const ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; + +// HTTP request helper +function httpGet(url) { + return new Promise((resolve) => { + const mod = url.startsWith('https') ? https : http; + const req = mod.get(url, { timeout: 10000 }, (res) => { + let body = ''; + res.on('data', (d) => body += d); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: body.substring(0, 300) })); + }); + req.on('error', (e) => resolve({ status: 0, error: e.message })); + req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); }); + }); +} + +const SSH_COMMANDS = [ + // Check PM2 process list + `echo "=== PM2 STATUS ===" && ${PM2} list`, + + // Check backend health + `echo "=== BACKEND HEALTH ===" && curl -s http://127.0.0.1:3001/api/health`, + + // Check frontend pages + `echo "=== FRONTEND / ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/`, + `echo "=== FRONTEND /login ===" && curl -s -o /dev/null -w "HTTP %{http_code}" http://127.0.0.1:3000/login`, + + // Security audit: ensure backend and proxy ports are NOT publicly accessible + `echo "=== SECURITY: Port 3001 external test ===" && ss -tlnp | grep 3001`, + `echo "=== SECURITY: Port 4000 external test ===" && ss -tlnp | grep 4000`, + + // Check upload directory permissions + `echo "=== UPLOAD DIR PERMS ===" && ls -la ${ROOT}/backend/public/api/uploads/ 2>/dev/null || echo "Upload dir does not exist yet (will be created on first upload)"`, + + // Check proxy MongoDB connection + `echo "=== PROXY STATUS ===" && ${PM2} logs backone-proxy --lines 5 --nostream 2>&1 | grep -E "(MongoDB|Connected|Capacity|Scheduler)" | tail -10`, + + // Verify no secrets in Next.js build output (sanity check) + `echo "=== SECURITY AUDIT: Check no API key in build ===" && grep -r "sk_db_live" ${ROOT}/.next/standalone/ 2>/dev/null | head -3 || echo "CLEAN: No Netify API key found in build output"`, + `echo "=== SECURITY AUDIT: Check no MongoDB URI in build ===" && grep -r "backone_user" ${ROOT}/.next/standalone/ 2>/dev/null | head -3 || echo "CLEAN: No MongoDB credentials in build output"`, + + // Check frontend log for readiness + `echo "=== FRONTEND READY LOG ===" && ${PM2} logs backone-frontend --lines 5 --nostream 2>&1 | grep -E "(Ready|Error|Next.js)" | tail -10`, + + // Check disk usage for uploads dir + `echo "=== DISK USAGE ===" && df -h /home/adminbackend/web/ 2>/dev/null | tail -2`, + + // Final summary + `echo ""`, + `echo "╔════════════════════════════════════════════╗"`, + `echo "║ BackOne DPI Production — Service Status ║"`, + `echo "╠════════════════════════════════════════════╣"`, + `${PM2} list 2>/dev/null | grep -E "(online|error|stopped)" | awk '{printf "║ %-12s %-10s %-8s ║\\n", $2, $10, $8}'`, + `echo "╚════════════════════════════════════════════╝"`, +]; + +function runSshChecks(commands) { + return new Promise((resolve, reject) => { + const ssh = new SshClient(); + + ssh.on('ready', () => { + console.log('[SSH] Connected for verification!\n'); + let index = 0; + + function runNext() { + if (index >= commands.length) { + ssh.end(); + return; + } + const cmd = commands[index++]; + console.log(`\n$ ${cmd.substring(0, 120)}${cmd.length > 120 ? '...' : ''}`); + + ssh.exec(cmd, (err, stream) => { + if (err) { console.error(`[ERROR] ${err.message}`); runNext(); return; } + stream.on('data', (d) => process.stdout.write(d.toString())); + stream.stderr.on('data', (d) => { + const t = d.toString(); + if (!t.includes('npm warn') && !t.includes('npm notice')) process.stdout.write(t); + }); + stream.on('close', runNext); + }); + } + + runNext(); + ssh.on('end', resolve); + }); + ssh.on('error', reject); + ssh.connect({ ...CONFIG, readyTimeout: 30000 }); + }); +} + +async function main() { + console.log('\n╔══════════════════════════════════════════════════════════╗'); + console.log('║ BackOne DPI — Post-Deployment Verification ║'); + console.log('╚══════════════════════════════════════════════════════════╝\n'); + + // ── SSH internal checks ─────────────────────────────────────────────────── + console.log('▶ Running internal server checks via SSH...'); + await runSshChecks(SSH_COMMANDS); + + // ── External HTTP checks (from this machine) ────────────────────────────── + console.log('\n\n▶ Running external HTTP checks from local machine...\n'); + + const checks = [ + { url: 'http://demoplace.my.id/', label: 'Domain root (redirect expected)' }, + { url: 'http://demoplace.my.id/login', label: 'Login page' }, + { url: 'http://demoplace.my.id/api/health', label: 'Backend health via domain' }, + ]; + + for (const { url, label } of checks) { + const result = await httpGet(url); + const status = result.status; + const icon = (status >= 200 && status < 400) ? '✅' : (status === 0 ? '⚠️' : '❌'); + console.log(` ${icon} ${label}: HTTP ${status} ${result.error || ''}`); + if (result.body && status >= 200 && status < 300) { + console.log(` Body preview: ${result.body.substring(0, 100)}`); + } + } + + console.log('\n══════════════════════════════════════════════════════════'); + console.log(' Production deployment verification complete!'); + console.log(' URL: https://demoplace.my.id'); + console.log('══════════════════════════════════════════════════════════\n'); +} + +main().catch((err) => { + console.error('[FATAL]', err.message); + process.exit(1); +}); diff --git a/sftp-deploy.js b/sftp-deploy.js new file mode 100644 index 0000000..ee82a63 --- /dev/null +++ b/sftp-deploy.js @@ -0,0 +1,80 @@ +// sftp-deploy.js +// Upload source2-deploy.zip ke server via SFTP menggunakan ssh2-sftp-client +// Run: node sftp-deploy.js + +const SftpClient = require('ssh2-sftp-client'); +const path = require('path'); +const fs = require('fs'); + +const config = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 30000, + retries: 2, + retry_factor: 2, + retry_minTimeout: 2000, +}; + +const LOCAL_ZIP = path.join(__dirname, '..', 'source2-deploy.zip'); +const REMOTE_DIR = '/home/adminbackend/web/dev.demoplace.my.id/public_html'; +const REMOTE_ZIP = `${REMOTE_DIR}/source2-deploy.zip`; + +async function deploy() { + const sftp = new SftpClient(); + + const zipSizeMB = (fs.statSync(LOCAL_ZIP).size / 1024 / 1024).toFixed(1); + console.log(`[SFTP] Connecting to ${config.host}:${config.port}...`); + console.log(`[SFTP] Local ZIP: ${LOCAL_ZIP} (${zipSizeMB} MB)`); + console.log(`[SFTP] Remote: ${REMOTE_ZIP}`); + + try { + await sftp.connect(config); + console.log('[SFTP] Connected!'); + + // Ensure remote directory exists + const dirExists = await sftp.exists(REMOTE_DIR); + if (!dirExists) { + console.log(`[SFTP] Creating remote dir: ${REMOTE_DIR}`); + await sftp.mkdir(REMOTE_DIR, true); + } + + // Upload with progress + console.log('[SFTP] Uploading ZIP...'); + let lastPercent = 0; + await sftp.fastPut(LOCAL_ZIP, REMOTE_ZIP, { + chunkSize: 131072, // 128 KB chunks + step: (totalTransferred, chunk, total) => { + const percent = Math.round((totalTransferred / total) * 100); + if (percent !== lastPercent && percent % 10 === 0) { + const mb = (totalTransferred / 1024 / 1024).toFixed(1); + console.log(`[SFTP] Progress: ${percent}% (${mb} MB / ${(total/1024/1024).toFixed(1)} MB)`); + lastPercent = percent; + } + } + }); + + console.log('[SFTP] ✓ Upload complete!'); + + // Verify file exists on server + const remoteInfo = await sftp.stat(REMOTE_ZIP); + console.log(`[SFTP] Remote file size: ${(remoteInfo.size / 1024 / 1024).toFixed(1)} MB`); + + await sftp.end(); + console.log('[SFTP] Connection closed.'); + console.log(''); + console.log('=== NEXT STEPS ==='); + console.log('1. SSH to server: ssh -p 2222 adminbackend@103.185.47.52'); + console.log(`2. cd ${REMOTE_DIR}`); + console.log('3. unzip -o source2-deploy.zip'); + console.log('4. chmod +x deploy-server-setup.sh && bash deploy-server-setup.sh'); + + } catch (err) { + console.error('[SFTP] ERROR:', err.message); + try { await sftp.end(); } catch {} + process.exit(1); + } +} + +deploy(); diff --git a/src/app/(dashboard)/agents/AgentsPageModals.tsx b/src/app/(dashboard)/agents/AgentsPageModals.tsx index ad0ad11..98335d8 100644 --- a/src/app/(dashboard)/agents/AgentsPageModals.tsx +++ b/src/app/(dashboard)/agents/AgentsPageModals.tsx @@ -3,7 +3,7 @@ import { AgentModals } from "@/components/admin/AgentModals"; import ExternalAccountModal from "@/components/admin/ExternalAccountModal"; import AgentLocationModal from "@/components/admin/AgentLocationModal"; -import { Agent } from "@/lib/actions/agents"; +import { Agent } from "@/lib/actions/agentsCore"; import { type ManagedUser } from "@/lib/admin-api"; interface AgentsPageModalsProps { @@ -101,6 +101,7 @@ export default function AgentsPageModals({ user={externalModalUser} targetSiteUuid={targetSiteUuid} targetCreatedBy={targetCreatedBy} + modalContext="agents" /> <AgentLocationModal diff --git a/src/app/(dashboard)/agents/AgentsTableSection.tsx b/src/app/(dashboard)/agents/AgentsTableSection.tsx index 36238e6..9b035d7 100644 --- a/src/app/(dashboard)/agents/AgentsTableSection.tsx +++ b/src/app/(dashboard)/agents/AgentsTableSection.tsx @@ -2,24 +2,37 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { DataTable, Column } from "@/components/ui/DataTable"; -import { Loader2 } from "lucide-react"; -import { Agent } from "@/lib/actions/agents"; +import { Loader2, Plus } from "lucide-react"; +import { Agent } from "@/lib/actions/agentsCore"; interface AgentsTableSectionProps { agents: Agent[]; isLoading: boolean; columns: Column<Agent>[]; + role: string | null; + onProvisionClick?: () => void; } export default function AgentsTableSection({ agents, isLoading, columns, + role, + onProvisionClick, }: AgentsTableSectionProps) { return ( <Card className="bg-card/50 backdrop-blur-sm border-border/50"> - <CardHeader> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4"> <CardTitle className="text-white">Provisioned Network Agents ({agents.length})</CardTitle> + {(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && onProvisionClick && ( + <button + onClick={onProvisionClick} + className="flex items-center gap-2 bg-primary hover:bg-primary/95 text-primary-foreground px-4 py-2 rounded-xl text-sm font-semibold transition-all shadow-lg shadow-blue-500/10" + > + <Plus className="w-4 h-4" /> + Provision Agent + </button> + )} </CardHeader> <CardContent> {isLoading ? ( diff --git a/src/app/(dashboard)/agents/ExternalAccountsSection.tsx b/src/app/(dashboard)/agents/ExternalAccountsSection.tsx index ce1f0ea..d83016d 100644 --- a/src/app/(dashboard)/agents/ExternalAccountsSection.tsx +++ b/src/app/(dashboard)/agents/ExternalAccountsSection.tsx @@ -51,7 +51,7 @@ export default function ExternalAccountsSection({ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors" > <Plus className="w-3.5 h-3.5" /> - Add External Account + Add Technical Account </button> </CardHeader> <CardContent> @@ -91,7 +91,7 @@ export default function ExternalAccountsSection({ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors" > <Plus className="w-3.5 h-3.5" /> - Add External Account + Add Technical Account </button> </CardHeader> <CardContent> @@ -117,21 +117,21 @@ export default function ExternalAccountsSection({ </CardContent> </Card> - {/* Table 3: Nexus Accounts */} + {/* Table 3: Office Accounts */} <Card className="bg-card/50 backdrop-blur-sm border-border/50"> <CardHeader className="flex flex-row items-center justify-between pb-2"> - <CardTitle className="text-white">Nexus Tenant External Accounts ({nexusUsers.length})</CardTitle> + <CardTitle className="text-white">Office Tenant External Accounts ({nexusUsers.length})</CardTitle> <button onClick={() => { setExternalModalUser(null); - setTargetSiteUuid('d7902405_0dc2_458b_8584_ed4d24b64f24'); - setTargetCreatedBy('nexus'); + setTargetSiteUuid('1959bb55_045b_47c7_bbdd_f33b7db197b9'); + setTargetCreatedBy('office'); setExternalModalOpen(true); }} className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors" > <Plus className="w-3.5 h-3.5" /> - Add External Account + Add Technical Account </button> </CardHeader> <CardContent> @@ -175,7 +175,7 @@ export default function ExternalAccountsSection({ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors" > <Plus className="w-3.5 h-3.5" /> - Add External Account + Add Technical Account </button> </CardHeader> <CardContent> @@ -203,5 +203,40 @@ export default function ExternalAccountsSection({ ); } + // EXECUTIVE: read-only view of all accounts, no create/edit buttons + if (role === 'EXECUTIVE') { + return ( + <Card className="bg-card/50 backdrop-blur-sm border-border/50"> + <CardHeader className="flex flex-row items-center justify-between pb-2"> + <CardTitle className="text-white flex items-center gap-2"> + <span>External Accounts Directory ({externalUsers.length})</span> + </CardTitle> + <span className="text-xs text-slate-500 italic px-3 py-1.5 border border-slate-700/50 rounded-lg">Read-Only View</span> + </CardHeader> + <CardContent> + {isLoading ? ( + <div className="flex items-center justify-center py-12"> + <Loader2 className="w-8 h-8 animate-spin text-primary" /> + </div> + ) : ( + <DataTable + data={externalUsers} + columns={externalColumns} + searchPlaceholder="Search accounts..." + searchFilter={(row, query) => { + const q = query.toLowerCase(); + return ( + (row.username || "").toLowerCase().includes(q) || + (row.account_name || "").toLowerCase().includes(q) || + (row.role || "").toLowerCase().includes(q) + ); + }} + /> + )} + </CardContent> + </Card> + ); + } + return null; } diff --git a/src/app/(dashboard)/agents/UptimeStatsCards.tsx b/src/app/(dashboard)/agents/UptimeStatsCards.tsx index ab44b11..7fe96fa 100644 --- a/src/app/(dashboard)/agents/UptimeStatsCards.tsx +++ b/src/app/(dashboard)/agents/UptimeStatsCards.tsx @@ -21,29 +21,29 @@ export default function UptimeStatsCards({ <div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity"> <Server className="w-12 h-12 text-blue-400" /> </div> - <span className="text-slate-400 uppercase tracking-wider mb-1">Total Agents</span> - <span className="text-2xl font-black text-white tracking-tight">{totalAgents}</span> + <span className="text-slate-400 uppercase tracking-wider mb-1 text-[10px] font-medium">Total Agents</span> + <span className="text-2xl font-bold text-white tracking-tight">{totalAgents}</span> </div> <div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group"> <div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity"> <CheckCircle2 className="w-12 h-12 text-emerald-400" /> </div> - <span className="text-slate-400 uppercase tracking-wider mb-1">Online Agents</span> - <span className="text-2xl font-black text-emerald-400 tracking-tight">{onlineAgents}</span> + <span className="text-slate-400 uppercase tracking-wider mb-1 text-[10px] font-medium">Online Agents</span> + <span className="text-2xl font-bold text-emerald-400 tracking-tight">{onlineAgents}</span> </div> <div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group"> <div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity"> <XCircle className="w-12 h-12 text-red-400" /> </div> - <span className="text-slate-400 uppercase tracking-wider mb-1">Offline Agents</span> - <span className="text-2xl font-black text-red-400 tracking-tight">{offlineAgents}</span> + <span className="text-slate-400 uppercase tracking-wider mb-1 text-[10px] font-medium">Offline Agents</span> + <span className="text-2xl font-bold text-red-400 tracking-tight">{offlineAgents}</span> </div> <div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group"> <div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity"> <Activity className="w-12 h-12 text-cyan-400" /> </div> - <span className="text-slate-400 uppercase tracking-wider mb-1">Avg. Historical Uptime</span> - <span className="text-2xl font-black text-cyan-400 tracking-tight">{avgUptime.toFixed(2)}%</span> + <span className="text-slate-400 uppercase tracking-wider mb-1 text-[10px] font-medium">Avg. Historical Uptime</span> + <span className="text-2xl font-bold text-cyan-400 tracking-tight">{avgUptime.toFixed(2)}%</span> </div> </div> ); diff --git a/src/app/(dashboard)/agents/columns.tsx b/src/app/(dashboard)/agents/columns.tsx index 2b3b6e3..84e90f5 100644 --- a/src/app/(dashboard)/agents/columns.tsx +++ b/src/app/(dashboard)/agents/columns.tsx @@ -1,7 +1,7 @@ "use client"; import { Column } from "@/components/ui/DataTable"; -import { Agent } from "@/lib/actions/agents"; +import { Agent } from "@/lib/actions/agentsCore"; import { Badge } from "@/components/ui/Badge"; import { resolveAgentLabel, type ManagedUser } from "@/lib/admin-api"; import { Loader2, Trash2, CheckCircle2, XCircle, ChevronRight, UserCog, Eye, MapPin } from "lucide-react"; @@ -17,8 +17,9 @@ interface GetColumnsProps { setAccountModalAgent: (agent: { uuid: string; label: string } | null) => void; handleViewAs: (uuid: string, label: string) => void; openDelete: (agent: Agent) => void; - onEditLocation?: (agentUuid: string, agentLabel: string) => void; - locations?: { agent_uuid: string; latitude: number; longitude: number; label?: string }[]; + handleEditLocation?: (agentUuid: string, agentLabel: string) => void; + /** Set of agent UUIDs that already have a location saved */ + locationUuids?: Set<string>; } export function getAgentColumns({ @@ -32,14 +33,13 @@ export function getAgentColumns({ setAccountModalAgent, handleViewAs, openDelete, - onEditLocation, - locations = [], + handleEditLocation, + locationUuids = new Set(), }: GetColumnsProps): Column<Agent>[] { - const isSuperAdmin = role === 'SUPER_ADMIN'; const cols: Column<Agent>[] = [ { header: "UUID / Serial", - className: isSuperAdmin ? "w-[12%] text-center" : "w-[14%] text-center", + className: "w-[12%] text-center", accessor: (row) => ( <button onClick={() => setDetailAgent(row)} className="flex items-center justify-center gap-2 group text-center mx-auto" title="Click to view agent details"> <span className="font-mono text-xs text-blue-400 group-hover:text-blue-300 transition-colors">{row.uuid || row.serial}</span> @@ -49,14 +49,19 @@ export function getAgentColumns({ }, { header: "Label", - className: isSuperAdmin ? "w-[18%] text-center" : "w-[24%] text-center", + className: "w-[15%] text-center", accessor: (row) => { const uuid = row.uuid || row.serial; const dynamicLabel = resolveAgentLabel(uuid, managedUsers); const hasAccount = managedUsers.some(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER'); + const displayLabel = dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid); return ( <div className="flex items-center justify-center gap-2 mx-auto max-w-full"> - <button onClick={() => setDetailAgent(row)} className="text-center hover:text-white transition-colors truncate"> + <button + onClick={() => setDetailAgent(row)} + className="text-center hover:text-white transition-colors truncate" + title={displayLabel} + > {dynamicLabel !== uuid ? dynamicLabel : (row.label || <span className="text-muted-foreground italic font-mono text-xs">{uuid}</span>)} </button> {!hasAccount && (role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && ( @@ -68,30 +73,33 @@ export function getAgentColumns({ }, { header: "Provisioned", - className: isSuperAdmin ? "w-[10%] text-center" : "w-[11%] text-center", + className: "w-[11%] text-center", accessor: (row) => row.provisioned ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge> }, { header: "Activated", - className: isSuperAdmin ? "w-[10%] text-center" : "w-[11%] text-center", + className: "w-[11%] text-center", accessor: (row) => row.activated ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge> }, { - header: "Status", - className: "w-[12%] text-center", + header: "Traffic Status", + className: "w-[15%] text-center", accessor: (row) => { const statusText = row.last_seen_at?.human || '-'; - if (statusText === 'Online') { - return <span className="text-emerald-400 font-medium flex items-center justify-center gap-1.5"><div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div> Online</span>; - } else if (statusText === 'Offline') { - return <span className="text-red-400 font-medium">Offline</span>; + if (statusText.startsWith('Last seen:')) { + return ( + <span className="text-emerald-400 font-medium flex items-center justify-center gap-1.5 mx-auto"> + <div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div> + {statusText} + </span> + ); } else { - return <span className="text-slate-400 text-xs">{statusText}</span>; + return <span className="text-slate-400 text-xs font-normal">{statusText}</span>; } } }, { - header: "Historical Uptime", + header: "Avg Uptime", className: "w-[10%] text-center", accessor: (row) => { const uuid = row.uuid || row.serial; @@ -99,60 +107,75 @@ export function getAgentColumns({ let color = "text-emerald-400"; if (uptime < 90) color = "text-red-400"; else if (uptime < 98) color = "text-amber-400"; - return <span className={`font-mono font-bold ${color}`}>{uptime.toFixed(1)}%</span>; + return <span className={`font-mono font-medium text-xs tracking-wide ${color}`}>{uptime.toFixed(1)}%</span>; } }, { header: "Actions", - className: "w-[18%] text-center", + className: "w-[16%] text-center", accessor: (row) => { const uuid = row.uuid || row.serial; const dynamicLabel = resolveAgentLabel(uuid, managedUsers); const existingUser = managedUsers.find(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER') || null; const isViewAsLoading = viewAsLoading === uuid; + const agentLabel = dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid); return ( - <div className="flex items-center justify-center gap-1.5"> - <button onClick={() => setDetailAgent(row)} className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors" title="Detail Agent"> + <div className="flex items-center justify-center gap-1 mx-auto"> + <button onClick={() => setDetailAgent(row)} className="p-1 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors" title="Detail Agent"> <ChevronRight className="w-4 h-4" /> </button> {(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && ( <> - {onEditLocation && (() => { - const hasCoords = locations.some(l => l.agent_uuid === uuid); - return ( - <button - onClick={(e) => { e.stopPropagation(); onEditLocation(uuid, dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid)); }} - className={`p-1.5 rounded transition-colors ${ - hasCoords - ? "text-blue-400 hover:text-blue-300 hover:bg-blue-950/30" - : "text-red-400/60 hover:text-red-400 hover:bg-red-950/30" - }`} - title={hasCoords ? "Edit Geolocation (Dikonfigurasi)" : "Configure Geolocation (Belum Dikonfigurasi)"} - > - <MapPin className="w-4 h-4" /> - </button> - ); - })()} <button - onClick={(e) => { e.stopPropagation(); setAccountModalAgent({ uuid, label: dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid) }); setAccountModalOpen(true); }} - className="p-1.5 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-950/30 rounded transition-colors" + onClick={(e) => { e.stopPropagation(); setAccountModalAgent({ uuid, label: agentLabel }); setAccountModalOpen(true); }} + className="p-1 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-950/30 rounded transition-colors" title={existingUser ? 'Edit Agent Account' : 'Create Agent Account'} > <UserCog className="w-4 h-4" /> </button> + {handleEditLocation && (() => { + const hasLocation = locationUuids.has(uuid); + return ( + <button + onClick={(e) => { e.stopPropagation(); handleEditLocation(uuid, agentLabel); }} + className={`relative p-1 rounded transition-colors ${ + hasLocation + ? "text-teal-400 hover:text-teal-300 hover:bg-teal-950/30" + : "text-slate-500 hover:text-indigo-400 hover:bg-indigo-950/30" + }`} + title={hasLocation ? "✅ Location set — click to update" : "⚠️ No location — click to set coordinates"} + > + <MapPin className="w-4 h-4" /> + {hasLocation && ( + <span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-teal-400 border border-slate-900" /> + )} + </button> + ); + })()} <button - onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid)); }} + onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, agentLabel); }} disabled={isViewAsLoading} - className="p-1.5 text-amber-400 hover:text-amber-300 hover:bg-amber-950/30 rounded transition-colors disabled:opacity-50" + className="p-1 text-amber-400 hover:text-amber-300 hover:bg-amber-950/30 rounded transition-colors disabled:opacity-50" title="View As Agent" > {isViewAsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />} </button> - <button onClick={(e) => { e.stopPropagation(); openDelete(row); }} className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors" title="Delete"> + <button onClick={(e) => { e.stopPropagation(); openDelete(row); }} className="p-1 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors" title="Delete"> <Trash2 className="w-4 h-4" /> </button> </> )} + {/* COMPANY_ADMIN dan COMPANY_OPERATOR: hanya View-As */} + {(role === 'COMPANY_ADMIN' || role === 'COMPANY_OPERATOR') && ( + <button + onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, agentLabel); }} + disabled={isViewAsLoading} + className="p-1 text-amber-400 hover:text-amber-300 hover:bg-amber-950/30 rounded transition-colors disabled:opacity-50" + title="View As Agent — see dashboard from this agent's perspective" + > + {isViewAsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />} + </button> + )} </div> ); } @@ -166,7 +189,15 @@ export function getAgentColumns({ accessor: (row) => { const uuid = row.uuid || row.serial; const sizeMB = storageMap[uuid] ?? 0; - return <span className="font-mono text-slate-300 font-semibold">{formatStorageSize(sizeMB)}</span>; + let displaySize = ""; + if (sizeMB >= 1024 * 1024) { + displaySize = `${(sizeMB / (1024 * 1024)).toFixed(2)} TB`; + } else if (sizeMB >= 1024) { + displaySize = `${(sizeMB / 1024).toFixed(2)} GB`; + } else { + displaySize = `${sizeMB.toFixed(2)} MB`; + } + return <span className="font-mono text-xs font-normal text-slate-300 tracking-wide">{displaySize}</span>; } }); } @@ -174,17 +205,4 @@ export function getAgentColumns({ return cols; } -function formatStorageSize(sizeMB: number): string { - if (sizeMB === 0) return "0.00 MB"; - if (sizeMB >= 1024 * 1024) { - return `${(sizeMB / (1024 * 1024)).toFixed(2)} TB`; - } - if (sizeMB >= 1024) { - return `${(sizeMB / 1024).toFixed(2)} GB`; - } - if (sizeMB < 1) { - return `${(sizeMB * 1024).toFixed(2)} KB`; - } - return `${sizeMB.toFixed(2)} MB`; -} diff --git a/src/app/(dashboard)/agents/externalColumns.tsx b/src/app/(dashboard)/agents/externalColumns.tsx index e2417b6..9a74903 100644 --- a/src/app/(dashboard)/agents/externalColumns.tsx +++ b/src/app/(dashboard)/agents/externalColumns.tsx @@ -2,6 +2,7 @@ import { Column } from "@/components/ui/DataTable"; import { Badge } from "@/components/ui/Badge"; +import { SafeImage } from "@/components/ui/SafeImage"; import { type ManagedUser } from "@/lib/admin-api"; import { UserCog, CheckCircle2, XCircle } from "lucide-react"; @@ -16,7 +17,12 @@ export function getExternalAccountColumns( <div className="flex items-center gap-3"> <div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center border border-slate-700 shrink-0 overflow-hidden"> {row.profile_picture ? ( - <img src={`/api/uploads/${row.profile_picture}`} alt={row.account_name || row.username} className="w-full h-full object-cover" /> + <SafeImage + src={`/api/uploads/${row.profile_picture}`} + alt={row.account_name || row.username} + fallbackName={row.account_name || row.username} + className="w-full h-full object-cover" + /> ) : ( <span className="text-xs font-bold text-slate-400">{(row.account_name || row.username).substring(0, 2).toUpperCase()}</span> )} @@ -36,6 +42,7 @@ export function getExternalAccountColumns( accessor: (row) => { let badgeColor = "bg-slate-500/20 text-slate-400 border-slate-500/30"; if (row.role === 'SUPER_ADMIN') badgeColor = "bg-purple-500/20 text-purple-400 border-purple-500/30"; + if (row.role === 'EXECUTIVE') badgeColor = "bg-yellow-500/20 text-yellow-400 border-yellow-500/30"; if (row.role === 'SOC_ANALYST') badgeColor = "bg-amber-500/20 text-amber-400 border-amber-500/30"; if (row.role === 'ENGINEER') badgeColor = "bg-blue-500/20 text-blue-400 border-blue-500/30"; if (row.role === 'TENANT_ADMIN') badgeColor = "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"; diff --git a/src/app/(dashboard)/agents/page.tsx b/src/app/(dashboard)/agents/page.tsx index d3e5199..0540b45 100644 --- a/src/app/(dashboard)/agents/page.tsx +++ b/src/app/(dashboard)/agents/page.tsx @@ -1,32 +1,22 @@ "use client"; -import { Loader2, Plus, Server } from "lucide-react"; +import { useEffect } from "react"; +import { Plus, Loader2, MapPin } from "lucide-react"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; +import { useAgentsData } from "./useAgentsData"; import { getAgentColumns } from "./columns"; import { getExternalAccountColumns } from "./externalColumns"; -import ExternalAccountsSection from "./ExternalAccountsSection"; import UptimeStatsCards from "./UptimeStatsCards"; import AgentsTableSection from "./AgentsTableSection"; +import ExternalAccountsSection from "./ExternalAccountsSection"; import AgentsPageModals from "./AgentsPageModals"; -import { useAgentsData } from "./useAgentsData"; -import { HelpTrigger } from "@/components/help/HelpTrigger"; -import dynamic from "next/dynamic"; - -const IndonesiaAgentMap = dynamic( - () => import("@/components/dashboard/IndonesiaAgentMap").then(m => ({ default: m.IndonesiaAgentMap })), - { - ssr: false, - loading: () => ( - <div className="h-[320px] w-full rounded-2xl border border-white/5 bg-slate-900/40 flex items-center justify-center"> - <div className="flex flex-col items-center gap-2"> - <Loader2 className="h-6 w-6 animate-spin text-primary" /> - <span className="text-xs text-slate-400">Loading Agent Location Map...</span> - </div> - </div> - ), - } -); +import AgentLocationMap from "@/components/admin/AgentLocationMap"; +import { resolveAgentLabel } from "@/lib/admin-api"; export default function AgentsPage() { + useEffect(() => { + document.title = "Agents | BackOne - Deep Package Inspection"; + }, []); const { role, agents, @@ -82,46 +72,45 @@ export default function AgentsPage() { setAccountModalAgent, handleViewAs, openDelete, - onEditLocation: handleEditLocation, - locations, + handleEditLocation, + locationUuids: new Set(locations.map((l: any) => l.agent_uuid)), }); - const totalAgents = agents.length; - const onlineAgents = agents.filter(a => a.last_seen_at?.human === 'Online').length; - const offlineAgents = totalAgents - onlineAgents; - const uptimeValues = agents.map(a => uptimeMap[a.uuid || a.serial] ?? 100); - const avgUptime = uptimeValues.length > 0 ? uptimeValues.reduce((s, v) => s + v, 0) / uptimeValues.length : 100; - - const externalUsers = managedUsers.filter(u => u.role !== 'AGENT_VIEWER'); - const superadminUsers = externalUsers.filter(u => u.role === 'SUPER_ADMIN' || !u.site_uuid || u.site_uuid === 'default' || (u.site_uuid !== '6681452d_9cae_4ff4_8ae8_0d504774265e' && u.site_uuid !== 'd7902405_0dc2_458b_8584_ed4d24b64f24')); - const siabUsers = externalUsers.filter(u => u.role !== 'SUPER_ADMIN' && u.site_uuid === '6681452d_9cae_4ff4_8ae8_0d504774265e'); - const nexusUsers = externalUsers.filter(u => u.role !== 'SUPER_ADMIN' && u.site_uuid === 'd7902405_0dc2_458b_8584_ed4d24b64f24'); - const externalColumns = getExternalAccountColumns((user) => { setExternalModalUser(user); setExternalModalOpen(true); }); + const totalAgents = agents.length; + const onlineAgents = agents.filter(a => { + const hum = a.last_seen_at?.human; + return hum === 'Online' || hum === 'Flows Detected' || (hum && hum.startsWith('Last seen:')); + }).length; + const offlineAgents = totalAgents - onlineAgents; + const uptimeValues = agents.map(a => uptimeMap[a.uuid || a.serial] ?? 100); + const avgUptime = uptimeValues.length > 0 ? uptimeValues.reduce((s, v) => s + v, 0) / uptimeValues.length : 100; + + // External Accounts di halaman Agents = akun eksternal teknikal/operasional saja + // AGENT_VIEWER = akun network agent (sudah ada di tabel agents) → BUKAN akun eksternal + // COMPANY_* = akun customer/client → sudah ada di halaman User Accounts + const EXCLUDED_ROLES = ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER', 'AGENT_VIEWER']; + const externalUsers = managedUsers.filter(u => !EXCLUDED_ROLES.includes(u.role || '')); + // Grouping by site — semua sudah dipastikan hanya SUPER_ADMIN, EXECUTIVE, TENANT_ADMIN, SOC_ANALYST, ENGINEER + const superadminUsers = externalUsers.filter(u => u.role === 'SUPER_ADMIN' || u.role === 'EXECUTIVE' || !u.site_uuid || u.site_uuid === 'default'); + const siabUsers = externalUsers.filter(u => u.site_uuid === '6681452d_9cae_4ff4_8ae8_0d504774265e' && u.role !== 'SUPER_ADMIN' && u.role !== 'EXECUTIVE'); + const nexusUsers = externalUsers.filter(u => u.site_uuid === '1959bb55_045b_47c7_bbdd_f33b7db197b9' && u.role !== 'SUPER_ADMIN' && u.role !== 'EXECUTIVE'); + return ( <div className="space-y-6"> - <div className="flex items-center justify-between gap-4"> - <h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3"> - <Server className="w-6 h-6 text-primary" /> - Agents Inventory - {isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />} - </h2> - <div className="flex items-center gap-3"> - <HelpTrigger pageId="agents" /> - {(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && ( - <button - onClick={() => setIsCreateOpen(true)} - className="flex items-center gap-2 bg-primary hover:bg-primary/95 text-primary-foreground px-4 py-2 rounded-xl text-sm font-semibold transition-all shadow-lg shadow-blue-500/10" - > - <Plus className="w-4 h-4" /> - Provision Agent - </button> - )} + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-semibold tracking-tight text-white flex items-center gap-3"> + Agents Inventory + {isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />} + </h2> + <p className="text-sm text-muted-foreground mt-1">Managed DPI probes, remote collectors, and probe infrastructure.</p> </div> + <HelpTrigger pageId="agents" /> </div> {error && ( @@ -137,35 +126,79 @@ export default function AgentsPage() { avgUptime={avgUptime} /> - {/* Indonesia Agent Geolocation & Flow Map */} - {!isLoading && ( - <IndonesiaAgentMap - agents={agents} - locations={locations} - onEditLocation={handleEditLocation} - role={role} - /> + {/* Agent Location Map — tampil untuk SUPER_ADMIN, TENANT_ADMIN, dan COMPANY_ADMIN */} + {(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN' || role === 'COMPANY_ADMIN') && ( + <div className="rounded-2xl border border-white/8 bg-slate-900/40 overflow-hidden relative z-0"> + <div className="flex items-center justify-between px-5 py-3.5 border-b border-white/8"> + <div className="flex items-center gap-2.5"> + <MapPin className="w-4 h-4 text-indigo-400" /> + <span className="text-sm font-semibold text-white">Agent Network Map</span> + <span className="text-xs text-slate-500"> + {locations.length > 0 + ? `${locations.length} of ${agents.length} agent${agents.length !== 1 ? 's' : ''} positioned` + : 'No positions configured yet'} + </span> + </div> + {agents.length > locations.length && ( + <span className="text-[11px] text-amber-400 bg-amber-400/10 border border-amber-400/20 px-2.5 py-1 rounded-full"> + {agents.length - locations.length} agent{agents.length - locations.length !== 1 ? 's' : ''} without location — click 📍 in the table + </span> + )} + </div> + <div className="p-3"> + <AgentLocationMap + pins={locations + .filter(loc => agents.some(a => (a.uuid || a.serial) === loc.agent_uuid)) + .map(loc => { + const agent = agents.find(a => (a.uuid || a.serial) === loc.agent_uuid); + const agentLabel = agent + ? (resolveAgentLabel(loc.agent_uuid, managedUsers) !== loc.agent_uuid + ? resolveAgentLabel(loc.agent_uuid, managedUsers) + : (agent.label || loc.label || loc.agent_uuid)) + : (loc.label || loc.agent_uuid); + const isOnline = !!(agent?.last_seen_at?.human === 'Online' || + agent?.last_seen_at?.human === 'Flows Detected' || + (agent?.last_seen_at?.human && agent.last_seen_at.human.startsWith('Last seen:'))); + const uptime = uptimeMap[loc.agent_uuid]; + return { + agent_uuid: loc.agent_uuid, + label: agentLabel, + latitude: loc.latitude, + longitude: loc.longitude, + isOnline, + uptimePercent: uptime, + }; + })} + onEditLocation={handleEditLocation} + canEdit={role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN'} + /> + </div> + </div> )} <AgentsTableSection agents={agents} isLoading={isLoading} columns={columns} + role={role} + onProvisionClick={() => setIsCreateOpen(true)} /> - <ExternalAccountsSection - role={role} - isLoading={isLoading} - externalUsers={externalUsers} - superadminUsers={superadminUsers} - siabUsers={siabUsers} - nexusUsers={nexusUsers} - externalColumns={externalColumns} - setExternalModalUser={setExternalModalUser} - setTargetSiteUuid={setTargetSiteUuid} - setTargetCreatedBy={setTargetCreatedBy} - setExternalModalOpen={setExternalModalOpen} - /> + {(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && ( + <ExternalAccountsSection + role={role} + isLoading={isLoading} + externalUsers={externalUsers} + superadminUsers={superadminUsers} + siabUsers={siabUsers} + nexusUsers={nexusUsers} + externalColumns={externalColumns} + setExternalModalUser={setExternalModalUser} + setTargetSiteUuid={setTargetSiteUuid} + setTargetCreatedBy={setTargetCreatedBy} + setExternalModalOpen={setExternalModalOpen} + /> + )} <AgentsPageModals isCreateOpen={isCreateOpen} diff --git a/src/app/(dashboard)/agents/useAgentsData.ts b/src/app/(dashboard)/agents/useAgentsData.ts index 34991aa..894fad2 100644 --- a/src/app/(dashboard)/agents/useAgentsData.ts +++ b/src/app/(dashboard)/agents/useAgentsData.ts @@ -2,7 +2,8 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { getAgents, Agent } from "@/lib/actions/agents"; +import { getAgents } from "@/lib/actions/agents"; +import { Agent } from "@/lib/actions/agentsCore"; import { getAdminUsers, startViewAs, getViewAsHeaders, type ManagedUser } from "@/lib/admin-api"; import { useTimeFilter } from "@/contexts/TimeFilterContext"; import { usePollingKey } from "@/lib/usePolling"; @@ -10,6 +11,7 @@ import { usePollingKey } from "@/lib/usePolling"; export function useAgentsData() { const router = useRouter(); const [role, setRole] = useState<string | null>(null); + const [myAgentUuids, setMyAgentUuids] = useState<string[]>([]); const [agents, setAgents] = useState<Agent[]>([]); const [uptimeMap, setUptimeMap] = useState<Record<string, number>>({}); const [storageMap, setStorageMap] = useState<Record<string, number>>({}); @@ -89,7 +91,7 @@ export function useAgentsData() { } }; - const loadAgents = async () => { + const loadAgents = async (allowedUuids?: string[], currentRole?: string) => { setIsLoading(true); try { const siteUuid = localStorage.getItem("backone_site_uuid") || undefined; @@ -97,8 +99,18 @@ export function useAgentsData() { getAgents(siteUuid), getAdminUsers(), ]); - if (agentData.status === 'fulfilled') setAgents(agentData.value); - else setError((agentData.reason as Error).message); + if (agentData.status === 'fulfilled') { + let allAgents = agentData.value; + // Filter agents untuk COMPANY_ADMIN dan COMPANY_OPERATOR — hanya tampilkan yang di-assign + const uuids = allowedUuids ?? myAgentUuids; + const r = currentRole ?? role; + if ((r === 'COMPANY_ADMIN' || r === 'COMPANY_OPERATOR') && uuids.length > 0) { + allAgents = allAgents.filter(a => uuids.includes(a.uuid || a.serial)); + } + setAgents(allAgents); + } else { + setError((agentData.reason as Error).message); + } if (userData.status === 'fulfilled') setManagedUsers(userData.value); await loadUptime(); await loadLocations(); @@ -127,11 +139,14 @@ export function useAgentsData() { .then(data => { if (data.user) { const userRole = data.user.role || null; + const uuids: string[] = data.user.agent_uuids || []; setRole(userRole); - if (userRole !== 'SUPER_ADMIN' && userRole !== 'TENANT_ADMIN') { + setMyAgentUuids(uuids); + const allowed = ['SUPER_ADMIN', 'TENANT_ADMIN', 'EXECUTIVE', 'COMPANY_ADMIN', 'COMPANY_OPERATOR']; + if (!allowed.includes(userRole || '')) { router.push('/'); } else { - loadAgents(); + loadAgents(uuids, userRole); } } else { setIsLoading(false); @@ -148,7 +163,7 @@ export function useAgentsData() { }, [timeRange]); useEffect(() => { - if (role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') { + if (role === 'SUPER_ADMIN') { loadStorage(); } if (role && role !== 'AGENT_VIEWER') { @@ -158,13 +173,8 @@ export function useAgentsData() { useEffect(() => { if (role && role !== 'AGENT_VIEWER' && refreshKey > 0) { - const siteUuid = localStorage.getItem("backone_site_uuid") || undefined; - getAgents(siteUuid) - .then(data => setAgents(data)) - .catch(err => console.warn("Failed to auto-refresh agents:", err)); - loadUptime(); - loadLocations(); - if (role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') { + loadAgents(); + if (role === 'SUPER_ADMIN') { loadStorage(); } } diff --git a/src/app/(dashboard)/apps/page.tsx b/src/app/(dashboard)/apps/page.tsx index 94c064b..a5bf251 100644 --- a/src/app/(dashboard)/apps/page.tsx +++ b/src/app/(dashboard)/apps/page.tsx @@ -9,15 +9,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { AppDetailModal } from "@/components/ui/AppDetailModal"; import { Loader2, ChevronRight, Globe } from "lucide-react"; import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; import { fmtBytes, getAppFavicon, formatAppLabel } from "@/lib/utils"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts"; -import { HelpTrigger } from "@/components/help/HelpTrigger"; -import { getViewAsStatusSync } from "@/lib/admin-api"; export default function AppsPage() { const [mounted, setMounted] = useState(false); const [page, setPage] = useState(0); const pageSize = 20; + const { data, isLoading } = useTopApps(); const [selectedApp, setSelectedApp] = useState<AppStat | null>(null); const { @@ -26,12 +26,9 @@ export default function AppsPage() { handleReset, activeCount } = useUniversalFilterState(); - // Pass dateFrom/dateTo so the backend filters by the exact calendar date range - const { data, isLoading } = useTopApps(pageSize, dateFrom, dateTo); - - useEffect(() => { setMounted(true); + document.title = "Apps | BackOne - Deep Package Inspection"; }, []); if (!mounted) return null; @@ -54,13 +51,15 @@ export default function AppsPage() { const filteredData = all.filter((row: any) => { if (filters.app_label && filters.app_label !== "All" && row.app_label !== filters.app_label) return false; if (filters.category && filters.category !== "All" && row.category !== filters.category) return false; - // Date filtering is handled server-side via date_from/date_to params. - // AppStat records do not have a last_seen field, so client-side date - // filtering is intentionally omitted here. + + if (dateFrom || dateTo) { + const d = row.last_seen ? new Date(row.last_seen).toISOString().slice(0, 10) : ""; + if (dateFrom && d < dateFrom) return false; + if (dateTo && d > dateTo) return false; + } return true; }); - let processedData = [...filteredData]; const sortUpload = filters.sort_upload || "All"; const sortDownload = filters.sort_download || "All"; @@ -74,10 +73,14 @@ export default function AppsPage() { processedData.sort((a, b) => sortDownload === "Descending" ? (b.download ?? 0) - (a.download ?? 0) : (a.download ?? 0) - (b.download ?? 0)); } - // Use the data retention window (7 days) as the minimum selectable date. - // Dynamic computation from loaded data causes a circular dependency where - // the sidebar time filter (e.g., Last 24h) restricts the date picker range. - const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const minTime = all.reduce((min: number, r: any) => { + if (r.last_seen) { + const time = new Date(r.last_seen).getTime(); + if (isFinite(time) && time < min) return time; + } + return min; + }, Infinity); + const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01"; const top5 = processedData.slice(0, 5); @@ -124,13 +127,12 @@ export default function AppsPage() { accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.download + row.upload)}</span> }, ]; - return ( <div className="space-y-6"> - <div className="flex items-center justify-between gap-4"> + <div className="flex items-center justify-between"> <div> <h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3"> - Applications + Apps {isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />} </h2> <p className="text-muted-foreground mt-1 text-sm"> @@ -139,7 +141,7 @@ export default function AppsPage() { </div> <HelpTrigger pageId="apps" /> </div> - + <UniversalFilters filters={filterConfigs} onChange={(id, val) => { @@ -182,7 +184,11 @@ export default function AppsPage() { stroke="#8b949e" tickLine={false} axisLine={false} - tickFormatter={(value) => fmtBytes(value)} + tickFormatter={(value) => { + const gb = value / 1073741824; + const mb = value / 1048576; + return gb >= 1 ? `${gb.toFixed(1)}GB` : `${mb.toFixed(0)}MB`; + }} /> <RechartsTooltip cursor={{ fill: "#21262d" }} @@ -223,10 +229,9 @@ export default function AppsPage() { appLabel={selectedApp.app_label} favicon={selectedApp.favicon} category={selectedApp.category} - agentUuid={getViewAsStatusSync().agent_uuid} onClose={() => setSelectedApp(null)} /> )} </div> ); -} +} \ No newline at end of file diff --git a/src/app/(dashboard)/device-labeling/EditOwnerModal.tsx b/src/app/(dashboard)/device-labeling/EditOwnerModal.tsx new file mode 100644 index 0000000..6aa6c48 --- /dev/null +++ b/src/app/(dashboard)/device-labeling/EditOwnerModal.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Modal } from "@/components/ui/Modal"; +import { Loader2 } from "lucide-react"; +import { getViewAsHeaders } from "@/lib/admin-api"; +import { DeviceLabelItem } from "./columns"; + +interface EditOwnerModalProps { + isOpen: boolean; + onClose: () => void; + device: DeviceLabelItem | null; + onSaveSuccess: (mac: string, newLabel: string) => void; +} + +export default function EditOwnerModal({ + isOpen, + onClose, + device, + onSaveSuccess, +}: EditOwnerModalProps) { + const [customLabelValue, setCustomLabelValue] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState<string | null>(null); + const [saveSuccessMsg, setSaveSuccessMsg] = useState<string | null>(null); + + useEffect(() => { + if (isOpen && device) { + setCustomLabelValue(device.custom_label || ""); + setError(null); + setSaveSuccessMsg(null); + } + }, [isOpen, device]); + + const handleSaveLabel = async (e: React.FormEvent) => { + e.preventDefault(); + if (!device) return; + + setIsSaving(true); + setError(null); + setSaveSuccessMsg(null); + + try { + const res = await fetch("/api/dashboard/devices/update-label", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getViewAsHeaders(), + }, + body: JSON.stringify({ + mac_address: device.mac_address, + device_label: customLabelValue.trim(), + }), + }); + + const resData = await res.json(); + if (resData.ok) { + setSaveSuccessMsg("Owner label updated successfully!"); + onSaveSuccess(device.mac_address, customLabelValue.trim()); + + // Delay closing modal slightly so user sees success state + setTimeout(() => { + onClose(); + }, 800); + } else { + setError(resData.error || "Failed to update label."); + } + } catch (err: any) { + setError(err.message || "Failed to update device label."); + } finally { + setIsSaving(false); + } + }; + + return ( + <Modal + isOpen={isOpen} + onClose={() => !isSaving && onClose()} + title="Edit Device Owner Label" + > + {device && ( + <form onSubmit={handleSaveLabel} className="space-y-5"> + <div className="space-y-1.5"> + <label className="text-xs font-bold uppercase tracking-wider text-slate-400"> + MAC Address + </label> + <div className="p-3 bg-white/[0.02] border border-white/5 rounded-lg"> + <code className="text-sm text-slate-200 font-mono">{device.mac_address}</code> + </div> + </div> + + <div className="space-y-1.5"> + <label className="text-xs font-bold uppercase tracking-wider text-slate-400"> + Default System Label + </label> + <div className="p-3 bg-white/[0.02] border border-white/5 rounded-lg text-sm text-slate-400"> + {device.default_label} + </div> + </div> + + <div className="space-y-1.5"> + <label className="text-xs font-bold uppercase tracking-wider text-slate-400"> + Custom Owner Label + </label> + <input + type="text" + value={customLabelValue} + onChange={(e) => setCustomLabelValue(e.target.value)} + placeholder="e.g. Finance Department Laptop" + className="w-full bg-white/[0.03] border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all" + disabled={isSaving} + autoFocus + /> + <p className="text-[10px] text-slate-500"> + Leave empty to clear the custom label and restore the system default. + </p> + </div> + + {error && ( + <div className="p-3 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-lg text-xs"> + ⚠️ {error} + </div> + )} + + {saveSuccessMsg && ( + <div className="p-3 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-lg text-xs font-semibold"> + ✓ {saveSuccessMsg} + </div> + )} + + <div className="flex justify-end gap-3 pt-2 border-t border-white/5"> + <button + type="button" + onClick={onClose} + className="px-4 py-2 bg-transparent hover:bg-white/5 text-slate-300 hover:text-white rounded-lg text-xs font-medium transition-all" + disabled={isSaving} + > + Cancel + </button> + <button + type="submit" + className="inline-flex items-center gap-1.5 px-4 py-2 bg-primary hover:bg-primary/90 text-white rounded-lg text-xs font-semibold shadow-md shadow-primary/10 transition-all" + disabled={isSaving} + > + {isSaving && <Loader2 className="w-3.5 h-3.5 animate-spin" />} + Save Changes + </button> + </div> + </form> + )} + </Modal> + ); +} diff --git a/src/app/(dashboard)/device-labeling/columns.tsx b/src/app/(dashboard)/device-labeling/columns.tsx new file mode 100644 index 0000000..205d491 --- /dev/null +++ b/src/app/(dashboard)/device-labeling/columns.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { Column } from "@/components/ui/DataTable"; + +export interface DeviceLabelItem { + mac_address: string; + ip_address: string; + device_type: string; + manufacturer: string; + default_label: string; + custom_label: string | null; + agent_uuid: string; + agent_name: string; + last_seen: string; +} + +interface GetColumnsProps { + userRole: string; + onEditClick: (device: DeviceLabelItem) => void; + onMacClick: (device: DeviceLabelItem) => void; +} + +export function getColumns({ + userRole, + onEditClick, + onMacClick, +}: GetColumnsProps): Column<DeviceLabelItem>[] { + return [ + { + header: "#", + className: "w-[4%] text-center", + accessor: (_, i) => <span className="text-slate-400 font-mono">{i + 1}</span> + }, + { + header: "MAC Address", + className: "w-[15%] text-center", + accessor: (row) => ( + <button + type="button" + onClick={() => onMacClick(row)} + className="text-xs bg-white/5 border border-white/10 hover:border-amber-500/30 hover:bg-amber-500/5 px-2 py-0.5 rounded font-mono text-slate-300 hover:text-amber-400 transition-all cursor-pointer outline-none" + title="Click to view device details & IP history" + > + {row.mac_address} + </button> + ) + }, + { + header: "Custom Owner Label", + className: "w-[20%] text-center", + accessor: (row) => row.custom_label ? ( + <span className="text-xs font-semibold text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 px-2.5 py-1 rounded-full"> + {row.custom_label} + </span> + ) : ( + <span className="text-xs text-slate-500 italic">No label configured</span> + ) + }, + { + header: "Default System Label", + className: "w-[20%] text-center", + accessor: (row) => ( + <span className="text-xs text-slate-400"> + {row.default_label} + </span> + ) + }, + { + header: "Network Agent", + className: "w-[18%] text-center", + accessor: (row) => ( + <span className="text-xs font-semibold text-purple-400 bg-purple-500/10 border border-purple-500/20 px-2.5 py-1 rounded-full"> + {row.agent_name} + </span> + ) + }, + { + header: "Last IP Address", + className: "w-[11%] text-center", + accessor: (row) => ( + <span className="text-xs font-mono text-slate-400"> + {row.ip_address} + </span> + ) + }, + { + header: "Action", + className: "w-[12%] text-center", + accessor: (row) => userRole === 'EXECUTIVE' ? ( + <span className="text-xs text-slate-500 italic">View Only</span> + ) : ( + <button + type="button" + onClick={() => onEditClick(row)} + className="inline-flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300 border border-blue-500/20 hover:border-blue-500/40 bg-blue-500/5 hover:bg-blue-500/10 px-3 py-1.5 rounded-lg transition-all font-medium cursor-pointer" + > + ✏️ Edit Owner + </button> + ) + } + ]; +} diff --git a/src/app/(dashboard)/device-labeling/page.tsx b/src/app/(dashboard)/device-labeling/page.tsx new file mode 100644 index 0000000..e789211 --- /dev/null +++ b/src/app/(dashboard)/device-labeling/page.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { DataTable } from "@/components/ui/DataTable"; +import { getViewAsHeaders } from "@/lib/admin-api"; +import { useTimeFilter } from "@/contexts/TimeFilterContext"; +import { Loader2, Tag } from "lucide-react"; +import { getColumns, DeviceLabelItem } from "./columns"; +import EditOwnerModal from "./EditOwnerModal"; +import DeviceMacDetailsModal from "@/components/admin/DeviceMacDetailsModal"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; + +export default function DeviceLabelingPage() { + const [mounted, setMounted] = useState(false); + const [devices, setDevices] = useState<DeviceLabelItem[]>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [userRole, setUserRole] = useState<string>(""); + + const { timeRange } = useTimeFilter(); + + // Modal states + const [isEditOpen, setIsEditOpen] = useState(false); + const [editingDevice, setEditingDevice] = useState<DeviceLabelItem | null>(null); + + const [isDetailOpen, setIsDetailOpen] = useState(false); + const [detailedDevice, setDetailedDevice] = useState<DeviceLabelItem | null>(null); + + const fetchDevices = async () => { + setIsLoading(true); + setError(null); + try { + const res = await fetch(`/api/dashboard/devices/labeling?timeRange=${timeRange}`, { + headers: getViewAsHeaders(), + }); + const resData = await res.json(); + if (resData.ok) { + setDevices(resData.data || []); + } else { + setError(resData.error || "Failed to load device listing."); + } + } catch (err: any) { + setError(err.message || "An error occurred while fetching devices."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + setMounted(true); + document.title = "Device Labeling | BackOne - Deep Package Inspection"; + // Fetch current user role to conditionally show/hide edit controls + fetch("/api/auth/me", { headers: getViewAsHeaders() }) + .then((r) => r.json()) + .then((data) => { + if (data.user?.role) setUserRole(data.user.role); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + if (mounted) { + fetchDevices(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timeRange, mounted]); + + const handleEditClick = (device: DeviceLabelItem) => { + setEditingDevice(device); + setIsEditOpen(true); + }; + + const handleMacClick = (device: DeviceLabelItem) => { + setDetailedDevice(device); + setIsDetailOpen(true); + }; + + const handleSaveSuccess = (mac: string, newLabel: string) => { + setDevices((prev) => + prev.map((d) => + d.mac_address === mac + ? { ...d, custom_label: newLabel || null } + : d + ) + ); + }; + + const searchFilter = (row: DeviceLabelItem, q: string) => { + const queryStr = q.toLowerCase(); + return ( + row.mac_address.toLowerCase().includes(queryStr) || + row.ip_address.toLowerCase().includes(queryStr) || + row.default_label.toLowerCase().includes(queryStr) || + (row.custom_label || "").toLowerCase().includes(queryStr) + ); + }; + + if (!mounted) return null; + + const columns = getColumns({ + userRole, + onEditClick: handleEditClick, + onMacClick: handleMacClick, + }); + + return ( + <div className="space-y-8 animate-fade-in pb-10"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> + Device Labeling + <Tag className="w-6 h-6 text-primary" /> + {isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />} + </h2> + <p className="text-muted-foreground mt-1"> + Assign custom names to MAC addresses to identify device owners across all charts and flows. + </p> + </div> + <HelpTrigger pageId="device-labeling" /> + </div> + + <Card className="border-white/5 bg-black/40 backdrop-blur-md"> + <CardHeader> + <CardTitle className="text-sm font-semibold tracking-wide uppercase text-slate-300"> + Device Asset Directory + </CardTitle> + </CardHeader> + <CardContent> + {error && !isEditOpen && !isDetailOpen && ( + <div className="mb-6 p-4 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-lg text-sm"> + ⚠️ {error} + </div> + )} + + <DataTable + data={devices} + columns={columns} + searchPlaceholder="Search MAC, IP, vendor, or custom label..." + searchFilter={searchFilter} + isLoading={isLoading} + /> + </CardContent> + </Card> + + {/* Edit Owner Modal */} + <EditOwnerModal + isOpen={isEditOpen} + onClose={() => { + setIsEditOpen(false); + setEditingDevice(null); + }} + device={editingDevice} + onSaveSuccess={handleSaveSuccess} + /> + + {/* Device Mac Details Modal */} + {detailedDevice && ( + <DeviceMacDetailsModal + isOpen={isDetailOpen} + onClose={() => { + setIsDetailOpen(false); + setDetailedDevice(null); + }} + macAddress={detailedDevice.mac_address} + customLabel={detailedDevice.custom_label} + defaultLabel={detailedDevice.default_label} + agentName={detailedDevice.agent_name} + manufacturer={detailedDevice.manufacturer} + deviceType={detailedDevice.device_type} + /> + )} + </div> + ); +} diff --git a/src/app/(dashboard)/devices/page.tsx b/src/app/(dashboard)/devices/page.tsx index 89139f6..e46ec65 100644 --- a/src/app/(dashboard)/devices/page.tsx +++ b/src/app/(dashboard)/devices/page.tsx @@ -1,246 +1,250 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; -import { DataTable, Column } from "@/components/ui/DataTable"; -import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal"; -import { useState, useEffect, useRef } from "react"; -import { TimestampCell } from "@/components/ui/TimestampCell"; -import { DeviceStat } from "@/lib/api"; -import { useDevices } from "@/lib/api-with-context"; -import { fmtBytes } from "@/lib/utils"; -import { Loader2, ChevronRight, ChevronDown } from "lucide-react"; - +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { DataTable, Column } from "@/components/ui/DataTable"; +import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal"; +import { useState, useEffect, useRef } from "react"; +import { TimestampCell } from "@/components/ui/TimestampCell"; +import { DeviceStat } from "@/lib/api"; +import { useDevices } from "@/lib/api-with-context"; +import { fmtBytes } from "@/lib/utils"; +import { Loader2, ChevronRight, ChevronDown } from "lucide-react"; + import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters"; -import { HelpTrigger } from "@/components/help/HelpTrigger"; - -export default function DevicesPage() { - const [mounted, setMounted] = useState(false); - const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null); - - const { - filters, dateFrom, dateTo, - handleFilterChange, setDateFrom, setDateTo, - handleReset, activeCount - } = useUniversalFilterState(); - - // Pass dateFrom/dateTo so the backend filters by the exact calendar date range - const devices = useDevices(5000, dateFrom, dateTo); - - useEffect(() => { - setMounted(true); - }, []); - - if (!mounted) return null; - - const all = devices.data || []; - - const opts = (key: string) => { - const raw = Array.from(new Set(all.map((r: any) => String(r[key] || "")).filter(Boolean))); - return raw.sort(); - }; - - const filterConfigs: FilterConfig[] = [ - { id: "device_type", label: "Device Type", type: "select", value: filters.device_type, options: opts("device_type") }, - { id: "os_label", label: "OS", type: "select", value: filters.os_label, options: opts("os_label") }, - { id: "manufacturer", label: "Manufacturer", type: "select", value: filters.manufacturer, options: opts("manufacturer") }, - { id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] }, - { id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] }, - ]; - - const columns: Column<DeviceStat>[] = [ - { - header: "Last Seen", - className: "w-[13%]", - accessor: (row) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} /> - }, - { header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" }, - { - header: "IP Address", - className: "w-[12%]", - accessor: (row) => ( - <div className="flex flex-col items-center justify-center"> - <button - onClick={() => setSelectedDevice(row)} - className="flex items-center justify-center gap-1 group text-center" - title="Click to view device details" - > - <span className="font-semibold text-blue-400 group-hover:text-blue-300 transition-colors font-mono text-sm"> - {row.ip_address || "-"} - </span> - <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" /> - </button> - {row.device_label && ( - <span className="text-[10px] text-slate-400 font-medium mt-0.5 truncate max-w-[120px]" title={row.device_label}> - {row.device_label} - </span> - )} - </div> - ), - }, - { - header: "MAC Address", - className: "w-[17%]", - accessor: (row) => { - const isRouted = row.is_gateway_routed === 1; - const hasMan = row.manufacturer && row.manufacturer !== "-" && row.manufacturer !== "Unknown"; - const info = hasMan ? row.manufacturer : (row.device_type && row.device_type !== "-" && row.device_type !== "Generic Client" ? row.device_type : ""); - - return row.mac_address ? ( - <div className="flex flex-col items-center justify-center gap-0.5"> - <code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground w-fit font-mono"> - {row.mac_address} - </code> - {(isRouted || info) && ( - <span className={`text-[10px] font-sans font-medium ${isRouted ? "text-amber-500/90 italic" : "text-slate-400/80"}`}> - {isRouted ? "Via Routed Gateway" : ""} - {isRouted && info ? ` • ${info}` : (!isRouted ? info : "")} - </span> - )} - </div> - ) : ( - "-" - ); - }, - }, - { - header: "Type", - accessor: (row) => row.device_type || "-", - className: "w-[11%]" - }, - { - header: "OS", - accessor: (row) => row.os_label || "-", - className: "w-[11%]" - }, - { - header: "Manufacturer", - accessor: (row) => row.manufacturer || "-", - className: "w-[14%]" - }, - { - header: "Download", - accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>, - className: "w-[9%]", - }, - { - header: "Upload", - accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>, - className: "w-[9%]", - }, - ]; - - const filteredData = all.filter((row: any) => { - if (filters.device_type && filters.device_type !== "All" && row.device_type !== filters.device_type) return false; - if (filters.os_label && filters.os_label !== "All" && row.os_label !== filters.os_label) return false; - if (filters.manufacturer && filters.manufacturer !== "All" && row.manufacturer !== filters.manufacturer) return false; - // Date filtering is handled server-side via date_from/date_to on timestamp. - // last_seen (DPI network activity time) != timestamp (collection cycle time), - // so client-side date filtering would incorrectly exclude valid records. - return true; - }); - - - let processedData = [...filteredData]; - const sortUpload = filters.sort_upload || "All"; - const sortDownload = filters.sort_download || "All"; - - if (sortUpload !== "All") { - processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload); - } else if (sortDownload !== "All") { - processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download); - } - - // Use the data retention window (7 days) as the minimum selectable date. - // Do NOT compute minDate from loaded data — the loaded data is already filtered - // by the sidebar time range (e.g., last 24h), which would incorrectly restrict - // the calendar to only dates within the current filter window. - const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - - return ( - <div className="space-y-8 animate-fade-in pb-10"> - <div className="flex items-center justify-between gap-4"> - <div> - <h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> - Devices - {devices.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />} - </h2> - <p className="text-muted-foreground mt-1"> - Monitor and manage connected network devices.{" "} - <span className="text-slate-500 text-xs">Click an IP to see app usage & flows.</span> - </p> - </div> - <HelpTrigger pageId="devices" /> - </div> - - <UniversalFilters - filters={filterConfigs} - onChange={(id, val) => { - if (id === 'sort_download') { - handleFilterChange('sort_download', val); - handleFilterChange('sort_upload', 'All'); - } else if (id === 'sort_upload') { - handleFilterChange('sort_upload', val); - handleFilterChange('sort_download', 'All'); - } else { - handleFilterChange(id, val); - } - }} - showDateRange={true} - dateFrom={dateFrom} - dateTo={dateTo} - minDate={minDate} - onDateChange={(f, t) => { setDateFrom(f); setDateTo(t); }} - activeFilterCount={activeCount} - onReset={handleReset} - /> - - <Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: "100ms" }}> - <CardHeader className="border-b border-border/50 pb-4 mb-4"> - <CardTitle className="text-white">Active Devices ({processedData.length})</CardTitle> - </CardHeader> - <CardContent> - <DataTable - data={processedData} - columns={columns} - searchPlaceholder="Search devices by IP, MAC..." - searchFilter={(row, query) => { - const q = query.trim().toLowerCase(); - const matchesSearch = ( - (row.ip_address || "").toLowerCase().includes(q) || - (row.mac_address || "").toLowerCase().includes(q) || - (row.os_label || "").toLowerCase().includes(q) || - (row.device_type || "").toLowerCase().includes(q) - ); - - return matchesSearch; - }} - csvExport={{ - filename: `devices-${new Date().toISOString().slice(0,10)}.csv`, - headers: ["IP Address", "Device Label", "MAC Address", "Device Type", "OS", "Manufacturer", "Download (bytes)", "Upload (bytes)", "Last Seen"], - rowSerializer: (row) => [ - row.ip_address || "", - row.device_label || "", - row.mac_address || "", - row.device_type || "", - row.os_label || "", - row.manufacturer || "", - row.download ?? 0, - row.upload ?? 0, - row.last_seen ? new Date(row.last_seen).toISOString() : "", - ], - }} - /> - </CardContent> - </Card> - - {/* Device Detail Modal */} - {selectedDevice && ( - <DeviceDetailModal - ip={selectedDevice.ip_address} - deviceData={selectedDevice} - onClose={() => setSelectedDevice(null)} - /> - )} - </div> - ); -} +import { HelpTrigger } from "@/components/help/HelpTrigger"; + +export default function DevicesPage() { + const [mounted, setMounted] = useState(false); + const devices = useDevices(); + const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null); + + const { + filters, dateFrom, dateTo, + handleFilterChange, setDateFrom, setDateTo, + handleReset, activeCount + } = useUniversalFilterState(); + + useEffect(() => { + setMounted(true); + document.title = "Devices | BackOne - Deep Package Inspection"; + }, []); + + if (!mounted) return null; + + const all = devices.data || []; + + const opts = (key: string) => { + const raw = Array.from(new Set(all.map((r: any) => String(r[key] || "")).filter(Boolean))); + return raw.sort(); + }; + + const filterConfigs: FilterConfig[] = [ + { id: "device_type", label: "Device Type", type: "select", value: filters.device_type, options: opts("device_type") }, + { id: "os_label", label: "OS", type: "select", value: filters.os_label, options: opts("os_label") }, + { id: "manufacturer", label: "Manufacturer", type: "select", value: filters.manufacturer, options: opts("manufacturer") }, + { id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] }, + { id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] }, + ]; + + const columns: Column<DeviceStat>[] = [ + { + header: "Last Seen", + className: "w-[13%]", + accessor: (row) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} /> + }, + { header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" }, + { + header: "IP Address", + className: "w-[12%]", + accessor: (row) => ( + <div className="flex flex-col items-center justify-center"> + <button + onClick={() => setSelectedDevice(row)} + className="flex items-center justify-center gap-1 group text-center" + title="Click to view device details" + > + <span className="font-semibold text-blue-400 group-hover:text-blue-300 transition-colors font-mono text-sm"> + {row.ip_address || "-"} + </span> + <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" /> + </button> + {row.device_label && ( + <span className="text-[10px] text-slate-400 font-medium mt-0.5 truncate max-w-[120px]" title={row.device_label}> + {row.device_label} + </span> + )} + </div> + ), + }, + { + header: "MAC Address", + className: "w-[17%]", + accessor: (row) => { + const isRouted = row.is_gateway_routed === 1; + const hasMan = row.manufacturer && row.manufacturer !== "-" && row.manufacturer !== "Unknown"; + const info = hasMan ? row.manufacturer : (row.device_type && row.device_type !== "-" && row.device_type !== "Generic Client" ? row.device_type : ""); + + return row.mac_address ? ( + <div className="flex flex-col items-center justify-center gap-0.5"> + <code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground w-fit font-mono"> + {row.mac_address} + </code> + {(isRouted || info) && ( + <span className={`text-[10px] font-sans font-medium ${isRouted ? "text-amber-500/90 italic" : "text-slate-400/80"}`}> + {isRouted ? "Via Routed Gateway" : ""} + {isRouted && info ? ` • ${info}` : (!isRouted ? info : "")} + </span> + )} + </div> + ) : ( + "-" + ); + }, + }, + { + header: "Type", + accessor: (row) => row.device_type || "-", + className: "w-[11%]" + }, + { + header: "OS", + accessor: (row) => row.os_label || "-", + className: "w-[11%]" + }, + { + header: "Manufacturer", + accessor: (row) => row.manufacturer || "-", + className: "w-[14%]" + }, + { + header: "Download", + accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>, + className: "w-[9%]", + }, + { + header: "Upload", + accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>, + className: "w-[9%]", + }, + ]; + + const filteredData = all.filter((row: any) => { + if (filters.device_type && filters.device_type !== "All" && row.device_type !== filters.device_type) return false; + if (filters.os_label && filters.os_label !== "All" && row.os_label !== filters.os_label) return false; + if (filters.manufacturer && filters.manufacturer !== "All" && row.manufacturer !== filters.manufacturer) return false; + + if (dateFrom || dateTo) { + const d = row.last_seen ? new Date(row.last_seen).toISOString().slice(0, 10) : ""; + if (dateFrom && d < dateFrom) return false; + if (dateTo && d > dateTo) return false; + } + return true; + }); + + let processedData = [...filteredData]; + const sortUpload = filters.sort_upload || "All"; + const sortDownload = filters.sort_download || "All"; + + if (sortUpload !== "All") { + processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload); + } else if (sortDownload !== "All") { + processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download); + } + + const minTime = all.reduce((min: number, r: any) => { + if (r.last_seen) { + const time = new Date(r.last_seen).getTime(); + if (isFinite(time) && time < min) return time; + } + return min; + }, Infinity); + const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01"; + + return ( + <div className="space-y-8 animate-fade-in pb-10"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> + Devices + {devices.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />} + </h2> + <p className="text-muted-foreground mt-1"> + Monitor and manage connected network devices.{" "} + <span className="text-slate-500 text-xs">Click an IP to see app usage & flows.</span> + </p> + </div> + <HelpTrigger pageId="devices" /> + </div> + + <UniversalFilters + filters={filterConfigs} + onChange={(id, val) => { + if (id === 'sort_download') { + handleFilterChange('sort_download', val); + handleFilterChange('sort_upload', 'All'); + } else if (id === 'sort_upload') { + handleFilterChange('sort_upload', val); + handleFilterChange('sort_download', 'All'); + } else { + handleFilterChange(id, val); + } + }} + showDateRange={true} + dateFrom={dateFrom} + dateTo={dateTo} + minDate={minDate} + onDateChange={(f, t) => { setDateFrom(f); setDateTo(t); }} + activeFilterCount={activeCount} + onReset={handleReset} + /> + + <Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: "100ms" }}> + <CardHeader className="border-b border-border/50 pb-4 mb-4"> + <CardTitle className="text-white">Active Devices ({processedData.length})</CardTitle> + </CardHeader> + <CardContent> + <DataTable + data={processedData} + columns={columns} + searchPlaceholder="Search devices by IP, MAC..." + searchFilter={(row, query) => { + const q = query.trim().toLowerCase(); + const matchesSearch = ( + (row.ip_address || "").toLowerCase().includes(q) || + (row.mac_address || "").toLowerCase().includes(q) || + (row.os_label || "").toLowerCase().includes(q) || + (row.device_type || "").toLowerCase().includes(q) + ); + + return matchesSearch; + }} + csvExport={{ + filename: `devices-${new Date().toISOString().slice(0,10)}.csv`, + headers: ["IP Address", "Device Label", "MAC Address", "Device Type", "OS", "Manufacturer", "Download (bytes)", "Upload (bytes)", "Last Seen"], + rowSerializer: (row) => [ + row.ip_address || "", + row.device_label || "", + row.mac_address || "", + row.device_type || "", + row.os_label || "", + row.manufacturer || "", + row.download ?? 0, + row.upload ?? 0, + row.last_seen ? new Date(row.last_seen).toISOString() : "", + ], + }} + /> + </CardContent> + </Card> + + {/* Device Detail Modal */} + {selectedDevice && ( + <DeviceDetailModal + ip={selectedDevice.ip_address} + deviceData={selectedDevice} + onClose={() => setSelectedDevice(null)} + /> + )} + </div> + ); +} diff --git a/src/app/(dashboard)/dns/page.tsx b/src/app/(dashboard)/dns/page.tsx index 2987ff1..6821e39 100644 --- a/src/app/(dashboard)/dns/page.tsx +++ b/src/app/(dashboard)/dns/page.tsx @@ -25,6 +25,7 @@ export default function DNSPage() { useEffect(() => { setMounted(true); + document.title = "DNS | BackOne - Deep Package Inspection"; }, []); useEffect(() => { @@ -96,9 +97,9 @@ export default function DNSPage() { return ( <div className="space-y-6"> - <div className="flex items-center justify-between gap-4"> + <div className="flex items-center justify-between"> <h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3"> - DNS Intelligence + DNS {isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />} </h2> <HelpTrigger pageId="dns" /> diff --git a/src/app/(dashboard)/dpi-analytics/DpiAnalyticsTabs.tsx b/src/app/(dashboard)/dpi-analytics/DpiAnalyticsTabs.tsx new file mode 100644 index 0000000..a13ba7e --- /dev/null +++ b/src/app/(dashboard)/dpi-analytics/DpiAnalyticsTabs.tsx @@ -0,0 +1,254 @@ +"use client"; + +import React from "react"; +import { Tabs, Tab } from "@/components/ui/Tabs"; +import { DataTable } from "@/components/ui/DataTable"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { fmtBytes, getKnownDomainService } from "@/lib/utils"; +import { MetadataDetailType } from "@/lib/api-metadata-detail"; + +interface DpiAnalyticsTabsProps { + sniHostnames: any[]; + loadingSni: boolean; + sslServerCn: any[]; + loadingSslCn: boolean; + quicHostnames: any[]; + loadingQuic: boolean; + netbios: any[]; + loadingNetbios: boolean; + mdnsHostnames: any[]; + loadingMdns: boolean; + discoveryOs: any[]; + loadingOs: boolean; + dhcpFingerprints: any[]; + loadingDhcp: boolean; + httpUserAgents: any[]; + loadingHttpUa: boolean; + sshVersions: any[]; + loadingSsh: boolean; + bittorrentHashes: any[]; + loadingBittorrent: boolean; + openDetail: (type: MetadataDetailType, value: string) => void; +} + +function ClickHint() { + return ( + <span className="ml-2 text-[10px] font-normal text-blue-400/70 border border-blue-400/30 rounded px-1 py-0.5 align-middle"> + Click row for details + </span> + ); +} + +export default function DpiAnalyticsTabs({ + sniHostnames, + loadingSni, + sslServerCn, + loadingSslCn, + quicHostnames, + loadingQuic, + netbios, + loadingNetbios, + mdnsHostnames, + loadingMdns, + discoveryOs, + loadingOs, + dhcpFingerprints, + loadingDhcp, + httpUserAgents, + loadingHttpUa, + sshVersions, + loadingSsh, + bittorrentHashes, + loadingBittorrent, + openDetail, +}: DpiAnalyticsTabsProps) { + return ( + <Tabs> + {/* ── Hostnames & Servers ──────────────────────────────────────────── */} + <Tab label="Hostnames & Servers"> + <div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> + <Card> + <CardHeader><CardTitle>SNI Hostnames<ClickHint /></CardTitle></CardHeader> + <CardContent> + <DataTable + data={sniHostnames} + isLoading={loadingSni} + onRowClick={(row: any) => openDetail("sni_hostname", row.sni_hostname)} + columns={[ + { + header: "Hostname", + accessor: (row: any) => { + const srv = getKnownDomainService(row.sni_hostname); + return ( + <div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center"> + <span className="truncate w-full block" title={row.sni_hostname}>{row.sni_hostname}</span> + {srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>} + </div> + ); + } + }, + { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, + ]} + /> + </CardContent> + </Card> + + <Card> + <CardHeader><CardTitle>SSL Server Common Names<ClickHint /></CardTitle></CardHeader> + <CardContent> + <DataTable + data={sslServerCn} + isLoading={loadingSslCn} + onRowClick={(row: any) => openDetail("ssl_server_cn", row.ssl_server_cn)} + columns={[ + { + header: "Common Name", + accessor: (row: any) => { + const srv = getKnownDomainService(row.ssl_server_cn); + return ( + <div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center"> + <span className="truncate w-full block" title={row.ssl_server_cn}>{row.ssl_server_cn}</span> + {srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>} + </div> + ); + } + }, + { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, + ]} + /> + </CardContent> + </Card> + + <Card> + <CardHeader><CardTitle>QUIC Hostnames<ClickHint /></CardTitle></CardHeader> + <CardContent> + <DataTable + data={quicHostnames} + isLoading={loadingQuic} + onRowClick={(row: any) => openDetail("quic_hostname", row.quic_hostname)} + columns={[ + { + header: "Hostname", + accessor: (row: any) => { + const srv = getKnownDomainService(row.quic_hostname); + return ( + <div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center"> + <span className="truncate w-full block" title={row.quic_hostname}>{row.quic_hostname}</span> + {srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>} + </div> + ); + } + }, + { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, + ]} + /> + </CardContent> + </Card> + + <Card> + <CardHeader><CardTitle>NetBIOS Hostnames<ClickHint /></CardTitle></CardHeader> + <CardContent> + <DataTable + data={netbios} + isLoading={loadingNetbios} + onRowClick={(row: any) => openDetail("netbios_hostname", row.hostname)} + columns={[{ header: "Hostname", accessor: (row: any) => row.hostname }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]} + /> + </CardContent> + </Card> + + <Card className="xl:col-span-2"> + <CardHeader><CardTitle>mDNS Hostnames<ClickHint /></CardTitle></CardHeader> + <CardContent> + <DataTable + data={mdnsHostnames} + isLoading={loadingMdns} + onRowClick={(row: any) => openDetail("mdns_hostname", row.mdns_hostname)} + columns={[{ header: "Hostname", accessor: (row: any) => row.mdns_hostname }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]} + /> + </CardContent> + </Card> + </div> + </Tab> + + {/* ── Client Fingerprints ──────────────────────────────────────────── */} + <Tab label="Client Fingerprints"> + <div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> + <Card className="xl:col-span-2"> + <CardHeader><CardTitle>Operating Systems Discovery<ClickHint /></CardTitle></CardHeader> + <CardContent> + <DataTable + data={discoveryOs} + isLoading={loadingOs} + onRowClick={(row: any) => openDetail("os_label", row.os_label)} + columns={[ + { header: "Operating System", accessor: (row: any) => row.os_label }, + { header: "Download", accessor: (row: any) => fmtBytes(row.download) }, + { header: "Upload", accessor: (row: any) => fmtBytes(row.upload) }, + { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, + ]} + /> + </CardContent> + </Card> + + <Card> + <CardHeader><CardTitle>DHCP Fingerprints<ClickHint /></CardTitle></CardHeader> + <CardContent> + <DataTable + data={dhcpFingerprints} + isLoading={loadingDhcp} + onRowClick={(row: any) => openDetail("dhcp_fingerprint", row.fingerprint)} + columns={[{ header: "Fingerprint", accessor: (row: any) => row.fingerprint }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]} + /> + </CardContent> + </Card> + + <Card> + <CardHeader><CardTitle>HTTP User Agents<ClickHint /></CardTitle></CardHeader> + <CardContent> + <DataTable + data={httpUserAgents} + isLoading={loadingHttpUa} + onRowClick={(row: any) => openDetail("http_useragent", row.user_agent)} + columns={[{ header: "User Agent", accessor: (row: any) => row.user_agent }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]} + /> + </CardContent> + </Card> + </div> + </Tab> + + {/* ── Specific Protocols ───────────────────────────────────────────── */} + <Tab label="Specific Protocols"> + <div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> + <Card> + <CardHeader><CardTitle>SSH Versions<ClickHint /></CardTitle></CardHeader> + <CardContent> + <DataTable + data={sshVersions} + isLoading={loadingSsh} + onRowClick={(row: any) => openDetail("ssh_version", row.ssh_version)} + columns={[{ header: "SSH Version", accessor: (row: any) => row.ssh_version }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]} + /> + </CardContent> + </Card> + + <Card> + <CardHeader><CardTitle>BitTorrent Hashes<ClickHint /></CardTitle></CardHeader> + <CardContent> + <DataTable + data={bittorrentHashes} + isLoading={loadingBittorrent} + onRowClick={(row: any) => openDetail("bittorrent_hash", row.info_hash)} + columns={[ + { header: "Label", accessor: (row: any) => row.label }, + { header: "Info Hash", accessor: (row: any) => row.info_hash }, + { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, + ]} + /> + </CardContent> + </Card> + </div> + </Tab> + </Tabs> + ); +} diff --git a/src/app/(dashboard)/dpi-analytics/page.tsx b/src/app/(dashboard)/dpi-analytics/page.tsx index e6529ba..c8c2d05 100644 --- a/src/app/(dashboard)/dpi-analytics/page.tsx +++ b/src/app/(dashboard)/dpi-analytics/page.tsx @@ -1,9 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; -import { Tabs, Tab } from "@/components/ui/Tabs"; -import { DataTable } from "@/components/ui/DataTable"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { useState, useCallback, useEffect } from "react"; import { useNetbios, useDiscoveryOs, useDhcpFingerprints, useHttpUserAgents, useSniHostnames, useSslServerCn, @@ -13,24 +10,18 @@ import { import { MetadataDetailPanel } from "@/components/dpi/MetadataDetailPanel"; import { MetadataDetailType } from "@/lib/api-metadata-detail"; import { useTimeFilter } from "@/contexts/TimeFilterContext"; -import { fmtBytes, getKnownDomainService } from "@/lib/utils"; import { HelpTrigger } from "@/components/help/HelpTrigger"; - -// Clickable hint badge shown in table column headers when rows are interactive -function ClickHint() { - return ( - <span className="ml-2 text-[10px] font-normal text-blue-400/70 border border-blue-400/30 rounded px-1 py-0.5 align-middle"> - Click row for details - </span> - ); -} +import DpiAnalyticsTabs from "./DpiAnalyticsTabs"; export default function DpiAnalyticsPage() { const { timeRange } = useTimeFilter(); - // Detail panel state - const [detailType, setDetailType] = useState<MetadataDetailType | null>(null); + const [detailType, setDetailType] = useState<MetadataDetailType | null>(null); const [detailValue, setDetailValue] = useState<string | null>(null); + useEffect(() => { + document.title = "DPI MetaData | BackOne - Deep Package Inspection"; + }, []); + const openDetail = useCallback((type: MetadataDetailType, value: string) => { if (!value || value === "—") return; setDetailType(type); @@ -42,25 +33,21 @@ export default function DpiAnalyticsPage() { setDetailValue(null); }, []); - // Hostnames & Servers Tab - const { data: sniHostnames, isLoading: loadingSni } = useSniHostnames(50); - const { data: sslServerCn, isLoading: loadingSslCn } = useSslServerCn(50); - const { data: quicHostnames, isLoading: loadingQuic } = useQuicHostnames(50); - const { data: netbios, isLoading: loadingNetbios } = useNetbios(50); - const { data: mdnsHostnames, isLoading: loadingMdns } = useMdnsHostnames(50); - - // Client Fingerprints Tab - const { data: discoveryOs, isLoading: loadingOs } = useDiscoveryOs(50); - const { data: dhcpFingerprints, isLoading: loadingDhcp } = useDhcpFingerprints(50); - const { data: httpUserAgents, isLoading: loadingHttpUa } = useHttpUserAgents(50); - - // Specific Protocols Tab - const { data: sshVersions, isLoading: loadingSsh } = useSshVersions(50); - const { data: bittorrentHashes, isLoading: loadingBittorrent } = useBittorrentHashes(50); + // Data Fetch Hooks + const { data: sniHostnames, isLoading: loadingSni } = useSniHostnames(); + const { data: sslServerCn, isLoading: loadingSslCn } = useSslServerCn(); + const { data: quicHostnames, isLoading: loadingQuic } = useQuicHostnames(); + const { data: netbios, isLoading: loadingNetbios } = useNetbios(); + const { data: mdnsHostnames, isLoading: loadingMdns } = useMdnsHostnames(); + const { data: discoveryOs, isLoading: loadingOs } = useDiscoveryOs(); + const { data: dhcpFingerprints, isLoading: loadingDhcp } = useDhcpFingerprints(); + const { data: httpUserAgents, isLoading: loadingHttpUa } = useHttpUserAgents(); + const { data: sshVersions, isLoading: loadingSsh } = useSshVersions(); + const { data: bittorrentHashes, isLoading: loadingBittorrent } = useBittorrentHashes(); return ( - <div className="space-y-6"> - <div className="flex items-center justify-between gap-4"> + <div className="space-y-6 animate-fade-in"> + <div className="flex items-center justify-between"> <div> <h1 className="text-3xl font-bold text-white tracking-tight">DPI MetaData</h1> <p className="text-slate-400 mt-2"> @@ -71,216 +58,30 @@ export default function DpiAnalyticsPage() { <HelpTrigger pageId="dpi-analytics" /> </div> - <Tabs> - {/* ── Hostnames & Servers ──────────────────────────────────────────── */} - <Tab label="Hostnames & Servers"> - <div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> + <DpiAnalyticsTabs + sniHostnames={sniHostnames || []} + loadingSni={loadingSni} + sslServerCn={sslServerCn || []} + loadingSslCn={loadingSslCn} + quicHostnames={quicHostnames || []} + loadingQuic={loadingQuic} + netbios={netbios || []} + loadingNetbios={loadingNetbios} + mdnsHostnames={mdnsHostnames || []} + loadingMdns={loadingMdns} + discoveryOs={discoveryOs || []} + loadingOs={loadingOs} + dhcpFingerprints={dhcpFingerprints || []} + loadingDhcp={loadingDhcp} + httpUserAgents={httpUserAgents || []} + loadingHttpUa={loadingHttpUa} + sshVersions={sshVersions || []} + loadingSsh={loadingSsh} + bittorrentHashes={bittorrentHashes || []} + loadingBittorrent={loadingBittorrent} + openDetail={openDetail} + /> - <Card> - <CardHeader><CardTitle>SNI Hostnames (TLS)<ClickHint /></CardTitle></CardHeader> - <CardContent> - <DataTable - data={sniHostnames} - isLoading={loadingSni} - onRowClick={(row: any) => openDetail("sni_hostname", row.sni_hostname)} - columns={[ - { - header: "Hostname", - accessor: (row: any) => { - const srv = getKnownDomainService(row.sni_hostname); - return ( - <div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center"> - <span className="truncate w-full block" title={row.sni_hostname}>{row.sni_hostname}</span> - {srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>} - </div> - ); - } - }, - { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - - <Card> - <CardHeader><CardTitle>SSL Server Common Names<ClickHint /></CardTitle></CardHeader> - <CardContent> - <DataTable - data={sslServerCn} - isLoading={loadingSslCn} - onRowClick={(row: any) => openDetail("ssl_server_cn", row.ssl_server_cn)} - columns={[ - { - header: "Common Name", - accessor: (row: any) => { - const srv = getKnownDomainService(row.ssl_server_cn); - return ( - <div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center"> - <span className="truncate w-full block" title={row.ssl_server_cn}>{row.ssl_server_cn}</span> - {srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>} - </div> - ); - } - }, - { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - - <Card> - <CardHeader><CardTitle>QUIC Hostnames<ClickHint /></CardTitle></CardHeader> - <CardContent> - <DataTable - data={quicHostnames} - isLoading={loadingQuic} - onRowClick={(row: any) => openDetail("quic_hostname", row.quic_hostname)} - columns={[ - { - header: "Hostname", - accessor: (row: any) => { - const srv = getKnownDomainService(row.quic_hostname); - return ( - <div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center"> - <span className="truncate w-full block" title={row.quic_hostname}>{row.quic_hostname}</span> - {srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>} - </div> - ); - } - }, - { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - - <Card> - <CardHeader><CardTitle>NetBIOS Hostnames<ClickHint /></CardTitle></CardHeader> - <CardContent> - <DataTable - data={netbios} - isLoading={loadingNetbios} - onRowClick={(row: any) => openDetail("netbios_hostname", row.hostname)} - columns={[ - { header: "Hostname", accessor: (row: any) => row.hostname }, - { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - - <Card className="xl:col-span-2"> - <CardHeader><CardTitle>mDNS Hostnames<ClickHint /></CardTitle></CardHeader> - <CardContent> - <DataTable - data={mdnsHostnames} - isLoading={loadingMdns} - onRowClick={(row: any) => openDetail("mdns_hostname", row.mdns_hostname)} - columns={[ - { header: "Hostname", accessor: (row: any) => row.mdns_hostname }, - { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - - </div> - </Tab> - - {/* ── Client Fingerprints ──────────────────────────────────────────── */} - <Tab label="Client Fingerprints"> - <div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> - - <Card className="xl:col-span-2"> - <CardHeader><CardTitle>Operating Systems Discovery<ClickHint /></CardTitle></CardHeader> - <CardContent> - <DataTable - data={discoveryOs} - isLoading={loadingOs} - onRowClick={(row: any) => openDetail("os_label", row.os_label)} - columns={[ - { header: "Operating System", accessor: (row: any) => row.os_label }, - { header: "Download", accessor: (row: any) => fmtBytes(row.download) }, - { header: "Upload", accessor: (row: any) => fmtBytes(row.upload) }, - { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - - <Card> - <CardHeader><CardTitle>DHCP Fingerprints<ClickHint /></CardTitle></CardHeader> - <CardContent> - <DataTable - data={dhcpFingerprints} - isLoading={loadingDhcp} - onRowClick={(row: any) => openDetail("dhcp_fingerprint", row.fingerprint)} - columns={[ - { header: "Fingerprint", accessor: (row: any) => row.fingerprint }, - { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - - <Card> - <CardHeader><CardTitle>HTTP User Agents<ClickHint /></CardTitle></CardHeader> - <CardContent> - <DataTable - data={httpUserAgents} - isLoading={loadingHttpUa} - onRowClick={(row: any) => openDetail("http_useragent", row.user_agent)} - columns={[ - { header: "User Agent", accessor: (row: any) => row.user_agent }, - { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - - </div> - </Tab> - - {/* ── Specific Protocols ───────────────────────────────────────────── */} - <Tab label="Specific Protocols"> - <div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> - - <Card> - <CardHeader><CardTitle>SSH Versions<ClickHint /></CardTitle></CardHeader> - <CardContent> - <DataTable - data={sshVersions} - isLoading={loadingSsh} - onRowClick={(row: any) => openDetail("ssh_version", row.ssh_version)} - columns={[ - { header: "SSH Version", accessor: (row: any) => row.ssh_version }, - { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - - <Card> - <CardHeader><CardTitle>BitTorrent Hashes<ClickHint /></CardTitle></CardHeader> - <CardContent> - <DataTable - data={bittorrentHashes} - isLoading={loadingBittorrent} - onRowClick={(row: any) => openDetail("bittorrent_hash", row.info_hash)} - columns={[ - { header: "Label", accessor: (row: any) => row.label }, - { header: "Info Hash", accessor: (row: any) => row.info_hash }, - { header: "Total", accessor: (row: any) => fmtBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - - </div> - </Tab> - </Tabs> - - {/* ── Row Detail Slide-Out Panel ────────────────────────────────────── */} <MetadataDetailPanel type={detailType} value={detailValue} diff --git a/src/app/(dashboard)/events/page.tsx b/src/app/(dashboard)/events/page.tsx index e24b37a..0393889 100644 --- a/src/app/(dashboard)/events/page.tsx +++ b/src/app/(dashboard)/events/page.tsx @@ -19,6 +19,7 @@ function EventsContent() { const [mounted, setMounted] = useState(false); const [selectedIp, setSelectedIp] = useState<string | null>(null); const [selectedMac, setSelectedMac] = useState<string | null>(null); + const { data, isLoading } = useEvents(); const searchParams = useSearchParams(); const highlightId = searchParams.get("highlight"); @@ -28,9 +29,6 @@ function EventsContent() { handleReset, activeCount } = useUniversalFilterState(); - // Pass dateFrom/dateTo so the backend filters by the exact calendar date range - const { data, isLoading } = useEvents(0, dateFrom, dateTo); - useEffect(() => { setMounted(true); }, []); @@ -65,10 +63,14 @@ function EventsContent() { return true; }); - // Use the data retention window (7 days) as the minimum selectable date. - // Dynamic computation from loaded data causes a circular dependency where - // the sidebar time filter (e.g., Last 24h) restricts the date picker range. - const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const minTime = all.reduce((min: number, r: any) => { + if (r.timestamp) { + const time = new Date(r.timestamp).getTime(); + if (isFinite(time) && time < min) return time; + } + return min; + }, Infinity); + const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01"; const getSeverityIcon = (severity: string) => { switch (severity.toLowerCase()) { @@ -84,11 +86,11 @@ function EventsContent() { }; const columns: Column<typeof data[0]>[] = [ - { header: "#", accessor: (row, i) => i + 1, className: "w-[60px] min-w-[60px] max-w-[60px]" }, + { header: "#", accessor: (row, i) => i + 1, className: "w-[4%] text-center" }, { header: "Timestamp (WIB)", accessor: (row) => <TimestampCell timestamp={row.timestamp} showActiveStatus={false} />, - className: "w-[180px] min-w-[180px] max-w-[180px]" + className: "w-[15%] text-center" }, { header: "Severity", @@ -98,32 +100,32 @@ function EventsContent() { <span className="capitalize">{row.severity}</span> </div> ), - className: "w-[120px] min-w-[120px] max-w-[120px]" + className: "w-[10%] text-center" }, - { header: "Event Type", accessor: (row) => row.event_type, className: "w-[130px] min-w-[130px] max-w-[130px]" }, + { header: "Event Type", accessor: (row) => row.event_type, className: "w-[12%] text-center" }, { header: "MAC Address", accessor: (row) => row.mac_address ? ( <button onClick={() => setSelectedMac(row.mac_address)} - className="font-mono text-xs text-emerald-400 hover:text-emerald-300 hover:underline text-center w-full focus:outline-none" + className="font-mono text-xs text-emerald-400 hover:text-emerald-300 hover:underline text-center w-full focus:outline-none truncate" > {row.mac_address} </button> ) : <span className="text-slate-500">-</span>, - className: "w-[160px] min-w-[160px] max-w-[160px]" + className: "w-[14%] text-center" }, { header: "Source IP", accessor: (row) => row.source_ip ? ( <button onClick={() => setSelectedIp(row.source_ip)} - className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs text-center w-full focus:outline-none" + className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs text-center w-full focus:outline-none truncate" > {row.source_ip} </button> ) : <span className="text-slate-500">-</span>, - className: "w-[140px] min-w-[140px] max-w-[140px]" + className: "w-[12%] text-center" }, { header: "Message", @@ -150,13 +152,13 @@ function EventsContent() { </div> ); }, - className: "text-center w-full min-w-0 max-w-full" + className: "w-[33%] text-center" } ]; return ( <div className="space-y-6"> - <div className="flex items-center justify-between gap-4"> + <div className="flex items-center justify-between"> <h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3"> Network Events {isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />} @@ -223,3 +225,4 @@ export default function EventsPage() { </Suspense> ); } +export { EventsContent }; diff --git a/src/app/(dashboard)/flows/explainFlow.ts b/src/app/(dashboard)/flows/explainFlow.ts new file mode 100644 index 0000000..a48b64a --- /dev/null +++ b/src/app/(dashboard)/flows/explainFlow.ts @@ -0,0 +1,46 @@ +export function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) { + const label = appLabel || domain || ""; + const portStr = String(port); + + const matches: Record<string, string> = { + "1433": "MSSQL Database Server", + "1434": "MSSQL Monitor Server", + "3306": "MySQL/MariaDB Database Server", + "5432": "PostgreSQL Database Server", + "1521": "Oracle Database Server", + "27017": "Database Server", + "6379": "Redis Key-Value Cache", + "80": "Unencrypted Web Traffic (HTTP)", + "443": "Encrypted Web Traffic (HTTPS/TLS)", + "22": "SSH Remote Management / SFTP", + "21": "FTP File Upload/Download (Unencrypted)", + "23": "Telnet Command Shell (Insecure)", + "25": "SMTP Mail Delivery", + "587": "Secure SMTP Mail Delivery", + "110": "POP3 Mail Storage Retrieval", + "993": "Secure IMAP Mail Service", + "53": "DNS Server Query (Domain Lookup)", + "123": "NTP Network Clock Synchronizer", + "161": "SNMP Network Device Monitoring", + "3389": "RDP Remote Desktop Management", + "445": "SMB Windows File Sharing", + "137": "NetBIOS Name Service", + "138": "NetBIOS Datagram Service", + "139": "NetBIOS Session Service", + "1812": "RADIUS Authentication Server", + "1813": "RADIUS Accounting Server" + }; + + let mainLabel = label; + let subLabel = matches[portStr] || (label.toLowerCase().includes("tls") || label.toLowerCase().includes("https") ? "Encrypted Connection (SSL/TLS)" : ""); + + if (appLabel && appLabel.includes("(") && domain && domain.startsWith("Port ")) { + mainLabel = appLabel; + subLabel = domain; + } + + return { + main: mainLabel || `Port ${port}`, + sub: subLabel + }; +} diff --git a/src/app/(dashboard)/flows/page.tsx b/src/app/(dashboard)/flows/page.tsx index a9f107d..0e0c65d 100644 --- a/src/app/(dashboard)/flows/page.tsx +++ b/src/app/(dashboard)/flows/page.tsx @@ -2,89 +2,66 @@ import { useState, useEffect } from "react"; import { TimestampCell } from "@/components/ui/TimestampCell"; -import { useFlows } from "@/lib/api-with-context"; +import { useFlows, useFlowsOptions } from "@/lib/api-with-context"; import { DataTable, Column } from "@/components/ui/DataTable"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; -import { Loader2 } from "lucide-react"; +import { Loader2, Download, Search } from "lucide-react"; import { fmtBytes, formatAppLabel } from "@/lib/utils"; import { IpDetails } from "@/components/ui/IpDetails"; import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal"; import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters"; +import { explainAppOrPort } from "./explainFlow"; +import { downloadCsv } from "@/components/ui/DataTableCsvHelper"; import { HelpTrigger } from "@/components/help/HelpTrigger"; -function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) { - const label = appLabel || domain || ""; - const portStr = String(port); - - const matches: Record<string, string> = { - "1433": "MSSQL Database Server", - "1434": "MSSQL Monitor Server", - "3306": "MySQL/MariaDB Database Server", - "5432": "PostgreSQL Database Server", - "1521": "Oracle Database Server", - "27017": "Database Server", - "6379": "Redis Key-Value Cache", - "80": "Unencrypted Web Traffic (HTTP)", - "443": "Encrypted Web Traffic (HTTPS/TLS)", - "22": "SSH Remote Management / SFTP", - "21": "FTP File Upload/Download (Unencrypted)", - "23": "Telnet Command Shell (Insecure)", - "25": "SMTP Mail Delivery", - "587": "Secure SMTP Mail Delivery", - "110": "POP3 Mail Storage Retrieval", - "993": "Secure IMAP Mail Service", - "53": "DNS Server Query (Domain Lookup)", - "123": "NTP Network Clock Synchronizer", - "161": "SNMP Network Device Monitoring", - "3389": "RDP Remote Desktop Management", - "445": "SMB Windows File Sharing", - "137": "NetBIOS Name Service", - "138": "NetBIOS Datagram Service", - "139": "NetBIOS Session Service", - "1812": "RADIUS Authentication Server", - "1813": "RADIUS Accounting Server" - }; - - let mainLabel = label; - let subLabel = matches[portStr] || (label.toLowerCase().includes("tls") || label.toLowerCase().includes("https") ? "Encrypted Connection (SSL/TLS)" : ""); - - if (appLabel && appLabel.includes("(") && domain && domain.startsWith("Port ")) { - mainLabel = appLabel; - subLabel = domain; - } - - return { - main: mainLabel || `Port ${port}`, - sub: subLabel - }; -} - export default function FlowsPage() { const [mounted, setMounted] = useState(false); const [selectedIp, setSelectedIp] = useState<string | null>(null); - + const [currentPage, setCurrentPage] = useState(1); + const [ipSearch, setIpSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const { filters, dateFrom, dateTo, handleFilterChange, setDateFrom, setDateTo, handleReset, activeCount } = useUniversalFilterState(); - // Pass dateFrom/dateTo so the backend filters by the exact calendar date range - const { data, isLoading } = useFlows(5000, dateFrom, dateTo); + // Debounce search query to prevent database query spamming + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(ipSearch); + }, 300); + return () => clearTimeout(timer); + }, [ipSearch]); + + // Reset page to 1 when filters, date picker, or search text changes + useEffect(() => { + setCurrentPage(1); + }, [filters, dateFrom, dateTo, debouncedSearch]); + + const { data: optionsData } = useFlowsOptions(); + const { data: flows = [], total = 0, isLoading } = useFlows(currentPage, 50, { + ...filters, + dateFrom, + dateTo, + search: debouncedSearch + }); useEffect(() => { setMounted(true); + document.title = "Flows | BackOne - Deep Package Inspection"; }, []); if (!mounted) return null; - const all = data || []; - - - // Extract unique options const opts = (key: string) => { - const raw = Array.from(new Set(all.map((r: any) => String(r[key] || "")).filter(Boolean))); - return raw.sort(); + if (!optionsData) return []; + const mapping: Record<string, string[]> = { + protocol: optionsData.protocols, src_ip: optionsData.srcIps, dst_ip: optionsData.dstIps, + dst_port: optionsData.dstPorts, app_label: optionsData.apps, domain: optionsData.domains + }; + return mapping[key] || []; }; const filterConfigs: FilterConfig[] = [ @@ -98,42 +75,15 @@ export default function FlowsPage() { { id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] }, ]; - const filteredData = all.filter((row: any) => { - if (filters.protocol && filters.protocol !== "All" && row.protocol !== filters.protocol) return false; - if (filters.src_ip && filters.src_ip !== "All" && row.src_ip !== filters.src_ip) return false; - if (filters.dst_ip && filters.dst_ip !== "All" && row.dst_ip !== filters.dst_ip) return false; - if (filters.dst_port && filters.dst_port !== "All" && String(row.dst_port) !== filters.dst_port) return false; - if (filters.app && filters.app !== "All" && row.app_label !== filters.app) return false; - if (filters.domain && filters.domain !== "All" && row.domain !== filters.domain) return false; - // Date filtering is handled server-side via date_from/date_to on timestamp. - // last_seen != timestamp (a flow collected July 13 can have last_seen=July 14) - // so client-side filtering by last_seen would incorrectly exclude valid records. - return true; - }); + const minDate = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString().slice(0, 10); + const handleExport = () => { + const headers = ["Flow ID", "Src IP", "Dst IP", "Dst Port", "Protocol", "App / Domain", "Download (bytes)", "Upload (bytes)"]; + const rows = [headers, ...flows.map(r => [r.flow_id || "", r.src_ip || "", r.dst_ip || "", r.dst_port ?? "", r.protocol || "", r.app_label || r.domain || `Port ${r.dst_port}`, r.bytes_download ?? 0, r.bytes_upload ?? 0])]; + downloadCsv(`flows-${new Date().toISOString().slice(0,10)}.csv`, rows); + }; - let processedData = [...filteredData]; - const sortUpload = filters.sort_upload || "All"; - const sortDownload = filters.sort_download || "All"; - - if (sortUpload !== "All") { - processedData.sort((a, b) => sortUpload === "Descending" ? (b.bytes_upload ?? 0) - (a.bytes_upload ?? 0) : (a.bytes_upload ?? 0) - (b.bytes_upload ?? 0)); - } else if (sortDownload !== "All") { - processedData.sort((a, b) => sortDownload === "Descending" ? (b.bytes_download ?? 0) - (a.bytes_download ?? 0) : (a.bytes_download ?? 0) - (b.bytes_download ?? 0)); - } else { - processedData.sort((a, b) => { - const timeA = a.last_seen ? new Date(a.last_seen).getTime() : 0; - const timeB = b.last_seen ? new Date(b.last_seen).getTime() : 0; - return timeB - timeA; - }); - } - - // Use the data retention window (7 days) as the minimum selectable date. - // Dynamic computation from loaded data causes a circular dependency where - // the sidebar time filter (e.g., Last 24h) restricts the date picker range. - const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - - const columns: Column<typeof data[0]>[] = [ + const columns: Column<any>[] = [ { header: "Last Seen", accessor: (row) => ( @@ -143,70 +93,107 @@ export default function FlowsPage() { showActiveStatus={true} /> ), - className: "w-[120px]" + className: "w-[14%] text-center", + tooltip: (row) => new Date(row.last_seen).toLocaleString() + }, + { + header: "#", + accessor: (row, i) => i + 1, + className: "w-[4%] text-center" + }, + { + header: "Flow ID", + accessor: (row) => row.flow_id, + className: "w-[11%] text-center font-mono text-xs truncate" }, - { header: "#", accessor: (row, i) => i + 1 }, - { header: "Flow ID", accessor: (row) => row.flow_id }, { header: "Src IP", accessor: (row) => ( - <button - onClick={() => setSelectedIp(row.src_ip)} - className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-left focus:outline-none" - > - {row.src_ip} - </button> - ) + <div className="flex flex-col items-center justify-center text-center"> + <button + onClick={() => setSelectedIp(row.src_ip)} + className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-center focus:outline-none truncate w-full" + > + {row.src_ip} + </button> + {row.src_label && ( + <span className="text-[10px] text-cyan-400 font-semibold tracking-wide mt-0.5 truncate w-full block"> + {row.src_label} + </span> + )} + </div> + ), + className: "w-[11%] text-center", + tooltip: (row) => row.src_label ? `${row.src_ip} (${row.src_label})` : row.src_ip }, { header: "Dst IP", accessor: (row) => ( - <div className="flex flex-col items-start"> + <div className="flex flex-col items-center justify-center"> <button onClick={() => setSelectedIp(row.dst_ip)} - className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-left focus:outline-none" + className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-center focus:outline-none truncate w-full" > {row.dst_ip} </button> <IpDetails ip={row.dst_ip} /> </div> - ) + ), + className: "w-[13%] text-center", + tooltip: (row) => row.dst_ip + }, + { + header: "Dst Port", + accessor: (row) => row.dst_port, + className: "w-[7%] text-center font-mono text-xs" + }, + { + header: "Protocol", + accessor: (row) => row.protocol, + className: "w-[7%] text-center text-xs" }, - { header: "Dst Port", accessor: (row) => row.dst_port }, - { header: "Protocol", accessor: (row) => row.protocol }, { header: "App / Domain", accessor: (row) => { const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port); return ( - <div className="flex flex-col"> - <span className="font-semibold text-white truncate max-w-[200px]" title={exp.main}> + <div className="flex flex-col items-center justify-center"> + <span className="font-semibold text-white truncate max-w-[200px] text-center" title={exp.main}> {exp.main} </span> {exp.sub && ( - <span className="text-[10px] text-slate-400 italic"> + <span className="text-[10px] text-slate-400 italic text-center truncate max-w-[200px]"> {exp.sub} </span> )} </div> ); + }, + className: "w-[18%] text-center", + tooltip: (row) => { + const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port); + return exp.sub ? `${exp.main} (${exp.sub})` : exp.main; } }, { header: "Download", - accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span> + accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span>, + className: "w-[8%] text-center", + tooltip: (row) => `${fmtBytes(row.bytes_download)} (${(row.bytes_download || 0).toLocaleString()} bytes)` }, { header: "Upload", - accessor: (row) => <span className="text-green-500">{fmtBytes(row.bytes_upload)}</span> + accessor: (row) => <span className="text-green-500">{fmtBytes(row.bytes_upload)}</span>, + className: "w-[7%] text-center", + tooltip: (row) => `${fmtBytes(row.bytes_upload)} (${(row.bytes_upload || 0).toLocaleString()} bytes)` } ]; return ( <div className="space-y-6"> - <div className="flex items-center justify-between gap-4"> + <div className="flex items-center justify-between"> <h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3"> - Active Flows + Flows {isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />} </h2> <HelpTrigger pageId="flows" /> @@ -235,38 +222,37 @@ export default function FlowsPage() { /> <Card className="bg-card/50 backdrop-blur-sm border-border/50"> - <CardHeader> - <CardTitle className="text-white">Active Flows ({processedData.length})</CardTitle> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4 gap-4 flex-wrap"> + <div className="flex items-center gap-4 flex-wrap"> + <CardTitle className="text-white">Active Flows ({total.toLocaleString()})</CardTitle> + <div className="relative group"> + <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground transition-colors group-focus-within:text-primary" /> + <input + type="text" + placeholder="Search IP (source or dest)..." + value={ipSearch} + onChange={(e) => setIpSearch(e.target.value)} + className="w-[240px] rounded-lg border border-border bg-card/30 backdrop-blur-sm pl-9 pr-4 py-1.5 text-sm text-white placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm" + /> + </div> + </div> + <button + onClick={handleExport} + title="Export current page flows to CSV" + className="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-emerald-500/40 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 hover:border-emerald-500/70 hover:text-emerald-300 transition-all shadow-sm whitespace-nowrap" + > + <Download className="w-4 h-4" /> + Export CSV ({total.toLocaleString()}) + </button> </CardHeader> <CardContent> <DataTable - data={processedData} + data={flows} columns={columns} - searchPlaceholder="Search flows by IP, App, or Domain..." - searchFilter={(row, query) => { - const q = query.toLowerCase(); - return ( - (row.src_ip || "").toLowerCase().includes(q) || - (row.dst_ip || "").toLowerCase().includes(q) || - (row.app_label || "").toLowerCase().includes(q) || - (row.domain || "").toLowerCase().includes(q) || - (row.protocol || "").toLowerCase().includes(q) - ); - }} - csvExport={{ - filename: `flows-${new Date().toISOString().slice(0,10)}.csv`, - headers: ["Flow ID", "Src IP", "Dst IP", "Dst Port", "Protocol", "App / Domain", "Download (bytes)", "Upload (bytes)"], - rowSerializer: (row) => [ - row.flow_id || "", - row.src_ip || "", - row.dst_ip || "", - row.dst_port ?? "", - row.protocol || "", - row.app_label || row.domain || `Port ${row.dst_port}`, - row.bytes_download ?? 0, - row.bytes_upload ?? 0, - ], - }} + serverSide={true} + totalCount={total} + currentPage={currentPage} + onPageChange={setCurrentPage} /> </CardContent> </Card> diff --git a/src/app/(dashboard)/geography/page.tsx b/src/app/(dashboard)/geography/page.tsx index 61d1a44..19d7f38 100644 --- a/src/app/(dashboard)/geography/page.tsx +++ b/src/app/(dashboard)/geography/page.tsx @@ -13,7 +13,7 @@ import { HelpTrigger } from "@/components/help/HelpTrigger"; export default function GeographyPage() { const [mounted, setMounted] = useState(false); const [showMap, setShowMap] = useState(false); - const { data, isLoading } = useCountries(50); // fetch 50 countries + const { data, isLoading } = useCountries(); const { filters, @@ -34,6 +34,7 @@ export default function GeographyPage() { useEffect(() => { setMounted(true); + document.title = "Geo Traffic | BackOne - Deep Package Inspection"; }, []); if (!mounted) return null; @@ -103,10 +104,10 @@ export default function GeographyPage() { return ( <div className="space-y-8 animate-fade-in pb-10"> - <div className="flex items-center justify-between gap-4"> + <div className="flex items-center justify-between"> <div> <h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> - Geographic Traffic + Geo Traffic {isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />} </h2> <p className="text-muted-foreground mt-1">Visualize data flow and activity across the globe.</p> diff --git a/src/app/(dashboard)/intelligence/DeviceDiscoveryTable.tsx b/src/app/(dashboard)/intelligence/DeviceDiscoveryTable.tsx new file mode 100644 index 0000000..86f0fab --- /dev/null +++ b/src/app/(dashboard)/intelligence/DeviceDiscoveryTable.tsx @@ -0,0 +1,71 @@ +"use client"; + +import React from "react"; +import { DataTable } from "@/components/ui/DataTable"; +import { TimestampCell } from "@/components/ui/TimestampCell"; +import { ChevronRight } from "lucide-react"; +import { fmtBytes } from "@/lib/utils"; + +interface DeviceDiscoveryTableProps { + data: any[]; + isLoading: boolean; + setSelectedDevice: (device: { ip: string; mac?: string } | null) => void; +} + +export function DeviceDiscoveryTable({ data, isLoading, setSelectedDevice }: DeviceDiscoveryTableProps) { + return ( + <DataTable + data={data} isLoading={isLoading} + searchPlaceholder="Search by IP, MAC, OS..." + searchFilter={(row: any, q: string) => { + const query = q.toLowerCase(); + return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query) || (row.device_type || '').toLowerCase().includes(query); + }} + csvExport={{ + filename: `device-discovery-${new Date().toISOString().slice(0,10)}.csv`, + headers: ["IP Address", "MAC Address", "Device Type", "OS", "Manufacturer", "Download", "Upload", "Last Seen"], + rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.device_type || "", row.os_label || "", row.manufacturer || "", row.download || 0, row.upload || 0, row.last_seen || ""] + }} + columns={[ + { + header: "Last Seen", + className: "w-[15%]", + accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} /> + }, + { header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" }, + { + header: "IP Address", + className: "w-[16%]", + accessor: (row: any) => ( + <div className="flex flex-col items-center justify-center"> + <button + onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }} + className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`} + title={row.ip_address ? "Click to view device details" : ""} + > + <span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}> + {row.ip_address || "Unknown IP"} + </span> + {row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />} + </button> + <span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span> + </div> + ) + }, + { header: "Device Profile", className: "w-[20%]", accessor: (row: any) => ( + <div className="flex flex-col items-center justify-center text-center"> + <span className="text-white font-medium">{row.device_type || "-"}</span> + <span className="text-[10px] text-slate-400 font-medium mt-0.5">{row.os_label || "-"}</span> + </div> + ) }, + { header: "Manufacturer", className: "w-[20%]", accessor: (row: any) => row.manufacturer || "-" }, + { header: "Bandwidth", className: "w-[25%]", accessor: (row: any) => ( + <div className="flex flex-col items-center justify-center text-center gap-0.5"> + <span className="text-cyan-400 text-xs font-mono">↓ {fmtBytes(row.download)}</span> + <span className="text-green-400 text-xs font-mono">↑ {fmtBytes(row.upload)}</span> + </div> + ) }, + ]} + /> + ); +} diff --git a/src/app/(dashboard)/intelligence/EncryptionAuditTable.tsx b/src/app/(dashboard)/intelligence/EncryptionAuditTable.tsx new file mode 100644 index 0000000..3ea63e9 --- /dev/null +++ b/src/app/(dashboard)/intelligence/EncryptionAuditTable.tsx @@ -0,0 +1,92 @@ +"use client"; + +import React from "react"; +import { DataTable } from "@/components/ui/DataTable"; +import { TimestampCell } from "@/components/ui/TimestampCell"; +import { ChevronRight } from "lucide-react"; +import { IpDetails } from "@/components/ui/IpDetails"; +import { fmtBytes } from "@/lib/utils"; + +interface EncryptionAuditTableProps { + data: any[]; + isLoading: boolean; + setSelectedDevice: (device: { ip: string; mac?: string } | null) => void; +} + +export function EncryptionAuditTable({ data, isLoading, setSelectedDevice }: EncryptionAuditTableProps) { + return ( + <DataTable + data={data} isLoading={isLoading} + searchPlaceholder="Search by Device or IP..." + searchFilter={(row: any, q: string) => { + const query = q.toLowerCase(); + return (row.ip_address || '').toLowerCase().includes(query) || (row.device_label || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query); + }} + csvExport={{ + filename: `encryption-audit-${new Date().toISOString().slice(0,10)}.csv`, + headers: ["Device Label", "MAC Address", "IP Address", "Encrypted Data", "Encrypted %", "Total Data", "Risk Level"], + rowSerializer: (row: any) => [row.device_label || "", row.mac_address || "", row.ip_address || "", row.encrypted || 0, row.encrypted_pct || 0, row.total || 0, row.risk_level || ""] + }} + columns={[ + { + header: "Last Seen", + className: "w-[15%]", + accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} /> + }, + { header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" }, + { + header: "Device", + className: "w-[25%]", + accessor: (row: any) => ( + <div className="flex flex-col items-center justify-center text-center"> + <span className="font-medium text-white">{row.device_label || row.ip_address}</span> + <span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span> + </div> + ) + }, + { + header: "IP Address", + className: "w-[20%]", + accessor: (row: any) => ( + <div className="flex flex-col items-center justify-center"> + <button + onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }} + className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`} + title={row.ip_address ? "Click to view device details" : ""} + > + <span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}> + {row.ip_address || "Unknown IP"} + </span> + {row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />} + </button> + {row.ip_address && <div className="mt-1 scale-90 origin-top"><IpDetails ip={row.ip_address} /></div>} + </div> + ) + }, + { + header: "Encrypted Data", + className: "w-[26%]", + accessor: (row: any) => ( + <div className="flex flex-col items-center justify-center text-center"> + <span className="text-green-400 font-mono text-sm">{fmtBytes(row.encrypted)} ({row.encrypted_pct}%)</span> + <span className="text-[10px] text-muted-foreground font-medium mt-0.5 uppercase tracking-wider">Total: {fmtBytes(row.total)}</span> + </div> + ) + }, + { + header: "Risk Level", + className: "w-[10%]", + accessor: (row: any) => ( + <div className="flex items-center justify-center w-full"> + <span className={`px-2 py-1 rounded text-xs font-medium ${ + row.risk_level === 'medium' ? 'bg-yellow-500/20 text-yellow-500' : 'bg-green-500/20 text-green-500' + }`}> + {row.risk_level === 'medium' ? 'Medium' : 'Safe'} + </span> + </div> + ) + }, + ]} + /> + ); +} diff --git a/src/app/(dashboard)/intelligence/IntelligenceTables.tsx b/src/app/(dashboard)/intelligence/IntelligenceTables.tsx new file mode 100644 index 0000000..79fb4ec --- /dev/null +++ b/src/app/(dashboard)/intelligence/IntelligenceTables.tsx @@ -0,0 +1,157 @@ +"use client"; + +import React from "react"; +import { DataTable } from "@/components/ui/DataTable"; +import { IpDetails } from "@/components/ui/IpDetails"; +import { EncryptionAuditTable } from "./EncryptionAuditTable"; +import { DeviceDiscoveryTable } from "./DeviceDiscoveryTable"; +import { ServerDiscoveryTable } from "./ServerDiscoveryTable"; + +interface IntelligenceTablesProps { + activeTab: string; + cryptoMining: any[]; + loadingCrypto: boolean; + torDetection: any[]; + loadingTor: boolean; + vpnDetection: any[]; + loadingVpn: boolean; + ipReputation: any[]; + loadingIpRep: boolean; + insecureProtocols: any[]; + loadingInsecure: boolean; + unencryptedPasswords: any[]; + loadingPasswords: boolean; + encryptionAudit: any[]; + loadingEncryption: boolean; + deviceDiscovery: any[]; + loadingDevice: boolean; + serverDiscovery: any[]; + loadingServer: boolean; + setSelectedDevice: (device: { ip: string; mac?: string } | null) => void; +} + +export function IntelligenceTables({ + activeTab, + cryptoMining, loadingCrypto, + torDetection, loadingTor, + vpnDetection, loadingVpn, + ipReputation, loadingIpRep, + insecureProtocols, loadingInsecure, + unencryptedPasswords, loadingPasswords, + encryptionAudit, loadingEncryption, + deviceDiscovery, loadingDevice, + serverDiscovery, loadingServer, + setSelectedDevice +}: IntelligenceTablesProps) { + switch (activeTab) { + case "Crypto Mining": + return ( + <DataTable + data={cryptoMining} isLoading={loadingCrypto} + columns={[ + { header: "IP Address", accessor: (row: any) => row.ip_address }, + { header: "MAC Address", accessor: (row: any) => row.mac_address }, + { header: "Pool Host", accessor: (row: any) => row.pool_host }, + { header: "App", accessor: (row: any) => row.app_label }, + { header: "Confidence", accessor: (row: any) => row.confidence }, + ]} + /> + ); + case "Tor Detection": + return ( + <DataTable + data={torDetection} isLoading={loadingTor} + columns={[ + { header: "IP Address", accessor: (row: any) => row.ip_address }, + { header: "Exit Node", accessor: (row: any) => row.exit_node }, + { header: "Country", accessor: (row: any) => row.country }, + ]} + /> + ); + case "VPN Detection": + return ( + <DataTable + data={vpnDetection} isLoading={loadingVpn} + columns={[ + { header: "IP Address", accessor: (row: any) => row.ip_address }, + { header: "VPN Type", accessor: (row: any) => row.vpn_type }, + { + header: "Remote IP", + accessor: (row: any) => row.remote_ip ? ( + <div className="flex flex-col gap-0.5"> + <span className="font-mono text-xs text-blue-400">{row.remote_ip}</span> + <IpDetails ip={row.remote_ip} /> + </div> + ) : "—" + }, + { header: "Country", accessor: (row: any) => row.country }, + ]} + /> + ); + case "IP Reputation": + return ( + <DataTable + data={ipReputation} isLoading={loadingIpRep} + columns={[ + { + header: "IP Address", + accessor: (row: any) => row.ip_address ? ( + <div className="flex flex-col gap-0.5"> + <span className="font-mono text-xs text-blue-400">{row.ip_address}</span> + <IpDetails ip={row.ip_address} /> + </div> + ) : "—" + }, + { header: "Reputation", accessor: (row: any) => row.reputation }, + { header: "App", accessor: (row: any) => row.app_label }, + { header: "Country", accessor: (row: any) => row.country }, + ]} + /> + ); + case "Insecure Protocols": + return ( + <DataTable + data={insecureProtocols} isLoading={loadingInsecure} + columns={[ + { header: "Protocol", accessor: (row: any) => row.protocol }, + { header: "IP Address", accessor: (row: any) => row.ip_address }, + { header: "Risk", accessor: (row: any) => row.risk }, + ]} + /> + ); + case "Unencrypted Passwords": + return ( + <DataTable + data={unencryptedPasswords} isLoading={loadingPasswords} + columns={[ + { header: "Protocol", accessor: (row: any) => row.protocol }, + { header: "IP Address", accessor: (row: any) => row.ip_address }, + { header: "Severity", accessor: (row: any) => row.severity }, + ]} + /> + ); + case "Encryption Audit": + return ( + <EncryptionAuditTable + data={encryptionAudit} isLoading={loadingEncryption} + setSelectedDevice={setSelectedDevice} + /> + ); + case "Device Discovery": + return ( + <DeviceDiscoveryTable + data={deviceDiscovery} isLoading={loadingDevice} + setSelectedDevice={setSelectedDevice} + /> + ); + case "Server Discovery": + return ( + <ServerDiscoveryTable + data={serverDiscovery} isLoading={loadingServer} + setSelectedDevice={setSelectedDevice} + /> + ); + default: + return null; + } +} diff --git a/src/app/(dashboard)/intelligence/ServerDiscoveryTable.tsx b/src/app/(dashboard)/intelligence/ServerDiscoveryTable.tsx new file mode 100644 index 0000000..4237ed4 --- /dev/null +++ b/src/app/(dashboard)/intelligence/ServerDiscoveryTable.tsx @@ -0,0 +1,64 @@ +"use client"; + +import React from "react"; +import { DataTable } from "@/components/ui/DataTable"; +import { TimestampCell } from "@/components/ui/TimestampCell"; +import { ChevronRight } from "lucide-react"; + +interface ServerDiscoveryTableProps { + data: any[]; + isLoading: boolean; + setSelectedDevice: (device: { ip: string; mac?: string } | null) => void; +} + +export function ServerDiscoveryTable({ data, isLoading, setSelectedDevice }: ServerDiscoveryTableProps) { + return ( + <DataTable + data={data} isLoading={isLoading} + searchPlaceholder="Search by IP, MAC, Type..." + searchFilter={(row: any, q: string) => { + const query = q.toLowerCase(); + return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.server_type || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query); + }} + csvExport={{ + filename: `server-discovery-${new Date().toISOString().slice(0,10)}.csv`, + headers: ["IP Address", "MAC Address", "Server Type", "Port", "OS"], + rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.server_type || "", row.port || "", row.os_label || ""] + }} + columns={[ + { + header: "Last Seen", + className: "w-[15%]", + accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} /> + }, + { header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" }, + { + header: "Server IP", + className: "w-[25%]", + accessor: (row: any) => ( + <div className="flex flex-col items-center justify-center"> + <button + onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }} + className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`} + title={row.ip_address ? "Click to view device details" : ""} + > + <span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}> + {row.ip_address || "Unknown IP"} + </span> + {row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />} + </button> + <span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span> + </div> + ) + }, + { header: "Server Type", className: "w-[28%]", accessor: (row: any) => ( + <div className="flex flex-col items-center justify-center text-center"> + <span className="text-white font-medium">{row.server_type}</span> + {row.port > 0 && <span className="text-[10px] text-purple-400 font-mono font-medium mt-0.5">Port {row.port}</span>} + </div> + ) }, + { header: "OS / Platform", className: "w-[28%]", accessor: (row: any) => row.os_label }, + ]} + /> + ); +} diff --git a/src/app/(dashboard)/intelligence/page.tsx b/src/app/(dashboard)/intelligence/page.tsx index 5370f6e..c5479c5 100644 --- a/src/app/(dashboard)/intelligence/page.tsx +++ b/src/app/(dashboard)/intelligence/page.tsx @@ -8,13 +8,10 @@ import { useIntelEncryptionAudit, useIntelDeviceDiscovery, useIntelServerDiscovery } from "@/lib/api-advanced"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; -import { DataTable, Column } from "@/components/ui/DataTable"; -import { IpDetails } from "@/components/ui/IpDetails"; -import { Loader2, Bitcoin, Shield, Server, Lock, AlertTriangle, Key, Globe, Network, ShieldAlert, ChevronRight } from "lucide-react"; +import { Loader2, Bitcoin, Shield, Server, Lock, AlertTriangle, Key, Globe, Network, ShieldAlert } from "lucide-react"; import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal"; -import { TimestampCell } from "@/components/ui/TimestampCell"; -import { fmtBytes } from "@/lib/utils"; import { HelpTrigger } from "@/components/help/HelpTrigger"; +import { IntelligenceTables } from "./IntelligenceTables"; export default function IntelligencePage() { const [mounted, setMounted] = useState(false); @@ -22,19 +19,20 @@ export default function IntelligencePage() { const [activeTab, setActiveTab] = useState<string>("Crypto Mining"); const [selectedDevice, setSelectedDevice] = useState<{ip: string, mac?: string} | null>(null); - // Advanced data hooks - const { data: cryptoMining, isLoading: loadingCrypto } = useIntelCryptoMining(20); - const { data: torDetection, isLoading: loadingTor } = useIntelTorDetection(20); - const { data: vpnDetection, isLoading: loadingVpn } = useIntelVpnDetection(20); - const { data: ipReputation, isLoading: loadingIpRep } = useIntelIpReputation(20); - const { data: insecureProtocols, isLoading: loadingInsecure } = useIntelInsecureProtocols(20); - const { data: unencryptedPasswords, isLoading: loadingPasswords } = useIntelUnencryptedPasswords(20); - const { data: encryptionAudit, isLoading: loadingEncryption } = useIntelEncryptionAudit(20); - const { data: deviceDiscovery, isLoading: loadingDevice } = useIntelDeviceDiscovery(20); - const { data: serverDiscovery, isLoading: loadingServer } = useIntelServerDiscovery(20); + // Advanced data hooks (no hardcoded limits, default to 1,000,000) + const { data: cryptoMining = [], isLoading: loadingCrypto } = useIntelCryptoMining(); + const { data: torDetection = [], isLoading: loadingTor } = useIntelTorDetection(); + const { data: vpnDetection = [], isLoading: loadingVpn } = useIntelVpnDetection(); + const { data: ipReputation = [], isLoading: loadingIpRep } = useIntelIpReputation(); + const { data: insecureProtocols = [], isLoading: loadingInsecure } = useIntelInsecureProtocols(); + const { data: unencryptedPasswords = [], isLoading: loadingPasswords } = useIntelUnencryptedPasswords(); + const { data: encryptionAudit = [], isLoading: loadingEncryption } = useIntelEncryptionAudit(); + const { data: deviceDiscovery = [], isLoading: loadingDevice } = useIntelDeviceDiscovery(); + const { data: serverDiscovery = [], isLoading: loadingServer } = useIntelServerDiscovery(); useEffect(() => { setMounted(true); + document.title = "Threat Intelligence | BackOne - Deep Package Inspection"; }, []); if (!mounted) return null; @@ -96,289 +94,16 @@ export default function IntelligencePage() { } ]; - const renderActiveTable = () => { - switch(activeTab) { - case "Crypto Mining": - return ( - <DataTable - data={cryptoMining} isLoading={loadingCrypto} - columns={[ - { header: "IP Address", accessor: (row: any) => row.ip_address }, - { header: "MAC Address", accessor: (row: any) => row.mac_address }, - { header: "Pool Host", accessor: (row: any) => row.pool_host }, - { header: "App", accessor: (row: any) => row.app_label }, - { header: "Confidence", accessor: (row: any) => row.confidence }, - ]} - /> - ); - case "Tor Detection": - return ( - <DataTable - data={torDetection} isLoading={loadingTor} - columns={[ - { header: "IP Address", accessor: (row: any) => row.ip_address }, - { header: "Exit Node", accessor: (row: any) => row.exit_node }, - { header: "Country", accessor: (row: any) => row.country }, - ]} - /> - ); - case "VPN Detection": - return ( - <DataTable - data={vpnDetection} isLoading={loadingVpn} - columns={[ - { header: "IP Address", accessor: (row: any) => row.ip_address }, - { header: "VPN Type", accessor: (row: any) => row.vpn_type }, - { - header: "Remote IP", - accessor: (row: any) => row.remote_ip ? ( - <div className="flex flex-col gap-0.5"> - <span className="font-mono text-xs text-blue-400">{row.remote_ip}</span> - <IpDetails ip={row.remote_ip} /> - </div> - ) : "—" - }, - { header: "Country", accessor: (row: any) => row.country }, - ]} - /> - ); - case "IP Reputation": - return ( - <DataTable - data={ipReputation} isLoading={loadingIpRep} - columns={[ - { - header: "IP Address", - accessor: (row: any) => row.ip_address ? ( - <div className="flex flex-col gap-0.5"> - <span className="font-mono text-xs text-blue-400">{row.ip_address}</span> - <IpDetails ip={row.ip_address} /> - </div> - ) : "—" - }, - { header: "Reputation", accessor: (row: any) => row.reputation }, - { header: "App", accessor: (row: any) => row.app_label }, - { header: "Country", accessor: (row: any) => row.country }, - ]} - /> - ); - case "Insecure Protocols": - return ( - <DataTable - data={insecureProtocols} isLoading={loadingInsecure} - columns={[ - { header: "Protocol", accessor: (row: any) => row.protocol }, - { header: "IP Address", accessor: (row: any) => row.ip_address }, - { header: "Risk", accessor: (row: any) => row.risk }, - ]} - /> - ); - case "Unencrypted Passwords": - return ( - <DataTable - data={unencryptedPasswords} isLoading={loadingPasswords} - columns={[ - { header: "Protocol", accessor: (row: any) => row.protocol }, - { header: "IP Address", accessor: (row: any) => row.ip_address }, - { header: "Severity", accessor: (row: any) => row.severity }, - ]} - /> - ); - case "Encryption Audit": - return ( - <DataTable - data={encryptionAudit} isLoading={loadingEncryption} - searchPlaceholder="Search by Device or IP..." - searchFilter={(row: any, q: string) => { - const query = q.toLowerCase(); - return (row.ip_address || '').toLowerCase().includes(query) || (row.device_label || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query); - }} - csvExport={{ - filename: `encryption-audit-${new Date().toISOString().slice(0,10)}.csv`, - headers: ["Device Label", "MAC Address", "IP Address", "Encrypted Data", "Encrypted %", "Total Data", "Risk Level"], - rowSerializer: (row: any) => [row.device_label || "", row.mac_address || "", row.ip_address || "", row.encrypted || 0, row.encrypted_pct || 0, row.total || 0, row.risk_level || ""] - }} - columns={[ - { - header: "Last Seen", - className: "w-[15%]", - accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} /> - }, - { header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" }, - { - header: "Device", - className: "w-[25%]", - accessor: (row: any) => ( - <div className="flex flex-col items-center justify-center text-center"> - <span className="font-medium text-white">{row.device_label || row.ip_address}</span> - <span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span> - </div> - ) - }, - { - header: "IP Address", - className: "w-[20%]", - accessor: (row: any) => ( - <div className="flex flex-col items-center justify-center"> - <button - onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }} - className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`} - title={row.ip_address ? "Click to view device details" : ""} - > - <span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}> - {row.ip_address || "Unknown IP"} - </span> - {row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />} - </button> - {row.ip_address && <div className="mt-1 scale-90 origin-top"><IpDetails ip={row.ip_address} /></div>} - </div> - ) - }, - { - header: "Encrypted Data", - className: "w-[26%]", - accessor: (row: any) => ( - <div className="flex flex-col items-center justify-center text-center"> - <span className="text-green-400 font-mono text-sm">{fmtBytes(row.encrypted)} ({row.encrypted_pct}%)</span> - <span className="text-[10px] text-muted-foreground font-medium mt-0.5 uppercase tracking-wider">Total: {fmtBytes(row.total)}</span> - </div> - ) - }, - { - header: "Risk Level", - className: "w-[10%]", - accessor: (row: any) => ( - <div className="flex items-center justify-center w-full"> - <span className={`px-2 py-1 rounded text-xs font-medium ${ - row.risk_level === 'medium' ? 'bg-yellow-500/20 text-yellow-500' : 'bg-green-500/20 text-green-500' - }`}> - {row.risk_level === 'medium' ? 'Medium' : 'Safe'} - </span> - </div> - ) }, - ]} - /> - ); - case "Device Discovery": - return ( - <DataTable - data={deviceDiscovery} isLoading={loadingDevice} - searchPlaceholder="Search by IP, MAC, OS..." - searchFilter={(row: any, q: string) => { - const query = q.toLowerCase(); - return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query) || (row.device_type || '').toLowerCase().includes(query); - }} - csvExport={{ - filename: `device-discovery-${new Date().toISOString().slice(0,10)}.csv`, - headers: ["IP Address", "MAC Address", "Device Type", "OS", "Manufacturer", "Download", "Upload", "Last Seen"], - rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.device_type || "", row.os_label || "", row.manufacturer || "", row.download || 0, row.upload || 0, row.last_seen || ""] - }} - columns={[ - { - header: "Last Seen", - className: "w-[15%]", - accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} /> - }, - { header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" }, - { - header: "IP Address", - className: "w-[16%]", - accessor: (row: any) => ( - <div className="flex flex-col items-center justify-center"> - <button - onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }} - className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`} - title={row.ip_address ? "Click to view device details" : ""} - > - <span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}> - {row.ip_address || "Unknown IP"} - </span> - {row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />} - </button> - <span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span> - </div> - ) - }, - { header: "Device Profile", className: "w-[20%]", accessor: (row: any) => ( - <div className="flex flex-col items-center justify-center text-center"> - <span className="text-white font-medium">{row.device_type || "-"}</span> - <span className="text-[10px] text-slate-400 font-medium mt-0.5">{row.os_label || "-"}</span> - </div> - ) }, - { header: "Manufacturer", className: "w-[20%]", accessor: (row: any) => row.manufacturer || "-" }, - { header: "Bandwidth", className: "w-[25%]", accessor: (row: any) => ( - <div className="flex flex-col items-center justify-center text-center gap-0.5"> - <span className="text-cyan-400 text-xs font-mono">↓ {fmtBytes(row.download)}</span> - <span className="text-green-400 text-xs font-mono">↑ {fmtBytes(row.upload)}</span> - </div> - ) }, - ]} - /> - ); - case "Server Discovery": - return ( - <DataTable - data={serverDiscovery} isLoading={loadingServer} - searchPlaceholder="Search by IP, MAC, Type..." - searchFilter={(row: any, q: string) => { - const query = q.toLowerCase(); - return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.server_type || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query); - }} - csvExport={{ - filename: `server-discovery-${new Date().toISOString().slice(0,10)}.csv`, - headers: ["IP Address", "MAC Address", "Server Type", "Port", "OS"], - rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.server_type || "", row.port || "", row.os_label || ""] - }} - columns={[ - { - header: "Last Seen", - className: "w-[15%]", - accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} /> - }, - { header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" }, - { - header: "Server IP", - className: "w-[25%]", - accessor: (row: any) => ( - <div className="flex flex-col items-center justify-center"> - <button - onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }} - className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`} - title={row.ip_address ? "Click to view device details" : ""} - > - <span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}> - {row.ip_address || "Unknown IP"} - </span> - {row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />} - </button> - <span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span> - </div> - ) - }, - { header: "Server Type", className: "w-[28%]", accessor: (row: any) => ( - <div className="flex flex-col items-center justify-center text-center"> - <span className="text-white font-medium">{row.server_type}</span> - {row.port > 0 && <span className="text-[10px] text-purple-400 font-mono font-medium mt-0.5">Port {row.port}</span>} - </div> - ) }, - { header: "OS / Platform", className: "w-[28%]", accessor: (row: any) => row.os_label }, - ]} - /> - ); - default: return null; - } - }; - return ( - <div className="space-y-6"> - <div className="flex items-center justify-between gap-4"> + <div className="space-y-6 animate-fade-in"> + <div className="flex items-center justify-between"> <h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3"> - Threat Intelligence Feeds + Threat Intelligence {loadingStats && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />} </h2> <HelpTrigger pageId="intelligence" /> </div> - + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> {intelItems.map((item, idx) => { @@ -414,7 +139,28 @@ export default function IntelligencePage() { <CardTitle>Detailed Report: {activeTab}</CardTitle> </CardHeader> <CardContent> - {renderActiveTable()} + <IntelligenceTables + activeTab={activeTab} + cryptoMining={cryptoMining} + loadingCrypto={loadingCrypto} + torDetection={torDetection} + loadingTor={loadingTor} + vpnDetection={vpnDetection} + loadingVpn={loadingVpn} + ipReputation={ipReputation} + loadingIpRep={loadingIpRep} + insecureProtocols={insecureProtocols} + loadingInsecure={loadingInsecure} + unencryptedPasswords={unencryptedPasswords} + loadingPasswords={loadingPasswords} + encryptionAudit={encryptionAudit} + loadingEncryption={loadingEncryption} + deviceDiscovery={deviceDiscovery} + loadingDevice={loadingDevice} + serverDiscovery={serverDiscovery} + loadingServer={loadingServer} + setSelectedDevice={setSelectedDevice} + /> </CardContent> </Card> diff --git a/src/app/(dashboard)/lookup/page.tsx b/src/app/(dashboard)/lookup/page.tsx index 21f5c9b..eff3067 100644 --- a/src/app/(dashboard)/lookup/page.tsx +++ b/src/app/(dashboard)/lookup/page.tsx @@ -2,37 +2,39 @@ import { useState, useEffect } from "react"; import { Tabs, Tab } from "@/components/ui/Tabs"; -import { HelpTrigger } from "@/components/help/HelpTrigger"; import { ApplicationCatalog } from "@/components/lookup/ApplicationCatalog"; import { BlacklistConfiguration } from "@/components/lookup/BlacklistConfiguration"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; + +import { getSiteBranding } from "@/lib/branding"; export default function LookupPage() { const [mounted, setMounted] = useState(false); + const branding = getSiteBranding(); useEffect(() => { setMounted(true); - }, []); + document.title = `App Lookup | ${branding.name} - Deep Package Inspection`; + }, [branding.name]); if (!mounted) return null; return ( <div className="space-y-6 animate-fade-in pb-10"> - {/* Page Header */} - <div className="flex items-center justify-between gap-4"> + <div className="flex items-center justify-between"> <div> - <h1 className="text-3xl font-bold text-white tracking-tight"> - Application Database + <h1 className="text-3xl font-bold text-white tracking-tight flex items-center gap-3"> + App Lookup </h1> <p className="text-slate-400 mt-2"> - Search application lookup catalog and configure traffic blacklist policy rules. + Search {branding.name === "BackOne" ? "BackOne's" : `${branding.name}'s`} extensive classification database of over 2,500+ apps, protocols, and services, and manage your blacklist configuration. </p> </div> <HelpTrigger pageId="lookup" /> </div> - {/* Tabs Container */} <Tabs> - <Tab label="Application Catalog"> + <Tab label="App Catalog"> <ApplicationCatalog /> </Tab> <Tab label="Blacklist Configuration"> diff --git a/src/app/(dashboard)/network-infrastructure/page.tsx b/src/app/(dashboard)/network-infrastructure/page.tsx index 3154e72..60b0e3e 100644 --- a/src/app/(dashboard)/network-infrastructure/page.tsx +++ b/src/app/(dashboard)/network-infrastructure/page.tsx @@ -1,249 +1,253 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Tabs, Tab } from "@/components/ui/Tabs"; -import { DataTable, Column } from "@/components/ui/DataTable"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; -import { - useRegions, useCities, useVlans, useInterfaces, - useIpVersions, useRemoteIps, useMacBandwidth, - useFlowTypes, useFlowOrigins -} from "@/lib/api-advanced"; -import { IpDetails } from "@/components/ui/IpDetails"; -import { RemoteIpDetailModal } from "@/components/ui/RemoteIpDetailModal"; -import { HelpTrigger } from "@/components/help/HelpTrigger"; - -export default function NetworkInfrastructurePage() { - const [selectedRemoteIp, setSelectedRemoteIp] = useState<string | null>(null); - - // Routing & Geography Tab - const { data: regions, isLoading: loadingRegions } = useRegions(20); - const { data: cities, isLoading: loadingCities } = useCities(20); - - // Interfaces & VLANs Tab - const { data: interfaces, isLoading: loadingInterfaces } = useInterfaces(20); - const { data: vlans, isLoading: loadingVlans } = useVlans(20); - - // IP & MAC Tab - const { data: remoteIps, isLoading: loadingRemoteIps } = useRemoteIps(20); - const { data: ipVersions, isLoading: loadingIpVersions } = useIpVersions(20); - const { data: macBandwidth, isLoading: loadingMacBandwidth } = useMacBandwidth(20); - - // Flow Profiles Tab - const { data: flowTypes, isLoading: loadingFlowTypes } = useFlowTypes(20); - const { data: flowOrigins, isLoading: loadingFlowOrigins } = useFlowOrigins(20); - - const formatBytes = (bytes: number) => { - if (bytes === 0 || !bytes) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; - }; - - return ( - <div className="space-y-6"> - <div className="flex items-center justify-between gap-4"> - <div> - <h1 className="text-3xl font-bold text-white tracking-tight">Network Infrastructure</h1> - <p className="text-slate-400 mt-2">Physical, logical, and geographic network topologies.</p> - </div> - <HelpTrigger pageId="network-infrastructure" /> - </div> - - <Tabs> - <Tab label="Routing & Geography"> - <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> - <Card> - <CardHeader> - <CardTitle>Top Regions</CardTitle> - </CardHeader> - <CardContent> - <DataTable - data={regions} - isLoading={loadingRegions} - columns={[ - { header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> }, - { header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> }, - { header: "Download", accessor: (row) => formatBytes(row.download) }, - ]} - /> - </CardContent> - </Card> - <Card> - <CardHeader> - <CardTitle>Top Cities</CardTitle> - </CardHeader> - <CardContent> - <DataTable - data={cities} - isLoading={loadingCities} - columns={[ - { header: "City", accessor: (row: any) => <span title={row.city_name}>{row.city_name}</span> }, - { header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> }, - { header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> }, - { header: "Download", accessor: (row) => formatBytes(row.download) }, - ]} - /> - </CardContent> - </Card> - </div> - </Tab> - - <Tab label="Interfaces & VLANs"> - <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> - <Card> - <CardHeader> - <CardTitle>Network Interfaces</CardTitle> - </CardHeader> - <CardContent> - <DataTable - data={interfaces} - isLoading={loadingInterfaces} - columns={[ - { header: "Interface", accessor: (row: any) => row.iface_name }, - { header: "Role", accessor: (row: any) => row.iface_role }, - { header: "Download", accessor: (row) => formatBytes(row.download) }, - { header: "Upload", accessor: (row) => formatBytes(row.upload) }, - { header: "Total", accessor: (row) => formatBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - <Card> - <CardHeader> - <CardTitle>VLANs</CardTitle> - </CardHeader> - <CardContent> - <DataTable - data={vlans} - isLoading={loadingVlans} - columns={[ - { header: "VLAN ID", accessor: (row: any) => row.vlan_id }, - { header: "Label", accessor: (row: any) => row.vlan_label }, - { header: "Download", accessor: (row) => formatBytes(row.download) }, - { header: "Upload", accessor: (row) => formatBytes(row.upload) }, - { header: "Total", accessor: (row) => formatBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - </div> - </Tab> - - <Tab label="IP & MAC Properties"> - <div className="grid grid-cols-1 xl:grid-cols-3 gap-6"> - <div className="xl:col-span-2"> - <Card> - <CardHeader> - <CardTitle>Top Remote IPs</CardTitle> - </CardHeader> - <CardContent> - <DataTable - data={remoteIps} - isLoading={loadingRemoteIps} - onRowClick={(row) => setSelectedRemoteIp(row.remote_ip)} - columns={[ - { - header: "IP Address", - accessor: (row: any) => row.remote_ip ? ( - <div className="flex flex-col gap-0.5"> - <span className="font-mono text-xs text-blue-400">{row.remote_ip}</span> - <IpDetails ip={row.remote_ip} /> - </div> - ) : "—" - }, - { header: "IP Version", accessor: (row) => `IPv${row.ip_version}` }, - { header: "Download", accessor: (row) => formatBytes(row.download) }, - { header: "Upload", accessor: (row) => formatBytes(row.upload) }, - { header: "Total", accessor: (row) => formatBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - </div> - <div className="space-y-6"> - <Card> - <CardHeader> - <CardTitle>IP Versions</CardTitle> - </CardHeader> - <CardContent> - <DataTable - data={ipVersions} - isLoading={loadingIpVersions} - columns={[ - { header: "Version", accessor: (row: any) => row.ip_version_label }, - { header: "Total Traffic", accessor: (row) => formatBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - <Card> - <CardHeader> - <CardTitle>MAC Bandwidth</CardTitle> - </CardHeader> - <CardContent> - <DataTable - data={macBandwidth} - isLoading={loadingMacBandwidth} - columns={[ - { header: "MAC Address", accessor: (row: any) => <span title={row.mac_address}>{row.mac_address}</span> }, - { header: "Manufacturer", accessor: (row: any) => <span title={row.manufacturer}>{row.manufacturer}</span> }, - { header: "Total", accessor: (row) => formatBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - </div> - </div> - </Tab> - - <Tab label="Flow Profiles"> - <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> - <Card> - <CardHeader> - <CardTitle>Flow Types</CardTitle> - </CardHeader> - <CardContent> - <DataTable - data={flowTypes} - isLoading={loadingFlowTypes} - columns={[ - { header: "Flow Type", accessor: (row: any) => row.flow_type_label }, - { header: "Download", accessor: (row) => formatBytes(row.download) }, - { header: "Upload", accessor: (row) => formatBytes(row.upload) }, - { header: "Total", accessor: (row) => formatBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - <Card> - <CardHeader> - <CardTitle>Flow Origins</CardTitle> - </CardHeader> - <CardContent> - <DataTable - data={flowOrigins} - isLoading={loadingFlowOrigins} - columns={[ - { header: "Origin", accessor: (row: any) => row.flow_origin_label }, - { header: "Download", accessor: (row) => formatBytes(row.download) }, - { header: "Upload", accessor: (row) => formatBytes(row.upload) }, - { header: "Total", accessor: (row) => formatBytes(row.total) }, - ]} - /> - </CardContent> - </Card> - </div> - </Tab> - </Tabs> - - {selectedRemoteIp && ( - <RemoteIpDetailModal - ip={selectedRemoteIp} - onClose={() => setSelectedRemoteIp(null)} - /> - )} - </div> - ); -} +"use client"; + +import { useState, useEffect } from "react"; +import { Tabs, Tab } from "@/components/ui/Tabs"; +import { DataTable, Column } from "@/components/ui/DataTable"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { + useRegions, useCities, useVlans, useInterfaces, + useIpVersions, useRemoteIps, useMacBandwidth, + useFlowTypes, useFlowOrigins +} from "@/lib/api-advanced"; +import { IpDetails } from "@/components/ui/IpDetails"; +import { RemoteIpDetailModal } from "@/components/ui/RemoteIpDetailModal"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; + +export default function NetworkInfrastructurePage() { + const [selectedRemoteIp, setSelectedRemoteIp] = useState<string | null>(null); + + useEffect(() => { + document.title = "Network Topology | BackOne - Deep Package Inspection"; + }, []); + + // Routing & Geography Tab + const { data: regions, isLoading: loadingRegions } = useRegions(); + const { data: cities, isLoading: loadingCities } = useCities(); + + // Interfaces & VLANs Tab + const { data: interfaces, isLoading: loadingInterfaces } = useInterfaces(); + const { data: vlans, isLoading: loadingVlans } = useVlans(); + + // IP & MAC Tab + const { data: remoteIps, isLoading: loadingRemoteIps } = useRemoteIps(); + const { data: ipVersions, isLoading: loadingIpVersions } = useIpVersions(); + const { data: macBandwidth, isLoading: loadingMacBandwidth } = useMacBandwidth(); + + // Flow Profiles Tab + const { data: flowTypes, isLoading: loadingFlowTypes } = useFlowTypes(); + const { data: flowOrigins, isLoading: loadingFlowOrigins } = useFlowOrigins(); + + const formatBytes = (bytes: number) => { + if (bytes === 0 || !bytes) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + }; + + return ( + <div className="space-y-6"> + <div className="flex items-center justify-between"> + <div> + <h1 className="text-3xl font-bold text-white tracking-tight">Network Topology</h1> + <p className="text-slate-400 mt-2">Physical, logical, and geographic network topologies.</p> + </div> + <HelpTrigger pageId="network-infrastructure" /> + </div> + + <Tabs> + <Tab label="Routing & Geography"> + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> + <Card> + <CardHeader> + <CardTitle>Top Regions</CardTitle> + </CardHeader> + <CardContent> + <DataTable + data={regions} + isLoading={loadingRegions} + columns={[ + { header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> }, + { header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> }, + { header: "Download", accessor: (row) => formatBytes(row.download) }, + ]} + /> + </CardContent> + </Card> + <Card> + <CardHeader> + <CardTitle>Top Cities</CardTitle> + </CardHeader> + <CardContent> + <DataTable + data={cities} + isLoading={loadingCities} + columns={[ + { header: "City", accessor: (row: any) => <span title={row.city_name}>{row.city_name}</span> }, + { header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> }, + { header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> }, + { header: "Download", accessor: (row) => formatBytes(row.download) }, + ]} + /> + </CardContent> + </Card> + </div> + </Tab> + + <Tab label="Interfaces & VLANs"> + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> + <Card> + <CardHeader> + <CardTitle>Network Interfaces</CardTitle> + </CardHeader> + <CardContent> + <DataTable + data={interfaces} + isLoading={loadingInterfaces} + columns={[ + { header: "Interface", accessor: (row: any) => row.iface_name }, + { header: "Role", accessor: (row: any) => row.iface_role }, + { header: "Download", accessor: (row) => formatBytes(row.download) }, + { header: "Upload", accessor: (row) => formatBytes(row.upload) }, + { header: "Total", accessor: (row) => formatBytes(row.total) }, + ]} + /> + </CardContent> + </Card> + <Card> + <CardHeader> + <CardTitle>VLANs</CardTitle> + </CardHeader> + <CardContent> + <DataTable + data={vlans} + isLoading={loadingVlans} + columns={[ + { header: "VLAN ID", accessor: (row: any) => row.vlan_id }, + { header: "Label", accessor: (row: any) => row.vlan_label }, + { header: "Download", accessor: (row) => formatBytes(row.download) }, + { header: "Upload", accessor: (row) => formatBytes(row.upload) }, + { header: "Total", accessor: (row) => formatBytes(row.total) }, + ]} + /> + </CardContent> + </Card> + </div> + </Tab> + + <Tab label="IP & MAC Properties"> + <div className="grid grid-cols-1 xl:grid-cols-3 gap-6"> + <div className="xl:col-span-2"> + <Card> + <CardHeader> + <CardTitle>Top Remote IPs</CardTitle> + </CardHeader> + <CardContent> + <DataTable + data={remoteIps} + isLoading={loadingRemoteIps} + onRowClick={(row) => setSelectedRemoteIp(row.remote_ip)} + columns={[ + { + header: "IP Address", + accessor: (row: any) => row.remote_ip ? ( + <div className="flex flex-col gap-0.5"> + <span className="font-mono text-xs text-blue-400">{row.remote_ip}</span> + <IpDetails ip={row.remote_ip} /> + </div> + ) : "—" + }, + { header: "IP Version", accessor: (row) => `IPv${row.ip_version}` }, + { header: "Download", accessor: (row) => formatBytes(row.download) }, + { header: "Upload", accessor: (row) => formatBytes(row.upload) }, + { header: "Total", accessor: (row) => formatBytes(row.total) }, + ]} + /> + </CardContent> + </Card> + </div> + <div className="space-y-6"> + <Card> + <CardHeader> + <CardTitle>IP Versions</CardTitle> + </CardHeader> + <CardContent> + <DataTable + data={ipVersions} + isLoading={loadingIpVersions} + columns={[ + { header: "Version", accessor: (row: any) => row.ip_version_label }, + { header: "Total Traffic", accessor: (row) => formatBytes(row.total) }, + ]} + /> + </CardContent> + </Card> + <Card> + <CardHeader> + <CardTitle>MAC Bandwidth</CardTitle> + </CardHeader> + <CardContent> + <DataTable + data={macBandwidth} + isLoading={loadingMacBandwidth} + columns={[ + { header: "MAC Address", accessor: (row: any) => <span title={row.mac_address}>{row.mac_address}</span> }, + { header: "Manufacturer", accessor: (row: any) => <span title={row.manufacturer}>{row.manufacturer}</span> }, + { header: "Total", accessor: (row) => formatBytes(row.total) }, + ]} + /> + </CardContent> + </Card> + </div> + </div> + </Tab> + + <Tab label="Flow Profiles"> + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> + <Card> + <CardHeader> + <CardTitle>Flow Types</CardTitle> + </CardHeader> + <CardContent> + <DataTable + data={flowTypes} + isLoading={loadingFlowTypes} + columns={[ + { header: "Flow Type", accessor: (row: any) => row.flow_type_label }, + { header: "Download", accessor: (row) => formatBytes(row.download) }, + { header: "Upload", accessor: (row) => formatBytes(row.upload) }, + { header: "Total", accessor: (row) => formatBytes(row.total) }, + ]} + /> + </CardContent> + </Card> + <Card> + <CardHeader> + <CardTitle>Flow Origins</CardTitle> + </CardHeader> + <CardContent> + <DataTable + data={flowOrigins} + isLoading={loadingFlowOrigins} + columns={[ + { header: "Origin", accessor: (row: any) => row.flow_origin_label }, + { header: "Download", accessor: (row) => formatBytes(row.download) }, + { header: "Upload", accessor: (row) => formatBytes(row.upload) }, + { header: "Total", accessor: (row) => formatBytes(row.total) }, + ]} + /> + </CardContent> + </Card> + </div> + </Tab> + </Tabs> + + {selectedRemoteIp && ( + <RemoteIpDetailModal + ip={selectedRemoteIp} + onClose={() => setSelectedRemoteIp(null)} + /> + )} + </div> + ); +} diff --git a/src/app/(dashboard)/network-intelligence/page.tsx b/src/app/(dashboard)/network-intelligence/page.tsx index 377ba3b..7ddd948 100644 --- a/src/app/(dashboard)/network-intelligence/page.tsx +++ b/src/app/(dashboard)/network-intelligence/page.tsx @@ -31,6 +31,7 @@ export default function NetworkIntelligencePage() { useEffect(() => { setMounted(true); + document.title = "Traffic Categories | BackOne - Deep Package Inspection"; }, []); if (!mounted) return null; @@ -64,26 +65,26 @@ export default function NetworkIntelligencePage() { } const appCols: Column<AppCategoryStat>[] = [ - { header: "#", accessor: (row, i) => i + 1, className: "w-[60px] min-w-[60px] max-w-[60px]" }, + { header: "#", accessor: (row, i) => i + 1, className: "w-[10%] text-center" }, { header: "Category", accessor: (row) => ( - <div title={row.category_label || '-'} className="font-semibold text-white truncate w-full block text-center cursor-help"> + <div title={row.category_label || '-'} className="font-semibold text-white truncate w-full block text-center cursor-help animate-pulse-subtle"> <span className="border-b border-dashed border-white/30 pb-0.5">{row.category_label || '-'}</span> </div> ), - className: "w-[180px] min-w-[180px] max-w-[180px]" + className: "w-[35%] text-center" }, - { header: "Download", accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>, className: "w-[120px] min-w-[120px] max-w-[120px]" }, - { header: "Upload", accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>, className: "w-[120px] min-w-[120px] max-w-[120px]" }, - { header: "Total", accessor: (row) => fmtBytes(row.total), className: "w-[120px] min-w-[120px] max-w-[120px]" }, + { header: "Download", accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>, className: "w-[18%] text-center" }, + { header: "Upload", accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>, className: "w-[18%] text-center" }, + { header: "Total", accessor: (row) => fmtBytes(row.total), className: "w-[19%] text-center" }, ]; const isLoading = appCategories.isLoading || continents.isLoading; return ( <div className="space-y-8 animate-fade-in pb-10"> - <div className="flex items-center justify-between gap-4"> + <div className="flex items-center justify-between"> <div> <h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> Traffic Categories diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index fc5a8c8..98cb8fa 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -75,6 +75,7 @@ export default function SummaryPage() { useEffect(() => { setMounted(true); + document.title = "Overview Dashboard | BackOne - Deep Package Inspection"; }, []); if (!mounted) return null; @@ -83,10 +84,10 @@ export default function SummaryPage() { return ( <div className="space-y-8 animate-fade-in pb-10"> - <div className="flex items-center justify-between gap-4"> + <div className="flex items-center justify-between"> <div> <h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> - Summary Overview + Overview Dashboard {isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />} </h2> <p className="text-muted-foreground mt-1">Real-time network traffic and intelligence summary.</p> diff --git a/src/app/(dashboard)/security-audit/page.tsx b/src/app/(dashboard)/security-audit/page.tsx index 4d68f9f..80e804b 100644 --- a/src/app/(dashboard)/security-audit/page.tsx +++ b/src/app/(dashboard)/security-audit/page.tsx @@ -15,9 +15,9 @@ import { HelpTrigger } from "@/components/help/HelpTrigger"; export default function SecurityAuditPage() { const [mounted, setMounted] = useState(false); - const versions = useTlsVersions(10); - const ciphers = useTlsCiphers(20); - const security = useTlsSecurity(10); + const versions = useTlsVersions(); + const ciphers = useTlsCiphers(); + const security = useTlsSecurity(); const [secDevices, setSecDevices] = useState<SecurityDevice[]>([]); const [secDevicesLoading, setSecDevicesLoading] = useState(true); @@ -54,14 +54,11 @@ export default function SecurityAuditPage() { return ( <div className="space-y-6"> - <div className="flex items-center justify-between gap-4"> - <div> - <h2 className="text-2xl font-bold text-white tracking-tight">Security & Encryption Audit</h2> - <p className="text-sm text-slate-400 mt-1"> - Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles. - </p> - </div> - <HelpTrigger pageId="security-audit" /> + <div> + <h2 className="text-2xl font-bold text-white tracking-tight">Security & Encryption Audit</h2> + <p className="text-sm text-slate-400 mt-1"> + Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles. + </p> </div> <UniversalFilters diff --git a/src/app/(dashboard)/threats/page.tsx b/src/app/(dashboard)/threats/page.tsx index 9686d25..1af8c0f 100644 --- a/src/app/(dashboard)/threats/page.tsx +++ b/src/app/(dashboard)/threats/page.tsx @@ -1,322 +1,8 @@ "use client"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; -import { DataTable, Column } from "@/components/ui/DataTable"; -import { Badge } from "@/components/ui/Badge"; -import { useState, useEffect, useMemo, Suspense } from "react"; -import { TimestampCell } from "@/components/ui/TimestampCell"; -import { ShieldCheck, Loader2 } from "lucide-react"; -import { ThreatStat } from "@/lib/api"; -import { useThreats } from "@/lib/api-with-context"; -import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal"; -import { ThreatFilters } from "@/components/threats/ThreatFilters"; -import { ThreatRecommendationsDrawer } from "@/components/threats/ThreatRecommendationsDrawer"; -import { useSearchParams } from "next/navigation"; -import { formatAppLabel } from "@/lib/utils"; -import { HelpTrigger } from "@/components/help/HelpTrigger"; - -// ─── ThreatsContent ─────────────────────────────────────────────────────────── -function ThreatsContent() { - const [mounted, setMounted] = useState(false); - const [selectedIp, setSelectedIp] = useState<string | null>(null); - const [selectedMac, setSelectedMac] = useState<string | null>(null); - const [selectedThreat, setSelectedThreat] = useState<ThreatStat | null>(null); - - const threats = useThreats(); - const searchParams = useSearchParams(); - const highlightId = searchParams.get("highlight"); - - // Column dropdown filters state - const [fType, setFType] = useState("All"); - const [fSev, setFSev] = useState("All"); - const [fSrcIp, setFSrcIp] = useState("All"); - const [fDstIp, setFDstIp] = useState("All"); - const [fMac, setFMac] = useState("All"); - const [fApp, setFApp] = useState("All"); - const [fDomain, setFDomain] = useState("All"); - - // Date range filter state - const [dateFrom, setDateFrom] = useState(""); - const [dateTo, setDateTo] = useState(""); - - useEffect(() => { - setMounted(true); - }, []); - - const all = threats.data || []; - - // Apply all filters - const filtered = useMemo(() => { - return all.filter((row) => { - if (fType !== "All" && (row.threat_type || "") !== fType) return false; - if (fSev !== "All" && (row.severity || "") !== fSev) return false; - if (fSrcIp !== "All" && (row.ip_address || "") !== fSrcIp) return false; - if (fDstIp !== "All" && (row.dst_ip || "") !== fDstIp) return false; - if (fMac !== "All" && (row.mac_address || "") !== fMac) return false; - if (fApp !== "All" && (row.app_label || "") !== fApp) return false; - if (fDomain !== "All" && (row.domain || "") !== fDomain) return false; - if (dateFrom || dateTo) { - const d = row.detected_at - ? new Date(row.detected_at).toISOString().slice(0, 10) - : ""; - if (dateFrom && d < dateFrom) return false; - if (dateTo && d > dateTo) return false; - } - return true; - }); - }, [all, fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain, dateFrom, dateTo]); - - // Automatically select a threat if highlightId is passed - useEffect(() => { - if (highlightId && all.length > 0) { - const match = all.find(r => r.id.toString() === highlightId); - if (match) { - setSelectedThreat(match); - } - } - }, [highlightId, all]); - - if (!mounted) return null; - - const getSeverityBadge = (sev?: string | null) => { - if (!sev) return <Badge variant="green">Unknown</Badge>; - const s = sev.toLowerCase(); - if (s.includes("high") || s.includes("critical")) return <Badge variant="red">{sev}</Badge>; - if (s.includes("med")) return <Badge variant="orange">{sev}</Badge>; - if (s.includes("low")) return <Badge variant="yellow">{sev}</Badge>; - return <Badge variant="blue">{sev}</Badge>; - }; - - const columns: Column<ThreatStat>[] = [ - { - header: "Timestamp", - className: "w-[155px] min-w-[155px] max-w-[155px] text-center", - accessor: (row) => <TimestampCell timestamp={row.detected_at} showActiveStatus={false} /> - }, - { header: "#", className: "w-[40px] min-w-[40px] max-w-[40px] text-center", accessor: (_row, i) => i + 1 }, - { - header: "Type", - className: "w-[170px] min-w-[170px] max-w-[170px]", - accessor: (row) => ( - <div - title={row.description || row.threat_type || "Unknown"} - className="truncate w-full block cursor-help text-xs leading-tight" - > - {row.threat_type || "Unknown"} - </div> - ), - }, - { - header: "Severity", - className: "w-[90px] min-w-[90px] max-w-[90px] text-center", - accessor: (row) => getSeverityBadge(row.severity), - }, - { - header: "Source IP", - className: "w-[120px] min-w-[120px] max-w-[120px] text-center", - accessor: (row) => - row.ip_address ? ( - <button - onClick={() => setSelectedIp(row.ip_address)} - className="font-medium text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none w-full text-center" - > - {row.ip_address} - </button> - ) : ( - <span className="text-muted-foreground text-xs" title="Source IP tidak tersedia dari data event keamanan (hanya MAC Address yang ter-capture)">–</span> - ), - }, - { - header: "Dest IP", - className: "w-[120px] min-w-[120px] max-w-[120px] text-center", - accessor: (row) => - row.dst_ip ? ( - <button - onClick={() => setSelectedIp(row.dst_ip)} - className="font-mono text-xs text-blue-400 hover:text-blue-300 hover:underline focus:outline-none w-full text-center" - > - {row.dst_ip} - </button> - ) : ( - <span className="text-muted-foreground text-xs" title="Dest IP tidak tersedia dari data event ini">–</span> - ), - }, - { - header: "MAC Addr", - className: "w-[140px] min-w-[140px] max-w-[140px] text-center", - accessor: (row) => - row.mac_address ? ( - <button - onClick={() => setSelectedMac(row.mac_address)} - className="text-xs bg-secondary/50 px-1.5 py-0.5 rounded text-muted-foreground hover:text-white hover:bg-secondary transition-colors font-mono focus:outline-none w-full text-center truncate" - title={row.mac_address} - > - {row.mac_address} - </button> - ) : "–", - }, - { - header: "App", - className: "w-[110px] min-w-[110px] max-w-[110px] text-center", - accessor: (row) => { - const formatted = formatAppLabel(row.app_label); - return formatted ? ( - <div title={formatted} className="truncate w-full block text-center cursor-help text-xs"> - {formatted} - </div> - ) : ( - <span className="text-muted-foreground text-xs" title="App tidak tersedia dari event ini">–</span> - ); - }, - }, - { - header: "Domain", - className: "w-[160px] min-w-[160px] max-w-[160px] text-center", - accessor: (row) => row.domain ? ( - <div title={row.domain} className="truncate w-full text-center text-xs cursor-help"> - {row.domain} - </div> - ) : ( - <span className="text-muted-foreground text-xs" title="Domain tidak tersedia dari event ini">–</span> - ), - }, - { - header: "Action", - className: "w-[130px] min-w-[130px] max-w-[130px] text-center", - accessor: (row) => ( - <button - onClick={() => setSelectedThreat(row)} - className="px-2 py-1 text-[11px] font-bold text-primary bg-primary/10 border border-primary/20 hover:bg-primary/20 hover:text-white rounded-lg transition-all shadow-sm cursor-pointer whitespace-nowrap focus:outline-none" - > - View Mitigation - </button> - ), - }, - ]; - - - const activeFilterCount = - [fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain].filter((v) => v !== "All").length + - (dateFrom || dateTo ? 1 : 0); - - const resetFilters = () => { - setFType("All"); - setFSev("All"); - setFSrcIp("All"); - setFDstIp("All"); - setFMac("All"); - setFApp("All"); - setFDomain("All"); - setDateFrom(""); - setDateTo(""); - }; - - return ( - <div className="space-y-8 animate-fade-in pb-10"> - <div className="flex items-center justify-between gap-4"> - <div> - <h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> - Threat Intelligence - {threats.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />} - </h2> - <p className="text-muted-foreground mt-1">Monitor and respond to network security threats.</p> - </div> - <div className="flex items-center gap-3"> - <HelpTrigger pageId="intelligence" /> - <div className="flex items-center gap-2 text-sm font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-4 py-2 rounded-lg shadow-[0_0_15px_rgba(16,185,129,0.1)]"> - <ShieldCheck className="h-4 w-4" /> - <span>Active Monitoring</span> - </div> - </div> - </div> - - {/* Filters above the table */} - <ThreatFilters - all={all} - fType={fType} - setFType={setFType} - fSev={fSev} - setFSev={setFSev} - fSrcIp={fSrcIp} - setFSrcIp={setFSrcIp} - fDstIp={fDstIp} - setFDstIp={setFDstIp} - fMac={fMac} - setFMac={setFMac} - fApp={fApp} - setFApp={setFApp} - fDomain={fDomain} - setFDomain={setFDomain} - dateFrom={dateFrom} - setDateFrom={setDateFrom} - dateTo={dateTo} - setDateTo={setDateTo} - activeFilterCount={activeFilterCount} - onReset={resetFilters} - /> - - {/* Threats Table - Full Width */} - <Card - className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up relative z-10 overflow-visible" - style={{ animationDelay: "100ms" }} - > - <CardHeader className="border-b border-border/50 pb-4 mb-4"> - <CardTitle className="text-lg text-white"> - Detected Threats - <span className="ml-2 text-muted-foreground font-normal"> - ({filtered.length} - {filtered.length !== all.length ? ` of ${all.length}` : ""}) - </span> - </CardTitle> - </CardHeader> - <CardContent> - <DataTable - data={filtered} - columns={columns} - searchPlaceholder="Search threats by IP, domain, type..." - searchFilter={(row, query) => { - const q = query.toLowerCase(); - return ( - (row.ip_address || "").toLowerCase().includes(q) || - (row.domain || "").toLowerCase().includes(q) || - (row.threat_type || "").toLowerCase().includes(q) || - (row.app_label || "").toLowerCase().includes(q) - ); - }} - getRowClassName={(row) => - highlightId && row.id.toString() === highlightId - ? "bg-red-500/15 border-l-2 border-red-500 animate-pulse font-semibold" - : "" - } - csvExport={{ - filename: `threats-${new Date().toISOString().slice(0, 10)}.csv`, - headers: ["Timestamp", "Type", "Severity", "Source IP", "Dest IP", "MAC Address", "App", "Domain"], - rowSerializer: (row) => [ - row.detected_at ? new Date(row.detected_at).toISOString() : "", - row.threat_type || "", - row.severity || "", - row.ip_address || "", - row.dst_ip || "", - row.mac_address || "", - row.app_label || "", - row.domain || "", - ], - }} - /> - </CardContent> - </Card> - - {selectedIp && <DeviceDetailModal ip={selectedIp} onClose={() => setSelectedIp(null)} />} - {selectedMac && <DeviceDetailModal ip="" mac={selectedMac} onClose={() => setSelectedMac(null)} />} - - {/* Mitigation recommendations drawer panel */} - <ThreatRecommendationsDrawer - threat={selectedThreat} - onClose={() => setSelectedThreat(null)} - /> - </div> - ); -} +import { Suspense } from "react"; +import { Loader2 } from "lucide-react"; +import ThreatsContent from "@/components/threats/ThreatsContent"; export default function ThreatsPage() { return ( diff --git a/src/app/(dashboard)/user-accounts/CompanyCard.tsx b/src/app/(dashboard)/user-accounts/CompanyCard.tsx new file mode 100644 index 0000000..43c1574 --- /dev/null +++ b/src/app/(dashboard)/user-accounts/CompanyCard.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Users, Plus, ShieldAlert } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { DataTable } from "@/components/ui/DataTable"; +import { type ManagedUser } from "@/lib/admin-api"; +import { Column } from "@/components/ui/DataTable"; + +export interface CompanyGroup { + name: string; + users: ManagedUser[]; +} + +interface CompanyCardProps { + company: CompanyGroup; + role: string | null; + columns: Column<ManagedUser>[]; + onAddUserClick: (companyName: string) => void; +} + +export default function CompanyCard({ + company, + role, + columns, + onAddUserClick, +}: CompanyCardProps) { + const count = company.users.length; + const isLimitReached = count >= 5; + + return ( + <Card className="bg-card/50 backdrop-blur-sm border-border/50 overflow-hidden shadow-xl shadow-black/10"> + <CardHeader className="flex flex-row items-center justify-between pb-3 border-b border-white/5 bg-white/[0.01]"> + <div className="flex items-center gap-3"> + <div className="p-2 bg-amber-500/10 border border-amber-500/20 rounded-lg shadow-sm"> + <Users className="w-4 h-4 text-amber-400" /> + </div> + <div> + <CardTitle className="text-white text-base font-bold">{company.name} Accounts</CardTitle> + <div className="flex items-center gap-2 mt-1"> + <span className={`text-xs px-2.5 py-0.5 rounded-full font-semibold border ${ + isLimitReached + ? "bg-red-500/10 text-red-400 border-red-500/20" + : "bg-slate-500/10 text-slate-400 border-slate-500/20" + }`}> + Accounts: {count} / 5 + </span> + {isLimitReached && ( + <span className="text-[10px] text-red-400 flex items-center gap-1"> + <ShieldAlert className="w-3.5 h-3.5" /> Max limit reached + </span> + )} + </div> + </div> + </div> + + {role !== "EXECUTIVE" && ( + <button + type="button" + onClick={() => onAddUserClick(company.name)} + disabled={isLimitReached} + className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg transition-all border cursor-pointer ${ + isLimitReached + ? "bg-slate-800 text-slate-500 border-slate-700 cursor-not-allowed" + : "bg-amber-600/10 text-amber-400 hover:bg-amber-600/20 border-amber-500/30 hover:border-amber-500/50 shadow-md shadow-amber-600/5" + }`} + > + <Plus className="w-3.5 h-3.5" /> + Add User for {company.name} + </button> + )} + </CardHeader> + <CardContent className="pt-6"> + <DataTable + data={company.users} + columns={columns} + searchPlaceholder={`Search users in ${company.name}...`} + searchFilter={(row, query) => { + const q = query.toLowerCase(); + return ( + (row.username || "").toLowerCase().includes(q) || + (row.account_name || "").toLowerCase().includes(q) || + (row.role || "").toLowerCase().includes(q) + ); + }} + /> + </CardContent> + </Card> + ); +} diff --git a/src/app/(dashboard)/user-accounts/columns.tsx b/src/app/(dashboard)/user-accounts/columns.tsx new file mode 100644 index 0000000..56dccb7 --- /dev/null +++ b/src/app/(dashboard)/user-accounts/columns.tsx @@ -0,0 +1,128 @@ +// src/app/(dashboard)/user-accounts/columns.tsx +"use client"; + +import { Column } from "@/components/ui/DataTable"; +import { type ManagedUser } from "@/lib/admin-api"; +import { Eye, Loader2 } from "lucide-react"; + +interface GetColumnsProps { + role: string | null; + currentUsername?: string | null; + viewAsLoading?: string | null; + onEditClick: (user: ManagedUser) => void; + onViewAsClick?: (user: ManagedUser) => void; +} + +export function getColumns({ + role, + currentUsername, + viewAsLoading, + onEditClick, + onViewAsClick, +}: GetColumnsProps): Column<ManagedUser>[] { + return [ + { + header: "#", + className: "w-[5%] text-center", + accessor: (_, i) => <span className="text-slate-400 font-mono">{i + 1}</span> + }, + { + header: "Account Name", + className: "w-[22%] text-center", + accessor: (row) => ( + <span className="text-sm font-medium text-slate-200"> + {row.account_name || row.username} + </span> + ) + }, + { + header: "Username", + className: "w-[18%] text-center", + accessor: (row) => <code className="text-xs text-slate-400 font-mono">{row.username}</code> + }, + { + header: "Role", + className: "w-[18%] text-center", + accessor: (row) => { + let badgeColor = "bg-slate-500/20 text-slate-400 border-slate-500/30"; + if (row.role === "COMPANY_ADMIN") badgeColor = "bg-purple-500/20 text-purple-400 border-purple-500/30"; + if (row.role === "COMPANY_OPERATOR") badgeColor = "bg-amber-500/20 text-amber-400 border-amber-500/30"; + if (row.role === "COMPANY_VIEWER") badgeColor = "bg-blue-500/20 text-blue-400 border-blue-500/30"; + + return ( + <span className={`px-2.5 py-1 rounded-full text-[10px] font-bold border uppercase tracking-wider ${badgeColor}`}> + {row.role.replace(/_/g, " ")} + </span> + ); + } + }, + { + header: "Assigned Devices", + className: "w-[15%] text-center", + accessor: (row) => { + const uuids = row.agent_uuids || []; + if (uuids.length === 0) return <span className="text-xs text-slate-500 italic">No device assigned</span>; + return ( + <span className="text-xs font-mono text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 px-2 py-0.5 rounded"> + {uuids.length} Device{uuids.length > 1 ? "s" : ""} + </span> + ); + } + }, + { + header: "View As", + className: "w-[10%] text-center", + accessor: (row) => { + const isSelf = currentUsername && row.username === currentUsername; + const isCompanyAdmin = row.role === "COMPANY_ADMIN"; + // View-As tersedia untuk COMPANY_OPERATOR dan COMPANY_VIEWER (bukan diri sendiri, bukan admin) + const canViewAs = onViewAsClick && + !isSelf && + !isCompanyAdmin && + (row.role === "COMPANY_OPERATOR" || row.role === "COMPANY_VIEWER") && + (role === "SUPER_ADMIN" || role === "COMPANY_ADMIN"); + + if (!canViewAs) { + return <span className="text-slate-600 text-xs">—</span>; + } + + const isLoading = viewAsLoading === row.username; + return ( + <button + type="button" + onClick={() => onViewAsClick(row)} + disabled={isLoading} + title={`View dashboard as ${row.account_name || row.username}`} + className="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] font-semibold text-amber-400 bg-amber-400/10 border border-amber-400/20 hover:bg-amber-400/20 hover:border-amber-400/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" + > + {isLoading ? ( + <Loader2 className="w-3 h-3 animate-spin" /> + ) : ( + <Eye className="w-3 h-3" /> + )} + View As + </button> + ); + } + }, + { + header: "Actions", + className: "w-[12%] text-center", + accessor: (row) => { + const isSelf = currentUsername && row.username === currentUsername; + const isDisabled = role === "EXECUTIVE" || isSelf; + return ( + <button + type="button" + onClick={() => !isSelf && onEditClick(row)} + disabled={!!isDisabled} + title={isSelf ? "Cannot edit your own account from here" : undefined} + className="text-xs text-blue-400 hover:text-blue-300 disabled:opacity-50 disabled:cursor-not-allowed hover:underline font-medium cursor-pointer" + > + {isSelf ? "👤 (You)" : "⚙️ Edit User"} + </button> + ); + } + } + ]; +} diff --git a/src/app/(dashboard)/user-accounts/page.tsx b/src/app/(dashboard)/user-accounts/page.tsx new file mode 100644 index 0000000..1e0cd97 --- /dev/null +++ b/src/app/(dashboard)/user-accounts/page.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Users, Plus } from "lucide-react"; +import { getAdminUsers, getViewAsHeaders, startViewAsUser, type ManagedUser } from "@/lib/admin-api"; + +import { HelpTrigger } from "@/components/help/HelpTrigger"; +import ExternalAccountModal from "@/components/admin/ExternalAccountModal"; +import { getColumns } from "./columns"; +import CompanyCard, { CompanyGroup } from "./CompanyCard"; + +export default function UserAccountsPage() { + const router = useRouter(); + const [mounted, setMounted] = useState(false); + const [role, setRole] = useState<string | null>(null); + const [currentUsername, setCurrentUsername] = useState<string | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const [managedUsers, setManagedUsers] = useState<ManagedUser[]>([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalUser, setModalUser] = useState<ManagedUser | null>(null); + const [targetCompany, setTargetCompany] = useState<string | null>(null); + const [viewAsLoading, setViewAsLoading] = useState<string | null>(null); + // Registry agents untuk lookup label agent (digunakan pada View-As banner) + const [agentRegistry, setAgentRegistry] = useState<Record<string, string>>({}); + + + const loadData = async () => { + setIsLoading(true); + setError(null); + try { + const users = await getAdminUsers(); + setManagedUsers(users); + } catch (err: any) { + setError(err.message || "Failed to retrieve user data."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + setMounted(true); + document.title = "User Accounts | BackOne - Deep Package Inspection"; + + fetch("/api/auth/me", { headers: getViewAsHeaders() }) + .then(res => res.json()) + .then(data => { + if (data.user) { + const userRole = data.user.role || null; + setRole(userRole); + setCurrentUsername(data.user.username || null); + if (userRole !== "SUPER_ADMIN" && userRole !== "EXECUTIVE" && userRole !== "COMPANY_ADMIN") { + router.push("/"); + } else { + loadData(); + // Juga load agent registry untuk label lookup (digunakan pada banner View-As) + fetch("/api/dashboard/agents/list", { headers: getViewAsHeaders() }) + .then(r => r.json()) + .then(json => { + if (json.ok && Array.isArray(json.data)) { + const map: Record<string, string> = {}; + json.data.forEach((a: any) => { + if (a.uuid) map[a.uuid] = a.label || a.uuid; + }); + setAgentRegistry(map); + } + }) + .catch(() => {}); + } + } else { + setIsLoading(false); + } + }) + .catch(() => { + setIsLoading(false); + }); + }, [router]); + + + if (!mounted) return null; + + // User Accounts = HANYA akun customer/client (COMPANY_* roles) + // Role teknikal/operasional (SOC_ANALYST, EXECUTIVE, ENGINEER, dll) ada di halaman Agents + const COMPANY_ROLES = ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER']; + const companyUsers = managedUsers.filter(u => COMPANY_ROLES.includes(u.role || '') && u.company_name); + + const companiesMap: Record<string, ManagedUser[]> = {}; + companyUsers.forEach(u => { + if (u.company_name) { + if (!companiesMap[u.company_name]) { + companiesMap[u.company_name] = []; + } + companiesMap[u.company_name].push(u); + } + }); + + const companies: CompanyGroup[] = Object.entries(companiesMap).map(([name, users]) => ({ + name, + users + })); + + const handleEditClick = (user: ManagedUser) => { + setModalUser(user); + setTargetCompany(user.company_name || null); + setIsModalOpen(true); + }; + + const handleAddUserClick = (companyName: string) => { + setModalUser(null); + setTargetCompany(companyName); + setIsModalOpen(true); + }; + + const handleViewAsUser = async (user: ManagedUser) => { + // View-As berdasarkan agent_uuids pertama yang di-assign ke user target + const agentUuids = user.agent_uuids || []; + if (agentUuids.length === 0) { + alert(`User "${user.account_name || user.username}" has no assigned agents. Cannot enter View-As mode.`); + return; + } + const targetAgentUuid = agentUuids[0]; + // Gunakan label agent dari registry (bukan UUID) untuk banner yang informatif + const agentLabel = agentRegistry[targetAgentUuid] || targetAgentUuid; + const userName = user.account_name || user.username; + const userRole = user.role; + setViewAsLoading(user.username); + try { + // startViewAsUser menyimpan: agent_label (label agent), user_name, user_role, view_as_type='user' + await startViewAsUser(targetAgentUuid, agentLabel, userName, userRole); + router.refresh(); + window.location.reload(); + } catch (err: any) { + alert('Failed to enter View As mode: ' + err.message); + } finally { + setViewAsLoading(null); + } + }; + + + const columns = getColumns({ + role, + currentUsername, + viewAsLoading, + onEditClick: handleEditClick, + onViewAsClick: (role === "SUPER_ADMIN" || role === "COMPANY_ADMIN") ? handleViewAsUser : undefined, + }); + + return ( + <div className="space-y-6"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-semibold tracking-tight text-white flex items-center gap-3"> + User Accounts Directory + {isLoading && <Loader2 className="w-5 h-5 animate-spin text-amber-500" />} + </h2> + <p className="text-sm text-muted-foreground mt-1"> + Manage company tenant user access, roles, and device ownership delegation. + </p> + </div> + <HelpTrigger pageId="user-accounts" /> + </div> + + {error && ( + <div className="bg-red-500/15 border border-red-500/30 text-red-400 px-4 py-3 rounded-xl text-sm font-medium"> + {error} + </div> + )} + + {isLoading ? ( + <div className="flex items-center justify-center py-24"> + <Loader2 className="w-10 h-10 animate-spin text-amber-500" /> + </div> + ) : ( + <div className="space-y-8"> + {companies.length === 0 ? ( + <div className="max-w-md mx-auto py-12 px-6"> + <div className="relative overflow-hidden bg-white/[0.02] backdrop-blur-md border border-white/10 rounded-3xl p-8 text-center shadow-2xl shadow-black/50"> + {/* Ambient glow highlight */} + <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-48 h-48 bg-amber-500/5 rounded-full blur-3xl pointer-events-none" /> + + <div className="relative w-16 h-16 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-5 text-amber-400 shadow-lg shadow-amber-500/5 animate-pulse-subtle"> + <Users className="w-8 h-8" /> + </div> + + <h3 className="relative text-white font-bold text-lg mb-1.5">No Company Accounts</h3> + <p className="relative text-sm text-slate-400 mb-6 max-w-xs mx-auto"> + Create the first company tenant admin account to get started. + </p> + + {role === "SUPER_ADMIN" && ( + <button + type="button" + onClick={() => handleAddUserClick("")} + className="relative inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 text-white rounded-xl text-xs font-semibold shadow-md shadow-amber-600/10 hover:shadow-lg transition-all cursor-pointer" + > + <Plus className="w-4 h-4" /> + Add Company Admin + </button> + )} + </div> + </div> + ) : ( + companies.map((company) => ( + <CompanyCard + key={company.name} + company={company} + role={role} + columns={columns} + onAddUserClick={handleAddUserClick} + /> + )) + )} + + {role === "SUPER_ADMIN" && companies.length > 0 && ( + <div className="flex justify-end pt-2"> + <button + type="button" + onClick={() => handleAddUserClick("")} + className="flex items-center gap-2 px-5 py-2.5 text-xs font-semibold bg-amber-600 hover:bg-amber-700 text-white rounded-xl transition-all shadow-md shadow-amber-600/10 hover:shadow-lg cursor-pointer" + > + <Plus className="w-4 h-4" /> + Add New Company Admin + </button> + </div> + )} + </div> + )} + + {/* Account Creation / Edit Modal */} + <ExternalAccountModal + isOpen={isModalOpen} + onClose={() => { + setIsModalOpen(false); + setModalUser(null); + setTargetCompany(null); + }} + onSuccess={() => { + setIsModalOpen(false); + setModalUser(null); + setTargetCompany(null); + loadData(); + }} + user={modalUser} + targetCompany={targetCompany} + targetSiteUuid={null} + targetCreatedBy={role === "SUPER_ADMIN" ? "admin" : undefined} + /> + </div> + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index ea0af4e..ff2005f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,419 +1,31 @@ @import "tailwindcss"; +@import "./theme.css"; +@import "./variables.css"; -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - - --font-backone: var(--font-backone); - - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - - --color-border: var(--border); - - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - - --color-sidebar: var(--sidebar); - - /* OVERRIDE TAILWIND HARDCODED COLORS FOR SEAMLESS LIGHT MODE */ - --color-slate-900: var(--slate-900); - --color-slate-800: var(--slate-800); - --color-slate-700: var(--slate-700); - --color-slate-600: var(--slate-600); - --color-slate-500: var(--slate-500); - --color-slate-400: var(--slate-400); - --color-slate-300: var(--slate-300); - --color-slate-200: var(--slate-200); - --color-slate-100: var(--slate-100); - --color-slate-50: var(--slate-50); - - --color-gray-900: var(--slate-900); - --color-gray-800: var(--slate-800); - --color-gray-700: var(--slate-700); - --color-gray-600: var(--slate-600); - --color-gray-500: var(--slate-500); - --color-gray-400: var(--slate-400); - --color-gray-300: var(--slate-300); - --color-gray-200: var(--slate-200); - --color-gray-100: var(--slate-100); - --color-gray-50: var(--slate-50); - - --color-white: var(--t-white); - --color-black: var(--t-black); - - --font-sans: var(--font-inter); - --font-mono: var(--font-inter); - - /* Micro-animations */ - --animate-fade-in: fade-in 0.5s ease-out forwards; - --animate-slide-up: slide-up 0.5s ease-out forwards; - - @keyframes fade-in { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } - } - - @keyframes slide-up { - 0% { - opacity: 0; - transform: translateY(20px); - } - - 100% { - opacity: 1; - transform: translateY(0); - } - } -} - -:root { - /* Premium Dark Theme */ - --background: #09090b; - /* Very dark zinc */ - --foreground: #fafafa; - - --card: rgba(24, 24, 27, 0.7); - /* Zinc 900 with transparency for glassmorphism */ - --card-foreground: #fafafa; - - --border: rgba(255, 255, 255, 0.1); - - --primary: #3b82f6; - /* Blue 500 */ - --primary-foreground: #ffffff; - - --secondary: rgba(39, 39, 42, 0.8); - /* Zinc 800 */ - --secondary-foreground: #fafafa; - - --muted: rgba(39, 39, 42, 0.5); - --muted-foreground: #a1a1aa; - /* Zinc 400 */ - - --destructive: #ef4444; - /* Red 500 */ - --destructive-foreground: #ffffff; - - --sidebar: #09090b; - - /* Overrides for hardcoded colors in Dark Mode (Normal) */ - --t-white: #ffffff; - --t-black: #000000; - --slate-900: #0f172a; - --slate-800: #1e293b; - --slate-700: #334155; - --slate-600: #475569; - --slate-500: #64748b; - --slate-400: #94a3b8; - --slate-300: #cbd5e1; - --slate-200: #e2e8f0; - --slate-100: #f1f5f9; - --slate-50: #f8fafc; -} - -.light { - /* Clean Light Theme */ - --background: #f1f5f9; - /* Slate 100 */ - --foreground: #0f172a; - - --card: rgba(255, 255, 255, 0.9); - --card-foreground: #0f172a; - - --border: rgba(0, 0, 0, 0.1); - - --primary: #2563eb; - /* Blue 600 */ - --primary-foreground: #ffffff; - - --secondary: rgba(241, 245, 249, 0.8); - /* Slate 100 */ - --secondary-foreground: #0f172a; - - --muted: rgba(226, 232, 240, 0.5); - /* Slate 200 */ - --muted-foreground: #64748b; - /* Slate 500 */ - - --sidebar: #ffffff; - - /* Overrides for hardcoded colors in Light Mode (Inverted) */ - --t-white: #0f172a; - /* Make text-white become very dark slate */ - --t-black: #ffffff; - --slate-900: #ffffff; - --slate-800: #f8fafc; - --slate-700: #f1f5f9; - --slate-600: #e2e8f0; - --slate-500: #cbd5e1; - --slate-400: #64748b; - /* Keep 400 slightly dark so it stays readable */ - --slate-300: #475569; - --slate-200: #334155; - --slate-100: #1e293b; - --slate-50: #0f172a; - - /* Enhance contrast for colored text and backgrounds in Light Mode */ - - /* RED */ - --color-red-300: #b91c1c; - /* red-700 */ - --color-red-400: #dc2626; - /* red-600 */ - --color-red-500: #b91c1c; - /* red-700 */ - --color-red-950: #ef4444; - /* red-500 (so that 950/20 becomes a visible pink bg) */ - - /* ORANGE */ - --color-orange-300: #c2410c; - /* orange-700 */ - --color-orange-400: #ea580c; - /* orange-600 */ - --color-orange-500: #c2410c; - /* orange-700 */ - --color-orange-950: #f97316; - /* orange-500 */ - - /* YELLOW */ - --color-yellow-300: #a16207; - /* yellow-700 */ - --color-yellow-400: #ca8a04; - /* yellow-600 */ - --color-yellow-500: #a16207; - /* yellow-700 */ - --color-yellow-950: #eab308; - /* yellow-500 */ - - /* GREEN */ - --color-green-300: #15803d; - /* green-700 */ - --color-green-400: #16a34a; - /* green-600 */ - --color-green-500: #15803d; - /* green-700 */ - --color-green-950: #22c55e; - /* green-500 */ - - /* EMERALD */ - --color-emerald-300: #047857; - /* emerald-700 */ - --color-emerald-400: #059669; - /* emerald-600 */ - --color-emerald-500: #047857; - /* emerald-700 */ - --color-emerald-950: #10b981; - /* emerald-500 */ - - /* BLUE */ - --color-blue-300: #1d4ed8; - /* blue-700 */ - --color-blue-400: #2563eb; - /* blue-600 */ - --color-blue-500: #1d4ed8; - /* blue-700 */ - --color-blue-950: #3b82f6; - /* blue-500 */ - - /* PURPLE */ - --color-purple-300: #7e22ce; - /* purple-700 */ - --color-purple-400: #9333ea; - /* purple-600 */ - --color-purple-500: #7e22ce; - /* purple-700 */ - --color-purple-950: #a855f7; - /* purple-500 */ +/* ── Zero Horizontal Scrollbar – Global Enforcement ── */ +/* Per AGENTS.md §9: horizontal scrollbar strictly prohibited at any level. */ +html { + overflow-x: hidden; } body { - font-family: var(--font-sans), sans-serif; + overflow-x: hidden; + width: 100%; +} + +body { + font-family: var(--font-sans); color: var(--foreground); + /* Optical sizing + smoothing */ + font-optical-sizing: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; } /* 1-Second Dual-Layer Smooth Background Crossfade */ -:root { - /* Premium Dark Theme */ - --background: #09090b; - /* Very dark zinc */ - --foreground: #fafafa; - - --card: rgba(24, 24, 27, 0.7); - /* Zinc 900 with transparency for glassmorphism */ - --card-foreground: #fafafa; - - --border: rgba(255, 255, 255, 0.1); - - --primary: #3b82f6; - /* Blue 500 */ - --primary-foreground: #ffffff; - - --secondary: rgba(39, 39, 42, 0.8); - /* Zinc 800 */ - --secondary-foreground: #fafafa; - - --muted: rgba(39, 39, 42, 0.5); - --muted-foreground: #a1a1aa; - /* Zinc 400 */ - - --destructive: #ef4444; - /* Red 500 */ - --destructive-foreground: #ffffff; - - --sidebar: #09090b; - - /* Overrides for hardcoded colors in Dark Mode (Normal) */ - --t-white: #ffffff; - --t-black: #000000; - --slate-900: #0f172a; - --slate-800: #1e293b; - --slate-700: #334155; - --slate-600: #475569; - --slate-500: #64748b; - --slate-400: #94a3b8; - --slate-300: #cbd5e1; - --slate-200: #e2e8f0; - --slate-100: #f1f5f9; - --slate-50: #f8fafc; -} - -.light { - /* Clean Light Theme */ - --background: #f1f5f9; - /* Slate 100 */ - --foreground: #0f172a; - - --card: rgba(255, 255, 255, 0.9); - --card-foreground: #0f172a; - - --border: rgba(0, 0, 0, 0.1); - - --primary: #2563eb; - /* Blue 600 */ - --primary-foreground: #ffffff; - - --secondary: rgba(241, 245, 249, 0.8); - /* Slate 100 */ - --secondary-foreground: #0f172a; - - --muted: rgba(226, 232, 240, 0.5); - /* Slate 200 */ - --muted-foreground: #64748b; - /* Slate 500 */ - - --sidebar: #ffffff; - - /* Overrides for hardcoded colors in Light Mode (Inverted) */ - --t-white: #0f172a; - /* Make text-white become very dark slate */ - --t-black: #ffffff; - --slate-900: #ffffff; - --slate-800: #f8fafc; - --slate-700: #f1f5f9; - --slate-600: #e2e8f0; - --slate-500: #cbd5e1; - --slate-400: #64748b; - /* Keep 400 slightly dark so it stays readable */ - --slate-300: #475569; - --slate-200: #334155; - --slate-100: #1e293b; - --slate-50: #0f172a; - - /* Enhance contrast for colored text and backgrounds in Light Mode */ - - /* RED */ - --color-red-300: #b91c1c; - /* red-700 */ - --color-red-400: #dc2626; - /* red-600 */ - --color-red-500: #b91c1c; - /* red-700 */ - --color-red-950: #ef4444; - /* red-500 (so that 950/20 becomes a visible pink bg) */ - - /* ORANGE */ - --color-orange-300: #c2410c; - /* orange-700 */ - --color-orange-400: #ea580c; - /* orange-600 */ - --color-orange-500: #c2410c; - /* orange-700 */ - --color-orange-950: #f97316; - /* orange-500 */ - - /* YELLOW */ - --color-yellow-300: #a16207; - /* yellow-700 */ - --color-yellow-400: #ca8a04; - /* yellow-600 */ - --color-yellow-500: #a16207; - /* yellow-700 */ - --color-yellow-950: #eab308; - /* yellow-500 */ - - /* GREEN */ - --color-green-300: #15803d; - /* green-700 */ - --color-green-400: #16a34a; - /* green-600 */ - --color-green-500: #15803d; - /* green-700 */ - --color-green-950: #22c55e; - /* green-500 */ - - /* EMERALD */ - --color-emerald-300: #047857; - /* emerald-700 */ - --color-emerald-400: #059669; - /* emerald-600 */ - --color-emerald-500: #047857; - /* emerald-700 */ - --color-emerald-950: #10b981; - /* emerald-500 */ - - /* BLUE */ - --color-blue-300: #1d4ed8; - /* blue-700 */ - --color-blue-400: #2563eb; - /* blue-600 */ - --color-blue-500: #1d4ed8; - /* blue-700 */ - --color-blue-950: #3b82f6; - /* blue-500 */ - - /* PURPLE */ - --color-purple-300: #7e22ce; - /* purple-700 */ - --color-purple-400: #9333ea; - /* purple-600 */ - --color-purple-500: #7e22ce; - /* purple-700 */ - --color-purple-950: #a855f7; - /* purple-500 */ -} - -body { - font-family: var(--font-sans), sans-serif; - color: var(--foreground); -} - -/* 1-Second Dual-Layer Smooth Background Crossfade */ +/* Dark Mode Background Layer (Active by default, fades out on .light) */ body::before, body::after { content: ""; @@ -426,7 +38,6 @@ body::after { background-attachment: fixed; } -/* Dark Mode Background Layer (Active by default, fades out on .light) */ body::before { background-color: #09090b; background-image: @@ -570,7 +181,6 @@ input::-ms-clear { display: none; } - /* Accordion Animation Fix */ .accordion-grid { display: grid; diff --git a/src/app/help/page.tsx b/src/app/help/page.tsx index 582fdbd..844633e 100644 --- a/src/app/help/page.tsx +++ b/src/app/help/page.tsx @@ -2,7 +2,7 @@ import HelpPageContent from "@/components/help/HelpPageContent"; export const metadata = { title: "User Guide — Deep Package Inspection", - description: "Panduan lengkap cara membaca data di setiap halaman dashboard DPI.", + description: "Comprehensive guide on reading data across every DPI dashboard page.", }; export default function HelpRoute() { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 6385d07..db226cb 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,116 +1,115 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { Lock, User, Eye, EyeOff } from "lucide-react"; -import Image from "next/image"; - -export default function LoginPage() { - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - const [showPassword, setShowPassword] = useState(false); - const router = useRouter(); - - const handleLogin = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - setLoading(true); - - try { - const res = await fetch("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username, password }), - }); - - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || "Login failed"); - } - - router.push("/"); - router.refresh(); // Refresh to update middleware state - } catch (err: any) { - setError(err.message); - } finally { - setLoading(false); - } - }; - - return ( - <div className="min-h-screen bg-background flex flex-col justify-center items-center relative overflow-hidden"> - {/* Background Glows */} - <div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-red-600/20 blur-[120px] pointer-events-none" /> - <div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] rounded-full bg-blue-700/20 blur-[120px] pointer-events-none" /> - - <div className="z-10 w-full max-w-md p-8 bg-sidebar/80 backdrop-blur-2xl rounded-2xl border border-border/50 shadow-2xl relative"> - <div className="flex flex-col items-center mb-8"> - <img src="/backone-logo.png" alt="BackOne" className="w-16 h-16 drop-shadow-[0_0_15px_rgba(220,38,38,0.5)] mb-4" /> - <h1 className="text-2xl text-center font-bold font-backone text-white tracking-normal leading-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70"> - BackOne Dashboard - </h1> - <p className="text-sm text-muted-foreground mt-2">Sign in to your account</p> - </div> - - {error && ( - <div className="mb-6 p-3 rounded-lg bg-red-500/10 border border-red-500/50 text-red-500 text-sm text-center"> - {error} - </div> - )} - - <form onSubmit={handleLogin} className="space-y-4"> - <div className="space-y-2"> - <label className="text-sm font-medium text-slate-300">Username</label> - <div className="relative"> - <User className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" /> - <input - type="text" - value={username} - onChange={(e) => setUsername(e.target.value)} - className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all" - placeholder="admin" - required - /> - </div> - </div> - - <div className="space-y-2"> - <label className="text-sm font-medium text-slate-300">Password</label> - <div className="relative"> - <Lock className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" /> - <input - type={showPassword ? "text" : "password"} - value={password} - onChange={(e) => setPassword(e.target.value)} - className="w-full pl-10 pr-10 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all" - placeholder="••••••••" - required - /> - <button - type="button" - onClick={() => setShowPassword(!showPassword)} - className="absolute right-3 top-2.5 text-muted-foreground hover:text-white transition-colors focus:outline-none" - > - {showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />} - </button> - </div> - </div> - - <button - type="submit" - disabled={loading} - className="w-full py-2.5 px-4 bg-gradient-to-r from-red-600 to-blue-700 hover:from-red-500 hover:to-blue-600 text-white font-medium rounded-lg shadow-[0_0_15px_rgba(220,38,38,0.3)] hover:shadow-[0_0_20px_rgba(220,38,38,0.5)] transition-all duration-300 flex items-center justify-center mt-6" - > - {loading ? ( - <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> - ) : ( - "Sign In" - )} - </button> - </form> - </div> - </div> - ); -} +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Lock, User, Eye, EyeOff } from "lucide-react"; +import Image from "next/image"; + +export default function LoginPage() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const router = useRouter(); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "Login failed"); + } + + window.location.href = "/"; + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( + <div className="min-h-screen bg-background flex flex-col justify-center items-center relative overflow-hidden"> + {/* Background Glows */} + <div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-red-600/20 blur-[120px] pointer-events-none" /> + <div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] rounded-full bg-blue-700/20 blur-[120px] pointer-events-none" /> + + <div className="z-10 w-full max-w-md p-8 bg-sidebar/80 backdrop-blur-2xl rounded-2xl border border-border/50 shadow-2xl relative"> + <div className="flex flex-col items-center mb-8"> + <img src="/backone-logo.png" alt="BackOne" className="w-16 h-16 drop-shadow-[0_0_15px_rgba(220,38,38,0.5)] mb-4" /> + <h1 className="text-2xl text-center font-bold font-backone text-white tracking-normal leading-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70"> + BackOne Dashboard + </h1> + <p className="text-sm text-muted-foreground mt-2">Sign in to your account</p> + </div> + + {error && ( + <div className="mb-6 p-3 rounded-lg bg-red-500/10 border border-red-500/50 text-red-500 text-sm text-center"> + {error} + </div> + )} + + <form onSubmit={handleLogin} className="space-y-4"> + <div className="space-y-2"> + <label className="text-sm font-medium text-slate-300">Username</label> + <div className="relative"> + <User className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" /> + <input + type="text" + value={username} + onChange={(e) => setUsername(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all" + placeholder="admin" + required + /> + </div> + </div> + + <div className="space-y-2"> + <label className="text-sm font-medium text-slate-300">Password</label> + <div className="relative"> + <Lock className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" /> + <input + type={showPassword ? "text" : "password"} + value={password} + onChange={(e) => setPassword(e.target.value)} + className="w-full pl-10 pr-10 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all" + placeholder="••••••••" + required + /> + <button + type="button" + onClick={() => setShowPassword(!showPassword)} + className="absolute right-3 top-2.5 text-muted-foreground hover:text-white transition-colors focus:outline-none" + > + {showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />} + </button> + </div> + </div> + + <button + type="submit" + disabled={loading} + className="w-full py-2.5 px-4 bg-gradient-to-r from-red-600 to-blue-700 hover:from-red-500 hover:to-blue-600 text-white font-medium rounded-lg shadow-[0_0_15px_rgba(220,38,38,0.3)] hover:shadow-[0_0_20px_rgba(220,38,38,0.5)] transition-all duration-300 flex items-center justify-center mt-6" + > + {loading ? ( + <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> + ) : ( + "Sign In" + )} + </button> + </form> + </div> + </div> + ); +} diff --git a/src/app/theme.css b/src/app/theme.css new file mode 100644 index 0000000..c985d0e --- /dev/null +++ b/src/app/theme.css @@ -0,0 +1,165 @@ +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + + --font-backone: var(--font-backone); + + /* Set Inter/sans-serif as the default globally */ + --font-sans: var(--font-inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif); + + + /* Mono also uses Inter first — matches demoplace (--default-mono-font-family: var(--font-inter)) */ + --font-mono: var(--font-inter, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + + --color-border: var(--border); + + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + + --color-sidebar: var(--sidebar); + + /* OVERRIDE TAILWIND HARDCODED COLORS FOR SEAMLESS LIGHT MODE */ + --color-slate-900: var(--slate-900); + --color-slate-800: var(--slate-800); + --color-slate-700: var(--slate-700); + --color-slate-600: var(--slate-600); + --color-slate-500: var(--slate-500); + --color-slate-400: var(--slate-400); + --color-slate-300: var(--slate-300); + --color-slate-200: var(--slate-200); + --color-slate-100: var(--slate-100); + --color-slate-50: var(--slate-50); + + --color-gray-900: var(--slate-900); + --color-gray-800: var(--slate-800); + --color-gray-700: var(--slate-700); + --color-gray-600: var(--slate-600); + --color-gray-500: var(--slate-500); + --color-gray-400: var(--slate-400); + --color-gray-300: var(--slate-300); + --color-gray-200: var(--slate-200); + --color-gray-100: var(--slate-100); + --color-gray-50: var(--slate-50); + + --color-white: var(--t-white); + --color-black: var(--t-black); + + /* Radix-style color palette — matching demoplace.my.id exactly */ + --color-red-50: #fef2f2; + --color-red-100: #ffe2e2; + --color-red-200: #ffcaca; + --color-red-300: #ffa3a3; + --color-red-400: #ff6568; + --color-red-500: #fb2c36; + --color-red-600: #e40014; + --color-red-700: #bf000f; + --color-red-800: #9f0712; + --color-red-900: #82181a; + --color-red-950: #460809; + + --color-orange-300: #ffb96d; + --color-orange-400: #ff8b1a; + --color-orange-500: #fe6e00; + --color-orange-900: #7e2a0c; + --color-orange-950: #441306; + + --color-amber-100: #fef3c6; + --color-amber-200: #fee685; + --color-amber-300: #ffd236; + --color-amber-400: #fcbb00; + --color-amber-500: #f99c00; + --color-amber-600: #dd7400; + --color-amber-700: #b75000; + --color-amber-800: #953d00; + --color-amber-900: #7b3306; + --color-amber-950: #461901; + + --color-yellow-300: #ffe02a; + --color-yellow-400: #fac800; + --color-yellow-500: #edb200; + + --color-green-300: #7bf1a8; + --color-green-400: #05df72; + --color-green-500: #00c758; + --color-green-800: #016630; + --color-green-900: #0d542b; + --color-green-950: #032e15; + + --color-emerald-100: #d0fae5; + --color-emerald-200: #a4f4cf; + --color-emerald-300: #5ee9b5; + --color-emerald-400: #00d294; + --color-emerald-500: #00bb7f; + --color-emerald-600: #009767; + --color-emerald-700: #007956; + --color-emerald-900: #004e3b; + --color-emerald-950: #002c22; + + --color-teal-400: #00d3bd; + --color-teal-500: #00baa7; + + --color-cyan-100: #cefafe; + --color-cyan-300: #53eafd; + --color-cyan-400: #00d2ef; + --color-cyan-500: #00b7d7; + --color-cyan-600: #0092b5; + + --color-sky-400: #00bcfe; + + --color-blue-50: #eff6ff; + --color-blue-200: #bedbff; + --color-blue-300: #90c5ff; + --color-blue-400: #54a2ff; + --color-blue-500: #3080ff; + --color-blue-600: #155dfc; + --color-blue-700: #1447e6; + --color-blue-800: #193cb8; + --color-blue-900: #1c398e; + --color-blue-950: #162456; + + --color-indigo-300: #a4b3ff; + --color-indigo-400: #7d87ff; + --color-indigo-500: #625fff; + --color-indigo-600: #4f39f6; + --color-indigo-900: #312c85; + + --color-violet-400: #a685ff; + --color-violet-500: #8d54ff; + + --color-purple-300: #d9b3ff; + --color-purple-400: #c07eff; + --color-purple-500: #ac4bff; + --color-purple-600: #9810fa; + --color-purple-900: #59168b; + --color-purple-950: #3c0366; + + --color-pink-400: #fb64b6; + --color-rose-400: #ff667f; + --color-rose-900: #8b0836; + + /* Micro-animations */ + --animate-fade-in: fade-in 0.5s ease-out forwards; + --animate-slide-up: slide-up 0.5s ease-out forwards; + + @keyframes fade-in { + 0% { opacity: 0; } + 100% { opacity: 1; } + } + + @keyframes slide-up { + 0% { opacity: 0; transform: translateY(20px); } + 100% { opacity: 1; transform: translateY(0); } + } +} diff --git a/src/app/variables.css b/src/app/variables.css new file mode 100644 index 0000000..d58b7c1 --- /dev/null +++ b/src/app/variables.css @@ -0,0 +1,159 @@ +:root { + /* Premium Dark Theme */ + --background: #09090b; + /* Very dark zinc */ + --foreground: #fafafa; + + --card: rgba(24, 24, 27, 0.7); + /* Zinc 900 with transparency for glassmorphism */ + --card-foreground: #fafafa; + + --border: rgba(255, 255, 255, 0.1); + + --primary: #3b82f6; + /* Blue 500 */ + --primary-foreground: #ffffff; + + --secondary: rgba(39, 39, 42, 0.8); + /* Zinc 800 */ + --secondary-foreground: #fafafa; + + --muted: rgba(39, 39, 42, 0.5); + --muted-foreground: #a1a1aa; + /* Zinc 400 */ + + --destructive: #ef4444; + /* Red 500 */ + --destructive-foreground: #ffffff; + + --sidebar: #09090b; + + /* Overrides for hardcoded colors in Dark Mode (Normal) */ + --t-white: #ffffff; + --t-black: #000000; + --slate-900: #0f172a; + --slate-800: #1e293b; + --slate-700: #334155; + --slate-600: #475569; + --slate-500: #64748b; + --slate-400: #94a3b8; + --slate-300: #cbd5e1; + --slate-200: #e2e8f0; + --slate-100: #f1f5f9; + --slate-50: #f8fafc; +} + +.light { + /* Clean Light Theme */ + --background: #f1f5f9; + /* Slate 100 */ + --foreground: #0f172a; + + --card: rgba(255, 255, 255, 0.9); + --card-foreground: #0f172a; + + --border: rgba(0, 0, 0, 0.1); + + --primary: #2563eb; + /* Blue 600 */ + --primary-foreground: #ffffff; + + --secondary: rgba(241, 245, 249, 0.8); + /* Slate 100 */ + --secondary-foreground: #0f172a; + + --muted: rgba(226, 232, 240, 0.5); + /* Slate 200 */ + --muted-foreground: #64748b; + /* Slate 500 */ + + --sidebar: #ffffff; + + /* Overrides for hardcoded colors in Light Mode (Inverted) */ + --t-white: #0f172a; + /* Make text-white become very dark slate */ + --t-black: #ffffff; + --slate-900: #ffffff; + --slate-800: #f8fafc; + --slate-700: #f1f5f9; + --slate-600: #e2e8f0; + --slate-500: #cbd5e1; + --slate-400: #64748b; + /* Keep 400 slightly dark so it stays readable */ + --slate-300: #475569; + --slate-200: #334155; + --slate-100: #1e293b; + --slate-50: #0f172a; + + /* Enhance contrast for colored text and backgrounds in Light Mode */ + + /* RED */ + --color-red-300: #b91c1c; + /* red-700 */ + --color-red-400: #dc2626; + /* red-600 */ + --color-red-500: #b91c1c; + /* red-700 */ + --color-red-950: #ef4444; + /* red-500 (so that 950/20 becomes a visible pink bg) */ + + /* ORANGE */ + --color-orange-300: #c2410c; + /* orange-700 */ + --color-orange-400: #ea580c; + /* orange-600 */ + --color-orange-500: #c2410c; + /* orange-700 */ + --color-orange-950: #f97316; + /* orange-500 */ + + /* YELLOW */ + --color-yellow-300: #a16207; + /* yellow-700 */ + --color-yellow-400: #ca8a04; + /* yellow-600 */ + --color-yellow-500: #a16207; + /* yellow-700 */ + --color-yellow-950: #eab308; + /* yellow-500 */ + + /* GREEN */ + --color-green-300: #15803d; + /* green-700 */ + --color-green-400: #16a34a; + /* green-600 */ + --color-green-500: #15803d; + /* green-700 */ + --color-green-950: #22c55e; + /* green-500 */ + + /* EMERALD */ + --color-emerald-300: #047857; + /* emerald-700 */ + --color-emerald-400: #059669; + /* emerald-600 */ + --color-emerald-500: #047857; + /* emerald-700 */ + --color-emerald-950: #10b981; + /* emerald-500 */ + + /* BLUE */ + --color-blue-300: #1d4ed8; + /* blue-700 */ + --color-blue-400: #2563eb; + /* blue-600 */ + --color-blue-500: #1d4ed8; + /* blue-700 */ + --color-blue-950: #3b82f6; + /* blue-500 */ + + /* PURPLE */ + --color-purple-300: #7e22ce; + /* purple-700 */ + --color-purple-400: #9333ea; + /* purple-600 */ + --color-purple-500: #7e22ce; + /* purple-700 */ + --color-purple-950: #a855f7; + /* purple-500 */ +} diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index 1ef0abb..335dc70 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -1,8 +1,8 @@ -"use client"; - -import * as React from "react" -import { ThemeProvider as NextThemesProvider } from "next-themes"; - -export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) { - return <NextThemesProvider {...props}>{children}</NextThemesProvider>; -} +"use client"; + +import * as React from "react" +import { ThemeProvider as NextThemesProvider } from "next-themes"; + +export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) { + return <NextThemesProvider {...props}>{children}</NextThemesProvider>; +} diff --git a/src/components/admin/AgentAccountModal.tsx b/src/components/admin/AgentAccountModal.tsx index 35d7787..b934190 100644 --- a/src/components/admin/AgentAccountModal.tsx +++ b/src/components/admin/AgentAccountModal.tsx @@ -265,5 +265,5 @@ export default function AgentAccountModal({ </div> </div> ); -} - +} + diff --git a/src/components/admin/AgentChecklist.tsx b/src/components/admin/AgentChecklist.tsx new file mode 100644 index 0000000..0c2b463 --- /dev/null +++ b/src/components/admin/AgentChecklist.tsx @@ -0,0 +1,58 @@ +"use client"; + +interface AgentChecklistProps { + availableAgents: string[]; + selectedAgents: string[]; + onAgentCheck: (uuid: string, checked: boolean) => void; + agentNamesMap: Record<string, string>; +} + +export default function AgentChecklist({ + availableAgents, + selectedAgents, + onAgentCheck, + agentNamesMap, +}: AgentChecklistProps) { + const filteredAgents = availableAgents + .filter(Boolean) + .filter((uuid) => uuid.trim() !== ""); + + return ( + <div> + <label className="block text-xs font-medium text-slate-400 mb-2"> + 🎯 Assign Network Agents ({selectedAgents.length} selected) + </label> + <div className="w-full max-h-40 overflow-y-auto bg-slate-800/40 border border-slate-700 rounded-lg p-3 space-y-2.5 custom-scrollbar"> + {filteredAgents.length === 0 ? ( + <p className="text-xs text-slate-500 italic">No network agents available</p> + ) : ( + filteredAgents.map((uuid) => { + const isChecked = selectedAgents.includes(uuid); + const agentName = agentNamesMap[uuid] || uuid; + return ( + <label + key={uuid} + className="flex items-center gap-3 cursor-pointer group text-xs text-slate-300 select-none" + > + <input + type="checkbox" + checked={isChecked} + onChange={(e) => onAgentCheck(uuid, e.target.checked)} + className="rounded border-slate-600 bg-slate-800 text-amber-500 focus:ring-amber-500/50 w-4 h-4 cursor-pointer" + /> + <span className="group-hover:text-white transition-colors"> + {agentName} + </span> + {agentName !== uuid && ( + <span className="text-[10px] text-slate-500 font-mono"> + ({uuid}) + </span> + )} + </label> + ); + }) + )} + </div> + </div> + ); +} diff --git a/src/components/admin/AgentEmptyMap.tsx b/src/components/admin/AgentEmptyMap.tsx new file mode 100644 index 0000000..b3ce0d1 --- /dev/null +++ b/src/components/admin/AgentEmptyMap.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React from "react"; +import { MapPin } from "lucide-react"; + +export function EmptyMap() { + return ( + <div className="flex flex-col items-center justify-center h-72 gap-4"> + <div className="w-16 h-16 rounded-full flex items-center justify-center" + style={{ background: "rgba(99,102,241,0.1)", border: "1px solid rgba(99,102,241,0.25)" }}> + <MapPin className="w-7 h-7 text-indigo-400 opacity-60" /> + </div> + <div className="text-center font-serif"> + <p className="text-sm font-semibold text-slate-300">No agent locations configured</p> + <p className="text-xs text-slate-400 mt-2.5 max-w-sm leading-relaxed flex items-center justify-center gap-1.5 flex-wrap"> + <span>Click the</span> + <span className="inline-flex items-center justify-center p-1 rounded bg-indigo-950/20 text-slate-500 border border-slate-700/30" title="Map Pin Icon"> + <MapPin className="w-3.5 h-3.5 text-slate-500" /> + </span> + <span>icon in the</span> + <span className="text-slate-300 font-semibold">Actions</span> + <span>column to set coordinates.</span> + </p> + </div> + </div> + ); +} diff --git a/src/components/admin/AgentLocationMap.tsx b/src/components/admin/AgentLocationMap.tsx new file mode 100644 index 0000000..4e6f496 --- /dev/null +++ b/src/components/admin/AgentLocationMap.tsx @@ -0,0 +1,248 @@ +// src/components/admin/AgentLocationMap.tsx +"use client"; + +import React, { useEffect, useRef } from "react"; +import { RotateCcw } from "lucide-react"; +import { getViewAsHeaders } from "@/lib/admin-api"; +import { + EmptyMap, + MAP_CSS, + drawFlowLines, + drawMarkers, + type AgentLocationPin +} from "./AgentLocationMapHelpers"; + +interface Props { + pins: AgentLocationPin[]; + onEditLocation?: (agentUuid: string, agentLabel: string) => void; + canEdit?: boolean; +} + +const AgentLocationMap = React.memo(function AgentLocationMap({ + pins, + onEditLocation, + canEdit = false, +}: Props) { + const mapRef = useRef<HTMLDivElement>(null); + const instanceRef = useRef<any>(null); + const LRef = useRef<any>(null); + const [flows, setFlows] = React.useState<any[]>([]); + + useEffect(() => { + let active = true; + fetch("/api/dashboard/agent-flows", { headers: getViewAsHeaders() }) + .then((res) => res.json()) + .then((json) => { + if (active && json.ok && json.data) { + setFlows(json.data); + } + }) + .catch((err) => console.error("Failed to load agent flows for map:", err)); + return () => { + active = false; + }; + }, [pins]); + + const resetView = () => { + if (!instanceRef.current || !pins.length || !LRef.current) return; + const L = LRef.current; + if (pins.length === 1) { + instanceRef.current.setView([pins[0].latitude, pins[0].longitude], 14, { animate: false }); + } else { + instanceRef.current.fitBounds(L.latLngBounds(pins.map((p) => [p.latitude, p.longitude])), { + padding: [70, 70], + maxZoom: 14, + animate: false, + }); + } + }; + + useEffect(() => { + let mounted = true; + if (!mapRef.current) return; + + // Inject styles + const styleId = "leaflet-custom-markers-css"; + if (!document.getElementById(styleId)) { + const s = document.createElement("style"); + s.id = styleId; + s.innerHTML = MAP_CSS; + document.head.appendChild(s); + } + + const boot = async () => { + if (!mounted || !mapRef.current) return; + + // Inject Leaflet CSS + if (!document.getElementById("lf-css-admin")) { + const lnk = document.createElement("link"); + lnk.id = "lf-css-admin"; + lnk.rel = "stylesheet"; + lnk.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"; + document.head.appendChild(lnk); + } + + const L = (await import("leaflet")).default; + if (!mounted || !mapRef.current) return; + LRef.current = L; + + if (instanceRef.current) { + try { + instanceRef.current.remove(); + } catch (_) {} + } + + const map = L.map(mapRef.current, { + center: pins[0] ? [pins[0].latitude, pins[0].longitude] : [-2.548926, 118.0148634], + zoom: pins.length === 1 ? 14 : 8, + zoomControl: false, + attributionControl: false, + minZoom: 2, + maxZoom: 20, + maxBounds: [[-90, -180], [90, 180]], + maxBoundsViscosity: 1.0, + zoomAnimation: false, + fadeAnimation: false, + markerZoomAnimation: false, + }); + instanceRef.current = map; + + L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", { + subdomains: "abcd", + maxZoom: 20, + noWrap: true, + }).addTo(map); + + if (pins.length > 1) { + map.fitBounds(L.latLngBounds(pins.map((p) => [p.latitude, p.longitude])), { + padding: [70, 70], + maxZoom: 14, + animate: false, + }); + } + + // ── Get heartbeats for agent uptime from window or fetch ─────────────── + const uptimeMap: Record<string, number> = {}; + try { + const res = await fetch("/api/dashboard/agent-heartbeats"); + if (res.ok) { + const json = await res.json(); + if (json.ok && json.data) { + json.data.forEach((item: any) => { + uptimeMap[item.agent_uuid] = item.uptime_percent ?? 100; + }); + } + } + } catch (_) {} + + if (!mounted) return; + + // Draw Flow lines + drawFlowLines(map, L, pins, flows); + + // Draw Markers + drawMarkers(map, L, pins, uptimeMap, canEdit, onEditLocation); + }; + + boot(); + return () => { + mounted = false; + if (instanceRef.current) { + try { + instanceRef.current.remove(); + } catch (_) {} + instanceRef.current = null; + } + }; + }, [pins, flows, canEdit]); + + if (!pins.length) return <EmptyMap />; + + return ( + <div className="relative w-full rounded-xl overflow-hidden" style={{ height: 420 }}> + <div ref={mapRef} style={{ width: "100%", height: "100%", background: "#0a1628" }} /> + + {/* Controls */} + <div className="absolute top-3 right-3 z-[1000] flex flex-col gap-1.5"> + {[ + { t: "Zoom In", l: "+", fn: () => instanceRef.current?.zoomIn() }, + { t: "Zoom Out", l: "−", fn: () => instanceRef.current?.zoomOut() }, + ].map((b) => ( + <button + key={b.t} + onClick={b.fn} + title={b.t} + className="w-9 h-9 flex items-center justify-center rounded-lg font-bold text-xl text-white hover:bg-indigo-600/30 transition-colors" + style={{ + background: "rgba(13,21,38,0.92)", + border: "1px solid rgba(99,102,241,0.35)", + backdropFilter: "blur(8px)", + }} + > + {b.l} + </button> + ))} + <button + onClick={resetView} + title="Fit all agents" + className="w-9 h-9 flex items-center justify-center rounded-lg text-indigo-400 hover:text-indigo-300 transition-colors" + style={{ + background: "rgba(13,21,38,0.92)", + border: "1px solid rgba(99,102,241,0.35)", + backdropFilter: "blur(8px)", + }} + > + <RotateCcw className="w-4 h-4" /> + </button> + </div> + + {/* Legend */} + <div + className="absolute bottom-3 left-3 z-[1000] flex gap-4 text-xs px-3.5 py-2 rounded-xl" + style={{ + background: "rgba(10,17,32,0.88)", + border: "1px solid rgba(99,102,241,0.2)", + backdropFilter: "blur(8px)", + }} + > + {[ + ["#10b981", "Online", true], + ["#ef4444", "Offline", false], + ].map(([c, l, glow]: any) => ( + <span key={l} className="flex items-center gap-1.5 text-slate-400"> + <span + style={{ + display: "inline-block", + width: 10, + height: 10, + borderRadius: "50%", + background: c, + boxShadow: glow ? `0 0 7px ${c}` : undefined, + }} + /> + {l} + </span> + ))} + {flows.length > 0 && ( + <span className="flex items-center gap-1.5 text-slate-500"> + <span + style={{ + display: "inline-block", + width: 16, + height: 2, + background: "rgba(99,102,241,0.7)", + borderTop: "1px dashed rgba(99,102,241,0.9)", + }} + /> + Traffic flow + </span> + )} + <span className="font-semibold font-mono" style={{ color: "#818cf8" }}> + {pins.length} agent{pins.length !== 1 ? "s" : ""} mapped + </span> + </div> + </div> + ); +}); + +export default AgentLocationMap; diff --git a/src/components/admin/AgentLocationMapHelpers.tsx b/src/components/admin/AgentLocationMapHelpers.tsx new file mode 100644 index 0000000..7dd74e4 --- /dev/null +++ b/src/components/admin/AgentLocationMapHelpers.tsx @@ -0,0 +1,182 @@ +"use client"; + +import React from "react"; +import { getFlowTooltipContent } from "../dashboard/IndonesiaAgentMapHelpers"; +import { EmptyMap } from "./AgentEmptyMap"; +import { MAP_CSS } from "./AgentMapCss"; + +export { EmptyMap, MAP_CSS }; + +export interface AgentLocationPin { + agent_uuid: string; + label: string; + latitude: number; + longitude: number; + isOnline?: boolean; + uptimePercent?: number; + site_uuid?: string; +} + +export function buildMarkerHtml(online: boolean) { + return ` + <div class="custom-agent-marker ${online ? 'online' : 'offline'}"> + <div class="marker-beacon-ring"></div> + <div class="marker-glass-disc"> + <div class="marker-core-dot"></div> + </div> + </div> + `; +} + +// ── Draw Flow lines ONLY if they exist in active flows ──────────────── +export function drawFlowLines(map: any, L: any, pins: AgentLocationPin[], flows: any[]) { + flows.forEach((flow) => { + const src = pins.find((p) => p.agent_uuid === flow.source); + const dst = pins.find((p) => p.agent_uuid === flow.target); + if (!src || !dst) return; + + const bothOn = src.isOnline && dst.isOnline; + const bothOff = !src.isOnline && !dst.isOnline; + const lineColor = bothOn ? "#34d399" : bothOff ? "#f87171" : "#fbbf24"; + const weight = Math.min(Math.max(flow.bytes / 1024 / 1024 / 80, 2.0), 6); + + // Background polyline for hover trigger area + const baseLine = L.polyline( + [[src.latitude, src.longitude], [dst.latitude, dst.longitude]], + { color: lineColor, weight: weight + 4, opacity: 0.15 } + ).addTo(map); + + // Main colored line + const activeLine = L.polyline( + [[src.latitude, src.longitude], [dst.latitude, dst.longitude]], + { color: lineColor, weight: weight, opacity: 0.5 } + ).addTo(map); + + // Moving animation line + const animLine = L.polyline( + [[src.latitude, src.longitude], [dst.latitude, dst.longitude]], + { + color: lineColor, + weight: weight * 0.6, + opacity: 0.85, + dashArray: "8 12", + className: "flow-line", + } + ).addTo(map); + + const tooltipHtml = getFlowTooltipContent( + src.label, + dst.label, + flow.bytes, + flow.flowsCount, + flow.details || [] + ); + + [baseLine, activeLine, animLine].forEach((line) => { + line.bindTooltip(tooltipHtml, { sticky: true, className: "leaflet-custom-popup shadow-2xl" }); + }); + }); +} + +// ── Draw Markers with Popups & Tooltips ─────────────────────────────── +export function drawMarkers( + map: any, + L: any, + pins: AgentLocationPin[], + uptimeMap: Record<string, number>, + canEdit: boolean, + onEditLocation?: (agentUuid: string, agentLabel: string) => void +) { + pins.forEach((pin) => { + const online = pin.isOnline ?? false; + const color = online ? "#10b981" : "#ef4444"; + const colorL = online ? "#34d399" : "#f87171"; + + const icon = L.divIcon({ + html: buildMarkerHtml(online), + className: "", + iconSize: [32, 32], + iconAnchor: [16, 16], + popupAnchor: [0, -18], + tooltipAnchor: [0, -18], + }); + + const marker = L.marker([pin.latitude, pin.longitude], { icon }); + + // ── Permanent label tooltip ────────────────────────────────────────── + const shortLabel = pin.label.length > 22 ? pin.label.slice(0, 20) + "…" : pin.label; + marker.bindTooltip( + `<div style=" + padding:4px 10px 4px 8px; border-radius:8px; white-space:nowrap; + background:rgba(10,18,36,0.93); border:1.5px solid ${colorL}; + color:${colorL}; font-size:11px; font-weight:700; + font-family:var(--font-sans),sans-serif; letter-spacing:0.02em; + box-shadow:0 0 10px ${color}55, 0 2px 12px rgba(0,0,0,0.7); + display:flex; align-items:center; gap:6px;"> + <span style="width:7px;height:7px;border-radius:50%;background:${color}; + flex-shrink:0;${online ? `box-shadow:0 0 6px ${color}` : ""};"></span> + ${shortLabel} + </div>`, + { permanent: true, direction: "top", offset: [0, -10], className: "agent-label-tooltip", interactive: false } + ); + + // ── Click popup ────────────────────────────────────────────────────── + const uptime = uptimeMap[pin.agent_uuid]; + const uptimeColor = + (uptime ?? 100) >= 98 + ? "#34d399" + : (uptime ?? 100) >= 90 + ? "#fbbf24" + : "#f87171"; + const editBtn = + canEdit && onEditLocation + ? `<button id="el-${pin.agent_uuid.replace(/\W/g, "")}" + style="margin-top:10px;width:100%;padding:6px;background:rgba(99,102,241,0.15); + border:1px solid rgba(99,102,241,0.35);border-radius:8px;color:#818cf8; + font-size:11px;font-weight:700;cursor:pointer;font-family:var(--font-sans),sans-serif;" + onmouseover="this.style.background='rgba(99,102,241,0.3)'" + onmouseout="this.style.background='rgba(99,102,241,0.15)'">✎ Update Location</button>` + : ""; + + marker.bindPopup( + `<div style="background:#0d1526;border:1px solid ${colorL}44;border-radius:14px;padding:14px 16px;min-width:200px;font-family:var(--font-sans),sans-serif;box-shadow:0 20px 60px rgba(0,0,0,0.85);"> + <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;"> + <span style="width:9px;height:9px;border-radius:50%;background:${color};${ + online ? `box-shadow:0 0 8px ${color};` : "" + };flex-shrink:0;"></span> + <span style="font-size:13px;font-weight:800;color:${colorL};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:170px;">${ + pin.label + }</span> + </div> + <div style="display:grid;grid-template-columns:auto 1fr;gap:3px 12px;font-size:10.5px;font-family:monospace;"> + <span style="color:#475569;">UUID</span><span style="color:#93c5fd;">${pin.agent_uuid}</span> + <span style="color:#475569;">Status</span><span style="color:${color};font-weight:700;">${ + online ? "● Online" : "○ Offline" + }</span> + <span style="color:#475569;">Lat</span><span style="color:#cbd5e1;">${pin.latitude.toFixed(6)}</span> + <span style="color:#475569;">Lng</span><span style="color:#cbd5e1;">${pin.longitude.toFixed(6)}</span> + ${ + uptime !== undefined + ? `<span style="color:#475569;">Uptime</span><span style="color:${uptimeColor};font-weight:700;">${uptime.toFixed( + 1 + )}%</span>` + : "" + } + </div>${editBtn} + </div>`, + { className: "agent-popup", maxWidth: 260 } + ); + + marker.on("popupopen", () => { + const id = `el-${pin.agent_uuid.replace(/\W/g, "")}`; + setTimeout(() => { + document.getElementById(id)?.addEventListener("click", () => { + onEditLocation?.(pin.agent_uuid, pin.label); + map.closePopup(); + }); + }, 50); + }); + + marker.addTo(map); + }); +} diff --git a/src/components/admin/AgentMapCss.ts b/src/components/admin/AgentMapCss.ts new file mode 100644 index 0000000..f6e146e --- /dev/null +++ b/src/components/admin/AgentMapCss.ts @@ -0,0 +1,97 @@ +export const MAP_CSS = ` + .leaflet-control-attribution { display:none !important; } + .agent-popup .leaflet-popup-content-wrapper { background:#0a1224!important; border:1px solid rgba(99,102,241,0.25)!important; box-shadow:0 12px 36px rgba(0,0,0,0.65)!important; padding:0!important; border-radius:12px!important; } + .agent-popup .leaflet-popup-content { margin:0!important; } + .agent-popup .leaflet-popup-tip-container { display:none!important; } + .agent-popup .leaflet-popup-close-button { color:#94a3b8!important; font-size:18px!important; top:8px!important; right:10px!important; } + .agent-label-tooltip { background:transparent!important; border:none!important; box-shadow:none!important; padding:0!important; } + .agent-label-tooltip::before { display:none!important; } + @keyframes dash-flow { to { stroke-dashoffset:-20; } } + .flow-line { animation: dash-flow 1.4s linear infinite; } + + /* Tooltip and popup custom classes */ + .leaflet-custom-popup .leaflet-popup-content-wrapper { + background: transparent !important; + box-shadow: none !important; + padding: 0 !important; + margin: 0 !important; + } + .leaflet-custom-popup .leaflet-popup-tip { + background: #0f172a !important; + border: 1px solid rgba(255,255,255,0.15); + } + .leaflet-custom-popup .leaflet-popup-content { + margin: 0 !important; + padding: 0 !important; + background: transparent !important; + } + + /* Premium Map Marker Styles */ + .custom-agent-marker { + display: flex; + align-items: center; + justify-content: center; + position: relative; + width: 32px; + height: 32px; + } + .marker-beacon-ring { + position: absolute; + width: 32px; + height: 32px; + border-radius: 50%; + opacity: 0; + pointer-events: none; + } + .online .marker-beacon-ring { + border: 2px solid #10b981; + animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1); + } + .offline .marker-beacon-ring { + border: 2px solid #ef4444; + animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1); + } + @keyframes beacon-ping { + 0% { transform: scale(0.4); opacity: 0.85; } + 50% { opacity: 0.45; } + 100% { transform: scale(1.8); opacity: 0; } + } + .marker-glass-disc { + width: 15px; + height: 15px; + border-radius: 50%; + background: rgba(10, 18, 36, 0.85); + border: 2px solid; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7), inset 0 1px 2px rgba(255, 255, 255, 0.15); + transition: all 0.2s ease-in-out; + } + .online .marker-glass-disc { + border-color: #10b981; + box-shadow: 0 0 10px rgba(16, 185, 129, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6); + } + .offline .marker-glass-disc { + border-color: #ef4444; + box-shadow: 0 0 10px rgba(239, 68, 68, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6); + } + .marker-core-dot { + width: 6px; + height: 6px; + border-radius: 50%; + transition: all 0.2s ease-in-out; + } + .online .marker-core-dot { + background: #10b981; + box-shadow: 0 0 6px #10b981; + } + .offline .marker-core-dot { + background: #ef4444; + box-shadow: 0 0 6px #ef4444; + } + .custom-agent-marker:hover .marker-glass-disc { + transform: scale(1.25); + } +`; diff --git a/src/components/admin/AgentModals.tsx b/src/components/admin/AgentModals.tsx index ef005bc..b1d7dd9 100644 --- a/src/components/admin/AgentModals.tsx +++ b/src/components/admin/AgentModals.tsx @@ -3,7 +3,8 @@ import { useState } from "react"; import { Modal } from "@/components/ui/Modal"; import { Loader2 } from "lucide-react"; -import { createAgent, deleteAgent, Agent } from "@/lib/actions/agents"; +import { createAgent, deleteAgent } from "@/lib/actions/agents"; +import { Agent } from "@/lib/actions/agentsCore"; import { AgentDetailModal } from "@/components/ui/AgentDetailModal"; import AgentAccountModal from "@/components/admin/AgentAccountModal"; import { resolveAgentLabel, type ManagedUser } from "@/lib/admin-api"; diff --git a/src/components/admin/DeviceMacDetailsModal.tsx b/src/components/admin/DeviceMacDetailsModal.tsx new file mode 100644 index 0000000..51ff246 --- /dev/null +++ b/src/components/admin/DeviceMacDetailsModal.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Modal } from "@/components/ui/Modal"; +import { fmtBytes } from "@/lib/utils"; +import { getViewAsHeaders } from "@/lib/admin-api"; +import { Loader2, Monitor, Shield, Tag, Cpu, Clock, Network } from "lucide-react"; + +interface IpHistoryItem { + ip_address: string; + first_seen: string; + last_seen: string; + download: number; + upload: number; + flows: number; +} + +interface DeviceMacDetailsModalProps { + isOpen: boolean; + onClose: () => void; + macAddress: string; + customLabel: string | null; + defaultLabel: string; + agentName: string; + manufacturer: string; + deviceType: string; +} + +export default function DeviceMacDetailsModal({ + isOpen, + onClose, + macAddress, + customLabel, + defaultLabel, + agentName, + manufacturer, + deviceType, +}: DeviceMacDetailsModalProps) { + const [ips, setIps] = useState<IpHistoryItem[]>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + if (!isOpen || !macAddress) return; + + setIsLoading(true); + setError(null); + + fetch(`/api/dashboard/devices/mac-details?mac=${encodeURIComponent(macAddress)}`, { + headers: getViewAsHeaders(), + }) + .then((res) => res.json()) + .then((data) => { + if (data.ok) { + setIps(data.ips || []); + } else { + setError(data.error || "Failed to load device details."); + } + }) + .catch((err) => { + setError(err.message || "An error occurred while loading details."); + }) + .finally(() => { + setIsLoading(false); + }); + }, [isOpen, macAddress]); + + const formatFriendlyDate = (dateStr?: string) => { + if (!dateStr) return "-"; + try { + const date = new Date(dateStr); + return date.toLocaleString("id-ID", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + " WIB"; + } catch { + return dateStr; + } + }; + + return ( + <Modal + isOpen={isOpen} + onClose={onClose} + title="🔍 Device Network Details" + > + <div className="space-y-6 max-h-[75vh] overflow-y-auto pr-1 custom-scrollbar"> + {/* Profile Card */} + <div className="grid grid-cols-2 gap-4"> + <div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1"> + <span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1"> + <Tag className="w-3 h-3" /> MAC Address + </span> + <code className="text-xs text-white font-mono block">{macAddress}</code> + </div> + + <div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1"> + <span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1"> + <Cpu className="w-3 h-3" /> Manufacturer + </span> + <span className="text-xs text-slate-200 block truncate" title={manufacturer}> + {manufacturer || "Unknown"} + </span> + </div> + + <div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1 col-span-2"> + <span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1"> + <Monitor className="w-3 h-3" /> Device Labels + </span> + <div className="space-y-1"> + <div className="flex items-center gap-2"> + <span className="text-[10px] text-slate-500 font-medium">Custom:</span> + {customLabel ? ( + <span className="text-xs font-semibold text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 px-2 py-0.5 rounded-full"> + {customLabel} + </span> + ) : ( + <span className="text-xs text-slate-500 italic">No label configured</span> + )} + </div> + <div className="flex items-center gap-2"> + <span className="text-[10px] text-slate-500 font-medium">System:</span> + <span className="text-xs text-slate-400 truncate">{defaultLabel}</span> + </div> + </div> + </div> + + <div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1"> + <span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1"> + <Shield className="w-3 h-3" /> Device Type + </span> + <span className="text-xs text-slate-200 block capitalize">{deviceType || "Unknown"}</span> + </div> + + <div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1"> + <span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1"> + <Network className="w-3 h-3" /> Network Agent + </span> + <span className="text-xs text-purple-400 font-semibold block truncate" title={agentName}> + {agentName} + </span> + </div> + </div> + + {/* IPs Section */} + <div className="space-y-3"> + <h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 flex items-center gap-2"> + 🔗 Associated IP Addresses + </h4> + + {isLoading ? ( + <div className="py-8 flex justify-center items-center gap-2 text-slate-400 text-xs"> + <Loader2 className="w-4 h-4 animate-spin text-primary" /> + Resolving active IP address histories... + </div> + ) : error ? ( + <div className="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-lg text-xs"> + ⚠️ {error} + </div> + ) : ips.length === 0 ? ( + <div className="py-8 text-center text-slate-500 italic text-xs border border-white/5 rounded-xl bg-white/[0.01]"> + No IP addresses recorded for this MAC address. + </div> + ) : ( + <div className="border border-white/5 rounded-xl overflow-hidden bg-white/[0.01]"> + <div className="max-h-60 overflow-y-auto custom-scrollbar"> + <table className="w-full text-xs text-slate-300"> + <thead className="bg-white/[0.03] text-slate-400 uppercase text-[9px] tracking-wider sticky top-0 z-10 border-b border-white/5"> + <tr> + <th className="px-3 py-2 text-center">IP Address</th> + <th className="px-3 py-2 text-center">First Seen</th> + <th className="px-3 py-2 text-center">Last Seen</th> + <th className="px-3 py-2 text-center">Traffic (Down / Up)</th> + <th className="px-3 py-2 text-center">Flows</th> + </tr> + </thead> + <tbody className="divide-y divide-white/5"> + {ips.map((ip) => ( + <tr key={ip.ip_address} className="hover:bg-white/[0.02] transition-colors"> + <td className="px-3 py-2.5 font-mono text-center text-slate-200"> + {ip.ip_address} + </td> + <td className="px-3 py-2.5 text-center text-slate-400"> + {formatFriendlyDate(ip.first_seen)} + </td> + <td className="px-3 py-2.5 text-center text-slate-400 flex items-center justify-center gap-1"> + <Clock className="w-3 h-3 text-slate-500" /> + {formatFriendlyDate(ip.last_seen)} + </td> + <td className="px-3 py-2.5 text-center font-mono"> + {fmtBytes(ip.download)} / {fmtBytes(ip.upload)} + </td> + <td className="px-3 py-2.5 text-center font-mono text-slate-400"> + {ip.flows} + </td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + )} + </div> + + <div className="flex justify-end pt-4 border-t border-white/5"> + <button + type="button" + onClick={onClose} + className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white rounded-lg text-xs font-semibold transition-all" + > + Close Details + </button> + </div> + </div> + </Modal> + ); +} diff --git a/src/components/admin/ExternalAccountModal.tsx b/src/components/admin/ExternalAccountModal.tsx index 275b114..ef41c3b 100644 --- a/src/components/admin/ExternalAccountModal.tsx +++ b/src/components/admin/ExternalAccountModal.tsx @@ -1,10 +1,12 @@ "use client"; -import { useState, useEffect, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { X, User, Key, Tag, Upload, Loader2, CheckCircle2, AlertCircle, Trash2, Shield } from "lucide-react"; +import { X, User, Key, Tag, Loader2, AlertCircle, CheckCircle2, Trash2, Shield } from "lucide-react"; import type { ManagedUser } from "@/lib/admin-api"; -import { updateAdminAgentUser, deleteAdminAgentUser, createAdminExternalUser } from "@/lib/admin-api"; +import { useExternalAccountForm } from "./useExternalAccountForm"; +import RoleSelect from "./RoleSelect"; +import AgentChecklist from "./AgentChecklist"; +import ProfilePictureInput from "./ProfilePictureInput"; interface ExternalAccountModalProps { isOpen: boolean; @@ -13,6 +15,8 @@ interface ExternalAccountModalProps { user?: ManagedUser | null; targetSiteUuid?: string | null; targetCreatedBy?: string | null; + targetCompany?: string | null; + modalContext?: 'agents' | 'user-accounts'; } export default function ExternalAccountModal({ @@ -22,107 +26,41 @@ export default function ExternalAccountModal({ user, targetSiteUuid, targetCreatedBy, + targetCompany, + modalContext = 'user-accounts', }: ExternalAccountModalProps) { - const fileInputRef = useRef<HTMLInputElement>(null); - - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [accountName, setAccountName] = useState(""); - const [role, setRole] = useState("SOC_ANALYST"); - const [previewPic, setPreviewPic] = useState<string | null>(null); - const [picFile, setPicFile] = useState<File | null>(null); - - const [isSubmitting, setIsSubmitting] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const [success, setSuccess] = useState(""); - const [error, setError] = useState(""); - - useEffect(() => { - if (!isOpen) return; - setError(""); - setSuccess(""); - setPicFile(null); - if (user) { - setUsername(user.username); - setAccountName(user.account_name || ""); - setRole(user.role); - setPassword(""); // Selalu kosong agar admin bisa memasukkan password baru untuk di-reset - setPreviewPic( - user.profile_picture - ? `/api/auth/uploads/${user.profile_picture}` - : null - ); - } else { - setUsername(""); - setAccountName(""); - setRole("SOC_ANALYST"); - setPassword(""); - setPreviewPic(null); - } - }, [isOpen, user]); - - const handlePicChange = (e: React.ChangeEvent<HTMLInputElement>) => { - const file = e.target.files?.[0]; - if (!file) return; - setPicFile(file); - setPreviewPic(URL.createObjectURL(file)); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const isEdit = !!user; - - setError(""); - setSuccess(""); - setIsSubmitting(true); - try { - const fd = new FormData(); - if (username.trim()) fd.append("username", username.trim()); - if (password) fd.append("password", password); - fd.append("account_name", accountName.trim()); - if (picFile) fd.append("profile_picture", picFile); - - if (isEdit) { - fd.append("user_id", String(user.id)); - await updateAdminAgentUser(fd); - setSuccess(password ? "Password successfully reset & Account updated!" : "Account successfully updated!"); - } else { - fd.append("role", role); - if (targetSiteUuid) fd.append("site_uuid", targetSiteUuid); - if (targetCreatedBy) fd.append("created_by", targetCreatedBy); - await createAdminExternalUser(fd); - setSuccess("External account successfully created!"); - } - - setTimeout(() => { - onSuccess(); - onClose(); - }, 1500); - } catch (err: any) { - setError(err.message); - } finally { - setIsSubmitting(false); - } - }; - - const handleDelete = async () => { - if (!user) return; - if (!confirm(`Delete account "${user.username}"? This action cannot be undone.`)) return; - - setIsDeleting(true); - try { - await deleteAdminAgentUser(user.id); - setSuccess("Account successfully deleted!"); - setTimeout(() => { - onSuccess(); - onClose(); - }, 1000); - } catch (err: any) { - setError(err.message); - } finally { - setIsDeleting(false); - } - }; + const { + adminRole, + username, + setUsername, + password, + setPassword, + accountName, + setAccountName, + role, + setRole, + companyName, + setCompanyName, + selectedAgents, + previewPic, + isSubmitting, + isDeleting, + success, + error, + availableAgents, + agentNamesMap, + handlePicChange, + handleAgentCheck, + handleSubmit, + handleDelete, + } = useExternalAccountForm({ + isOpen, + onClose, + onSuccess, + user, + targetCompany, + modalContext, + }); const isEdit = !!user; @@ -130,7 +68,6 @@ export default function ExternalAccountModal({ <AnimatePresence> {isOpen && ( <div className="fixed inset-0 z-[60] flex items-center justify-center p-4"> - {/* Backdrop */} <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} @@ -140,164 +77,190 @@ export default function ExternalAccountModal({ onClick={onClose} /> - {/* Modal */} <motion.div initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.5, ease: "easeInOut" }} - className="relative z-10 w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden" + className="relative z-10 w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col" > - {/* Header */} - <div className="flex items-center justify-between p-5 border-b border-slate-700 bg-gradient-to-r from-amber-900/40 to-slate-900"> - <div> - <h3 className="text-lg font-bold text-white"> - {isEdit ? "Manage External Account" : "Create External Account"} - </h3> - {isEdit && <p className="text-xs text-slate-400 mt-0.5 font-mono">Role: <span className="font-semibold text-amber-400">{user.role.replace('_', ' ')}</span></p>} - </div> - <button onClick={onClose} className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors"> - <X className="w-5 h-5" /> - </button> - </div> - - <form onSubmit={handleSubmit} className="p-5 space-y-4"> - {isEdit && ( - <div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg flex gap-3 text-xs text-amber-200/80 mb-2"> - <AlertCircle className="w-4 h-4 shrink-0 text-amber-400" /> - <p><strong>Password Recovery:</strong> For security reasons, original passwords cannot be viewed. To recover an account, enter a new password below to reset it.</p> - </div> - )} - - {/* Profile Picture */} - <div className="flex items-center gap-4"> - <div - className="w-16 h-16 rounded-full border-2 border-dashed border-slate-600 flex items-center justify-center cursor-pointer hover:border-amber-500 transition-colors overflow-hidden bg-slate-800" - onClick={() => fileInputRef.current?.click()} - > - {previewPic ? ( - <img src={previewPic} alt="Profile" className="w-full h-full object-cover" /> - ) : ( - <Upload className="w-6 h-6 text-slate-500" /> - )} - </div> - <div> - <p className="text-sm font-medium text-slate-300">Profile Picture</p> - <p className="text-xs text-slate-500">Click to upload image</p> - </div> - <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handlePicChange} /> - </div> - - {/* Account Name */} - <div> - <label className="block text-xs font-medium text-slate-400 mb-1.5"> - <Tag className="w-3.5 h-3.5 inline mr-1" /> - Account Name - </label> - <input - type="text" - value={accountName} - onChange={e => setAccountName(e.target.value)} - placeholder="Full Name" - className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors" - /> - </div> - - {/* Role (Only for Create mode) */} - {!isEdit && ( - <div> - <label className="block text-xs font-medium text-slate-400 mb-1.5"> - <Shield className="w-3.5 h-3.5 inline mr-1" /> - Account Role - </label> - <select - value={role} - onChange={e => setRole(e.target.value)} - className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:border-amber-500 transition-colors" - > - <option value="SOC_ANALYST">SOC Analyst</option> - <option value="ENGINEER">Engineer</option> - <option value="TENANT_ADMIN">Tenant Admin</option> - </select> - </div> - )} - - {/* Username */} - <div> - <label className="block text-xs font-medium text-slate-400 mb-1.5"> - <User className="w-3.5 h-3.5 inline mr-1" /> - Login Username - </label> - <input - type="text" - value={username} - onChange={e => setUsername(e.target.value)} - placeholder="Username for login" - required - className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors" - /> - </div> - - {/* Password Reset / Create */} - <div> - <label className="block text-xs font-medium text-slate-400 mb-1.5"> - <Key className="w-3.5 h-3.5 inline mr-1" /> - {isEdit ? "Reset Password" : "Password"} {isEdit && <span className="text-slate-500">(leave blank to keep unchanged)</span>} - </label> - <input - type="password" - value={password} - onChange={e => setPassword(e.target.value)} - placeholder={isEdit ? "Type new password to override" : "Create password"} - required={!isEdit} - className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors" - /> - </div> - - {/* Feedback */} - {error && ( - <div className="flex items-center gap-2 p-3 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm"> - <AlertCircle className="w-4 h-4 flex-shrink-0" /> - {error} - </div> - )} - {success && ( - <div className="flex items-center gap-2 p-3 rounded-lg bg-green-900/30 border border-green-800 text-green-300 text-sm"> - <CheckCircle2 className="w-4 h-4 flex-shrink-0" /> - {success} - </div> - )} - - {/* Actions */} - <div className="flex gap-2 pt-2"> - {isEdit && ( + <div className="flex items-center justify-between p-5 border-b border-slate-700 bg-gradient-to-r from-amber-900/40 to-slate-900 shrink-0"> + <div> + <h3 className="text-lg font-bold text-white"> + {isEdit + ? "Manage Account" + : modalContext === 'agents' + ? "Create Technical Account" + : "Create Company Account"} + </h3> + {isEdit && ( + <p className="text-xs text-slate-400 mt-0.5 font-mono"> + Role:{" "} + <span className="font-semibold text-amber-400"> + {user.role.replace("_", " ")} + </span> + </p> + )} + </div> <button - type="button" - onClick={handleDelete} - disabled={isDeleting || isSubmitting} - className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm hover:bg-red-900/50 transition-colors disabled:opacity-50" + onClick={onClose} + className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors" > - {isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />} - Delete + <X className="w-5 h-5" /> </button> - )} - <button - type="button" - onClick={onClose} - disabled={isDeleting || isSubmitting} - className="flex-1 px-4 py-2.5 rounded-lg border border-slate-600 text-slate-300 text-sm hover:bg-slate-800 transition-colors disabled:opacity-50" + </div> + + <form + onSubmit={handleSubmit} + className="p-5 space-y-4 overflow-y-auto flex-1 custom-scrollbar" > - Cancel</button> - <button - type="submit" - disabled={isSubmitting || isDeleting} - className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium transition-colors disabled:opacity-50" - > - {isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null} - {isEdit ? "Update & Reset" : "Create Account"} - </button> - </div> - </form> + {isEdit && ( + <div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg flex gap-3 text-xs text-amber-200/80 mb-2"> + <AlertCircle className="w-4 h-4 shrink-0 text-amber-400" /> + <p> + <strong>Password Recovery:</strong> For security reasons, original passwords cannot + be viewed. To recover an account, enter a new password below to reset it. + </p> + </div> + )} + + <ProfilePictureInput previewPic={previewPic} onChange={handlePicChange} /> + + <div> + <label className="block text-xs font-medium text-slate-400 mb-1.5"> + <Tag className="w-3.5 h-3.5 inline mr-1" /> + Account Name + </label> + <input + type="text" + value={accountName} + onChange={(e) => setAccountName(e.target.value)} + placeholder="Full Name" + className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors" + /> + </div> + + {!isEdit && ( + <div> + <label className="block text-xs font-medium text-slate-400 mb-1.5"> + <Shield className="w-3.5 h-3.5 inline mr-1" /> + Account Role + </label> + <RoleSelect adminRole={adminRole} value={role} onChange={setRole} modalContext={modalContext} /> + </div> + )} + + {/* Company Name: hanya untuk User Accounts context */} + {modalContext === 'user-accounts' && adminRole === "SUPER_ADMIN" && (role === "COMPANY_ADMIN" || isEdit) && ( + <div> + <label className="block text-xs font-medium text-slate-400 mb-1.5"> + 🏢 Company Name + </label> + <input + type="text" + value={companyName} + onChange={(e) => setCompanyName(e.target.value)} + placeholder="Nama Perusahaan (e.g. IFG)" + required + className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors" + /> + </div> + )} + + {/* Agent Checklist: hanya untuk User Accounts context (COMPANY_*) */} + {modalContext === 'user-accounts' && ( + role === "COMPANY_ADMIN" || + role === "COMPANY_OPERATOR" || + role === "COMPANY_VIEWER" + ) && ( + <AgentChecklist + availableAgents={availableAgents} + selectedAgents={selectedAgents} + onAgentCheck={handleAgentCheck} + agentNamesMap={agentNamesMap} + /> + )} + + <div> + <label className="block text-xs font-medium text-slate-400 mb-1.5"> + <User className="w-3.5 h-3.5 inline mr-1" /> + Login Username + </label> + <input + type="text" + value={username} + onChange={(e) => setUsername(e.target.value)} + placeholder="Username for login" + required + className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors" + /> + </div> + + <div> + <label className="block text-xs font-medium text-slate-400 mb-1.5"> + <Key className="w-3.5 h-3.5 inline mr-1" /> + {isEdit ? "Reset Password" : "Password"}{" "} + {isEdit && ( + <span className="text-slate-500">(leave blank to keep unchanged)</span> + )} + </label> + <input + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder={isEdit ? "Type new password to override" : "Create password"} + required={!isEdit} + className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors" + /> + </div> + + {error && ( + <div className="flex items-center gap-2 p-3 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm"> + <AlertCircle className="w-4 h-4 flex-shrink-0" /> + {error} + </div> + )} + {success && ( + <div className="flex items-center gap-2 p-3 rounded-lg bg-green-900/30 border border-green-800 text-green-300 text-sm"> + <CheckCircle2 className="w-4 h-4 flex-shrink-0" /> + {success} + </div> + )} + + <div className="flex gap-2 pt-2 shrink-0"> + {isEdit && ( + <button + type="button" + onClick={handleDelete} + disabled={isDeleting || isSubmitting} + className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm hover:bg-red-900/50 transition-colors disabled:opacity-50" + > + {isDeleting ? ( + <Loader2 className="w-4 h-4 animate-spin" /> + ) : ( + <Trash2 className="w-4 h-4" /> + )} + Delete + </button> + )} + <button + type="button" + onClick={onClose} + disabled={isDeleting || isSubmitting} + className="flex-1 px-4 py-2.5 rounded-lg border border-slate-600 text-slate-300 text-sm hover:bg-slate-800 transition-colors disabled:opacity-50" + > + Cancel + </button> + <button + type="submit" + disabled={isSubmitting || isDeleting} + className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium transition-colors disabled:opacity-50" + > + {isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null} + {isEdit ? "Update & Reset" : "Create Account"} + </button> + </div> + </form> </motion.div> </div> )} diff --git a/src/components/admin/ProfilePictureInput.tsx b/src/components/admin/ProfilePictureInput.tsx new file mode 100644 index 0000000..db90a7f --- /dev/null +++ b/src/components/admin/ProfilePictureInput.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useRef } from "react"; +import { Upload } from "lucide-react"; + +interface ProfilePictureInputProps { + previewPic: string | null; + onChange: (file: File) => void; +} + +export default function ProfilePictureInput({ + previewPic, + onChange, +}: ProfilePictureInputProps) { + const fileInputRef = useRef<HTMLInputElement>(null); + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0]; + if (file) { + onChange(file); + } + }; + + return ( + <div className="flex items-center gap-4"> + <div + className="w-16 h-16 rounded-full border-2 border-dashed border-slate-600 flex items-center justify-center cursor-pointer hover:border-amber-500 transition-colors overflow-hidden bg-slate-800" + onClick={() => fileInputRef.current?.click()} + > + {previewPic ? ( + <img src={previewPic} alt="Profile" className="w-full h-full object-cover" /> + ) : ( + <Upload className="w-6 h-6 text-slate-500" /> + )} + </div> + <div> + <p className="text-sm font-medium text-slate-300">Profile Picture</p> + <p className="text-xs text-slate-500">Click to upload image</p> + </div> + <input + ref={fileInputRef} + type="file" + accept="image/*" + className="hidden" + onChange={handleFileChange} + /> + </div> + ); +} diff --git a/src/components/admin/RoleSelect.tsx b/src/components/admin/RoleSelect.tsx new file mode 100644 index 0000000..9973edf --- /dev/null +++ b/src/components/admin/RoleSelect.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { ChevronDown, Shield } from "lucide-react"; + +interface RoleOption { + value: string; + label: string; +} + +interface RoleSelectProps { + adminRole: string; + value: string; + onChange: (val: string) => void; + modalContext?: 'agents' | 'user-accounts'; +} + +// Role untuk konteks Agents (teknikal/operasional) +const AGENTS_ROLES: RoleOption[] = [ + { value: "EXECUTIVE", label: "Executive" }, + { value: "TENANT_ADMIN", label: "Tenant Admin" }, + { value: "SOC_ANALYST", label: "SOC Analyst" }, + { value: "ENGINEER", label: "Engineer" }, +]; + +// Role untuk konteks User Accounts (customer/client) +const USER_ACCOUNTS_ROLES_SUPER: RoleOption[] = [ + { value: "COMPANY_ADMIN", label: "Company Admin" }, + { value: "COMPANY_OPERATOR", label: "Company Operator" }, + { value: "COMPANY_VIEWER", label: "Company Viewer" }, +]; + +const USER_ACCOUNTS_ROLES_COMPANY: RoleOption[] = [ + { value: "COMPANY_OPERATOR", label: "Company Operator" }, + { value: "COMPANY_VIEWER", label: "Company Viewer" }, +]; + +export default function RoleSelect({ adminRole, value, onChange, modalContext = 'user-accounts' }: RoleSelectProps) { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef<HTMLDivElement>(null); + + // Pilih role options berdasarkan konteks modal + const options: RoleOption[] = (() => { + if (modalContext === 'agents') { + // Agents page: selalu tampilkan role teknikal saja + return AGENTS_ROLES; + } + // User Accounts page: tampilkan COMPANY_* sesuai admin role + if (adminRole === 'SUPER_ADMIN' || adminRole === 'EXECUTIVE') return USER_ACCOUNTS_ROLES_SUPER; + return USER_ACCOUNTS_ROLES_COMPANY; + })(); + + const currentOption = options.find((opt) => opt.value === value) || options[0]; + + // Set default role if current value is not in the options + useEffect(() => { + if (options.length > 0 && !options.some((opt) => opt.value === value)) { + onChange(options[0].value); + } + }, [adminRole, value, onChange, options]); + + useEffect(() => { + const handleOutsideClick = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleOutsideClick); + return () => document.removeEventListener("mousedown", handleOutsideClick); + }, []); + + return ( + <div className="relative w-full" ref={containerRef}> + <button + type="button" + onClick={() => setIsOpen((prev) => !prev)} + className="flex items-center justify-between w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:border-amber-500 hover:border-slate-500 transition-colors text-left" + > + <span className="truncate">{currentOption?.label || value}</span> + <ChevronDown + className={`w-4 h-4 text-slate-400 transition-transform duration-300 ${ + isOpen ? "rotate-180 text-amber-500" : "" + }`} + /> + </button> + + {isOpen && ( + <div className="absolute top-full left-0 right-0 mt-1 max-h-60 overflow-y-auto bg-slate-900 border border-slate-700 rounded-lg shadow-xl z-50 py-1 custom-scrollbar"> + {options.map((opt) => { + const isSelected = opt.value === value; + return ( + <button + key={opt.value} + type="button" + onClick={() => { + onChange(opt.value); + setIsOpen(false); + }} + className={`w-full px-3 py-2 text-sm text-left transition-colors truncate flex items-center justify-between ${ + isSelected + ? "bg-amber-600/20 text-amber-400 font-medium" + : "text-slate-300 hover:bg-slate-800 hover:text-white" + }`} + > + <span>{opt.label}</span> + {isSelected && <Shield className="w-3.5 h-3.5 text-amber-400" />} + </button> + ); + })} + </div> + )} + </div> + ); +} diff --git a/src/components/admin/ViewAsAgentBanner.tsx b/src/components/admin/ViewAsAgentBanner.tsx index 0ec1bff..a704095 100644 --- a/src/components/admin/ViewAsAgentBanner.tsx +++ b/src/components/admin/ViewAsAgentBanner.tsx @@ -54,5 +54,5 @@ export default function ViewAsAgentBanner() { </button> </div> ); -} - +} + diff --git a/src/components/admin/useExternalAccountForm.ts b/src/components/admin/useExternalAccountForm.ts new file mode 100644 index 0000000..d813314 --- /dev/null +++ b/src/components/admin/useExternalAccountForm.ts @@ -0,0 +1,224 @@ +import { useState, useEffect } from "react"; +import type { ManagedUser } from "@/lib/admin-api"; +import { updateAdminAgentUser, deleteAdminAgentUser, createAdminExternalUser, getViewAsHeaders } from "@/lib/admin-api"; + +interface UseExternalAccountFormProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + user?: ManagedUser | null; + targetCompany?: string | null; + modalContext?: 'agents' | 'user-accounts'; +} + +export function useExternalAccountForm({ + isOpen, + onClose, + onSuccess, + user, + targetCompany, + modalContext = 'user-accounts', +}: UseExternalAccountFormProps) { + const [adminRole, setAdminRole] = useState<string>(""); + const [adminCompany, setAdminCompany] = useState<string>(""); + const [adminAgentUuids, setAdminAgentUuids] = useState<string[]>([]); + const [globalAgents, setGlobalAgents] = useState<string[]>([]); + const [agentNamesMap, setAgentNamesMap] = useState<Record<string, string>>({}); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [accountName, setAccountName] = useState(""); + const [role, setRole] = useState("COMPANY_ADMIN"); + const [companyName, setCompanyName] = useState(""); + const [selectedAgents, setSelectedAgents] = useState<string[]>([]); + const [previewPic, setPreviewPic] = useState<string | null>(null); + const [picFile, setPicFile] = useState<File | null>(null); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [success, setSuccess] = useState(""); + const [error, setError] = useState(""); + + // Get current admin user context & global agent registry + useEffect(() => { + if (!isOpen) return; + + fetch("/api/auth/me", { headers: getViewAsHeaders() }) + .then(res => res.json()) + .then(data => { + if (data.user) { + setAdminRole(data.user.role || ""); + setAdminCompany(data.user.company_name || ""); + setAdminAgentUuids(data.user.agent_uuids || []); + + // Default role berdasarkan konteks modal + if (modalContext === 'agents') { + // Agents: default ke TENANT_ADMIN (role teknikal) + setRole("TENANT_ADMIN"); + } else if (data.user.role === "COMPANY_ADMIN") { + setRole("COMPANY_OPERATOR"); + } else { + setRole("COMPANY_ADMIN"); + } + } + }) + .catch(console.error); + + fetch("/api/dashboard/agents", { headers: getViewAsHeaders() }) + .then(res => res.json()) + .then(json => { + if (json.ok && json.agents) { + setGlobalAgents(json.agents); + } + }) + .catch(console.error); + + fetch("/api/auth/admin/users", { credentials: "include", headers: getViewAsHeaders() }) + .then(res => { + if (!res.ok) return null; + return res.json(); + }) + .then(data => { + if (data && data.ok && Array.isArray(data.data)) { + const map: Record<string, string> = {}; + data.data.forEach((u: any) => { + if (u.role === "AGENT_VIEWER" && u.agent_uuid) { + map[u.agent_uuid] = u.account_name || u.agent_uuid; + } + }); + setAgentNamesMap(map); + } + }) + .catch(console.error); + }, [isOpen]); + + // Load existing user data when in edit mode + useEffect(() => { + if (!isOpen) return; + setError(""); + setSuccess(""); + setPicFile(null); + if (user) { + setUsername(user.username); + setAccountName(user.account_name || ""); + setRole(user.role); + setCompanyName(user.company_name || ""); + setSelectedAgents(user.agent_uuids || []); + setPassword(""); + setPreviewPic( + user.profile_picture + ? `/api/auth/uploads/${user.profile_picture}` + : null + ); + } else { + setUsername(""); + setAccountName(""); + setCompanyName(targetCompany || ""); + setSelectedAgents([]); + setPassword(""); + setPreviewPic(null); + } + }, [isOpen, user, targetCompany]); + + const handlePicChange = (file: File) => { + setPicFile(file); + setPreviewPic(URL.createObjectURL(file)); + }; + + const handleAgentCheck = (agentUuid: string, checked: boolean) => { + if (checked) { + setSelectedAgents(prev => [...prev, agentUuid]); + } else { + setSelectedAgents(prev => prev.filter(uuid => uuid !== agentUuid)); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const isEdit = !!user; + + setError(""); + setSuccess(""); + setIsSubmitting(true); + try { + const fd = new FormData(); + if (username.trim()) fd.append("username", username.trim()); + if (password) fd.append("password", password); + fd.append("account_name", accountName.trim()); + if (picFile) fd.append("profile_picture", picFile); + + const finalCompany = adminRole === "COMPANY_ADMIN" ? adminCompany : companyName; + if (finalCompany) fd.append("company_name", finalCompany); + fd.append("agent_uuids", JSON.stringify(selectedAgents)); + + if (isEdit) { + fd.append("user_id", String(user.id)); + await updateAdminAgentUser(fd); + setSuccess(password ? "Password successfully reset & Account updated!" : "Account successfully updated!"); + } else { + fd.append("role", role); + await createAdminExternalUser(fd); + setSuccess("External account successfully created!"); + } + + setTimeout(() => { + onSuccess(); + onClose(); + }, 1500); + } catch (err: any) { + setError(err.message); + } finally { + setIsSubmitting(false); + } + }; + + const handleDelete = async () => { + if (!user) return; + if (!confirm(`Delete account "${user.username}"? This action cannot be undone.`)) return; + + setIsDeleting(true); + try { + await deleteAdminAgentUser(user.id); + setSuccess("Account successfully deleted!"); + setTimeout(() => { + onSuccess(); + onClose(); + }, 1000); + } catch (err: any) { + setError(err.message); + } finally { + setIsDeleting(false); + } + }; + + const availableAgents = adminRole === "COMPANY_ADMIN" ? adminAgentUuids : globalAgents; + + return { + adminRole, + adminCompany, + username, + setUsername, + password, + setPassword, + accountName, + setAccountName, + role, + setRole, + companyName, + setCompanyName, + selectedAgents, + previewPic, + setPreviewPic, + isSubmitting, + isDeleting, + success, + error, + setError, + availableAgents, + agentNamesMap, + handlePicChange, + handleAgentCheck, + handleSubmit, + handleDelete, + }; +} diff --git a/src/components/dashboard/IndonesiaAgentMap.tsx b/src/components/dashboard/IndonesiaAgentMap.tsx index 4646173..6049f5e 100644 --- a/src/components/dashboard/IndonesiaAgentMap.tsx +++ b/src/components/dashboard/IndonesiaAgentMap.tsx @@ -2,8 +2,6 @@ import React, { useEffect, useRef, useState, useMemo } from "react"; import { MapPin, AlertTriangle, ChevronDown, ChevronUp, Plus, Info, Loader2 } from "lucide-react"; -import * as L from "leaflet"; -import "leaflet/dist/leaflet.css"; import { useTheme } from "next-themes"; import { getViewAsHeaders } from "@/lib/admin-api"; import { @@ -12,7 +10,7 @@ import { MAP_CSS_INJECT, type FlowDetail } from "./IndonesiaAgentMapHelpers"; -import { Agent } from "@/lib/actions/agents"; +import { Agent } from "@/lib/actions/agentsCore"; interface LocationData { agent_uuid: string; @@ -109,185 +107,254 @@ export function IndonesiaAgentMap({ // Initialize and Update Map useEffect(() => { if (!mapContainerRef.current) return; + let mounted = true; - // Build unique mapping of agent UUIDs → their registered coordinates. - // Only agents with real entries in CustomAgentLocation are rendered on the map. - const agentMap: Record<string, { lat: number; lng: number; label: string; agentLabel: string; status: string }> = {}; + const buildMarkerHtml = (online: boolean): string => { + return ` + <div class="custom-agent-marker ${online ? 'online' : 'offline'}"> + <div class="marker-beacon-ring"></div> + <div class="marker-glass-disc"> + <div class="marker-core-dot"></div> + </div> + </div> + `; + }; - agents.forEach((agent) => { - const uuid = agent.uuid || agent.serial || ""; - const loc = locations.find((l) => l.agent_uuid === uuid); + const boot = async () => { + if (!mounted) return; + const L = (await import("leaflet")).default; + if (!mounted || !mapContainerRef.current) return; - // Only map agents that have a real coordinate registered. - // Agents without coordinates are intentionally excluded from the map. - if (loc) { - agentMap[uuid] = { - lat: loc.latitude, - lng: loc.longitude, - label: loc.label || "", - agentLabel: agent.label || "", - status: agent.activated ? "Online" : "Offline", - }; + // Inject CSS once + if (!document.getElementById("lf-css-dpi")) { + const lnk = document.createElement("link"); + lnk.id = "lf-css-dpi"; lnk.rel = "stylesheet"; + lnk.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"; + document.head.appendChild(lnk); } - }); - - const activeCoords = Object.values(agentMap); - const centerLat = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lat, 0) / activeCoords.length : -6.2088; - const centerLng = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lng, 0) / activeCoords.length : 106.8456; - - // Voyager for light mode: colorful, ocean blue, matches Globe Map aesthetic - const tileUrl = isDark - ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" - : "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"; - - if (!mapInstanceRef.current) { - // Canvas renderer: redraws from geo-coords every frame, no SVG offset bugs - const canvasRenderer = L.canvas({ padding: 0.5 }); - canvasRendererRef.current = canvasRenderer; - - const map = L.map(mapContainerRef.current, { - zoomControl: false, - attributionControl: false, - renderer: canvasRenderer, - }).setView([centerLat, centerLng], 10); - - const tiles = L.tileLayer(tileUrl, { maxZoom: 19 }).addTo(map); - tileLayerRef.current = tiles; - - L.control.zoom({ position: "bottomright" }).addTo(map); - - mapInstanceRef.current = map; - layerGroupRef.current = L.layerGroup().addTo(map); - } else { - const map = mapInstanceRef.current; - if (tileLayerRef.current) { - tileLayerRef.current.setUrl(tileUrl); + if (!document.getElementById("dpi-map-extra-css")) { + const s = document.createElement("style"); + s.id = "dpi-map-extra-css"; + s.textContent = ` + @keyframes dpi-beacon { 0%,100%{opacity:.18;transform:scale(1);transform-origin:20px 20px;} 50%{opacity:.05;transform:scale(1.65);transform-origin:20px 20px;} } + .dpi-beacon { animation: dpi-beacon 2.2s ease-in-out infinite; } + @keyframes flowDash { to { stroke-dashoffset:-24; } } + .animated-flow-line { animation: flowDash 1.2s linear infinite; } + .agent-label-tooltip { background:transparent!important;border:none!important;box-shadow:none!important;padding:0!important; } + .agent-label-tooltip::before { display:none!important; } + .dpi-agent-label { display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;border:1px solid;white-space:nowrap;letter-spacing:0.02em; } + .leaflet-control-attribution { display:none!important; } + .leaflet-custom-popup .leaflet-popup-content-wrapper { background:#0a1224!important; border:1px solid rgba(99,102,241,0.25)!important; box-shadow:0 12px 36px rgba(0,0,0,0.65)!important; padding:0!important; border-radius:12px!important; } + .leaflet-custom-popup .leaflet-popup-content { margin:0!important;padding:0!important; } + + /* Premium Map Marker Styles */ + .custom-agent-marker { + display: flex; + align-items: center; + justify-content: center; + position: relative; + width: 32px; + height: 32px; + } + .marker-beacon-ring { + position: absolute; + width: 32px; + height: 32px; + border-radius: 50%; + opacity: 0; + pointer-events: none; + } + .online .marker-beacon-ring { + border: 2px solid #10b981; + animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1); + } + .offline .marker-beacon-ring { + border: 2px solid #ef4444; + animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1); + } + @keyframes beacon-ping { + 0% { transform: scale(0.4); opacity: 0.85; } + 50% { opacity: 0.45; } + 100% { transform: scale(1.8); opacity: 0; } + } + .marker-glass-disc { + width: 15px; + height: 15px; + border-radius: 50%; + background: rgba(10, 18, 36, 0.85); + border: 2px solid; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7), inset 0 1px 2px rgba(255, 255, 255, 0.15); + transition: all 0.2s ease-in-out; + } + .online .marker-glass-disc { + border-color: #10b981; + box-shadow: 0 0 10px rgba(16, 185, 129, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6); + } + .offline .marker-glass-disc { + border-color: #ef4444; + box-shadow: 0 0 10px rgba(239, 68, 68, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6); + } + .marker-core-dot { + width: 6px; + height: 6px; + border-radius: 50%; + transition: all 0.2s ease-in-out; + } + .online .marker-core-dot { + background: #10b981; + box-shadow: 0 0 6px #10b981; + } + .offline .marker-core-dot { + background: #ef4444; + box-shadow: 0 0 6px #ef4444; + } + .custom-agent-marker:hover .marker-glass-disc { + transform: scale(1.25); + } + `; + document.head.appendChild(s); } - if (activeCoords.length > 0) { - map.panTo([centerLat, centerLng]); - } - } - const group = layerGroupRef.current; - group.clearLayers(); - - const renderer = canvasRendererRef.current; - const registeredUuids = new Set(locations.map(l => l.agent_uuid)); - - // ─── A. DRAW FLOW LINES ──────────────────────────────────────────────────── - // Drawn BEFORE markers so markers render on top - flows.forEach((flow) => { - const src = agentMap[flow.source]; - const dst = agentMap[flow.target]; - if (!src || !dst || !registeredUuids.has(flow.source) || !registeredUuids.has(flow.target)) return; - - const weight = Math.min(Math.max(flow.bytes / 1024 / 1024 / 80, 2.5), 8); - const color = isDark ? "#38bdf8" : "#1d4ed8"; - - // Glow / shadow underlay - L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], { - color: isDark ? "#0ea5e9" : "#3b82f6", - weight: weight + 4, - opacity: 0.15, - renderer, - }).addTo(group); - - // Solid base line - const baseLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], { - color, - weight, - opacity: 0.5, - renderer, - }).addTo(group); - - // Animated dashed overlay (motion effect via CSS on SVG; canvas uses stroke-dasharray) - const animLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], { - color, - weight: weight * 0.55, - opacity: 0.95, - dashArray: "10 15", - className: "animated-flow-line", - renderer, - }).addTo(group); - - const tooltipHtml = getFlowTooltipContent( - src.agentLabel, - dst.agentLabel, - flow.bytes, - flow.flowsCount, - flow.details || [] - ); - - [baseLine, animLine].forEach(line => { - line.bindTooltip(tooltipHtml, { - sticky: true, - className: "leaflet-custom-popup shadow-2xl", - }); - }); - }); - - // ─── B. DRAW AGENT MARKERS (circleMarker = same Canvas, pixel-perfect) ───── - Object.entries(agentMap).forEach(([uuid, info]) => { - const isOnline = info.status === "Online"; - const fillColor = isOnline ? "#22c55e" : "#ef4444"; - const glowColor = isOnline ? "#4ade80" : "#f87171"; - - // Outer glow ring - L.circleMarker([info.lat, info.lng], { - radius: 11, - color: glowColor, - weight: 2, - fillColor: glowColor, - fillOpacity: 0.15, - opacity: 0.5, - renderer, - }).addTo(group); - - // Inner solid dot - const dot = L.circleMarker([info.lat, info.lng], { - radius: 6, - color: "#ffffff", - weight: 2, - fillColor, - fillOpacity: 1, - opacity: 1, - renderer, - }).addTo(group); - - // Agent label — permanent tooltip anchored below the dot - const labelClass = isDark - ? "bg-slate-950/90 text-slate-200 border-white/10" - : "bg-white/95 text-slate-800 border-slate-200"; - - dot.bindTooltip( - `<div class="agent-map-label ${labelClass}">${info.agentLabel}</div>`, - { permanent: true, direction: "bottom", offset: [0, 8], className: "agent-label-tooltip" } - ).openTooltip(); - - // Detail popup on click - const popupHtml = getAgentTooltipContent( - uuid, - info.agentLabel, - info.status, - isOnline, - info.label, - info.lat, - info.lng - ); - - dot.bindPopup(popupHtml, { - closeButton: false, - className: "leaflet-custom-popup", - offset: [0, -8], - }); - - dot.on("popupopen", () => { - const btn = document.getElementById(`btn-edit-${uuid}`); - if (btn) { - btn.addEventListener("click", () => onEditLocation(uuid, info.agentLabel)); + // Build agent map + const agentMap: Record<string, { lat: number; lng: number; label: string; agentLabel: string; status: string }> = {}; + agents.forEach((agent) => { + const uuid = agent.uuid || agent.serial || ""; + const loc = locations.find((l) => l.agent_uuid === uuid); + if (loc) { + agentMap[uuid] = { + lat: loc.latitude, + lng: loc.longitude, + label: loc.label || "", + agentLabel: agent.label || "", + status: agent.activated ? "Online" : "Offline", + }; } }); - }); + + const activeCoords = Object.values(agentMap); + const centerLat = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lat, 0) / activeCoords.length : -6.2088; + const centerLng = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lng, 0) / activeCoords.length : 106.8456; + + const tileUrl = isDark + ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" + : "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"; + + if (!mapInstanceRef.current) { + const map = L.map(mapContainerRef.current, { + zoomControl: false, + attributionControl: false, + zoomAnimation: false, // prevents _leaflet_pos crash on React StrictMode remount + fadeAnimation: false, + markerZoomAnimation: false, + }).setView([centerLat, centerLng], 10, { animate: false }); + + const tiles = L.tileLayer(tileUrl, { maxZoom: 19 }).addTo(map); + tileLayerRef.current = tiles; + L.control.zoom({ position: "bottomright" }).addTo(map); + mapInstanceRef.current = map; + layerGroupRef.current = L.layerGroup().addTo(map); + } else { + const map = mapInstanceRef.current; + if (tileLayerRef.current) tileLayerRef.current.setUrl(tileUrl); + if (activeCoords.length > 0) map.panTo([centerLat, centerLng]); + } + + if (!mounted) return; + const group = layerGroupRef.current; + group.clearLayers(); + const registeredUuids = new Set(locations.map(l => l.agent_uuid)); + + // ── A. FLOW LINES ──────────────────────────────────────────────────────── + flows.forEach((flow) => { + const src = agentMap[flow.source]; + const dst = agentMap[flow.target]; + if (!src || !dst || !registeredUuids.has(flow.source) || !registeredUuids.has(flow.target)) return; + const weight = Math.min(Math.max(flow.bytes / 1024 / 1024 / 80, 2.5), 8); + const color = isDark ? "#38bdf8" : "#1d4ed8"; + + L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], { + color: isDark ? "#0ea5e9" : "#3b82f6", weight: weight + 4, opacity: 0.12, + }).addTo(group); + + const baseLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], { + color, weight, opacity: 0.5, + }).addTo(group); + + const animLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], { + color, weight: weight * 0.55, opacity: 0.95, dashArray: "10 15", + className: "animated-flow-line", + }).addTo(group); + + const tooltipHtml = getFlowTooltipContent( + src.agentLabel, dst.agentLabel, flow.bytes, flow.flowsCount, flow.details || [] + ); + [baseLine, animLine].forEach(line => { + line.bindTooltip(tooltipHtml, { sticky: true, className: "leaflet-custom-popup shadow-2xl" }); + }); + }); + + // ── B. PREMIUM SVG MARKERS ─────────────────────────────────────────────── + Object.entries(agentMap).forEach(([uuid, info]) => { + if (!mounted) return; + const isOnline = info.status === "Online"; + const labelColor = isOnline ? "#34d399" : "#f87171"; + const borderCol = isOnline ? "#10b981" : "#ef4444"; + + const icon = L.divIcon({ + html: buildMarkerHtml(isOnline), + className: "", + iconSize: [32, 32], + iconAnchor: [16, 16], + tooltipAnchor: [0, -18], + popupAnchor: [0, -18], + }); + + const marker = L.marker([info.lat, info.lng], { icon }); + + const shortLabel = info.agentLabel.length > 24 ? info.agentLabel.slice(0, 22) + "…" : info.agentLabel; + marker.bindTooltip( + `<div class="dpi-agent-label" style="background:rgba(10,18,36,0.92);color:${labelColor};border-color:${borderCol};box-shadow:0 0 10px ${borderCol}44,0 2px 8px rgba(0,0,0,0.7);">` + + `<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${borderCol};margin-right:5px;${isOnline?`box-shadow:0 0 6px ${borderCol}`:""}"></span>` + + `${shortLabel}</div>`, + { permanent: true, direction: "top", offset: [0, -10], className: "agent-label-tooltip", interactive: false } + ); + + const popupHtml = getAgentTooltipContent( + uuid, info.agentLabel, info.status, isOnline, info.label, info.lat, info.lng + ); + marker.bindPopup(popupHtml, { closeButton: false, className: "leaflet-custom-popup", offset: [0, -8] }); + marker.on("popupopen", () => { + const btn = document.getElementById(`btn-edit-${uuid}`); + if (btn) btn.addEventListener("click", () => onEditLocation(uuid, info.agentLabel)); + }); + + marker.addTo(group); + }); + + // Fit bounds + if (activeCoords.length > 1 && mounted) { + const L2 = L as any; + const bounds = L2.latLngBounds(activeCoords.map((c: any) => [c.lat, c.lng])); + mapInstanceRef.current.fitBounds(bounds, { padding: [60, 60], maxZoom: 14, animate: false }); + } + }; + + boot(); + return () => { + mounted = false; + // Properly destroy the Leaflet instance so React StrictMode double-invoke + // and HMR remounts don't leave orphaned panes that trigger _leaflet_pos errors. + if (mapInstanceRef.current) { + try { mapInstanceRef.current.remove(); } catch (_) {} + mapInstanceRef.current = null; + layerGroupRef.current = null; + tileLayerRef.current = null; + } + }; }, [agents, locations, flows, isDark]); if (mapError) { diff --git a/src/components/dashboard/KPICards.tsx b/src/components/dashboard/KPICards.tsx index 7efe02f..464a805 100644 --- a/src/components/dashboard/KPICards.tsx +++ b/src/components/dashboard/KPICards.tsx @@ -28,17 +28,17 @@ export function KPICards({ summary }: KPICardsProps) { return ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> - <Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-xl"> - <Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-blue-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '0ms' }}> + <Link href="/flows" className="block h-full focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-xl"> + <Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-blue-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '0ms' }}> <div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity"> <Download className="h-16 w-16 text-blue-500" /> </div> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10"> + <CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10"> <CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-blue-400 transition-colors">Download</CardTitle> <Download className="h-4 w-4 text-blue-500" /> </CardHeader> - <CardContent className="relative z-10"> - <div className="text-3xl font-bold text-white tracking-tight"> + <CardContent className="p-4 pt-0 relative z-10"> + <div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap"> {summary ? fmtBytes(summary.bandwidth_down) : "0 B"} </div> {summary && ( @@ -55,17 +55,17 @@ export function KPICards({ summary }: KPICardsProps) { </Card> </Link> - <Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-emerald-500/50 rounded-xl"> - <Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-emerald-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '50ms' }}> + <Link href="/flows" className="block h-full focus:outline-none focus:ring-2 focus:ring-emerald-500/50 rounded-xl"> + <Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-emerald-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '50ms' }}> <div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity"> <Upload className="h-16 w-16 text-emerald-500" /> </div> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10"> + <CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10"> <CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-emerald-400 transition-colors">Upload</CardTitle> <Upload className="h-4 w-4 text-emerald-500" /> </CardHeader> - <CardContent className="relative z-10"> - <div className="text-3xl font-bold text-white tracking-tight"> + <CardContent className="p-4 pt-0 relative z-10"> + <div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap"> {summary ? fmtBytes(summary.bandwidth_up) : "0 B"} </div> {summary && ( @@ -82,77 +82,82 @@ export function KPICards({ summary }: KPICardsProps) { </Card> </Link> - <Link href="/devices" className="block focus:outline-none focus:ring-2 focus:ring-orange-500/50 rounded-xl"> - <Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-orange-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '100ms' }}> + <Link href="/devices" className="block h-full focus:outline-none focus:ring-2 focus:ring-orange-500/50 rounded-xl"> + <Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-orange-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '100ms' }}> <div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity"> <Server className="h-16 w-16 text-orange-500" /> </div> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10"> + <CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10"> <CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-orange-400 transition-colors">Devices</CardTitle> <Server className="h-4 w-4 text-orange-500" /> </CardHeader> - <CardContent className="relative z-10"> - <div className="text-3xl font-bold text-white tracking-tight"> + <CardContent className="p-4 pt-0 relative z-10"> + <div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap"> {summary?.total_devices?.toLocaleString() || "0"} </div> + <div className="text-xs text-transparent select-none mt-1.5 pointer-events-none"> </div> </CardContent> </Card> </Link> - <Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-purple-500/50 rounded-xl"> - <Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-purple-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '150ms' }}> + <Link href="/flows" className="block h-full focus:outline-none focus:ring-2 focus:ring-purple-500/50 rounded-xl"> + <Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-purple-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '150ms' }}> <div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity"> <Activity className="h-16 w-16 text-purple-500" /> </div> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10"> + <CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10"> <CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-purple-400 transition-colors">Flows</CardTitle> <Activity className="h-4 w-4 text-purple-500" /> </CardHeader> - <CardContent className="relative z-10"> - <div className="text-3xl font-bold text-white tracking-tight"> + <CardContent className="p-4 pt-0 relative z-10"> + <div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap"> {summary?.active_flows?.toLocaleString() || "0"} </div> - {summary && flowsPerHour > 0 && ( + {summary && flowsPerHour > 0 ? ( <div className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1 group-hover:text-purple-400/80 transition-colors"> <span className="w-1.5 h-1.5 rounded-full bg-purple-500 animate-pulse" /> Speed: {flowsPerHour.toLocaleString()} /hr </div> + ) : ( + <div className="text-xs text-transparent select-none mt-1.5 pointer-events-none"> </div> )} </CardContent> </Card> </Link> - <Link href="/threats" className="block focus:outline-none focus:ring-2 focus:ring-red-500/50 rounded-xl"> - <Card className="group relative overflow-hidden bg-red-50/90 dark:bg-card/80 backdrop-blur-xl border-red-200 dark:border-red-500/20 hover:bg-red-100 hover:dark:bg-card/100 hover:border-red-300 hover:dark:border-red-500/50 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '200ms' }}> + <Link href="/threats" className="block h-full focus:outline-none focus:ring-2 focus:ring-red-500/50 rounded-xl"> + <Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-red-50/90 dark:bg-card/80 backdrop-blur-xl border-red-200 dark:border-red-500/20 hover:bg-red-100 hover:dark:bg-card/100 hover:border-red-300 hover:dark:border-red-500/50 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '200ms' }}> <div className="absolute inset-0 bg-red-500/10 dark:bg-red-500/5 pointer-events-none" /> <div className="absolute top-0 right-0 p-4 opacity-20 dark:opacity-10 group-hover:opacity-30 dark:group-hover:opacity-20 transition-opacity text-red-600 dark:text-red-500"> <ShieldAlert className="h-16 w-16" /> </div> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10"> + <CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10"> <CardTitle className="text-sm font-semibold text-red-700 dark:text-red-400 group-hover:text-red-800 dark:group-hover:text-red-300 transition-colors">Threats</CardTitle> <ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-500 animate-pulse" /> </CardHeader> - <CardContent className="relative z-10"> - <div className="text-3xl font-bold text-red-600 dark:text-red-500 tracking-tight"> + <CardContent className="p-4 pt-0 relative z-10"> + <div className="text-[25px] font-bold text-red-600 dark:text-red-500 tracking-tight whitespace-nowrap"> {summary?.total_threats?.toLocaleString() || "0"} </div> + <div className="text-xs text-transparent select-none mt-1.5 pointer-events-none"> </div> </CardContent> </Card> </Link> - <Link href="/events" className="block focus:outline-none focus:ring-2 focus:ring-yellow-500/50 rounded-xl"> - <Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-yellow-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '250ms' }}> + <Link href="/events" className="block h-full focus:outline-none focus:ring-2 focus:ring-yellow-500/50 rounded-xl"> + <Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-yellow-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '250ms' }}> <div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-yellow-500"> <Zap className="h-16 w-16" /> </div> - <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10"> + <CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10"> <CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-yellow-400 transition-colors">Events</CardTitle> <Zap className="h-4 w-4 text-yellow-500" /> </CardHeader> - <CardContent className="relative z-10"> - <div className="text-3xl font-bold text-white tracking-tight"> + <CardContent className="p-4 pt-0 relative z-10"> + <div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap"> {summary?.total_events?.toLocaleString() || "0"} </div> + <div className="text-xs text-transparent select-none mt-1.5 pointer-events-none"> </div> </CardContent> </Card> </Link> diff --git a/src/components/dashboard/TopWidgets.tsx b/src/components/dashboard/TopWidgets.tsx index 59084a3..1bb95bc 100644 --- a/src/components/dashboard/TopWidgets.tsx +++ b/src/components/dashboard/TopWidgets.tsx @@ -41,7 +41,10 @@ export function TopWidgets({ topApps, topProtocols }: TopWidgetsProps) { fontSize={11} tickLine={false} axisLine={false} - tickFormatter={(value) => fmtBytes(value)} + tickFormatter={(value) => { + const mb = value / 1048576; + return `${mb.toFixed(0)}M`; + }} /> <RechartsTooltip cursor={{fill: 'rgba(255,255,255,0.05)'}} diff --git a/src/components/help/ContextualHelpModal.tsx b/src/components/help/ContextualHelpModal.tsx index 86cc469..5707671 100644 --- a/src/components/help/ContextualHelpModal.tsx +++ b/src/components/help/ContextualHelpModal.tsx @@ -12,7 +12,7 @@ interface ContextualHelpModalProps { onClose: () => void; } -function SectionBlock({ heading, content, columns, items }: PageGuide["sections"][0]) { +function SectionBlock({ heading, content, steps, columns, items }: PageGuide["sections"][0]) { const [open, setOpen] = useState(true); return ( @@ -37,11 +37,24 @@ function SectionBlock({ heading, content, columns, items }: PageGuide["sections" <p className="text-xs text-slate-400 leading-relaxed">{content}</p> )} + {steps && steps.length > 0 && ( + <ol className="space-y-2"> + {steps.map((step, i) => ( + <li key={i} className="flex gap-2.5 items-start"> + <span className="shrink-0 flex h-4.5 w-4.5 min-w-[18px] items-center justify-center rounded-full bg-blue-600/30 border border-blue-500/40 text-[9px] font-bold text-blue-300 mt-0.5"> + {i + 1} + </span> + <span className="text-xs text-slate-300 leading-relaxed">{step}</span> + </li> + ))} + </ol> + )} + {columns && columns.length > 0 && ( <div className="rounded-lg overflow-hidden border border-white/5"> <div className="grid grid-cols-[140px_1fr] bg-white/[0.04] px-3 py-2 border-b border-white/5"> - <span className="text-[10px] font-bold uppercase tracking-widest text-blue-400">Kolom</span> - <span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Cara Membaca</span> + <span className="text-[10px] font-bold uppercase tracking-widest text-blue-400">Column</span> + <span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">How to Read</span> </div> {columns.map((col, i) => ( <div @@ -136,12 +149,9 @@ export function ContextualHelpModal({ pageId, isOpen, onClose }: ContextualHelpM </span> <div> <div className="flex items-center gap-2"> - <h3 className="text-base font-bold text-white">Panduan: {guide.title}</h3> - <code className="text-[10px] px-1.5 py-0.5 rounded font-mono bg-white/5 text-slate-400 border border-white/5"> - {guide.url} - </code> + <h3 className="text-base font-bold text-white">Guide: {guide.title}</h3> </div> - <p className="text-xs text-slate-400 mt-0.5">Penjelasan tata letak data dan kolom metrik</p> + <p className="text-xs text-slate-400 mt-0.5">Metric columns and data layout guide</p> </div> </div> <button @@ -155,7 +165,7 @@ export function ContextualHelpModal({ pageId, isOpen, onClose }: ContextualHelpM {/* Scrollable Body */} <div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4"> <div className="rounded-xl border border-blue-500/10 bg-blue-500/5 px-4 py-3 text-xs text-slate-300 leading-relaxed"> - <span className="font-semibold text-blue-400">Tentang Halaman Ini:</span> {guide.summary} + <span className="font-semibold text-blue-400">About This Page:</span> {guide.summary} </div> {guide.sections.map((section, idx) => ( @@ -166,20 +176,20 @@ export function ContextualHelpModal({ pageId, isOpen, onClose }: ContextualHelpM {/* Footer */} <div className="border-t border-white/6 bg-white/[0.02] px-6 py-4 flex items-center justify-between"> <span className="text-[10px] text-slate-500"> - Pencarian detail lengkap tersedia di User Guide utama. + Full detailed lookup is available in the main User Guide. </span> <div className="flex items-center gap-3"> <button onClick={onClose} className="rounded-xl border border-white/8 bg-white/5 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white transition-all" > - Tutup Panduan + Close Guide </button> <Link href="/help" className="flex items-center gap-1 rounded-xl bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white shadow-md shadow-blue-600/10 hover:bg-blue-500 transition-all" > - Buka Panduan Lengkap + Open Full Guide <ExternalLink className="h-3.5 w-3.5" /> </Link> </div> diff --git a/src/components/help/HelpPageContent.tsx b/src/components/help/HelpPageContent.tsx index 986538b..beac229 100644 --- a/src/components/help/HelpPageContent.tsx +++ b/src/components/help/HelpPageContent.tsx @@ -7,7 +7,7 @@ import { ChevronRight, Home, Radio, Shuffle, Package, Monitor, Globe2, BarChart3, Map, Shield, Bell, Lock, BookOpen, ChevronDown, ChevronUp, Info, Lightbulb, ArrowLeft, - Activity, Plug, Globe, Search + Activity, Plug, Globe, Search, Tag } from "lucide-react"; import { PAGE_GUIDES, UI_CONVENTIONS, type PageGuide } from "@/lib/helpContent"; @@ -26,9 +26,10 @@ const ICON_MAP: Record<string, React.ReactNode> = { "security-audit": <Lock className="h-4 w-4" />, "network-infrastructure": <Globe className="h-4 w-4" />, lookup: <Search className="h-4 w-4" />, + "device-labeling": <Tag className="h-4 w-4" />, }; -function SectionBlock({ heading, content, columns, items }: PageGuide["sections"][0]) { +function SectionBlock({ heading, content, steps, columns, items }: PageGuide["sections"][0]) { const [open, setOpen] = useState(true); return ( <div className="rounded-2xl border border-white/8 bg-gradient-to-b from-slate-900/80 to-slate-900/40 overflow-hidden shadow-lg"> @@ -46,11 +47,23 @@ function SectionBlock({ heading, content, columns, items }: PageGuide["sections" {content && ( <p className="text-sm text-slate-400 leading-relaxed">{content}</p> )} + {steps && ( + <ol className="space-y-2"> + {steps.map((step, i) => ( + <li key={i} className="flex gap-3 items-start"> + <span className="shrink-0 flex h-5 w-5 items-center justify-center rounded-full bg-blue-600/30 border border-blue-500/40 text-[10px] font-bold text-blue-300 mt-0.5"> + {i + 1} + </span> + <span className="text-sm text-slate-300 leading-relaxed">{step}</span> + </li> + ))} + </ol> + )} {columns && ( <div className="rounded-xl overflow-hidden border border-white/8"> <div className="grid grid-cols-[180px_1fr] bg-gradient-to-r from-blue-600/20 to-indigo-600/10 px-4 py-2.5 border-b border-white/8"> - <span className="text-[11px] font-bold uppercase tracking-widest text-blue-400">Kolom / Field</span> - <span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">Cara Membaca</span> + <span className="text-[11px] font-bold uppercase tracking-widest text-blue-400">Column / Field</span> + <span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">How to Read</span> </div> {columns.map((col, i) => ( <div key={i} className={`grid grid-cols-[180px_1fr] px-4 py-3 gap-4 border-b border-white/5 last:border-0 ${i % 2 === 0 ? "bg-white/[0.02]" : ""} hover:bg-white/[0.04] transition-colors`}> @@ -95,12 +108,12 @@ export default function HelpPage() { </div> <div> <h1 className="text-base font-bold text-white">User Guide</h1> - <p className="text-xs text-slate-400 mt-0.5">Panduan membaca data dashboard Deep Package Inspection</p> + <p className="text-xs text-slate-400 mt-0.5">Guide to reading Deep Packet Inspection dashboard data</p> </div> </div> <Link href="/" className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-xs font-medium text-slate-300 hover:bg-white/10 hover:text-white transition-all hover:border-white/20 group"> <ArrowLeft className="h-3.5 w-3.5 group-hover:-translate-x-0.5 transition-transform" /> - Kembali ke Dashboard + Back to Dashboard </Link> </div> </div> @@ -109,7 +122,7 @@ export default function HelpPage() { {/* ── Sidebar ── */} <aside className="w-52 shrink-0"> <div className="sticky top-6 space-y-1"> - <p className="px-3 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-500">Halaman Dashboard</p> + <p className="px-3 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-500">Dashboard Pages</p> {PAGE_GUIDES.map(page => { const isActive = activeId === page.id; return ( @@ -140,7 +153,7 @@ export default function HelpPage() { > {activeId === "conventions" && <span className="absolute left-0 top-1/4 h-1/2 w-0.5 rounded-full bg-blue-500" />} <Info className={`h-4 w-4 shrink-0 ${activeId === "conventions" ? "text-blue-400" : "text-slate-500"}`} /> - <span>Konvensi Visual</span> + <span>Visual Conventions</span> </button> </div> </div> @@ -154,15 +167,15 @@ export default function HelpPage() { <div className="flex items-center gap-3 mb-1"> <span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/30 to-indigo-500/20 border border-purple-500/20 text-lg">🎨</span> <div> - <h2 className="text-lg font-bold text-white">Konvensi Visual</h2> - <p className="text-xs text-slate-400">Panduan membaca indikator dan simbol di seluruh halaman dashboard</p> + <h2 className="text-lg font-bold text-white">Visual Conventions</h2> + <p className="text-xs text-slate-400">Guide to reading indicators and symbols across dashboard pages</p> </div> </div> </div> <div className="rounded-2xl border border-white/8 overflow-hidden shadow-lg"> <div className="grid grid-cols-[200px_1fr] bg-gradient-to-r from-purple-600/20 to-indigo-600/10 px-5 py-3 border-b border-white/8"> - <span className="text-[11px] font-bold uppercase tracking-widest text-purple-400">Simbol / Elemen</span> - <span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">Artinya</span> + <span className="text-[11px] font-bold uppercase tracking-widest text-purple-400">Symbol / Element</span> + <span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">Meaning</span> </div> {UI_CONVENTIONS.map((c, i) => ( <div key={i} className={`grid grid-cols-[200px_1fr] px-5 py-3.5 gap-4 border-b border-white/5 last:border-0 ${i % 2 === 0 ? "bg-white/[0.02]" : ""} hover:bg-white/[0.04] transition-colors`}> @@ -174,10 +187,17 @@ export default function HelpPage() { <div className="rounded-2xl border border-blue-500/20 bg-gradient-to-br from-blue-600/10 to-indigo-600/5 p-5"> <div className="flex items-center gap-2 mb-3"> <Lightbulb className="h-4 w-4 text-yellow-400" /> - <span className="text-sm font-semibold text-yellow-300">Tips Membaca Data Secara Efektif</span> + <span className="text-sm font-semibold text-yellow-300">Tips for Reading Data Effectively</span> </div> <ul className="space-y-2 text-xs text-slate-300"> - {["Selalu cek Time Filter sebelum menganalisis — pastikan rentang waktu sesuai kebutuhan.","Gunakan filter tabel untuk mempersempit data secara spesifik.","Hover ke teks yang terpotong (...) untuk melihat nilai lengkap.","Bandingkan Download vs Upload — upload jauh lebih besar bisa mengindikasikan data exfiltration.","Gunakan Geo Traffic untuk mendeteksi koneksi ke negara yang tidak seharusnya.","Cek Threat Intelligence secara rutin untuk mengetahui ancaman aktif."].map((tip, i) => ( + {[ + "Always check the Time Filter before analyzing — ensure the time range fits your needs.", + "Use table filters to narrow down data specifically.", + "Hover over truncated text (...) to view the full value.", + "Compare Download vs Upload — a significantly larger upload could indicate data exfiltration.", + "Use Geo Traffic to detect connections to countries that should not be accessed.", + "Regularly check Threat Intelligence to identify active threats." + ].map((tip, i) => ( <li key={i} className="flex gap-2"><span className="text-blue-400 shrink-0 mt-0.5">•</span><span className="leading-relaxed">{tip}</span></li> ))} </ul> @@ -207,7 +227,7 @@ export default function HelpPage() { </div> ) : ( <div className="flex h-40 items-center justify-center text-slate-500 text-sm"> - Pilih halaman dari sidebar untuk melihat panduannya. + Select a page from the sidebar to view its guide. </div> )} </main> diff --git a/src/components/help/HelpTrigger.tsx b/src/components/help/HelpTrigger.tsx index e370744..7095c02 100644 --- a/src/components/help/HelpTrigger.tsx +++ b/src/components/help/HelpTrigger.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState } from "react"; -import { HelpCircle } from "lucide-react"; +import { BookOpen } from "lucide-react"; import { ContextualHelpModal } from "./ContextualHelpModal"; interface HelpTriggerProps { @@ -17,10 +17,10 @@ export function HelpTrigger({ pageId, className = "" }: HelpTriggerProps) { <button onClick={() => setIsOpen(true)} className={`group flex items-center gap-1.5 rounded-lg border border-white/8 bg-white/5 px-2.5 py-1.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white hover:border-white/20 transition-all ${className}`} - title="Pelajari cara membaca data halaman ini" + title="Learn how to read data on this page" > - <HelpCircle className="h-3.5 w-3.5 text-blue-500 group-hover:text-blue-400 transition-colors" /> - <span>Pelajari Halaman Ini</span> + <BookOpen className="h-3.5 w-3.5 text-blue-500 group-hover:text-blue-400 transition-colors" /> + <span>Learn This Page</span> </button> <ContextualHelpModal diff --git a/src/components/lookup/AppLookupDetailModal.tsx b/src/components/lookup/AppLookupDetailModal.tsx new file mode 100644 index 0000000..b66956d --- /dev/null +++ b/src/components/lookup/AppLookupDetailModal.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { X, Info, Box, Tag, Shield, Server, AppWindow } from "lucide-react"; +import { LookupApp } from "@/lib/api"; + +interface AppLookupDetailModalProps { + selectedApp: LookupApp; + isClosingModal: boolean; + handleCloseModal: () => void; +} + +export function AppLookupDetailModal({ selectedApp, isClosingModal, handleCloseModal }: AppLookupDetailModalProps) { + return ( + <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6" onClick={handleCloseModal}> + <div + className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosingModal ? 'animate-overlay-out' : 'animate-overlay-in'}`} + /> + <div + className={`relative w-full max-w-2xl bg-card border border-border rounded-2xl shadow-2xl shadow-black/60 overflow-hidden ${isClosingModal ? 'animate-modal-out' : 'animate-modal-in'}`} + onClick={(e) => e.stopPropagation()} + > + {/* Header Background */} + <div className="h-32 bg-gradient-to-br from-primary/20 via-primary/5 to-transparent relative"> + <button + onClick={handleCloseModal} + className="absolute top-4 right-4 p-2 bg-black/20 hover:bg-black/40 rounded-full text-white/70 hover:text-white transition-colors" + > + <X className="w-5 h-5" /> + </button> + </div> + + {/* Content */} + <div className="px-6 pb-6 sm:px-8 sm:pb-8 relative -mt-16"> + {/* Icon/Logo */} + <div className="w-24 h-24 bg-card rounded-2xl border-4 border-card flex items-center justify-center overflow-hidden shadow-xl relative z-10 mb-4 bg-secondary"> + {selectedApp.logo || selectedApp.favicon ? ( + <img + src={selectedApp.logo || selectedApp.favicon} + alt={selectedApp.label} + className="w-16 h-16 object-contain" + onError={(e) => { + const img = e.target as HTMLElement; + img.style.display = 'none'; + const sibling = img.nextSibling as HTMLElement; + if (sibling) sibling.style.display = 'block'; + }} + /> + ) : null} + <AppWindow className="w-12 h-12 text-slate-400" style={{ display: (selectedApp.logo || selectedApp.favicon) ? 'none' : 'block' }} /> + </div> + + {/* Title & Category */} + <div className="space-y-2"> + <div className="flex items-center gap-3 flex-wrap"> + <h2 className="text-2xl sm:text-3xl font-bold text-white tracking-tight">{selectedApp.label}</h2> + {selectedApp.application_category?.label && ( + <span className="px-3 py-1 bg-primary/10 border border-primary/20 text-primary text-xs font-bold uppercase tracking-widest rounded-full"> + {selectedApp.application_category.label} + </span> + )} + </div> + {selectedApp.full_name && selectedApp.full_name !== selectedApp.label && ( + <p className="text-slate-400 font-medium">{selectedApp.full_name}</p> + )} + </div> + + <div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6"> + {/* Description */} + <div className="space-y-4 md:col-span-2"> + <div className="flex items-start gap-3 text-slate-300 bg-secondary/30 p-4 rounded-xl border border-border/30"> + <Info className="w-5 h-5 text-primary shrink-0 mt-0.5" /> + <p className="text-sm leading-relaxed"> + {selectedApp.description || "No detailed description provided for this application or service."} + </p> + </div> + </div> + + {/* Metadata Info */} + <div className="space-y-3"> + <h3 className="text-xs uppercase font-bold text-slate-500 tracking-wider">System Metadata</h3> + + <div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20"> + <div className="flex items-center gap-2 text-slate-400"> + <Box className="w-4 h-4" /> + <span className="text-sm">App ID</span> + </div> + <span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.id}</span> + </div> + + <div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20"> + <div className="flex items-center gap-2 text-slate-400"> + <Tag className="w-4 h-4" /> + <span className="text-sm">System Name</span> + </div> + <span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.name || 'N/A'}</span> + </div> + + <div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20"> + <div className="flex items-center gap-2 text-slate-400"> + <Shield className="w-4 h-4" /> + <span className="text-sm">Tag / Alias</span> + </div> + <span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.tag || 'N/A'}</span> + </div> + </div> + + {/* Application Category Detail */} + {selectedApp.application_category && ( + <div className="space-y-3"> + <h3 className="text-xs uppercase font-bold text-slate-500 tracking-wider">Category Info</h3> + + <div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20"> + <div className="flex items-center gap-2 text-slate-400"> + <Server className="w-4 h-4" /> + <span className="text-sm">Category ID</span> + </div> + <span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.application_category.id}</span> + </div> + + <div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20"> + <div className="flex items-center gap-2 text-slate-400"> + <Info className="w-4 h-4" /> + <span className="text-sm">Category Tag</span> + </div> + <span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.application_category.tag}</span> + </div> + </div> + )} + </div> + </div> + </div> + </div> + ); +} diff --git a/src/components/security-audit/SecurityAuditPdfDocument.tsx b/src/components/security-audit/SecurityAuditPdfDocument.tsx new file mode 100644 index 0000000..49130e2 --- /dev/null +++ b/src/components/security-audit/SecurityAuditPdfDocument.tsx @@ -0,0 +1,195 @@ +import React from "react"; +import { Document, Page, Text, View } from "@react-pdf/renderer"; +import { sharedStyles } from "../ui/sharedPdfStyles"; + +interface SecurityAuditPdfDocumentProps { + secDevices: any[]; + versionsData: any[]; + ciphersData: any[]; + sslSansData: any[]; + vulnerableCount: number; + moderateCount: number; + safeCount: number; + reportDateStr: string; +} + +interface TableColumn { + header: string; + width: string; + align?: "center" | "right" | "left"; + cell: (row: any) => string | number; +} + +function formatBytes(bytes: number): string { + if (!bytes || bytes === 0) return "0 B"; + const k = 1000; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +} + +function formatIndonesiaTime(dateStr: string | Date): string { + if (!dateStr) return "Never"; + try { + const d = new Date(dateStr); + return d.toLocaleString("id-ID", { + timeZone: "Asia/Jakarta", + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + " WIB"; + } catch (e) { + return String(dateStr); + } +} + +function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) { + return ( + <View style={{ marginBottom: 15 }}> + <Text style={sharedStyles.sectionTitle}>{title}</Text> + <View style={sharedStyles.table}> + <View style={sharedStyles.tableHeader}> + {columns.map((col, idx) => ( + <View key={idx} style={{ width: col.width, paddingRight: 6 }}> + <Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}> + {col.header} + </Text> + </View> + ))} + </View> + {data.length === 0 ? ( + <View style={sharedStyles.tableRow}> + <Text style={sharedStyles.emptyText}>No Data</Text> + </View> + ) : ( + data.map((row, rowIdx) => ( + <View key={rowIdx} style={sharedStyles.tableRow} wrap={false}> + {columns.map((col, colIdx) => ( + <View key={colIdx} style={{ width: col.width, paddingRight: 6 }}> + <Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}> + {col.cell(row)} + </Text> + </View> + ))} + </View> + )) + )} + </View> + </View> + ); +} + +export default function SecurityAuditPdfDocument({ + secDevices, + versionsData, + ciphersData, + sslSansData, + vulnerableCount, + moderateCount, + safeCount, + reportDateStr, +}: SecurityAuditPdfDocumentProps) { + // Compliance check: show all data up to 50 threshold + const vulnerableDevices = secDevices.filter((d) => d.risk_level === "Vulnerable").slice(0, 50); + const otherDevices = secDevices.filter((d) => d.risk_level !== "Vulnerable").slice(0, 50); + const sslSans = (sslSansData || []).slice(0, 50); + const versions = (versionsData || []).slice(0, 50); + const ciphers = (ciphersData || []).slice(0, 50); + + return ( + <Document> + <Page size="A4" style={sharedStyles.page}> + {/* Header */} + <View style={sharedStyles.header}> + <View> + <Text style={sharedStyles.logoText}>Deep Package Inspection</Text> + <Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text> + </View> + <View style={sharedStyles.titleContainer}> + <Text style={sharedStyles.title}>Security & Encryption Audit</Text> + <Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text> + </View> + </View> + + {/* Global Security Summary */} + <Text style={sharedStyles.sectionTitle}>Compliance & Risk Summary</Text> + <View style={sharedStyles.bandwidthGrid}> + <View style={{ ...sharedStyles.bandwidthCardCyan, backgroundColor: "#fef2f2", borderColor: "#fecaca" }}> + <Text style={{ ...sharedStyles.bandwidthLabel, color: "#b91c1c" }}>VULNERABLE DEVICES</Text> + <Text style={{ ...sharedStyles.bandwidthValue, color: "#991b1b" }}>{vulnerableCount}</Text> + </View> + <View style={{ ...sharedStyles.bandwidthCardCyan, backgroundColor: "#fffbeb", borderColor: "#fef3c7" }}> + <Text style={{ ...sharedStyles.bandwidthLabel, color: "#d97706" }}>MODERATE RISK DEVICES</Text> + <Text style={{ ...sharedStyles.bandwidthValue, color: "#b45309" }}>{moderateCount}</Text> + </View> + <View style={sharedStyles.bandwidthCardGreen}> + <Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>SECURE DEVICES</Text> + <Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{safeCount}</Text> + </View> + </View> + + {/* TLS Versions */} + <RenderPdfTable + title="TLS Version Distribution" + data={versions} + columns={[ + { header: "Protocol Version", width: "40%", cell: (r) => r.version || "Unknown TLS" }, + { header: "Flow Count", width: "30%", align: "right", cell: (r) => r.count ?? 0 }, + { header: "Data Transmitted", width: "30%", align: "right", cell: (r) => formatBytes(r.bytes) }, + ]} + /> + + {/* Top Active Cipher Suites */} + <RenderPdfTable + title="Top Active Cipher Suites" + data={ciphers} + columns={[ + { header: "Cipher Suite", width: "50%", cell: (r) => r.cipher || "Unknown" }, + { header: "Version", width: "20%", cell: (r) => r.version || "—" }, + { header: "Usage Count", width: "30%", align: "right", cell: (r) => r.count ?? 0 }, + ]} + /> + + {/* SSL/TLS Subject Alternative Names */} + <RenderPdfTable + title="SSL/TLS Subject Alternative Names (SAN)" + data={sslSans} + columns={[ + { header: "Subject Alternative Name (SAN)", width: "40%", cell: (r) => r.alt_name || "—" }, + { header: "Flows", width: "15%", align: "center", cell: (r) => r.flow_count ?? 0 }, + { header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) }, + { header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) }, + { header: "Last Seen", width: "15%", cell: (r) => r.last_seen ? formatIndonesiaTime(r.last_seen) : "Never" }, + ]} + /> + + {/* Vulnerable Devices Table */} + <RenderPdfTable + title="Vulnerable Devices (Action Required)" + data={vulnerableDevices} + columns={[ + { header: "IP Address", width: "30%", cell: (r) => r.ip_address || "—" }, + { header: "Device Label / OS", width: "30%", cell: (r) => r.device_label || r.os_label || "Unknown" }, + { header: "Encrypted %", width: "20%", align: "right", cell: (r) => `${r.encrypted_pct ?? 0}%` }, + { header: "Risk Level", width: "20%", align: "center", cell: (r) => r.risk_level || "—" }, + ]} + /> + + {/* Other Devices Table */} + <RenderPdfTable + title="Other Monitored Devices" + data={otherDevices} + columns={[ + { header: "IP Address", width: "30%", cell: (r) => r.ip_address || "—" }, + { header: "Device Label / OS", width: "30%", cell: (r) => r.device_label || r.os_label || "Unknown" }, + { header: "Encrypted %", width: "20%", align: "right", cell: (r) => `${r.encrypted_pct ?? 0}%` }, + { header: "Risk Level", width: "20%", align: "center", cell: (r) => r.risk_level || "—" }, + ]} + /> + </Page> + </Document> + ); +} diff --git a/src/components/security-audit/TlsCipherDetailModal.tsx b/src/components/security-audit/TlsCipherDetailModal.tsx index 46f9b4d..81634db 100644 --- a/src/components/security-audit/TlsCipherDetailModal.tsx +++ b/src/components/security-audit/TlsCipherDetailModal.tsx @@ -176,4 +176,4 @@ export function TlsCipherDetailModal({ cipher, onClose }: Props) { </div> </> ); -} +} diff --git a/src/components/security-audit/TlsTables.tsx b/src/components/security-audit/TlsTables.tsx index 5f56aea..27ae26d 100644 --- a/src/components/security-audit/TlsTables.tsx +++ b/src/components/security-audit/TlsTables.tsx @@ -128,4 +128,4 @@ export default function TlsTables({ versionsData, ciphersData }: Props) { )} </> ); -} +} diff --git a/src/components/security-audit/TlsVersionsChart.tsx b/src/components/security-audit/TlsVersionsChart.tsx index 49d0c1d..2687b78 100644 --- a/src/components/security-audit/TlsVersionsChart.tsx +++ b/src/components/security-audit/TlsVersionsChart.tsx @@ -89,7 +89,12 @@ export default function TlsVersionsChart({ versionsData }: Props) { scale="log" domain={[1, 'auto']} allowDataOverflow - tickFormatter={(value) => fmtBytes(value)} + tickFormatter={(value) => { + if (value >= 1073741824) return `${(value / 1073741824).toFixed(0)}GB`; + if (value >= 1048576) return `${(value / 1048576).toFixed(0)}MB`; + if (value >= 1024) return `${(value / 1024).toFixed(0)}KB`; + return `${value}B`; + }} /> <RechartsTooltip content={<TlsVersionTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} /> <Bar dataKey="total" radius={[4, 4, 0, 0]} minPointSize={6}> diff --git a/src/components/threats/ThreatFilters.tsx b/src/components/threats/ThreatFilters.tsx index 7a7d976..063ce8e 100644 --- a/src/components/threats/ThreatFilters.tsx +++ b/src/components/threats/ThreatFilters.tsx @@ -218,4 +218,4 @@ export function ThreatFilters({ </CardContent> </Card> ); -} +} diff --git a/src/components/threats/ThreatRecommendationsDrawer.tsx b/src/components/threats/ThreatRecommendationsDrawer.tsx index 3f62c6b..204e235 100644 --- a/src/components/threats/ThreatRecommendationsDrawer.tsx +++ b/src/components/threats/ThreatRecommendationsDrawer.tsx @@ -200,4 +200,4 @@ export function ThreatRecommendationsDrawer({ </div> </div> ); -} +} diff --git a/src/components/threats/ThreatsContent.tsx b/src/components/threats/ThreatsContent.tsx new file mode 100644 index 0000000..7830245 --- /dev/null +++ b/src/components/threats/ThreatsContent.tsx @@ -0,0 +1,240 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { DataTable } from "@/components/ui/DataTable"; +import { ShieldCheck, Loader2 } from "lucide-react"; +import { ThreatStat } from "@/lib/api"; +import { useThreats } from "@/lib/api-with-context"; +import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal"; +import { ThreatFilters } from "@/components/threats/ThreatFilters"; +import { ThreatRecommendationsDrawer } from "@/components/threats/ThreatRecommendationsDrawer"; +import { useSearchParams } from "next/navigation"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; +import { getThreatColumns } from "./threatColumns"; + +export default function ThreatsContent() { + const [mounted, setMounted] = useState(false); + const [selectedIp, setSelectedIp] = useState<string | null>(null); + const [selectedMac, setSelectedMac] = useState<string | null>(null); + const [selectedThreat, setSelectedThreat] = useState<ThreatStat | null>(null); + + const threats = useThreats(); + const searchParams = useSearchParams(); + const highlightId = searchParams.get("highlight"); + + // Column dropdown filters state + const [fType, setFType] = useState("All"); + const [fSev, setFSev] = useState("All"); + const [fSrcIp, setFSrcIp] = useState("All"); + const [fDstIp, setFDstIp] = useState("All"); + const [fMac, setFMac] = useState("All"); + const [fApp, setFApp] = useState("All"); + const [fDomain, setFDomain] = useState("All"); + + // Date range filter state + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + + useEffect(() => { + setMounted(true); + document.title = "Detected Threats | BackOne - Deep Package Inspection"; + }, []); + + const all = threats.data || []; + + // Apply all filters + const filtered = useMemo(() => { + return all.filter((row) => { + if (fType !== "All" && (row.threat_type || "") !== fType) return false; + if (fSev !== "All" && (row.severity || "") !== fSev) return false; + if (fSrcIp !== "All" && (row.ip_address || "") !== fSrcIp) return false; + if (fDstIp !== "All" && (row.dst_ip || "") !== fDstIp) return false; + if (fMac !== "All" && (row.mac_address || "") !== fMac) return false; + if (fApp !== "All" && (row.app_label || "") !== fApp) return false; + if (fDomain !== "All" && (row.domain || "") !== fDomain) return false; + if (dateFrom || dateTo) { + const d = row.detected_at + ? new Date(row.detected_at).toISOString().slice(0, 10) + : ""; + if (dateFrom && d < dateFrom) return false; + if (dateTo && d > dateTo) return false; + } + return true; + }); + }, [all, fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain, dateFrom, dateTo]); + + // Default sort: newest detected threats first (for table display) + const sortedFiltered = useMemo(() => + [...filtered].sort((a, b) => + new Date(b.detected_at || 0).getTime() - new Date(a.detected_at || 0).getTime() + ), + [filtered] + ); + + // Automatically select a threat if highlightId is passed + useEffect(() => { + if (highlightId && all.length > 0) { + const match = all.find(r => r.id.toString() === highlightId); + if (match) { + setSelectedThreat(match); + } + } + }, [highlightId, all]); + + const [isExporting, setIsExporting] = useState(false); + + const exportToPdf = async () => { + setIsExporting(true); + try { + const { pdf } = await import("@react-pdf/renderer"); + const { default: ThreatsSummaryPdfDocument } = await import("./ThreatsSummaryPdfDocument"); + + const reportDateStr = + new Date().toLocaleString("id-ID", { + timeZone: "Asia/Jakarta", + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + " WIB"; + + const doc = <ThreatsSummaryPdfDocument threatsData={filtered} reportDateStr={reportDateStr} />; + + const blob = await pdf(doc).toBlob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `Threat_Intelligence_Summary_Report.pdf`; + link.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error("Failed to export PDF:", err); + alert("Failed to generate PDF report. Please try again."); + } finally { + setIsExporting(false); + } + }; + + const columns = useMemo(() => getThreatColumns(setSelectedIp, setSelectedMac, setSelectedThreat, highlightId), [highlightId]); + + const activeFilterCount = + [fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain].filter((v) => v !== "All").length + + (dateFrom || dateTo ? 1 : 0); + + const resetFilters = () => { + setFType("All"); setFSev("All"); setFSrcIp("All"); setFDstIp("All"); + setFMac("All"); setFApp("All"); setFDomain("All"); setDateFrom(""); setDateTo(""); + }; + + if (!mounted) return null; + + return ( + <div className="space-y-8 animate-fade-in pb-10"> + <div className="flex items-center justify-between gap-4"> + <div> + <h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> + Detected Threats + {threats.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />} + </h2> + <p className="text-muted-foreground mt-1">Monitor and respond to network security threats.</p> + </div> + <div className="flex items-center gap-3"> + <HelpTrigger pageId="threats" /> + <div className="flex items-center gap-2 text-sm font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-4 py-2 rounded-lg shadow-[0_0_15px_rgba(16,185,129,0.1)]"> + <ShieldCheck className="h-4 w-4" /> + <span>Active Monitoring</span> + </div> + </div> + </div> + + <ThreatFilters + all={all} + fType={fType} + setFType={setFType} + fSev={fSev} + setFSev={setFSev} + fSrcIp={fSrcIp} + setFSrcIp={setFSrcIp} + fDstIp={fDstIp} + setFDstIp={setFDstIp} + fMac={fMac} + setFMac={setFMac} + fApp={fApp} + setFApp={setFApp} + fDomain={fDomain} + setFDomain={setFDomain} + dateFrom={dateFrom} + setDateFrom={setDateFrom} + dateTo={dateTo} + setDateTo={setDateTo} + activeFilterCount={activeFilterCount} + onReset={resetFilters} + /> + + <Card + className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up relative z-10 overflow-visible" + style={{ animationDelay: "100ms" }} + > + <CardHeader className="border-b border-border/50 pb-4 mb-4"> + <CardTitle className="text-lg text-white"> + Detected Threats + <span className="ml-2 text-muted-foreground font-normal"> + ({filtered.length} + {filtered.length !== all.length ? ` of ${all.length}` : ""}) + </span> + </CardTitle> + </CardHeader> + <CardContent> + <DataTable + data={sortedFiltered} + columns={columns} + searchPlaceholder="Search threats by IP, domain, type..." + searchFilter={(row, query) => { + const q = query.toLowerCase(); + return ( + (row.ip_address || "").toLowerCase().includes(q) || + (row.domain || "").toLowerCase().includes(q) || + (row.threat_type || "").toLowerCase().includes(q) || + (row.app_label || "").toLowerCase().includes(q) + ); + }} + getRowClassName={(row) => + highlightId && row.id.toString() === highlightId + ? "bg-red-500/15 border-l-2 border-red-500 animate-pulse font-semibold" + : "" + } + csvExport={{ + filename: `threats-${new Date().toISOString().slice(0, 10)}.csv`, + headers: ["Timestamp", "Type", "Severity", "Source IP", "Dest IP", "MAC Address", "App", "Domain"], + rowSerializer: (row) => [ + row.detected_at ? new Date(row.detected_at).toISOString() : "", + row.threat_type || "", + row.severity || "", + row.ip_address || "", + row.dst_ip || "", + row.mac_address || "", + row.app_label || "", + row.domain || "", + ], + }} + pdfExport={{ + onExport: exportToPdf, + isExporting, + }} + /> + </CardContent> + </Card> + + {selectedIp && <DeviceDetailModal ip={selectedIp} onClose={() => setSelectedIp(null)} />} + {selectedMac && <DeviceDetailModal ip="" mac={selectedMac} onClose={() => setSelectedMac(null)} />} + + <ThreatRecommendationsDrawer + threat={selectedThreat} + onClose={() => setSelectedThreat(null)} + /> + </div> + ); +} diff --git a/src/components/threats/ThreatsSummaryPdfDocument.tsx b/src/components/threats/ThreatsSummaryPdfDocument.tsx new file mode 100644 index 0000000..37e0cc1 --- /dev/null +++ b/src/components/threats/ThreatsSummaryPdfDocument.tsx @@ -0,0 +1,196 @@ +import React from "react"; +import { Document, Page, Text, View } from "@react-pdf/renderer"; +import { sharedStyles } from "../ui/sharedPdfStyles"; + +interface ThreatsSummaryPdfDocumentProps { + threatsData: any[]; + reportDateStr: string; +} + +interface TableColumn { + header: string; + width: string; + align?: "center" | "right" | "left"; + cell: (row: any) => string | number; +} + +function formatIndonesiaTime(dateStr: string | Date): string { + if (!dateStr) return "Never"; + try { + const d = new Date(dateStr); + return d.toLocaleString("id-ID", { + timeZone: "Asia/Jakarta", + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + " WIB"; + } catch (e) { + return String(dateStr); + } +} + +function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) { + return ( + <View style={{ marginBottom: 15 }}> + <Text style={sharedStyles.sectionTitle}>{title}</Text> + <View style={sharedStyles.table}> + <View style={sharedStyles.tableHeader}> + {columns.map((col, idx) => ( + <View key={idx} style={{ width: col.width, paddingRight: 6 }}> + <Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}> + {col.header} + </Text> + </View> + ))} + </View> + {data.length === 0 ? ( + <View style={sharedStyles.tableRow}> + <Text style={sharedStyles.emptyText}>No Data</Text> + </View> + ) : ( + data.map((row, rowIdx) => ( + <View key={rowIdx} style={sharedStyles.tableRow} wrap={false}> + {columns.map((col, colIdx) => ( + <View key={colIdx} style={{ width: col.width, paddingRight: 6 }}> + <Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}> + {col.cell(row)} + </Text> + </View> + ))} + </View> + )) + )} + </View> + </View> + ); +} + +export default function ThreatsSummaryPdfDocument({ + threatsData, + reportDateStr, +}: ThreatsSummaryPdfDocumentProps) { + const total = threatsData.length; + const criticalHigh = threatsData.filter( + (t) => { + const s = (t.severity || "").toLowerCase(); + return s.includes("high") || s.includes("critical"); + } + ).length; + + const medium = threatsData.filter( + (t) => (t.severity || "").toLowerCase().includes("med") + ).length; + + const lowInfo = total - criticalHigh - medium; + + // Group by threat type + const typeCounts: { [key: string]: number } = {}; + threatsData.forEach((t) => { + const key = t.threat_type || "Unknown Alert"; + typeCounts[key] = (typeCounts[key] || 0) + 1; + }); + const topTypes = Object.entries(typeCounts) + .map(([type, count]) => ({ type, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + // Group by source IP + const ipCounts: { [key: string]: number } = {}; + threatsData.forEach((t) => { + const key = t.ip_address || "Unknown IP"; + ipCounts[key] = (ipCounts[key] || 0) + 1; + }); + const topIps = Object.entries(ipCounts) + .map(([ip, count]) => ({ ip, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + // Sort incidents by severity (Critical > High > Medium > Low > Info) for PDF export + const SEVERITY_RANK: Record<string, number> = { + critical: 0, high: 1, medium: 2, med: 2, low: 3, info: 4, + }; + const getSeverityRank = (sev: string): number => { + const s = (sev || "").toLowerCase(); + const match = Object.entries(SEVERITY_RANK).find(([k]) => s.includes(k)); + return match ? match[1] : 5; + }; + const incidents = [...threatsData] + .sort((a, b) => getSeverityRank(a.severity) - getSeverityRank(b.severity)) + .slice(0, 50); + + return ( + <Document> + <Page size="A4" style={sharedStyles.page}> + {/* Header */} + <View style={sharedStyles.header}> + <View> + <Text style={sharedStyles.logoText}>Deep Package Inspection</Text> + <Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text> + </View> + <View style={sharedStyles.titleContainer}> + <Text style={sharedStyles.title}>Threat Intelligence Summary</Text> + <Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text> + </View> + </View> + + {/* Threat Counters */} + <Text style={sharedStyles.sectionTitle}>Threat Metrics Summary</Text> + <View style={sharedStyles.bandwidthGrid}> + <View style={{ ...sharedStyles.bandwidthCardCyan, backgroundColor: "#fef2f2", borderColor: "#fecaca" }}> + <Text style={{ ...sharedStyles.bandwidthLabel, color: "#b91c1c" }}>CRITICAL / HIGH THREATS</Text> + <Text style={{ ...sharedStyles.bandwidthValue, color: "#991b1b" }}>{criticalHigh}</Text> + </View> + <View style={{ ...sharedStyles.bandwidthCardCyan, backgroundColor: "#fffbeb", borderColor: "#fef3c7" }}> + <Text style={{ ...sharedStyles.bandwidthLabel, color: "#d97706" }}>MEDIUM THREATS</Text> + <Text style={{ ...sharedStyles.bandwidthValue, color: "#b45309" }}>{medium}</Text> + </View> + <View style={sharedStyles.bandwidthCardGreen}> + <Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>LOW / INFO THREATS</Text> + <Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{lowInfo}</Text> + </View> + </View> + + {/* Threat Types & Targets */} + <View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 10 }}> + <View style={{ width: "48%" }}> + <RenderPdfTable + title="Top Threat Types" + data={topTypes} + columns={[ + { header: "Threat Type", width: "70%", cell: (r) => r.type }, + { header: "Incidents", width: "30%", align: "right", cell: (r) => r.count }, + ]} + /> + </View> + + <View style={{ width: "48%" }}> + <RenderPdfTable + title="Top Targeted Host IPs" + data={topIps} + columns={[ + { header: "Host IP Address", width: "70%", cell: (r) => r.ip }, + { header: "Incidents", width: "30%", align: "right", cell: (r) => r.count }, + ]} + /> + </View> + </View> + + {/* Detailed Incidents List */} + <RenderPdfTable + title="Detailed Threat Log (Latest 30)" + data={incidents} + columns={[ + { header: "Time", width: "25%", cell: (r) => r.detected_at ? formatIndonesiaTime(r.detected_at) : "N/A" }, + { header: "Threat Type", width: "25%", cell: (r) => r.threat_type || "Security Alert" }, + { header: "Severity", width: "15%", align: "center", cell: (r) => (r.severity || "Unknown").toUpperCase() }, + { header: "Host IP", width: "15%", cell: (r) => r.ip_address || "—" }, + { header: "Target Destination", width: "20%", cell: (r) => r.dst_ip || r.domain || "—" }, + ]} + /> + </Page> + </Document> + ); +} diff --git a/src/components/threats/getMitigationSteps.ts b/src/components/threats/getMitigationSteps.ts index cdadf0f..28f1b2e 100644 --- a/src/components/threats/getMitigationSteps.ts +++ b/src/components/threats/getMitigationSteps.ts @@ -158,4 +158,4 @@ export function getMitigationSteps(type: string): { intro: string; steps: Mitiga ] }; } -} +} diff --git a/src/components/threats/sortAppOptions.ts b/src/components/threats/sortAppOptions.ts index d432aee..399c555 100644 --- a/src/components/threats/sortAppOptions.ts +++ b/src/components/threats/sortAppOptions.ts @@ -33,4 +33,4 @@ return infoA.portNum - infoB.portNum; }); -} +} diff --git a/src/components/threats/threatColumns.tsx b/src/components/threats/threatColumns.tsx new file mode 100644 index 0000000..6739b9b --- /dev/null +++ b/src/components/threats/threatColumns.tsx @@ -0,0 +1,128 @@ +import { Column } from "@/components/ui/DataTable"; +import { Badge } from "@/components/ui/Badge"; +import { TimestampCell } from "@/components/ui/TimestampCell"; +import { ThreatStat } from "@/lib/api"; +import { formatAppLabel } from "@/lib/utils"; + +export const getSeverityBadge = (sev?: string | null) => { + if (!sev) return <Badge variant="green">Unknown</Badge>; + const s = sev.toLowerCase(); + if (s.includes("high") || s.includes("critical")) return <Badge variant="red">{sev}</Badge>; + if (s.includes("med")) return <Badge variant="orange">{sev}</Badge>; + if (s.includes("low")) return <Badge variant="yellow">{sev}</Badge>; + return <Badge variant="blue">{sev}</Badge>; +}; + +export function getThreatColumns( + setSelectedIp: (ip: string) => void, + setSelectedMac: (mac: string) => void, + setSelectedThreat: (threat: ThreatStat) => void, + highlightId: string | null +): Column<ThreatStat>[] { + return [ + { + header: "Timestamp", + className: "w-[14%] text-center", + accessor: (row) => <TimestampCell timestamp={row.detected_at} showActiveStatus={false} />, + }, + { header: "#", className: "w-[4%] text-center", accessor: (_row, i) => i + 1 }, + { + header: "Type", + className: "w-[16%] text-center", + accessor: (row) => ( + <div + title={row.description || row.threat_type || "Unknown"} + className="truncate w-full block cursor-help text-xs leading-tight" + > + {row.threat_type || "Unknown"} + </div> + ), + }, + { + header: "Severity", + className: "w-[8%] text-center", + accessor: (row) => getSeverityBadge(row.severity), + }, + { + header: "Source IP", + className: "w-[12%] text-center", + accessor: (row) => + row.ip_address ? ( + <button + onClick={() => setSelectedIp(row.ip_address || "")} + className="font-medium text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none w-full text-center truncate" + > + {row.ip_address} + </button> + ) : ( + <span className="text-muted-foreground text-xs" title="Source IP is not available from security event data (only MAC Address was captured)">–</span> + ), + }, + { + header: "Dest IP", + className: "w-[12%] text-center", + accessor: (row) => + row.dst_ip ? ( + <button + onClick={() => setSelectedIp(row.dst_ip || "")} + className="font-mono text-xs text-blue-400 hover:text-blue-300 hover:underline focus:outline-none w-full text-center truncate" + > + {row.dst_ip} + </button> + ) : ( + <span className="text-muted-foreground text-xs" title="Dest IP is not available from this event data">–</span> + ), + }, + { + header: "MAC Addr", + className: "w-[13%] text-center", + accessor: (row) => + row.mac_address ? ( + <button + onClick={() => setSelectedMac(row.mac_address || "")} + className="text-xs bg-secondary/50 px-1.5 py-0.5 rounded text-muted-foreground hover:text-white hover:bg-secondary transition-colors font-mono focus:outline-none w-full text-center truncate" + title={row.mac_address} + > + {row.mac_address} + </button> + ) : "–", + }, + { + header: "App", + className: "w-[9%] text-center", + accessor: (row) => { + const formatted = formatAppLabel(row.app_label); + return formatted ? ( + <div title={formatted} className="truncate w-full block text-center cursor-help text-xs"> + {formatted} + </div> + ) : ( + <span className="text-muted-foreground text-xs" title="App is not available from this event">–</span> + ); + }, + }, + { + header: "Domain", + className: "w-[12%] text-center", + accessor: (row) => row.domain ? ( + <div title={row.domain} className="truncate w-full text-center text-xs cursor-help"> + {row.domain} + </div> + ) : ( + <span className="text-muted-foreground text-xs" title="Domain is not available from this event">–</span> + ), + }, + { + header: "Action", + className: "w-[10%] text-center", + accessor: (row) => ( + <button + onClick={() => setSelectedThreat(row)} + className="px-2 py-1 text-[11px] font-bold text-primary bg-primary/10 border border-primary/20 hover:bg-primary/20 hover:text-white rounded-lg transition-all shadow-sm cursor-pointer focus:outline-none leading-tight" + > + View Mitigation + </button> + ), + }, + ]; +} diff --git a/src/components/ui/AgentAppsTab.tsx b/src/components/ui/AgentAppsTab.tsx new file mode 100644 index 0000000..fbf0978 --- /dev/null +++ b/src/components/ui/AgentAppsTab.tsx @@ -0,0 +1,54 @@ +"use client"; + +import React from "react"; +import { ChevronRight } from "lucide-react"; +import { fmtBytes } from "@/lib/utils"; + +interface AgentAppItem { + app_id: number | null; + app_label: string; + category: string | null; + download: number; + upload: number; +} + +interface AgentAppsTabProps { + topApps: AgentAppItem[]; + onSelectApp: (label: string) => void; +} + +export default function AgentAppsTab({ topApps, onSelectApp }: AgentAppsTabProps) { + if (topApps.length === 0) { + return <p className="text-muted-foreground text-sm text-center py-10">No applications accessed by this agent.</p>; + } + + return ( + <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> + {topApps.map((app) => { + const appTotal = app.download + app.upload; + return ( + <button + key={app.app_id} + onClick={() => onSelectApp(app.app_label)} + className="flex items-center gap-3 p-3 bg-white/[0.03] hover:bg-white/[0.07] border border-border/50 rounded-xl transition-all group w-full text-left" + > + <div className="flex-1 min-w-0"> + <p className="font-semibold text-card-foreground text-sm truncate group-hover:text-cyan-300 transition-colors"> + {app.app_label} + </p> + <p className="text-xs text-muted-foreground mt-0.5">{app.category || "Web"}</p> + </div> + <div className="text-right flex-shrink-0"> + <p className="text-xs text-card-foreground font-semibold">{fmtBytes(appTotal)}</p> + <span className="text-[10px] text-muted-foreground flex gap-2"> + <span className="text-cyan-400">DL: {fmtBytes(app.download)}</span> + <span className="text-green-400">UL: {fmtBytes(app.upload)}</span> + </span> + </div> + <ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-cyan-400 transition-colors" /> + </button> + ); + })} + </div> + ); +} diff --git a/src/components/ui/AgentDetailModal.tsx b/src/components/ui/AgentDetailModal.tsx index 07ad0b2..89a2fcd 100644 --- a/src/components/ui/AgentDetailModal.tsx +++ b/src/components/ui/AgentDetailModal.tsx @@ -79,7 +79,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) { return ( <> - <div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}> + <div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" onClick={handleClose}> <div className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} /> <div className={`relative bg-card border border-border rounded-2xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`} diff --git a/src/components/ui/AgentPdfDocument.tsx b/src/components/ui/AgentPdfDocument.tsx new file mode 100644 index 0000000..40c616b --- /dev/null +++ b/src/components/ui/AgentPdfDocument.tsx @@ -0,0 +1,245 @@ +import React from "react"; +import { Document, Page, Text, View } from "@react-pdf/renderer"; +import { sharedStyles } from "./sharedPdfStyles"; + +interface AgentPdfDocumentProps { + data: any; + agentLabel: string; + agentUuid: string; + reportDateStr: string; +} + +interface TableColumn { + header: string; + width: string; + align?: "center" | "right" | "left"; + cell: (row: any) => string | number; +} + +function formatBytes(bytes: number): string { + if (!bytes || bytes === 0) return "0 B"; + const k = 1000; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +} + +function formatIndonesiaTime(dateStr: string | Date): string { + if (!dateStr) return "Never"; + try { + const d = new Date(dateStr); + return d.toLocaleString("id-ID", { + timeZone: "Asia/Jakarta", + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + " WIB"; + } catch (e) { + return String(dateStr); + } +} + +function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) { + return ( + <View style={{ marginBottom: 15 }}> + <Text style={sharedStyles.sectionTitle}>{title}</Text> + <View style={sharedStyles.table}> + <View style={sharedStyles.tableHeader}> + {columns.map((col, idx) => ( + <View key={idx} style={{ width: col.width, paddingRight: 6 }}> + <Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}> + {col.header} + </Text> + </View> + ))} + </View> + {data.length === 0 ? ( + <View style={sharedStyles.tableRow}> + <Text style={sharedStyles.emptyText}>No Data</Text> + </View> + ) : ( + data.map((row, rowIdx) => ( + <View key={rowIdx} style={sharedStyles.tableRow} wrap={false}> + {columns.map((col, colIdx) => ( + <View key={colIdx} style={{ width: col.width, paddingRight: 6 }}> + <Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}> + {col.cell(row)} + </Text> + </View> + ))} + </View> + )) + )} + </View> + </View> + ); +} + +export default function AgentPdfDocument({ + data, + agentLabel, + agentUuid, + reportDateStr, +}: AgentPdfDocumentProps) { + const summary = data.summary || {}; + + // Sort by total bandwidth (download + upload) descending for PDF export + const sortByBandwidth = (arr: any[]) => + [...arr].sort((a, b) => + ((b.download ?? b.bytes_download ?? 0) + (b.upload ?? b.bytes_upload ?? 0)) - + ((a.download ?? a.bytes_download ?? 0) + (a.upload ?? a.bytes_upload ?? 0)) + ); + + const devices = sortByBandwidth(data.devices || []).slice(0, 50); + const flows = sortByBandwidth(data.flows || []).slice(0, 50); + const topApps = sortByBandwidth(data.top_apps || []).slice(0, 50); + const macBw = sortByBandwidth(data.mac_bandwidth || []).slice(0, 50); + // Security alerts: not sorted by bandwidth — keep original order + const insecure = (data.security?.insecure_protocols || []).slice(0, 50); + const reputation = (data.security?.ip_reputation || []).slice(0, 50); + const events = (data.events || []).slice(0, 50); + + return ( + <Document> + <Page size="A4" style={sharedStyles.page}> + {/* Header */} + <View style={sharedStyles.header}> + <View> + <Text style={sharedStyles.logoText}>Deep Package Inspection</Text> + <Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text> + </View> + <View style={sharedStyles.titleContainer}> + <Text style={sharedStyles.title}>Network Agent Report</Text> + <Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text> + </View> + </View> + + {/* Agent Info */} + <Text style={sharedStyles.sectionTitle}>Agent Identification</Text> + <View style={sharedStyles.grid}> + <View style={{ ...sharedStyles.gridCol, width: "35%" }}> + <Text style={sharedStyles.gridLabel}>Agent Name</Text> + <Text style={sharedStyles.gridValue}>{agentLabel || "N/A"}</Text> + </View> + <View style={{ ...sharedStyles.gridCol, width: "65%" }}> + <Text style={sharedStyles.gridLabel}>Agent UUID</Text> + <Text style={sharedStyles.gridValue}>{agentUuid || "N/A"}</Text> + </View> + <View style={sharedStyles.gridCol}> + <Text style={sharedStyles.gridLabel}>Active Devices</Text> + <Text style={sharedStyles.gridValue}>{summary.total_devices ?? 0}</Text> + </View> + <View style={sharedStyles.gridCol}> + <Text style={sharedStyles.gridLabel}>Active Flows</Text> + <Text style={sharedStyles.gridValue}>{summary.active_flows ?? 0}</Text> + </View> + </View> + + {/* Bandwidth Usage */} + <Text style={sharedStyles.sectionTitle}>Bandwidth Metrics</Text> + <View style={sharedStyles.bandwidthGrid}> + <View style={sharedStyles.bandwidthCardCyan}> + <Text style={{ ...sharedStyles.bandwidthLabel, color: "#0d9488" }}>TOTAL DOWNLOAD</Text> + <Text style={{ ...sharedStyles.bandwidthValue, color: "#115e59" }}>{formatBytes(summary.bandwidth_down)}</Text> + </View> + <View style={sharedStyles.bandwidthCardGreen}> + <Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>TOTAL UPLOAD</Text> + <Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{formatBytes(summary.bandwidth_up)}</Text> + </View> + </View> + + {/* Top Apps */} + <RenderPdfTable + title="Top Applications" + data={topApps} + columns={[ + { header: "Application", width: "40%", cell: (r) => r.app_label || "Unknown" }, + { header: "Category", width: "20%", cell: (r) => r.category || "—" }, + { header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) }, + { header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) }, + ]} + /> + + {/* Monitored Devices */} + <RenderPdfTable + title="Monitored Devices" + data={devices} + columns={[ + { header: "IP Address", width: "22%", cell: (r) => r.ip_address || "N/A" }, + { header: "OS / Manufacturer", width: "28%", cell: (r) => r.os_label || r.manufacturer || "Unknown" }, + { header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) }, + { header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) }, + { header: "Last Seen", width: "20%", cell: (r) => r.last_seen ? formatIndonesiaTime(r.last_seen) : "Never" }, + ]} + /> + + {/* Recent Network Flows */} + <RenderPdfTable + title="Recent Network Flows" + data={flows} + columns={[ + { header: "Source IP", width: "20%", cell: (r) => r.src_ip || "—" }, + { header: "Destination IP", width: "20%", cell: (r) => r.dst_ip || "—" }, + { header: "Port", width: "10%", align: "center", cell: (r) => r.dst_port || "—" }, + { header: "App / Protocol", width: "20%", cell: (r) => r.app_label || r.protocol || "Unknown" }, + { header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) }, + { header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) }, + ]} + /> + + {/* Insecure Protocols */} + <RenderPdfTable + title="Insecure Protocols" + data={insecure} + columns={[ + { header: "Host IP", width: "25%", cell: (r) => r.ip_address || "—" }, + { header: "Protocol", width: "15%", cell: (r) => r.protocol || "—" }, + { header: "Risk Level", width: "15%", cell: (r) => r.risk || "—" }, + { header: "Destination", width: "25%", cell: (r) => r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? ""}` : "—" }, + { header: "Bytes Traced", width: "20%", align: "right", cell: (r) => formatBytes((r.download ?? 0) + (r.upload ?? 0)) }, + ]} + /> + + {/* IP Reputation Log */} + <RenderPdfTable + title="IP Reputation Log" + data={reputation} + columns={[ + { header: "Remote IP", width: "30%", cell: (r) => r.ip_address || "—" }, + { header: "Local IP", width: "30%", cell: (r) => r.local_ip || "—" }, + { header: "Reputation", width: "20%", cell: (r) => r.reputation || "—" }, + { header: "Risk Score", width: "20%", align: "right", cell: (r) => r.score ?? "—" }, + ]} + /> + + {/* Security Events */} + <RenderPdfTable + title="Security Events Log" + data={events} + columns={[ + { header: "Event Type", width: "20%", cell: (r) => r.event_type || "—" }, + { header: "Severity", width: "15%", align: "center", cell: (r) => r.severity || "—" }, + { header: "IP Address", width: "20%", cell: (r) => r.ip_address || "—" }, + { header: "Description", width: "25%", cell: (r) => r.description || "—" }, + { header: "Event Time", width: "20%", cell: (r) => r.event_at ? formatIndonesiaTime(r.event_at) : "—" }, + ]} + /> + + {/* MAC Bandwidth */} + <RenderPdfTable + title="MAC Bandwidth Distribution" + data={macBw} + columns={[ + { header: "MAC Address", width: "30%", cell: (r) => r.mac_address || "—" }, + { header: "Manufacturer", width: "30%", cell: (r) => r.manufacturer || "Unknown" }, + { header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) }, + { header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) }, + ]} + /> + </Page> + </Document> + ); +} diff --git a/src/components/ui/AppDetailModal.tsx b/src/components/ui/AppDetailModal.tsx index b63928e..9b9f70a 100644 --- a/src/components/ui/AppDetailModal.tsx +++ b/src/components/ui/AppDetailModal.tsx @@ -90,7 +90,7 @@ export function AppDetailModal({ appLabel, favicon, category, agentUuid, onClose return ( <> - <div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}> + <div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" onClick={handleClose}> <div className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} /> <div className={`relative bg-card border border-border rounded-2xl w-full max-w-3xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`} diff --git a/src/components/ui/AppPdfDocument.tsx b/src/components/ui/AppPdfDocument.tsx new file mode 100644 index 0000000..485dcd5 --- /dev/null +++ b/src/components/ui/AppPdfDocument.tsx @@ -0,0 +1,164 @@ +import React from "react"; +import { Document, Page, Text, View } from "@react-pdf/renderer"; +import { sharedStyles } from "./sharedPdfStyles"; + +interface AppPdfDocumentProps { + data: any; + appLabel: string; + category: string; + reportDateStr: string; +} + +interface TableColumn { + header: string; + width: string; + align?: "center" | "right" | "left"; + cell: (row: any) => string | number; +} + +function formatBytes(bytes: number): string { + if (!bytes || bytes === 0) return "0 B"; + const k = 1000; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +} + +function formatIndonesiaTime(dateStr: string | Date): string { + if (!dateStr) return "Never"; + try { + const d = new Date(dateStr); + return d.toLocaleString("id-ID", { + timeZone: "Asia/Jakarta", + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + " WIB"; + } catch (e) { + return String(dateStr); + } +} + +function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) { + return ( + <View style={{ marginBottom: 15 }}> + <Text style={sharedStyles.sectionTitle}>{title}</Text> + <View style={sharedStyles.table}> + <View style={sharedStyles.tableHeader}> + {columns.map((col, idx) => ( + <View key={idx} style={{ width: col.width, paddingRight: 6 }}> + <Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}> + {col.header} + </Text> + </View> + ))} + </View> + {data.length === 0 ? ( + <View style={sharedStyles.tableRow}> + <Text style={sharedStyles.emptyText}>No Data</Text> + </View> + ) : ( + data.map((row, rowIdx) => ( + <View key={rowIdx} style={sharedStyles.tableRow} wrap={false}> + {columns.map((col, colIdx) => ( + <View key={colIdx} style={{ width: col.width, paddingRight: 6 }}> + <Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}> + {col.cell(row)} + </Text> + </View> + ))} + </View> + )) + )} + </View> + </View> + ); +} + +export default function AppPdfDocument({ + data, + appLabel, + category, + reportDateStr, +}: AppPdfDocumentProps) { + // Sort by total bandwidth (download + upload) descending for PDF export + const sortByBandwidth = (arr: any[]) => + [...arr].sort((a, b) => + ((b.download ?? 0) + (b.upload ?? 0)) - ((a.download ?? 0) + (a.upload ?? 0)) + ); + + const topIps = sortByBandwidth(data.top_ips || []).slice(0, 50); + const agents = sortByBandwidth(data.agent_scorecard || []).slice(0, 50); + + return ( + <Document> + <Page size="A4" style={sharedStyles.page}> + {/* Header */} + <View style={sharedStyles.header}> + <View> + <Text style={sharedStyles.logoText}>Deep Package Inspection</Text> + <Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text> + </View> + <View style={sharedStyles.titleContainer}> + <Text style={sharedStyles.title}>Application Activity Report</Text> + <Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text> + </View> + </View> + + {/* Application Details */} + <Text style={sharedStyles.sectionTitle}>Application Summary</Text> + <View style={sharedStyles.grid}> + <View style={{ ...sharedStyles.gridCol, width: "50%" }}> + <Text style={sharedStyles.gridLabel}>Application Name</Text> + <Text style={sharedStyles.gridValue}>{appLabel}</Text> + </View> + <View style={{ ...sharedStyles.gridCol, width: "50%" }}> + <Text style={sharedStyles.gridLabel}>Category</Text> + <Text style={sharedStyles.gridValue}>{category || "General Web"}</Text> + </View> + </View> + + {/* Bandwidth Usage */} + <Text style={sharedStyles.sectionTitle}>Traffic Metrics</Text> + <View style={sharedStyles.bandwidthGrid}> + <View style={sharedStyles.bandwidthCardCyan}> + <Text style={{ ...sharedStyles.bandwidthLabel, color: "#0d9488" }}>TOTAL DOWNLOAD</Text> + <Text style={{ ...sharedStyles.bandwidthValue, color: "#115e59" }}>{formatBytes(data.total_download)}</Text> + </View> + <View style={sharedStyles.bandwidthCardGreen}> + <Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>TOTAL UPLOAD</Text> + <Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{formatBytes(data.total_upload)}</Text> + </View> + </View> + + {/* Top Users */} + <RenderPdfTable + title="Top User Devices" + data={topIps} + columns={[ + { header: "IP Address", width: "30%", cell: (r) => r.ip_address || "—" }, + { header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) }, + { header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) }, + { header: "Flows", width: "10%", align: "center", cell: (r) => r.flow_count ?? 0 }, + { header: "Last Seen", width: "20%", cell: (r) => r.last_seen ? formatIndonesiaTime(r.last_seen) : "Never" }, + ]} + /> + + {/* Top Agents */} + <RenderPdfTable + title="Agent Telemetry Distribution" + data={agents} + columns={[ + { header: "Agent Node", width: "50%", cell: (r) => r.agent_label || "Unknown Agent" }, + { header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) }, + { header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) }, + { header: "Flows", width: "10%", align: "center", cell: (r) => r.flow_count ?? 0 }, + ]} + /> + </Page> + </Document> + ); +} diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index 73abfe5..4f3a3da 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -1,26 +1,26 @@ -import { cn } from "@/lib/utils"; - -interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> { - variant?: "default" | "red" | "orange" | "yellow" | "green" | "blue" | "cyan"; -} - -export function Badge({ className, variant = "default", ...props }: BadgeProps) { - return ( - <div - className={cn( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", - { - "border-primary/20 bg-primary/10 text-primary": variant === "default", - "border-red-500/20 bg-red-500/10 text-red-500": variant === "red", - "border-orange-500/20 bg-orange-500/10 text-orange-500": variant === "orange", - "border-yellow-500/20 bg-yellow-500/10 text-yellow-500": variant === "yellow", - "border-emerald-500/20 bg-emerald-500/10 text-emerald-500": variant === "green", - "border-blue-500/20 bg-blue-500/10 text-blue-500": variant === "blue", - "border-cyan-500/20 bg-cyan-500/10 text-cyan-500": variant === "cyan", - }, - className - )} - {...props} - /> - ); -} +import { cn } from "@/lib/utils"; + +interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> { + variant?: "default" | "red" | "orange" | "yellow" | "green" | "blue" | "cyan"; +} + +export function Badge({ className, variant = "default", ...props }: BadgeProps) { + return ( + <div + className={cn( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + "border-primary/20 bg-primary/10 text-primary": variant === "default", + "border-red-500/20 bg-red-500/10 text-red-500": variant === "red", + "border-orange-500/20 bg-orange-500/10 text-orange-500": variant === "orange", + "border-yellow-500/20 bg-yellow-500/10 text-yellow-500": variant === "yellow", + "border-emerald-500/20 bg-emerald-500/10 text-emerald-500": variant === "green", + "border-blue-500/20 bg-blue-500/10 text-blue-500": variant === "blue", + "border-cyan-500/20 bg-cyan-500/10 text-cyan-500": variant === "cyan", + }, + className + )} + {...props} + /> + ); +} diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx index 6df421e..0b4945b 100644 --- a/src/components/ui/Card.tsx +++ b/src/components/ui/Card.tsx @@ -1,68 +1,68 @@ -import { cn } from "@/lib/utils"; -import { ReactNode } from "react"; - -export function Card({ - children, - className, - ...props -}: React.HTMLAttributes<HTMLDivElement> & { - children: ReactNode; - className?: string; -}) { - return ( - <div - className={cn( - "rounded-xl border border-border bg-card/80 backdrop-blur-xl text-card-foreground shadow-sm hover:shadow-primary/5 hover:border-primary/20 transition-all duration-300 ease-in-out hover:-translate-y-0.5", - className - )} - {...props} - > - {children} - </div> - ); -} - -export function CardHeader({ - children, - className, -}: { - children: ReactNode; - className?: string; -}) { - return ( - <div className={cn("flex flex-col space-y-1.5 p-6", className)}> - {children} - </div> - ); -} - -export function CardTitle({ - children, - className, -}: { - children: ReactNode; - className?: string; -}) { - return ( - <h3 - className={cn( - "font-semibold leading-none tracking-tight", - className - )} - > - {children} - </h3> - ); -} - -export function CardContent({ - children, - className, -}: { - children: ReactNode; - className?: string; -}) { - return ( - <div className={cn("p-6 pt-0", className)}>{children}</div> - ); -} +import { cn } from "@/lib/utils"; +import { ReactNode } from "react"; + +export function Card({ + children, + className, + ...props +}: React.HTMLAttributes<HTMLDivElement> & { + children: ReactNode; + className?: string; +}) { + return ( + <div + className={cn( + "rounded-xl border border-border bg-card/80 backdrop-blur-xl text-card-foreground shadow-sm hover:shadow-primary/5 hover:border-primary/20 transition-all duration-300 ease-in-out hover:-translate-y-0.5", + className + )} + {...props} + > + {children} + </div> + ); +} + +export function CardHeader({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( + <div className={cn("flex flex-col space-y-1.5 p-6", className)}> + {children} + </div> + ); +} + +export function CardTitle({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( + <h3 + className={cn( + "font-semibold leading-none tracking-tight", + className + )} + > + {children} + </h3> + ); +} + +export function CardContent({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( + <div className={cn("p-6 pt-0", className)}>{children}</div> + ); +} diff --git a/src/components/ui/DataTable.tsx b/src/components/ui/DataTable.tsx index 42c378b..2a13723 100644 --- a/src/components/ui/DataTable.tsx +++ b/src/components/ui/DataTable.tsx @@ -1,7 +1,9 @@ "use client"; -import { useState } from "react"; -import { Search, Download } from "lucide-react"; +import { useState, useRef, useEffect } from "react"; +import { Search, Download, ChevronDown, FileText, FileSpreadsheet } from "lucide-react"; +import { CsvExportOptions, downloadCsv } from "./DataTableCsvHelper"; +import { DataTablePagination } from "./DataTablePagination"; export interface Column<T> { header: React.ReactNode; @@ -9,12 +11,7 @@ export interface Column<T> { className?: string; sortable?: boolean; sortAccessor?: (row: T) => number | string | null | undefined; -} - -export interface CsvExportOptions<T> { - filename: string; - headers: string[]; - rowSerializer: (row: T) => (string | number | null | undefined)[]; + tooltip?: (row: T) => string; } interface DataTableProps<T> { @@ -26,27 +23,12 @@ interface DataTableProps<T> { getRowClassName?: (row: T) => string; pageSize?: number; csvExport?: CsvExportOptions<T>; + pdfExport?: { onExport: () => void; isExporting?: boolean; }; onRowClick?: (row: T) => void; -} - -function escapeCsvCell(val: string | number | null | undefined): string { - const str = val == null ? "" : String(val); - if (str.includes(",") || str.includes('"') || str.includes("\n")) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; -} - -function downloadCsv(filename: string, rows: (string | number | null | undefined)[][]): void { - const bom = "\uFEFF"; - const csv = bom + rows.map(row => row.map(escapeCsvCell).join(",")).join("\r\n"); - const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - link.click(); - URL.revokeObjectURL(url); + serverSide?: boolean; + totalCount?: number; + currentPage?: number; + onPageChange?: (page: number) => void; } export function DataTable<T>({ @@ -58,61 +40,71 @@ export function DataTable<T>({ getRowClassName, pageSize = 50, csvExport, + pdfExport, onRowClick, + serverSide = false, + totalCount = 0, + currentPage = 1, + onPageChange, }: DataTableProps<T>) { const [query, setQuery] = useState(""); - const [currentPage, setCurrentPage] = useState(1); + const [localCurrentPage, setLocalCurrentPage] = useState(1); const [sortConfig, setSortConfig] = useState<{ key: number; direction: 'asc' | 'desc' } | null>(null); - const handleSort = (columnIndex: number) => { - let direction: 'asc' | 'desc' = 'asc'; - if (sortConfig && sortConfig.key === columnIndex && sortConfig.direction === 'asc') { - direction = 'desc'; - } - setSortConfig({ key: columnIndex, direction }); + const activePage = serverSide ? currentPage : localCurrentPage; + const setActivePage = serverSide ? (onPageChange || (() => {})) : setLocalCurrentPage; + + const handleSort = (key: number) => { + const direction = sortConfig?.key === key && sortConfig.direction === 'asc' ? 'desc' : 'asc'; + setSortConfig({ key, direction }); }; let processedData = [...data]; - if (searchFilter && query.trim()) { + if (!serverSide && searchFilter && query.trim()) { processedData = processedData.filter(row => searchFilter(row, query.trim())); } - if (sortConfig !== null) { + if (!serverSide && sortConfig !== null) { const column = columns[sortConfig.key]; if (column && column.sortable && column.sortAccessor) { processedData.sort((a, b) => { - const valA = column.sortAccessor!(a); - const valB = column.sortAccessor!(b); + const valA = column.sortAccessor!(a), valB = column.sortAccessor!(b); if (valA === valB) return 0; - if (valA === null || valA === undefined) return 1; - if (valB === null || valB === undefined) return -1; - if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1; - if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1; - return 0; + if (valA == null) return 1; + if (valB == null) return -1; + const asc = sortConfig.direction === 'asc'; + return valA < valB ? (asc ? -1 : 1) : (asc ? 1 : -1); }); } } const filteredData = processedData; - const totalPages = Math.max(1, Math.ceil(filteredData.length / pageSize)); - const startIndex = (currentPage - 1) * pageSize; - const paginatedData = filteredData.slice(startIndex, startIndex + pageSize); + const totalItems = serverSide ? totalCount : filteredData.length; + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + const startIndex = (activePage - 1) * pageSize; + const paginatedData = serverSide ? filteredData : filteredData.slice(startIndex, startIndex + pageSize); + + const [exportOpen, setExportOpen] = useState(false); + const exportRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + const onClick = (e: MouseEvent) => exportRef.current && !exportRef.current.contains(e.target as Node) && setExportOpen(false); + document.addEventListener('mousedown', onClick); + return () => document.removeEventListener('mousedown', onClick); + }, []); const handleCsvExport = () => { if (!csvExport) return; - const rows = [ - csvExport.headers, - ...filteredData.map(row => csvExport.rowSerializer(row)), - ]; - downloadCsv(csvExport.filename, rows); + downloadCsv(csvExport.filename, [csvExport.headers, ...filteredData.map(csvExport.rowSerializer)]); + setExportOpen(false); }; return ( <div className="space-y-4 animate-fade-in"> <div className="flex items-center justify-between gap-3 flex-wrap"> - {searchFilter && ( + {!serverSide && searchFilter && ( <div className="relative group"> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground transition-colors group-focus-within:text-primary" /> <input @@ -121,45 +113,75 @@ export function DataTable<T>({ value={query} onChange={(e) => { setQuery(e.target.value); - setCurrentPage(1); + setActivePage(1); }} className="w-full max-w-sm rounded-lg border border-border bg-card/30 backdrop-blur-sm pl-9 pr-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm" /> </div> )} - {csvExport && ( - <button - onClick={handleCsvExport} - title={`Export ${filteredData.length} rows to CSV`} - className="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-emerald-500/40 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 hover:border-emerald-500/70 hover:text-emerald-300 transition-all shadow-sm whitespace-nowrap" - > - <Download className="w-4 h-4" /> - Export CSV ({filteredData.length}) - </button> + {(csvExport || pdfExport) && ( + <div className="relative" ref={exportRef}> + <button + onClick={() => setExportOpen(prev => !prev)} + className="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-emerald-500/40 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 hover:border-emerald-500/70 hover:text-emerald-300 transition-all shadow-sm whitespace-nowrap" + > + <Download className="w-4 h-4" /> + Export ({totalItems}) + <ChevronDown className={`w-3.5 h-3.5 transition-transform ${exportOpen ? 'rotate-180' : ''}`} /> + </button> + {exportOpen && ( + <div className="absolute right-0 mt-1.5 w-44 rounded-xl border border-border/60 bg-slate-900/95 backdrop-blur-xl shadow-xl z-50 overflow-hidden"> + {csvExport && ( + <button + onClick={handleCsvExport} + className="flex items-center gap-2.5 w-full px-4 py-2.5 text-sm text-emerald-400 hover:bg-emerald-500/10 transition-colors" + > + <FileSpreadsheet className="w-4 h-4" /> + Export CSV + </button> + )} + {pdfExport && ( + <button + onClick={() => { pdfExport.onExport(); setExportOpen(false); }} + disabled={pdfExport.isExporting} + className="flex items-center gap-2.5 w-full px-4 py-2.5 text-sm text-blue-400 hover:bg-blue-500/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + <FileText className="w-4 h-4" /> + {pdfExport.isExporting ? 'Generating...' : 'Export PDF'} + </button> + )} + </div> + )} + </div> )} </div> <div className="rounded-xl border border-border bg-card/50 backdrop-blur-xl shadow-sm overflow-hidden"> <div className="w-full max-h-[400px] overflow-y-auto custom-scrollbar [&::-webkit-scrollbar-track]:mt-[48px]"> - <table className="w-full text-sm text-left table-fixed"> + <table className="w-full text-sm text-center table-fixed"> <thead className="bg-slate-900 text-muted-foreground border-b border-border/50 sticky top-0 z-20"> <tr> - {columns.map((col, i) => ( - <th - key={i} - onClick={() => col.sortable && handleSort(i)} - className={`px-4 py-3.5 h-[48px] font-medium tracking-wide whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""} ${col.sortable ? "cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors group select-none" : ""}`} - > - <div className={`flex items-center gap-1 ${col.className?.includes("text-") && !col.className?.includes("text-center") ? "" : "justify-center"}`}> - {col.header} - {col.sortable && ( - <div className="flex flex-col text-[10px] opacity-40 group-hover:opacity-100 transition-opacity"> - <span className={`${sortConfig?.key === i && sortConfig.direction === 'asc' ? 'text-primary opacity-100' : ''}`}>▲</span> - <span className={`-mt-1 ${sortConfig?.key === i && sortConfig.direction === 'desc' ? 'text-primary opacity-100' : ''}`}>▼</span> - </div> - )} - </div> - </th> - ))} + {columns.map((col, i) => { + const hasWidth = col.className?.split(' ').some(c => c.startsWith('w-[') || c.startsWith('w-')); + const widthStyle = hasWidth ? undefined : { width: `${100 / columns.length}%` }; + return ( + <th + key={i} + onClick={() => col.sortable && handleSort(i)} + className={`px-4 py-3.5 h-[48px] font-medium tracking-wide text-center ${col.className || ""} ${col.sortable ? "cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors group select-none" : ""}`} + style={widthStyle} + > + <div className="flex items-center gap-1 justify-center"> + {col.header} + {col.sortable && ( + <div className="flex flex-col text-[10px] opacity-40 group-hover:opacity-100 transition-opacity"> + <span className={`${sortConfig?.key === i && sortConfig.direction === 'asc' ? 'text-primary opacity-100' : ''}`}>▲</span> + <span className={`-mt-1 ${sortConfig?.key === i && sortConfig.direction === 'desc' ? 'text-primary opacity-100' : ''}`}>▼</span> + </div> + )} + </div> + </th> + ); + })} </tr> </thead> <tbody className="divide-y divide-border/50"> @@ -184,7 +206,6 @@ export function DataTable<T>({ <div className="flex flex-col items-center justify-center text-muted-foreground"> <Search className="h-8 w-8 mb-3 opacity-20" /> <span className="text-sm font-medium">No data found</span> - {query && <span className="text-xs mt-1 opacity-70">Try adjusting your search query</span>} </div> </td> </tr> @@ -195,11 +216,24 @@ export function DataTable<T>({ onClick={() => onRowClick?.(row)} className={`hover:bg-primary/5 border-b border-border/50 transition-colors duration-200 group h-[48px] ${onRowClick ? "cursor-pointer hover:bg-primary/10" : ""} ${getRowClassName ? getRowClassName(row) : ""}`} > - {columns.map((col, j) => ( - <td key={j} className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground transition-colors truncate whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""}`}> - {col.accessor(row, i)} - </td> - ))} + {columns.map((col, j) => { + const hasWidth = col.className?.split(' ').some(c => c.startsWith('w-[') || c.startsWith('w-')); + const widthStyle = hasWidth ? undefined : { width: `${100 / columns.length}%` }; + const content = col.accessor(row, (activePage - 1) * pageSize + i); + const tooltipText = col.tooltip + ? col.tooltip(row) + : (typeof content === 'string' || typeof content === 'number' ? String(content) : undefined); + return ( + <td + key={j} + className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground text-center transition-colors ${col.className || ""}`} + style={widthStyle} + title={tooltipText} + > + {content} + </td> + ); + })} </tr> )) )} @@ -207,37 +241,14 @@ export function DataTable<T>({ </table> </div> - {filteredData.length > pageSize && ( - <div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-card/30"> - <div className="text-sm text-muted-foreground"> - Showing <span className="font-medium text-foreground">{startIndex + 1}</span> to{" "} - <span className="font-medium text-foreground"> - {Math.min(startIndex + pageSize, filteredData.length)} - </span>{" "} - of <span className="font-medium text-foreground">{filteredData.length}</span> results - </div> - <div className="flex items-center space-x-2"> - <button - onClick={() => setCurrentPage(p => Math.max(1, p - 1))} - disabled={currentPage === 1} - className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50" - > - Previous - </button> - <div className="text-sm text-muted-foreground font-medium px-2"> - Page {currentPage} of {totalPages} - </div> - <button - onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} - disabled={currentPage === totalPages} - className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50" - > - Next - </button> - </div> - </div> - )} + <DataTablePagination + totalItems={totalItems} + pageSize={pageSize} + currentPage={activePage} + totalPages={totalPages} + onPageChange={setActivePage} + /> </div> </div> ); -} +} diff --git a/src/components/ui/DataTableCsvHelper.ts b/src/components/ui/DataTableCsvHelper.ts new file mode 100644 index 0000000..bffb49a --- /dev/null +++ b/src/components/ui/DataTableCsvHelper.ts @@ -0,0 +1,25 @@ +export interface CsvExportOptions<T> { + filename: string; + headers: string[]; + rowSerializer: (row: T) => (string | number | null | undefined)[]; +} + +export function escapeCsvCell(val: string | number | null | undefined): string { + const str = val == null ? "" : String(val); + if (str.includes(",") || str.includes('"') || str.includes("\n")) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +export function downloadCsv(filename: string, rows: (string | number | null | undefined)[][]): void { + const bom = "\uFEFF"; + const csv = bom + rows.map(row => row.map(escapeCsvCell).join(",")).join("\r\n"); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/src/components/ui/DataTablePagination.tsx b/src/components/ui/DataTablePagination.tsx new file mode 100644 index 0000000..75e41c9 --- /dev/null +++ b/src/components/ui/DataTablePagination.tsx @@ -0,0 +1,54 @@ +"use client"; + +import React from "react"; + +interface DataTablePaginationProps { + totalItems: number; + pageSize: number; + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +export function DataTablePagination({ + totalItems, + pageSize, + currentPage, + totalPages, + onPageChange, +}: DataTablePaginationProps) { + if (totalItems <= pageSize) return null; + + const startIndex = (currentPage - 1) * pageSize; + + return ( + <div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-card/30 flex-wrap gap-3"> + <div className="text-sm text-muted-foreground"> + Showing <span className="font-medium text-foreground">{startIndex + 1}</span> to{" "} + <span className="font-medium text-foreground"> + {Math.min(startIndex + pageSize, totalItems)} + </span>{" "} + of <span className="font-medium text-foreground">{totalItems}</span> results + </div> + <div className="flex items-center space-x-2"> + <button + onClick={() => onPageChange(Math.max(1, currentPage - 1))} + disabled={currentPage === 1} + className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50 cursor-pointer" + > + Previous + </button> + <div className="text-sm text-muted-foreground font-medium px-2"> + Page {currentPage} of {totalPages} + </div> + <button + onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))} + disabled={currentPage === totalPages} + className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50 cursor-pointer" + > + Next + </button> + </div> + </div> + ); +} diff --git a/src/components/ui/DateRangePicker.tsx b/src/components/ui/DateRangePicker.tsx index cf2231e..5be7f6b 100644 --- a/src/components/ui/DateRangePicker.tsx +++ b/src/components/ui/DateRangePicker.tsx @@ -68,7 +68,7 @@ export function DateRangePicker({ {open && ( <div className="absolute top-full left-0 right-0 mt-1 bg-slate-900 border border-border/50 rounded-xl shadow-2xl z-50 p-4 min-w-[260px]"> <p className="text-xs text-muted-foreground mb-3 font-medium"> - Filter by Date Range + Filter Threats by Date </p> <div className="space-y-3"> <div> @@ -119,6 +119,17 @@ export function DateRangePicker({ > 7 Days </button> + <button + onClick={() => { + const d = new Date(); + d.setDate(d.getDate() - 29); + onChange(d.toISOString().slice(0, 10), today); + setOpen(false); + }} + className="flex-1 px-2 py-1.5 text-xs bg-secondary/50 text-white border border-border/50 rounded-lg hover:bg-secondary transition-colors" + > + 30 Days + </button> <button onClick={() => { onClear(); @@ -133,4 +144,4 @@ export function DateRangePicker({ )} </div> ); -} +} diff --git a/src/components/ui/DeviceDetailModal.tsx b/src/components/ui/DeviceDetailModal.tsx index 687ca35..ac74e67 100644 --- a/src/components/ui/DeviceDetailModal.tsx +++ b/src/components/ui/DeviceDetailModal.tsx @@ -1,16 +1,15 @@ "use client"; - import { useState, useEffect } from "react"; import { fetchDeviceDetails, DeviceDetails, DeviceStat } from "@/lib/api"; import { fmtBytes } from "@/lib/utils"; import { Loader2, X, Monitor, Activity, Shield, Box, Server, Cpu, Clock, HardDrive, ArrowDownToLine, ArrowUpFromLine, RefreshCcw, Globe, Link2 } from "lucide-react"; import ActiveDurationDisplay from "./ActiveDurationDisplay"; - import DeviceAppsTab from "./DeviceAppsTab"; import DeviceProtocolsTab from "./DeviceProtocolsTab"; import DeviceDomainsTab from "./DeviceDomainsTab"; import DeviceFlowsTab from "./DeviceFlowsTab"; import DeviceThreatsTab from "./DeviceThreatsTab"; +import DeviceIdentityCard from "./DeviceIdentityCard"; interface Props { ip: string; @@ -21,6 +20,15 @@ interface Props { type Tab = "info" | "apps" | "protocols" | "domains" | "flows" | "threats"; +const tabs: { id: Tab; label: string; icon: any }[] = [ + { id: "info", label: "Overview", icon: Monitor }, + { id: "apps", label: "Applications", icon: Box }, + { id: "protocols", label: "Protocols", icon: Link2 }, + { id: "domains", label: "Domains / Web", icon: Globe }, + { id: "flows", label: "Network Flows", icon: Activity }, + { id: "threats", label: "Threats", icon: Shield } +]; + export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props) { const [data, setData] = useState<DeviceDetails | null>(null); const [isLoading, setIsLoading] = useState(true); @@ -54,14 +62,35 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props) loadData(false); }, [ip, mac]); - const tabs: { id: Tab; label: string; icon: any }[] = [ - { id: "info", label: "Overview", icon: Monitor }, - { id: "apps", label: "Applications", icon: Box }, - { id: "protocols", label: "Protocols", icon: Link2 }, - { id: "domains", label: "Domains / Web", icon: Globe }, - { id: "flows", label: "Network Flows", icon: Activity }, - { id: "threats", label: "Threats", icon: Shield } - ]; + const [isExporting, setIsExporting] = useState(false); + + const exportToPdf = async () => { + if (!data) return; + setIsExporting(true); + try { + const { pdf } = await import("@react-pdf/renderer"); + const { default: DevicePdfDocument } = await import("./DevicePdfDocument"); + const reportDateStr = new Date().toLocaleString("id-ID", { + timeZone: "Asia/Jakarta", day: "2-digit", month: "2-digit", year: "numeric", + hour: "2-digit", minute: "2-digit", second: "2-digit", + }) + " WIB"; + + const doc = <DevicePdfDocument data={data} ip={ip || data.ip_address || ""} mac={mac || data.mac_address || ""} deviceData={deviceData} reportDateStr={reportDateStr} />; + + const blob = await pdf(doc).toBlob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `Device_DPI_Telemetry_Report_${ip || data.ip_address || "device"}.pdf`; + link.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error("Failed to export PDF:", err); + alert("Failed to generate PDF report. Please try again."); + } finally { + setIsExporting(false); + } + }; return ( <div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}> @@ -84,7 +113,7 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props) <div> <div className="flex items-center gap-3"> <h3 className="text-xl font-bold text-card-foreground tracking-tight"> - {ip || data?.ip_address || (isLoading ? 'Resolving IP...' : 'No IP Address')} + {ip || data?.ip_address || 'Resolving IP...'} </h3> {isRefreshing && <RefreshCcw className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 animate-spin" />} </div> @@ -101,9 +130,25 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props) )} </div> </div> - <button onClick={handleClose} className="p-2.5 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-xl transition-all"> - <X className="w-5 h-5" /> - </button> + <div className="flex items-center gap-3"> + {data && ( + <button + disabled={isExporting} + onClick={exportToPdf} + className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition-all shadow-md shadow-blue-600/15 disabled:opacity-50 disabled:cursor-not-allowed" + > + {isExporting ? ( + <Loader2 className="w-3.5 h-3.5 animate-spin" /> + ) : ( + <ArrowDownToLine className="w-3.5 h-3.5" /> + )} + Export PDF + </button> + )} + <button onClick={handleClose} className="p-2.5 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-xl transition-all"> + <X className="w-5 h-5" /> + </button> + </div> </div> {/* Body */} @@ -172,29 +217,12 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props) </div> </div> - <div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6"> - <h4 className="text-sm font-bold text-card-foreground uppercase tracking-wider mb-6 flex items-center gap-2"> - <Server className="w-4 h-4 text-blue-500" /> Identity & Metadata - </h4> - <div className="grid grid-cols-2 lg:grid-cols-4 gap-6"> - <div> - <p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Cpu className="w-3.5 h-3.5" /> OS</p> - <p className="text-card-foreground font-medium text-sm">{data.os_label || deviceData?.os_label || 'Unknown'}</p> - </div> - <div> - <p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Monitor className="w-3.5 h-3.5" /> Type</p> - <p className="text-card-foreground font-medium text-sm">{data.device_type || deviceData?.device_type || 'Unknown'}</p> - </div> - <div> - <p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><HardDrive className="w-3.5 h-3.5" /> Manufacturer</p> - <p className="text-card-foreground font-medium text-sm">{data.manufacturer || deviceData?.manufacturer || 'Unknown'}</p> - </div> - <div> - <p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Clock className="w-3.5 h-3.5" /> Last Seen</p> - <p className="text-card-foreground font-medium text-sm">{data.last_seen || deviceData?.last_seen ? new Date(data.last_seen || deviceData?.last_seen || '').toLocaleString() : 'Never'}</p> - </div> - </div> - </div> + <DeviceIdentityCard + osLabel={data.os_label || deviceData?.os_label || ""} + deviceType={data.device_type || deviceData?.device_type || ""} + manufacturer={data.manufacturer || deviceData?.manufacturer || ""} + lastSeen={data.last_seen || deviceData?.last_seen || ""} + /> </div> )} diff --git a/src/components/ui/DeviceIdentityCard.tsx b/src/components/ui/DeviceIdentityCard.tsx new file mode 100644 index 0000000..3e86ea8 --- /dev/null +++ b/src/components/ui/DeviceIdentityCard.tsx @@ -0,0 +1,54 @@ +"use client"; + +import React from "react"; +import { Server, Cpu, Monitor, HardDrive, Clock } from "lucide-react"; + +interface DeviceIdentityCardProps { + osLabel: string; + deviceType: string; + manufacturer: string; + lastSeen: string; +} + +export default function DeviceIdentityCard({ + osLabel, + deviceType, + manufacturer, + lastSeen, +}: DeviceIdentityCardProps) { + return ( + <div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6 text-xs"> + <h4 className="text-sm font-bold text-card-foreground uppercase tracking-wider mb-6 flex items-center gap-2"> + <Server className="w-4 h-4 text-blue-500" /> Identity & Metadata + </h4> + <div className="grid grid-cols-2 lg:grid-cols-4 gap-6"> + <div> + <p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"> + <Cpu className="w-3.5 h-3.5" /> OS + </p> + <p className="text-card-foreground font-medium text-sm">{osLabel || "Unknown"}</p> + </div> + <div> + <p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"> + <Monitor className="w-3.5 h-3.5" /> Type + </p> + <p className="text-card-foreground font-medium text-sm">{deviceType || "Unknown"}</p> + </div> + <div> + <p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"> + <HardDrive className="w-3.5 h-3.5" /> Manufacturer + </p> + <p className="text-card-foreground font-medium text-sm">{manufacturer || "Unknown"}</p> + </div> + <div> + <p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"> + <Clock className="w-3.5 h-3.5" /> Last Seen + </p> + <p className="text-card-foreground font-medium text-sm"> + {lastSeen ? new Date(lastSeen).toLocaleString() : "Never"} + </p> + </div> + </div> + </div> + ); +} diff --git a/src/components/ui/DeviceOverviewTab.tsx b/src/components/ui/DeviceOverviewTab.tsx new file mode 100644 index 0000000..a3b9d84 --- /dev/null +++ b/src/components/ui/DeviceOverviewTab.tsx @@ -0,0 +1,65 @@ +"use client"; + +import React from "react"; +import { DeviceDetails, DeviceStat } from "@/lib/api"; +import { fmtBytes } from "@/lib/utils"; +import { Server, Cpu, Monitor, HardDrive, Clock, ArrowDownToLine, ArrowUpFromLine } from "lucide-react"; +import ActiveDurationDisplay from "./ActiveDurationDisplay"; + +interface DeviceOverviewTabProps { + data: DeviceDetails; + deviceData?: DeviceStat; +} + +export default function DeviceOverviewTab({ data, deviceData }: DeviceOverviewTabProps) { + return ( + <div className="space-y-6 animate-fade-in text-xs"> + <ActiveDurationDisplay firstSeen={data.first_seen || undefined} lastSeen={data.last_seen || undefined} /> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="p-6 bg-gradient-to-br from-cyan-500/5 dark:from-cyan-500/10 to-transparent border border-cyan-100 dark:border-cyan-500/20 rounded-2xl relative overflow-hidden group"> + <div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity"> + <ArrowDownToLine className="w-16 h-16 text-cyan-500 dark:text-cyan-400" /> + </div> + <p className="text-sm font-medium text-cyan-600 dark:text-cyan-400/80 mb-2 flex items-center gap-2"> + <ArrowDownToLine className="w-4 h-4" /> Total Download + </p> + <p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_download)}</p> + </div> + <div className="p-6 bg-gradient-to-br from-emerald-500/5 dark:from-emerald-500/10 to-transparent border border-emerald-100 dark:border-emerald-500/20 rounded-2xl relative overflow-hidden group"> + <div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity"> + <ArrowUpFromLine className="w-16 h-16 text-emerald-500 dark:text-emerald-400" /> + </div> + <p className="text-sm font-medium text-emerald-600 dark:text-emerald-400/80 mb-2 flex items-center gap-2"> + <ArrowUpFromLine className="w-4 h-4" /> Total Upload + </p> + <p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_upload)}</p> + </div> + </div> + + <div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6"> + <h4 className="text-sm font-bold text-card-foreground uppercase tracking-wider mb-6 flex items-center gap-2"> + <Server className="w-4 h-4 text-blue-500" /> Identity & Metadata + </h4> + <div className="grid grid-cols-2 lg:grid-cols-4 gap-6"> + <div> + <p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Cpu className="w-3.5 h-3.5" /> OS</p> + <p className="text-card-foreground font-medium text-sm">{data.os_label || deviceData?.os_label || 'Unknown'}</p> + </div> + <div> + <p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Monitor className="w-3.5 h-3.5" /> Type</p> + <p className="text-card-foreground font-medium text-sm">{data.device_type || deviceData?.device_type || 'Unknown'}</p> + </div> + <div> + <p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><HardDrive className="w-3.5 h-3.5" /> Manufacturer</p> + <p className="text-card-foreground font-medium text-sm">{data.manufacturer || deviceData?.manufacturer || 'Unknown'}</p> + </div> + <div> + <p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Clock className="w-3.5 h-3.5" /> Last Seen</p> + <p className="text-card-foreground font-medium text-sm">{data.last_seen || deviceData?.last_seen ? new Date(data.last_seen || deviceData?.last_seen || '').toLocaleString() : 'Never'}</p> + </div> + </div> + </div> + </div> + ); +} diff --git a/src/components/ui/DevicePdfDocument.tsx b/src/components/ui/DevicePdfDocument.tsx new file mode 100644 index 0000000..b31411f --- /dev/null +++ b/src/components/ui/DevicePdfDocument.tsx @@ -0,0 +1,228 @@ +import React from "react"; +import { Document, Page, Text, View } from "@react-pdf/renderer"; +import { styles } from "./DevicePdfStyles"; +import { formatAppLabel } from "@/lib/utils"; + +interface DevicePdfDocumentProps { + data: any; + ip: string; + mac: string; + deviceData: any; + reportDateStr: string; +} + +interface TableColumn { + header: string; + width: string; + align?: "center" | "right" | "left"; + cell: (row: any) => string | number; +} + +function formatBytes(bytes: number): string { + if (!bytes || bytes === 0) return "0 B"; + const k = 1000; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +} + +function formatIndonesiaTime(dateStr: string | Date): string { + if (!dateStr) return "Never"; + try { + const d = new Date(dateStr); + return d.toLocaleString("id-ID", { + timeZone: "Asia/Jakarta", + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + " WIB"; + } catch (e) { + return String(dateStr); + } +} + +function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) { + return ( + <View style={{ marginBottom: 15 }}> + <Text style={styles.sectionTitle}>{title}</Text> + <View style={styles.table}> + <View style={styles.tableHeader}> + {columns.map((col, idx) => ( + <View key={idx} style={{ width: col.width, paddingRight: 6 }}> + <Text style={{ ...styles.tableHeaderCell, textAlign: col.align || "left" }}> + {col.header} + </Text> + </View> + ))} + </View> + {data.length === 0 ? ( + <View style={styles.tableRow}> + <Text style={styles.emptyText}>No Data</Text> + </View> + ) : ( + data.map((row, rowIdx) => ( + <View key={rowIdx} style={styles.tableRow} wrap={false}> + {columns.map((col, colIdx) => ( + <View key={colIdx} style={{ width: col.width, paddingRight: 6 }}> + <Text style={{ ...styles.tableCell, textAlign: col.align || "left" }}> + {col.cell(row)} + </Text> + </View> + ))} + </View> + )) + )} + </View> + </View> + ); +} + +export default function DevicePdfDocument({ + data, + ip, + mac, + deviceData, + reportDateStr, +}: DevicePdfDocumentProps) { + // Sort by total bandwidth (download + upload) descending for PDF export + const sortByBandwidth = (arr: any[]) => + [...arr].sort((a, b) => + ((b.download ?? b.bytes_download ?? 0) + (b.upload ?? b.bytes_upload ?? 0)) - + ((a.download ?? a.bytes_download ?? 0) + (a.upload ?? a.bytes_upload ?? 0)) + ); + + const topApps = sortByBandwidth(data.apps || []).slice(0, 50); + const topProtocols = sortByBandwidth(data.protocols || []).slice(0, 50); + const topDomains = sortByBandwidth(data.domains || []).slice(0, 50); + // Threats: sorted by detected_at descending (newest first), not by bandwidth + const detectedThreats = [...(data.threats || [])] + .sort((a, b) => new Date(b.detected_at || 0).getTime() - new Date(a.detected_at || 0).getTime()) + .slice(0, 50); + const flows = sortByBandwidth(data.flows || []).slice(0, 50); + + return ( + <Document> + <Page size="A4" style={styles.page}> + {/* Header */} + <View style={styles.header}> + <View> + <Text style={styles.logoText}>Deep Package Inspection</Text> + <Text style={styles.logoSubtext}>Security & Traffic Analytics Dashboard</Text> + </View> + <View style={styles.titleContainer}> + <Text style={styles.title}>Device Analysis Report</Text> + <Text style={styles.subtitle}>Generated at: {reportDateStr}</Text> + </View> + </View> + + {/* Device Information */} + <Text style={styles.sectionTitle}>Device Identification</Text> + <View style={styles.grid}> + <View style={styles.gridCol}> + <Text style={styles.gridLabel}>IP Address</Text> + <Text style={styles.gridValue}>{ip || "N/A"}</Text> + </View> + <View style={styles.gridCol}> + <Text style={styles.gridLabel}>MAC Address</Text> + <Text style={styles.gridValue}>{mac || "N/A"}</Text> + </View> + <View style={{ ...styles.gridCol, width: "50%" }}> + <Text style={styles.gridLabel}>Device OS</Text> + <Text style={styles.gridValue}>{data.os_label || deviceData?.os_label || "Unknown"}</Text> + </View> + <View style={styles.gridCol}> + <Text style={styles.gridLabel}>Manufacturer</Text> + <Text style={styles.gridValue}>{data.manufacturer || deviceData?.manufacturer || "Unknown"}</Text> + </View> + <View style={styles.gridCol}> + <Text style={styles.gridLabel}>Source Agent</Text> + <Text style={styles.gridValue}>{data.agent_label || "N/A"}</Text> + </View> + <View style={{ ...styles.gridCol, width: "50%" }}> + <Text style={styles.gridLabel}>Last Seen</Text> + <Text style={styles.gridValue}> + {data.last_seen || deviceData?.last_seen + ? formatIndonesiaTime(data.last_seen || deviceData?.last_seen) + : "Never"} + </Text> + </View> + </View> + + {/* Bandwidth Usage */} + <Text style={styles.sectionTitle}>Bandwidth Metrics</Text> + <View style={styles.bandwidthGrid}> + <View style={styles.bandwidthCardCyan}> + <Text style={{ ...styles.bandwidthLabel, color: "#0d9488" }}>TOTAL DOWNLOAD</Text> + <Text style={{ ...styles.bandwidthValue, color: "#115e59" }}>{formatBytes(data.total_download)}</Text> + </View> + <View style={styles.bandwidthCardGreen}> + <Text style={{ ...styles.bandwidthLabel, color: "#16a34a" }}>TOTAL UPLOAD</Text> + <Text style={{ ...styles.bandwidthValue, color: "#166534" }}>{formatBytes(data.total_upload)}</Text> + </View> + </View> + + {/* Security Threats Table */} + <RenderPdfTable + title={`Security Threats (${detectedThreats.length})`} + data={detectedThreats} + columns={[ + { header: "Time", width: "25%", cell: (r) => r.detected_at ? formatIndonesiaTime(r.detected_at) : "N/A" }, + { header: "Threat Type / Description", width: "35%", cell: (r) => r.threat_type || "Security Alert" }, + { header: "Severity", width: "15%", align: "center", cell: (r) => (r.severity || "UNKNOWN").toUpperCase() }, + { header: "Target IP", width: "25%", cell: (r) => r.dst_ip || r.ip_address || "—" }, + ]} + /> + + {/* Top Applications */} + <RenderPdfTable + title="Top Applications (By Traffic)" + data={topApps} + columns={[ + { header: "Application", width: "50%", cell: (r) => r.app_label || "Unknown" }, + { header: "Download", width: "25%", align: "right", cell: (r) => formatBytes(r.download) }, + { header: "Upload", width: "25%", align: "right", cell: (r) => formatBytes(r.upload) }, + ]} + /> + + {/* Top Network Protocols */} + <RenderPdfTable + title="Top Protocols" + data={topProtocols} + columns={[ + { header: "Protocol / Port", width: "50%", cell: (r) => formatAppLabel(r.app_label) }, + { header: "Download", width: "25%", align: "right", cell: (r) => formatBytes(r.download) }, + { header: "Upload", width: "25%", align: "right", cell: (r) => formatBytes(r.upload) }, + ]} + /> + + {/* Top Visited Domains */} + <RenderPdfTable + title="Top Domains" + data={topDomains} + columns={[ + { header: "Domain", width: "50%", cell: (r) => r.domain || "—" }, + { header: "Download", width: "25%", align: "right", cell: (r) => formatBytes(r.download) }, + { header: "Upload", width: "25%", align: "right", cell: (r) => formatBytes(r.upload) }, + ]} + /> + + {/* Recent Network Flows */} + <RenderPdfTable + title="Recent Network Flows" + data={flows} + columns={[ + { header: "Source IP", width: "20%", cell: (r) => r.src_ip || "—" }, + { header: "Destination IP", width: "20%", cell: (r) => r.dst_ip || "—" }, + { header: "Port", width: "10%", align: "center", cell: (r) => r.dst_port || "—" }, + { header: "App / Protocol", width: "20%", cell: (r) => r.app_label || r.protocol || "Unknown" }, + { header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) }, + { header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) }, + ]} + /> + </Page> + </Document> + ); +} diff --git a/src/components/ui/DevicePdfStyles.ts b/src/components/ui/DevicePdfStyles.ts new file mode 100644 index 0000000..c19ffda --- /dev/null +++ b/src/components/ui/DevicePdfStyles.ts @@ -0,0 +1,153 @@ +import { StyleSheet } from "@react-pdf/renderer"; + +export const styles = StyleSheet.create({ + page: { + padding: 30, + fontSize: 9, + fontFamily: "Helvetica", + color: "#334155", + backgroundColor: "#ffffff", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + borderBottomWidth: 1.5, + borderBottomColor: "#3b82f6", + paddingBottom: 12, + marginBottom: 15, + }, + logoText: { + fontSize: 16, + fontWeight: "bold", + color: "#1e3a8a", + }, + logoSubtext: { + fontSize: 8, + color: "#64748b", + marginTop: 2, + }, + titleContainer: { + alignItems: "flex-end", + }, + title: { + fontSize: 12, + fontWeight: "bold", + color: "#0f172a", + }, + subtitle: { + fontSize: 8, + color: "#64748b", + marginTop: 2, + }, + sectionTitle: { + fontSize: 10, + fontWeight: "bold", + color: "#1e3a8a", + backgroundColor: "#f8fafc", + padding: "4 8", + marginTop: 15, + marginBottom: 8, + borderLeftWidth: 3, + borderLeftColor: "#3b82f6", + }, + grid: { + flexDirection: "row", + flexWrap: "wrap", + marginBottom: 10, + }, + gridCol: { + width: "25%", + marginBottom: 8, + }, + gridLabel: { + fontSize: 7, + color: "#64748b", + textTransform: "uppercase", + marginBottom: 2, + }, + gridValue: { + fontSize: 8.5, + fontWeight: "bold", + color: "#0f172a", + }, + bandwidthGrid: { + flexDirection: "row", + justifyContent: "space-between", + marginBottom: 12, + }, + bandwidthCardCyan: { + width: "48%", + padding: 10, + borderRadius: 6, + borderWidth: 1, + borderColor: "#e2e8f0", + backgroundColor: "#f0fdfa", + }, + bandwidthCardGreen: { + width: "48%", + padding: 10, + borderRadius: 6, + borderWidth: 1, + borderColor: "#e2e8f0", + backgroundColor: "#f0fdf4", + }, + bandwidthLabel: { + fontSize: 7.5, + fontWeight: "bold", + marginBottom: 3, + }, + bandwidthValue: { + fontSize: 15, + fontWeight: "bold", + }, + table: { + width: "100%", + marginBottom: 10, + }, + tableHeader: { + flexDirection: "row", + backgroundColor: "#f1f5f9", + borderBottomWidth: 1, + borderBottomColor: "#cbd5e1", + padding: "4 6", + }, + tableHeaderCell: { + fontSize: 7.5, + fontWeight: "bold", + color: "#475569", + }, + tableRow: { + flexDirection: "row", + borderBottomWidth: 1, + borderBottomColor: "#f1f5f9", + padding: "4 6", + }, + tableCell: { + fontSize: 7.5, + color: "#334155", + }, + alertBox: { + padding: 8, + borderRadius: 6, + backgroundColor: "#fef2f2", + borderWidth: 1, + borderColor: "#fecaca", + marginBottom: 10, + }, + alertTitle: { + fontSize: 8.5, + fontWeight: "bold", + color: "#991b1b", + marginBottom: 2, + }, + alertText: { + fontSize: 7.5, + color: "#7f1d1d", + }, + emptyText: { + fontSize: 8, + color: "#64748b", + fontStyle: "italic", + padding: 6, + }, +}); diff --git a/src/components/ui/DropdownFilterSelect.tsx b/src/components/ui/DropdownFilterSelect.tsx index b40115d..a157b24 100644 --- a/src/components/ui/DropdownFilterSelect.tsx +++ b/src/components/ui/DropdownFilterSelect.tsx @@ -98,4 +98,4 @@ export function DropdownFilterSelect({ </div> </div> ); -} +} diff --git a/src/components/ui/HelpCenterFAB.tsx b/src/components/ui/HelpCenterFAB.tsx index 5d74d05..7704cec 100644 --- a/src/components/ui/HelpCenterFAB.tsx +++ b/src/components/ui/HelpCenterFAB.tsx @@ -1,46 +1,34 @@ "use client"; -import React, { useState } from "react"; -import Link from "next/link"; -import { motion, AnimatePresence } from "framer-motion"; -import { - HelpCircle, - X, - ChevronDown, - ChevronUp, - BookOpen, - ArrowRight, -} from "lucide-react"; -import { useTheme } from "next-themes"; -import { useTenantConfig } from "@/contexts/TenantConfigContext"; -import { FAQS } from "@/lib/helpContent"; +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { HelpCircle, X, Search, ChevronDown, ChevronUp } from 'lucide-react'; +import { useTenantConfig } from '@/contexts/TenantConfigContext'; +import { FAQS } from '@/lib/helpContent'; -// ─── Expandable FAQ Item ─────────────────────────────────────────────────────── -const FAQItem = ({ question, answer }: { question: string; answer: string }) => { +const FAQItem = ({ question, answer }: { question: string, answer: string }) => { const [isOpen, setIsOpen] = useState(false); - + return ( - <div className="border-b border-border/50 py-3 last:border-0"> - <button + <div className="border-b border-border/50 py-3"> + <button onClick={() => setIsOpen(!isOpen)} - className="flex w-full items-center justify-between text-left focus:outline-none gap-3" + className="flex w-full items-center justify-between text-left focus:outline-none" > - <span className="text-sm font-medium text-foreground leading-snug">{question}</span> - {isOpen ? ( - <ChevronUp className="h-4 w-4 text-muted-foreground shrink-0" /> - ) : ( - <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" /> - )} + <span className="text-sm font-medium text-foreground">{question}</span> + {isOpen ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />} </button> <AnimatePresence> {isOpen && ( <motion.div initial={{ height: 0, opacity: 0 }} - animate={{ height: "auto", opacity: 1 }} + animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="overflow-hidden" > - <p className="pt-2 text-sm text-muted-foreground leading-relaxed">{answer}</p> + <p className="pt-2 text-sm text-muted-foreground leading-relaxed"> + {answer} + </p> </motion.div> )} </AnimatePresence> @@ -48,10 +36,19 @@ const FAQItem = ({ question, answer }: { question: string; answer: string }) => ); }; -// ─── Main FAB Component ──────────────────────────────────────────────────────── export function HelpCenterFAB() { - const { tenantConfig } = useTenantConfig(); const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const { tenantConfig } = useTenantConfig(); + + const brandName = tenantConfig?.brandName || "System"; + const footerCopyright = tenantConfig?.footerCopyright || `${brandName} © 2026`; + + const filteredFaqs = FAQS.filter( + faq => + faq.question.toLowerCase().includes(searchQuery.toLowerCase()) || + faq.answer.toLowerCase().includes(searchQuery.toLowerCase()) + ); return ( <> @@ -64,10 +61,11 @@ export function HelpCenterFAB() { aria-label="Help and Resources" > <HelpCircle className="h-6 w-6" /> - <span className="absolute -inset-1 animate-ping rounded-full bg-blue-400 opacity-20" /> + {/* Pulse effect */} + <span className="absolute -inset-1 animate-ping rounded-full bg-blue-400 opacity-20"></span> </motion.button> - {/* Drawer Overlay + Panel */} + {/* Drawer Overlay */} <AnimatePresence> {isOpen && ( <> @@ -78,19 +76,20 @@ export function HelpCenterFAB() { onClick={() => setIsOpen(false)} className="fixed inset-0 z-50 bg-background/40 backdrop-blur-sm" /> - + + {/* Drawer Panel */} <motion.div - initial={{ x: "100%" }} + initial={{ x: '100%' }} animate={{ x: 0 }} - exit={{ x: "100%" }} - transition={{ type: "spring", damping: 25, stiffness: 200 }} + exit={{ x: '100%' }} + transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed right-0 top-0 z-50 flex h-full w-full max-w-sm flex-col bg-card/95 backdrop-blur-xl border-l border-border shadow-2xl" > {/* Header */} <div className="flex items-center justify-between border-b border-border/50 px-6 py-5"> <div> - <h2 className="text-lg font-bold text-foreground">Help & Resources</h2> - <p className="text-xs text-muted-foreground mt-0.5">User Guide & Documentation</p> + <h2 className="text-lg font-bold text-foreground">Help & Resources</h2> + <p className="text-xs text-muted-foreground mt-0.5">{brandName} Guide & Documentation</p> </div> <button onClick={() => setIsOpen(false)} @@ -101,52 +100,46 @@ export function HelpCenterFAB() { </div> {/* Scrollable Content */} - <div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6"> - - {/* Full User Guide Banner */} - <div className="space-y-2"> - <h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2"> - <BookOpen className="h-4 w-4" /> Documentation - </h3> - <Link - href="/help" - onClick={() => setIsOpen(false)} - className="flex items-center justify-between gap-3 rounded-xl border border-blue-500/30 bg-blue-600/10 px-4 py-3 hover:bg-blue-600/20 transition-colors group" - > - <div> - <p className="text-sm font-semibold text-blue-500 dark:text-blue-400"> - Read Full User Guide - </p> - <p className="text-xs text-muted-foreground mt-0.5"> - Panduan membaca data per halaman, tab, dan kolom tabel - </p> - </div> - <ArrowRight className="h-4 w-4 text-blue-500 shrink-0 group-hover:translate-x-0.5 transition-transform" /> - </Link> + <div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-8"> + + {/* Search Bar */} + <div className="relative"> + <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <input + type="text" + placeholder="Search articles or FAQs..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="w-full rounded-xl border border-border bg-secondary/50 py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-all" + /> </div> + + + {/* FAQs */} - <div className="space-y-2 pb-6"> + <div className="space-y-3 pb-8"> <h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2"> <HelpCircle className="h-4 w-4" /> Frequently Asked Questions </h3> <div className="rounded-xl border border-border/50 bg-card px-4"> - {FAQS.map((faq, index) => ( - <FAQItem key={index} question={faq.question} answer={faq.answer} /> - ))} + {filteredFaqs.length === 0 ? ( + <p className="text-xs text-muted-foreground py-4 text-center">No matching FAQs found</p> + ) : ( + filteredFaqs.map((faq, index) => ( + <FAQItem key={index} question={faq.question} answer={faq.answer} /> + )) + )} </div> </div> </div> - + {/* Footer */} <div className="border-t border-border/50 bg-secondary/30 p-4 text-center"> - <p className="text-xs text-muted-foreground"> - {tenantConfig?.brandName || "DPI Dashboard"} © 2026 - <br /> - {tenantConfig?.footerCopyright || "PT. Data Bisnis Solusi"} - </p> + <p className="text-xs text-muted-foreground" dangerouslySetInnerHTML={{ __html: footerCopyright }} /> </div> + </motion.div> </> )} diff --git a/src/components/ui/IpDetails.tsx b/src/components/ui/IpDetails.tsx index 474c499..8af613f 100644 --- a/src/components/ui/IpDetails.tsx +++ b/src/components/ui/IpDetails.tsx @@ -1,68 +1,68 @@ -"use client"; - -import { useState, useEffect } from "react"; - -// Client-side cache to prevent duplicate fetches for the same IP -const geoIpCache: Record<string, { isp: string; country: string; as_org: string }> = {}; - -export function IpDetails({ ip }: { ip: string }) { - const [details, setDetails] = useState<{ isp: string; country: string; as_org: string } | null>(null); - - useEffect(() => { - if (!ip) return; - - // Check memory cache first - if (geoIpCache[ip]) { - setDetails(geoIpCache[ip]); - return; - } - - let isMounted = true; - - fetch(`/api/auth/geoip?ip=${encodeURIComponent(ip)}`) - .then((res) => res.json()) - .then((data) => { - if (!isMounted) return; - const resolved = { - isp: data.isp || "Unknown ISP", - country: data.country || "Remote", - as_org: data.as_org || "Public Network", - }; - geoIpCache[ip] = resolved; - setDetails(resolved); - }) - .catch((err) => { - console.warn("GeoIP lookup client error:", err); - if (isMounted) { - setDetails({ - isp: "Public IP", - country: "Remote", - as_org: "Public Network", - }); - } - }); - - return () => { - isMounted = false; - }; - }, [ip]); - - if (!details) { - return <span className="text-[10px] text-slate-500 animate-pulse">loading...</span>; - } - - const isLocal = details.country === "Local"; - - return ( - <div className="text-[10px] leading-tight mt-0.5 max-w-[180px] truncate" title={`${details.isp}${!isLocal ? ` • ${details.country}` : ''}`}> - <span className={isLocal ? "text-emerald-500/90 font-medium" : "text-cyan-400/90 font-medium"}> - {details.isp} - </span> - {!isLocal && ( - <span className="text-slate-400 font-normal"> - {" • "}{details.country} - </span> - )} - </div> - ); -} +"use client"; + +import { useState, useEffect } from "react"; + +// Client-side cache to prevent duplicate fetches for the same IP +const geoIpCache: Record<string, { isp: string; country: string; as_org: string }> = {}; + +export function IpDetails({ ip }: { ip: string }) { + const [details, setDetails] = useState<{ isp: string; country: string; as_org: string } | null>(null); + + useEffect(() => { + if (!ip) return; + + // Check memory cache first + if (geoIpCache[ip]) { + setDetails(geoIpCache[ip]); + return; + } + + let isMounted = true; + + fetch(`/api/auth/geoip?ip=${encodeURIComponent(ip)}`) + .then((res) => res.json()) + .then((data) => { + if (!isMounted) return; + const resolved = { + isp: data.isp || "Unknown ISP", + country: data.country || "Remote", + as_org: data.as_org || "Public Network", + }; + geoIpCache[ip] = resolved; + setDetails(resolved); + }) + .catch((err) => { + console.warn("GeoIP lookup client error:", err); + if (isMounted) { + setDetails({ + isp: "Public IP", + country: "Remote", + as_org: "Public Network", + }); + } + }); + + return () => { + isMounted = false; + }; + }, [ip]); + + if (!details) { + return <span className="text-[10px] text-slate-500 animate-pulse">loading...</span>; + } + + const isLocal = details.country === "Local"; + + return ( + <div className="text-[10px] leading-tight mt-0.5 max-w-[180px] truncate" title={`${details.isp}${!isLocal ? ` • ${details.country}` : ''}`}> + <span className={isLocal ? "text-emerald-500/90 font-medium" : "text-cyan-400/90 font-medium"}> + {details.isp} + </span> + {!isLocal && ( + <span className="text-slate-400 font-normal"> + {" • "}{details.country} + </span> + )} + </div> + ); +} diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 6151612..91040df 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -1,82 +1,82 @@ -"use client"; - -import { ReactNode, useEffect, useState } from "react"; -import { createPortal } from "react-dom"; -import { X } from "lucide-react"; -import { cn } from "@/lib/utils"; - -interface ModalProps { - isOpen: boolean; - onClose: () => void; - title: string; - children: ReactNode; - className?: string; -} - -export function Modal({ isOpen, onClose, title, children, className }: ModalProps) { - const [isMounted, setIsMounted] = useState(false); - const [isClosing, setIsClosing] = useState(false); - const [visible, setVisible] = useState(false); - - useEffect(() => { - setIsMounted(true); - }, []); - - useEffect(() => { - if (isOpen) { - setVisible(true); - setIsClosing(false); - document.body.style.overflow = "hidden"; - } else { - if (visible) { - setIsClosing(true); - const timer = setTimeout(() => { - setVisible(false); - setIsClosing(false); - document.body.style.overflow = "unset"; - }, 480); - return () => clearTimeout(timer); - } - } - }, [isOpen, visible]); - - useEffect(() => { - return () => { - document.body.style.overflow = "unset"; - }; - }, []); - - if (!isMounted || !visible) return null; - - return createPortal( - <div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-0"> - {/* Backdrop */} - <div - className={`fixed inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} - onClick={onClose} - /> - - {/* Dialog */} - <div className={cn( - "relative z-50 w-full max-w-md transform overflow-hidden rounded-xl border border-border bg-card shadow-2xl sm:my-8", - isClosing ? 'animate-modal-out' : 'animate-modal-in', - className - )}> - <div className="flex items-center justify-between border-b border-border px-6 py-4"> - <h3 className="text-lg font-semibold text-foreground tracking-tight">{title}</h3> - <button - onClick={onClose} - className="rounded-md p-1 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50" - > - <span className="sr-only">Close</span> - <X className="h-5 w-5" /> - </button> - </div> - <div className="px-6 py-4"> - {children} - </div> - </div> - </div>, - document.body - ); -} +"use client"; + +import { ReactNode, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: ReactNode; + className?: string; +} + +export function Modal({ isOpen, onClose, title, children, className }: ModalProps) { + const [isMounted, setIsMounted] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const [visible, setVisible] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + useEffect(() => { + if (isOpen) { + setVisible(true); + setIsClosing(false); + document.body.style.overflow = "hidden"; + } else { + if (visible) { + setIsClosing(true); + const timer = setTimeout(() => { + setVisible(false); + setIsClosing(false); + document.body.style.overflow = "unset"; + }, 480); + return () => clearTimeout(timer); + } + } + }, [isOpen, visible]); + + useEffect(() => { + return () => { + document.body.style.overflow = "unset"; + }; + }, []); + + if (!isMounted || !visible) return null; + + return createPortal( + <div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-0"> + {/* Backdrop */} + <div + className={`fixed inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} + onClick={onClose} + /> + + {/* Dialog */} + <div className={cn( + "relative z-[9999] w-full max-w-md transform overflow-hidden rounded-xl border border-border bg-card shadow-2xl sm:my-8", + isClosing ? 'animate-modal-out' : 'animate-modal-in', + className + )}> + <div className="flex items-center justify-between border-b border-border px-6 py-4"> + <h3 className="text-lg font-semibold text-foreground tracking-tight">{title}</h3> + <button + onClick={onClose} + className="rounded-md p-1 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50" + > + <span className="sr-only">Close</span> + <X className="h-5 w-5" /> + </button> + </div> + <div className="px-6 py-4"> + {children} + </div> + </div> + </div>, + document.body + ); +} diff --git a/src/components/ui/RemoteIpDetailModal.tsx b/src/components/ui/RemoteIpDetailModal.tsx index 524279b..549ea33 100644 --- a/src/components/ui/RemoteIpDetailModal.tsx +++ b/src/components/ui/RemoteIpDetailModal.tsx @@ -63,7 +63,7 @@ export function RemoteIpDetailModal({ ip, onClose }: Props) { ]; return ( - <div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}> + <div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" onClick={handleClose}> <div className={`absolute inset-0 bg-black/60 backdrop-blur-md ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} /> <div className={`relative bg-card border border-border rounded-3xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/10 dark:shadow-blue-900/20 overflow-hidden backdrop-blur-xl ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`} diff --git a/src/components/ui/SafeImage.tsx b/src/components/ui/SafeImage.tsx index 3922f7a..0e92803 100644 --- a/src/components/ui/SafeImage.tsx +++ b/src/components/ui/SafeImage.tsx @@ -4,9 +4,10 @@ import React, { useState, useEffect } from "react"; interface SafeImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { src: string; + fallbackName?: string; } -export function SafeImage({ src, ...props }: SafeImageProps) { +export function SafeImage({ src, fallbackName, ...props }: SafeImageProps) { const [imageSrc, setImageSrc] = useState<string>(""); const [error, setError] = useState<boolean>(false); @@ -44,6 +45,9 @@ export function SafeImage({ src, ...props }: SafeImageProps) { }, [src]); if (error || !imageSrc) { + const initials = fallbackName + ? fallbackName.substring(0, 2).toUpperCase() + : "Profile"; return ( <div className={props.className} @@ -55,7 +59,7 @@ export function SafeImage({ src, ...props }: SafeImageProps) { }} > <span className="text-[10px] text-slate-500 font-bold uppercase tracking-wider"> - Profile + {initials} </span> </div> ); diff --git a/src/components/ui/Tabs.tsx b/src/components/ui/Tabs.tsx index 45880fb..b94ad3c 100644 --- a/src/components/ui/Tabs.tsx +++ b/src/components/ui/Tabs.tsx @@ -1,49 +1,49 @@ -import React, { useState } from 'react'; - -interface TabProps { - label: string; - children: React.ReactNode; -} - -export function Tab({ children }: TabProps) { - return <>{children}</>; -} - -interface TabsProps { - children: React.ReactElement<TabProps>[]; -} - -export function Tabs({ children }: TabsProps) { - const [activeTab, setActiveTab] = useState(0); - - return ( - <div className="flex flex-col space-y-4"> - <div className="flex space-x-1 border-b border-white/10 overflow-x-auto pb-1"> - {React.Children.map(children, (child, index) => { - if (!React.isValidElement(child)) return null; - const isActive = index === activeTab; - return ( - <button - key={index} - onClick={() => setActiveTab(index)} - className={` - px-4 py-2 text-sm font-medium rounded-t-lg transition-colors whitespace-nowrap - ${isActive - ? 'bg-blue-600/20 text-blue-400 border-b-2 border-blue-500' - : 'text-gray-400 hover:text-gray-200 hover:bg-white/5'} - `} - > - {child.props.label} - </button> - ); - })} - </div> - <div className="mt-4"> - {React.Children.map(children, (child, index) => { - if (index !== activeTab) return null; - return child; - })} - </div> - </div> - ); -} +import React, { useState } from 'react'; + +interface TabProps { + label: string; + children: React.ReactNode; +} + +export function Tab({ children }: TabProps) { + return <>{children}</>; +} + +interface TabsProps { + children: React.ReactElement<TabProps>[]; +} + +export function Tabs({ children }: TabsProps) { + const [activeTab, setActiveTab] = useState(0); + + return ( + <div className="flex flex-col space-y-4"> + <div className="flex space-x-1 border-b border-white/10 overflow-x-auto pb-1"> + {React.Children.map(children, (child, index) => { + if (!React.isValidElement(child)) return null; + const isActive = index === activeTab; + return ( + <button + key={index} + onClick={() => setActiveTab(index)} + className={` + px-4 py-2 text-sm font-medium rounded-t-lg transition-colors whitespace-nowrap + ${isActive + ? 'bg-blue-600/20 text-blue-400 border-b-2 border-blue-500' + : 'text-gray-400 hover:text-gray-200 hover:bg-white/5'} + `} + > + {child.props.label} + </button> + ); + })} + </div> + <div className="mt-4"> + {React.Children.map(children, (child, index) => { + if (index !== activeTab) return null; + return child; + })} + </div> + </div> + ); +} diff --git a/src/components/ui/UniversalFilters.tsx b/src/components/ui/UniversalFilters.tsx index 9b3996d..82acd49 100644 --- a/src/components/ui/UniversalFilters.tsx +++ b/src/components/ui/UniversalFilters.tsx @@ -99,7 +99,12 @@ export function UniversalFilters({ value={f.value || "All"} onChange={(val) => onChange(f.id, val)} options={f.options || []} - placeholder={f.placeholder || `All ${f.label}s`} + placeholder={ + f.placeholder || + (f.label.toLowerCase().endsWith('y') + ? `All ${f.label.slice(0, -1)}ies` + : `All ${f.label}s`) + } optionFormatter={f.formatter} /> </div> diff --git a/src/components/ui/WorldMap.tsx b/src/components/ui/WorldMap.tsx index aa04464..2dd01c4 100644 --- a/src/components/ui/WorldMap.tsx +++ b/src/components/ui/WorldMap.tsx @@ -1,232 +1,232 @@ -"use client"; - -import React, { memo, useState, useEffect } from "react"; -import { - ComposableMap, - Geographies, - Geography, - Sphere, - Graticule, - ZoomableGroup -} from "react-simple-maps"; -import { scaleLinear } from "d3-scale"; -import { fmtBytes } from "@/lib/utils"; -import { useTheme } from "next-themes"; -import { ZoomIn, ZoomOut } from "lucide-react"; - -const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"; - -interface WorldMapProps { - data: { - countryCode: string; - value: number; - download?: number; - upload?: number; - label: string; - }[]; -} - -const WorldMapComponent = ({ data }: WorldMapProps) => { - const [tooltipData, setTooltipData] = useState<any>(null); - const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); - const [logBounds, setLogBounds] = useState({ min: 0, max: 1 }); - const [mounted, setMounted] = useState(false); - const [position, setPosition] = useState({ coordinates: [0, 30] as [number, number], zoom: 1 }); - - const { theme } = useTheme(); - const isLight = theme === 'light'; - - useEffect(() => { - setMounted(true); - }, []); - - useEffect(() => { - if (data.length > 0) { - const logs = data.filter(d => d.value > 0).map(d => Math.log10(d.value)); - if (logs.length > 0) { - const minLog = Math.min(...logs); - const maxLog = Math.max(...logs); - setLogBounds({ - min: minLog, - max: maxLog > minLog ? maxLog : minLog + 1 - }); - } - } - }, [data]); - - const { min, max } = logBounds; - const step = (max - min) / 4; - const colorScale = scaleLinear<string>() - .domain([min, min + step, min + step * 2, min + step * 3, max]) - .range(isLight - ? ["#e0f2fe", "#bae6fd", "#7dd3fc", "#38bdf8", "#0284c7"] // Light Mode: Clean Sky Blues - : ["#0f172a", "#1e3a8a", "#1d4ed8", "#2563eb", "#60a5fa"] // Dark Mode: Deep Blues - ); - - const defaultLandColor = isLight ? "#f1f5f9" : "#111827"; - const oceanColor = isLight ? "#f8fafc" : "transparent"; - const strokeColor = isLight ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.05)"; - - function handleZoomIn() { - if (position.zoom >= 4) return; - setPosition((pos) => ({ ...pos, zoom: pos.zoom * 1.5 })); - } - - function handleZoomOut() { - if (position.zoom <= 1) return; - setPosition((pos) => ({ ...pos, zoom: pos.zoom / 1.5 })); - } - - function handleMoveEnd(pos: { coordinates: [number, number]; zoom: number }) { - setPosition(pos); - } - - if (!mounted) return null; - - return ( - <div - className="relative w-full aspect-[2/1] bg-card rounded-xl overflow-hidden border border-border/50 shadow-inner group" - onMouseMove={(e) => { - const rect = e.currentTarget.getBoundingClientRect(); - setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); - }} - onMouseLeave={() => setTooltipData(null)} - > - {/* Legend */} - <div className="absolute top-4 left-4 z-10 flex flex-col gap-1 pointer-events-none bg-background/80 p-3 rounded-lg border border-border/50 backdrop-blur-md shadow-lg"> - <div className="text-sm font-medium text-foreground flex items-center gap-2"> - Heatmap Intensity - </div> - <div className="flex items-center gap-2 mt-2"> - <div className="text-xs text-muted-foreground">Low</div> - <div - className="w-24 h-2 rounded-full shadow-sm" - style={{ - background: isLight - ? 'linear-gradient(to right, #e0f2fe, #bae6fd, #7dd3fc, #38bdf8, #0284c7)' - : 'linear-gradient(to right, #1e3a8a, #1d4ed8, #2563eb, #60a5fa)' - }} - ></div> - <div className="text-xs text-muted-foreground">High</div> - </div> - </div> - - {/* Zoom Controls */} - <div className="absolute bottom-4 right-4 z-10 flex flex-col gap-2"> - <button - onClick={handleZoomIn} - className="p-2 bg-background/80 hover:bg-secondary border border-border/50 rounded-lg backdrop-blur-md text-foreground transition-colors shadow-sm" - title="Zoom In" - > - <ZoomIn className="w-4 h-4" /> - </button> - <button - onClick={handleZoomOut} - className="p-2 bg-background/80 hover:bg-secondary border border-border/50 rounded-lg backdrop-blur-md text-foreground transition-colors shadow-sm" - title="Zoom Out" - > - <ZoomOut className="w-4 h-4" /> - </button> - </div> - - {/* Floating Mouse Tooltip */} - {tooltipData && ( - <div - style={{ top: mousePos.y + 15, left: mousePos.x + 15 }} - className="absolute z-50 bg-background/95 backdrop-blur-xl border border-border px-4 py-3 rounded-xl shadow-2xl pointer-events-none animate-in fade-in zoom-in-95 duration-100 min-w-[200px]" - > - <div className="text-sm font-bold text-foreground mb-2 flex items-center gap-2 border-b border-border/50 pb-2"> - {tooltipData.label} - </div> - {tooltipData.value > 0 ? ( - <div className="space-y-1.5"> - <div className="flex justify-between items-center text-xs"> - <span className="text-muted-foreground">Download</span> - <span className="font-semibold text-sky-400">{fmtBytes(tooltipData.download || 0)}</span> - </div> - <div className="flex justify-between items-center text-xs"> - <span className="text-muted-foreground">Upload</span> - <span className="font-semibold text-emerald-400">{fmtBytes(tooltipData.upload || 0)}</span> - </div> - <div className="flex justify-between items-center text-xs pt-1 mt-1 border-t border-border/30"> - <span className="text-muted-foreground">Total</span> - <span className="font-bold text-rose-400">{fmtBytes(tooltipData.value)}</span> - </div> - </div> - ) : ( - <div className="text-xs text-muted-foreground italic">No traffic recorded</div> - )} - </div> - )} - - <ComposableMap - projection="geoMercator" - projectionConfig={{ - scale: 140, - center: [0, 30] - }} - width={800} - height={400} - className="w-full h-full outline-none cursor-grab active:cursor-grabbing" - > - <ZoomableGroup - zoom={position.zoom} - center={position.coordinates} - onMoveEnd={handleMoveEnd} - maxZoom={6} - translateExtent={[[-200, -100], [1000, 600]]} - > - <Sphere stroke={strokeColor} strokeWidth={0.5} id="sphere" fill={oceanColor} /> - <Graticule stroke={strokeColor} strokeWidth={0.5} /> - - <Geographies geography={geoUrl}> - {({ geographies }) => - geographies.map((geo) => { - const d = data.find((s) => { - if (!geo.properties.name) return false; - const geoName = geo.properties.name.toLowerCase(); - const sLabel = s.label.toLowerCase(); - - return geoName === sLabel || - (sLabel === 'united states' && geoName === 'united states of america') || - (sLabel === 'the netherlands' && geoName === 'netherlands') || - (sLabel === 'south korea' && geoName === 'korea'); - }); - - const hasData = d && d.value > 0; - const fillColor = hasData ? colorScale(Math.log10(d.value)) : defaultLandColor; - - return ( - <Geography - key={geo.rsmKey} - geography={geo} - fill={fillColor} - stroke={strokeColor} - strokeWidth={0.5} - style={{ - default: { outline: "none", transition: "fill 300ms" }, - hover: { - fill: hasData ? "#38bdf8" : (isLight ? "#e2e8f0" : "#334155"), // Sky-400 glow or fallback - stroke: "#ffffff", - strokeWidth: 1, - outline: "none", - transition: "all 150ms ease", - filter: hasData ? "drop-shadow(0 0 8px rgba(56,189,248,0.6))" : "none" - }, - pressed: { outline: "none" } - }} - onMouseEnter={() => { - setTooltipData(d ? d : { label: geo.properties.name, value: 0 }); - }} - /> - ); - }) - } - </Geographies> - </ZoomableGroup> - </ComposableMap> - </div> - ); -}; - -export const WorldMap = memo(WorldMapComponent); +"use client"; + +import React, { memo, useState, useEffect } from "react"; +import { + ComposableMap, + Geographies, + Geography, + Sphere, + Graticule, + ZoomableGroup +} from "react-simple-maps"; +import { scaleLinear } from "d3-scale"; +import { fmtBytes } from "@/lib/utils"; +import { useTheme } from "next-themes"; +import { ZoomIn, ZoomOut } from "lucide-react"; + +const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"; + +interface WorldMapProps { + data: { + countryCode: string; + value: number; + download?: number; + upload?: number; + label: string; + }[]; +} + +const WorldMapComponent = ({ data }: WorldMapProps) => { + const [tooltipData, setTooltipData] = useState<any>(null); + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + const [logBounds, setLogBounds] = useState({ min: 0, max: 1 }); + const [mounted, setMounted] = useState(false); + const [position, setPosition] = useState({ coordinates: [0, 30] as [number, number], zoom: 1 }); + + const { theme } = useTheme(); + const isLight = theme === 'light'; + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (data.length > 0) { + const logs = data.filter(d => d.value > 0).map(d => Math.log10(d.value)); + if (logs.length > 0) { + const minLog = Math.min(...logs); + const maxLog = Math.max(...logs); + setLogBounds({ + min: minLog, + max: maxLog > minLog ? maxLog : minLog + 1 + }); + } + } + }, [data]); + + const { min, max } = logBounds; + const step = (max - min) / 4; + const colorScale = scaleLinear<string>() + .domain([min, min + step, min + step * 2, min + step * 3, max]) + .range(isLight + ? ["#e0f2fe", "#bae6fd", "#7dd3fc", "#38bdf8", "#0284c7"] // Light Mode: Clean Sky Blues + : ["#0f172a", "#1e3a8a", "#1d4ed8", "#2563eb", "#60a5fa"] // Dark Mode: Deep Blues + ); + + const defaultLandColor = isLight ? "#f1f5f9" : "#111827"; + const oceanColor = isLight ? "#f8fafc" : "transparent"; + const strokeColor = isLight ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.05)"; + + function handleZoomIn() { + if (position.zoom >= 4) return; + setPosition((pos) => ({ ...pos, zoom: pos.zoom * 1.5 })); + } + + function handleZoomOut() { + if (position.zoom <= 1) return; + setPosition((pos) => ({ ...pos, zoom: pos.zoom / 1.5 })); + } + + function handleMoveEnd(pos: { coordinates: [number, number]; zoom: number }) { + setPosition(pos); + } + + if (!mounted) return null; + + return ( + <div + className="relative w-full aspect-[2/1] bg-card rounded-xl overflow-hidden border border-border/50 shadow-inner group" + onMouseMove={(e) => { + const rect = e.currentTarget.getBoundingClientRect(); + setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + }} + onMouseLeave={() => setTooltipData(null)} + > + {/* Legend */} + <div className="absolute top-4 left-4 z-10 flex flex-col gap-1 pointer-events-none bg-background/80 p-3 rounded-lg border border-border/50 backdrop-blur-md shadow-lg"> + <div className="text-sm font-medium text-foreground flex items-center gap-2"> + Heatmap Intensity + </div> + <div className="flex items-center gap-2 mt-2"> + <div className="text-xs text-muted-foreground">Low</div> + <div + className="w-24 h-2 rounded-full shadow-sm" + style={{ + background: isLight + ? 'linear-gradient(to right, #e0f2fe, #bae6fd, #7dd3fc, #38bdf8, #0284c7)' + : 'linear-gradient(to right, #1e3a8a, #1d4ed8, #2563eb, #60a5fa)' + }} + ></div> + <div className="text-xs text-muted-foreground">High</div> + </div> + </div> + + {/* Zoom Controls */} + <div className="absolute bottom-4 right-4 z-10 flex flex-col gap-2"> + <button + onClick={handleZoomIn} + className="p-2 bg-background/80 hover:bg-secondary border border-border/50 rounded-lg backdrop-blur-md text-foreground transition-colors shadow-sm" + title="Zoom In" + > + <ZoomIn className="w-4 h-4" /> + </button> + <button + onClick={handleZoomOut} + className="p-2 bg-background/80 hover:bg-secondary border border-border/50 rounded-lg backdrop-blur-md text-foreground transition-colors shadow-sm" + title="Zoom Out" + > + <ZoomOut className="w-4 h-4" /> + </button> + </div> + + {/* Floating Mouse Tooltip */} + {tooltipData && ( + <div + style={{ top: mousePos.y + 15, left: mousePos.x + 15 }} + className="absolute z-50 bg-background/95 backdrop-blur-xl border border-border px-4 py-3 rounded-xl shadow-2xl pointer-events-none animate-in fade-in zoom-in-95 duration-100 min-w-[200px]" + > + <div className="text-sm font-bold text-foreground mb-2 flex items-center gap-2 border-b border-border/50 pb-2"> + {tooltipData.label} + </div> + {tooltipData.value > 0 ? ( + <div className="space-y-1.5"> + <div className="flex justify-between items-center text-xs"> + <span className="text-muted-foreground">Download</span> + <span className="font-semibold text-sky-400">{fmtBytes(tooltipData.download || 0)}</span> + </div> + <div className="flex justify-between items-center text-xs"> + <span className="text-muted-foreground">Upload</span> + <span className="font-semibold text-emerald-400">{fmtBytes(tooltipData.upload || 0)}</span> + </div> + <div className="flex justify-between items-center text-xs pt-1 mt-1 border-t border-border/30"> + <span className="text-muted-foreground">Total</span> + <span className="font-bold text-rose-400">{fmtBytes(tooltipData.value)}</span> + </div> + </div> + ) : ( + <div className="text-xs text-muted-foreground italic">No traffic recorded</div> + )} + </div> + )} + + <ComposableMap + projection="geoMercator" + projectionConfig={{ + scale: 140, + center: [0, 30] + }} + width={800} + height={400} + className="w-full h-full outline-none cursor-grab active:cursor-grabbing" + > + <ZoomableGroup + zoom={position.zoom} + center={position.coordinates} + onMoveEnd={handleMoveEnd} + maxZoom={6} + translateExtent={[[-200, -100], [1000, 600]]} + > + <Sphere stroke={strokeColor} strokeWidth={0.5} id="sphere" fill={oceanColor} /> + <Graticule stroke={strokeColor} strokeWidth={0.5} /> + + <Geographies geography={geoUrl}> + {({ geographies }) => + geographies.map((geo) => { + const d = data.find((s) => { + if (!geo.properties.name) return false; + const geoName = geo.properties.name.toLowerCase(); + const sLabel = s.label.toLowerCase(); + + return geoName === sLabel || + (sLabel === 'united states' && geoName === 'united states of america') || + (sLabel === 'the netherlands' && geoName === 'netherlands') || + (sLabel === 'south korea' && geoName === 'korea'); + }); + + const hasData = d && d.value > 0; + const fillColor = hasData ? colorScale(Math.log10(d.value)) : defaultLandColor; + + return ( + <Geography + key={geo.rsmKey} + geography={geo} + fill={fillColor} + stroke={strokeColor} + strokeWidth={0.5} + style={{ + default: { outline: "none", transition: "fill 300ms" }, + hover: { + fill: hasData ? "#38bdf8" : (isLight ? "#e2e8f0" : "#334155"), // Sky-400 glow or fallback + stroke: "#ffffff", + strokeWidth: 1, + outline: "none", + transition: "all 150ms ease", + filter: hasData ? "drop-shadow(0 0 8px rgba(56,189,248,0.6))" : "none" + }, + pressed: { outline: "none" } + }} + onMouseEnter={() => { + setTooltipData(d ? d : { label: geo.properties.name, value: 0 }); + }} + /> + ); + }) + } + </Geographies> + </ZoomableGroup> + </ComposableMap> + </div> + ); +}; + +export const WorldMap = memo(WorldMapComponent); diff --git a/src/components/ui/sharedPdfStyles.ts b/src/components/ui/sharedPdfStyles.ts new file mode 100644 index 0000000..ba0a0ee --- /dev/null +++ b/src/components/ui/sharedPdfStyles.ts @@ -0,0 +1,153 @@ +import { StyleSheet } from "@react-pdf/renderer"; + +export const sharedStyles = StyleSheet.create({ + page: { + padding: 30, + fontSize: 9, + fontFamily: "Helvetica", + color: "#334155", + backgroundColor: "#ffffff", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + borderBottomWidth: 1.5, + borderBottomColor: "#3b82f6", + paddingBottom: 12, + marginBottom: 15, + }, + logoText: { + fontSize: 16, + fontWeight: "bold", + color: "#1e3a8a", + }, + logoSubtext: { + fontSize: 8, + color: "#64748b", + marginTop: 2, + }, + titleContainer: { + alignItems: "flex-end", + }, + title: { + fontSize: 12, + fontWeight: "bold", + color: "#0f172a", + }, + subtitle: { + fontSize: 8, + color: "#64748b", + marginTop: 2, + }, + sectionTitle: { + fontSize: 10, + fontWeight: "bold", + color: "#1e3a8a", + backgroundColor: "#f8fafc", + padding: "4 8", + marginTop: 15, + marginBottom: 8, + borderLeftWidth: 3, + borderLeftColor: "#3b82f6", + }, + grid: { + flexDirection: "row", + flexWrap: "wrap", + marginBottom: 10, + }, + gridCol: { + width: "25%", + marginBottom: 8, + }, + gridLabel: { + fontSize: 7, + color: "#64748b", + textTransform: "uppercase", + marginBottom: 2, + }, + gridValue: { + fontSize: 8.5, + fontWeight: "bold", + color: "#0f172a", + }, + bandwidthGrid: { + flexDirection: "row", + justifyContent: "space-between", + marginBottom: 12, + }, + bandwidthCardCyan: { + width: "48%", + padding: 10, + borderRadius: 6, + borderWidth: 1, + borderColor: "#e2e8f0", + backgroundColor: "#f0fdfa", + }, + bandwidthCardGreen: { + width: "48%", + padding: 10, + borderRadius: 6, + borderWidth: 1, + borderColor: "#e2e8f0", + backgroundColor: "#f0fdf4", + }, + bandwidthLabel: { + fontSize: 7.5, + fontWeight: "bold", + marginBottom: 3, + }, + bandwidthValue: { + fontSize: 15, + fontWeight: "bold", + }, + table: { + width: "100%", + marginBottom: 10, + }, + tableHeader: { + flexDirection: "row", + backgroundColor: "#f1f5f9", + borderBottomWidth: 1, + borderBottomColor: "#cbd5e1", + padding: "4 6", + }, + tableHeaderCell: { + fontSize: 7.5, + fontWeight: "bold", + color: "#475569", + }, + tableRow: { + flexDirection: "row", + borderBottomWidth: 1, + borderBottomColor: "#f1f5f9", + padding: "4 6", + }, + tableCell: { + fontSize: 7.5, + color: "#334155", + }, + alertBox: { + padding: 8, + borderRadius: 6, + backgroundColor: "#fef2f2", + borderWidth: 1, + borderColor: "#fecaca", + marginBottom: 10, + }, + alertTitle: { + fontSize: 8.5, + fontWeight: "bold", + color: "#991b1b", + marginBottom: 2, + }, + alertText: { + fontSize: 7.5, + color: "#7f1d1d", + }, + emptyText: { + fontSize: 8, + color: "#64748b", + fontStyle: "italic", + padding: 6, + }, +}); diff --git a/src/contexts/TimeFilterContext.tsx b/src/contexts/TimeFilterContext.tsx index 672d636..5e2810e 100644 --- a/src/contexts/TimeFilterContext.tsx +++ b/src/contexts/TimeFilterContext.tsx @@ -2,7 +2,7 @@ import React, { createContext, useContext, useState, ReactNode } from 'react'; -type TimeRange = '5m' | '30m' | '1h' | '1d' | '7d'; +type TimeRange = '5m' | '30m' | '1h' | '1d' | '7d' | '30d'; interface TimeFilterContextType { timeRange: TimeRange; diff --git a/src/lib/actions/agents.ts b/src/lib/actions/agents.ts index 054a502..55c6b4b 100644 --- a/src/lib/actions/agents.ts +++ b/src/lib/actions/agents.ts @@ -1,140 +1,203 @@ "use server"; -import { revalidatePath } from "next/cache"; -import { cookies } from "next/headers"; -import jwt from "jsonwebtoken"; -import mongoose from "mongoose"; +import { + getApiKey, + getSiteUuid, + getHeaders, + API_URL, + createAgent, + updateAgent, + deleteAgent +} from "./agentsCore"; +import type { Agent } from "./agentsCore"; -const BASE_URL = process.env.BACKONE_BASE_URL || process.env.NETIFY_BASE_URL; -const API_URL = BASE_URL ? `${BASE_URL}/assets/agents` : "https://manager.netify.ai/api/v1/assets/agents"; +import { getAuthUser } from "./authHelper"; -const getApiKey = () => process.env.BACKONE_API_KEY || process.env.NETIFY_API_KEY; -const getJwtToken = () => process.env.BACKONE_JWT_TOKEN || process.env.NETIFY_JWT_TOKEN; -const getSiteUuid = () => process.env.BACKONE_SITE_UUID || process.env.NETIFY_SITE_UUID; - -// Helper function to get headers -const getHeaders = () => { - const API_KEY = getApiKey(); - const JWT_TOKEN = getJwtToken(); - const SITE_UUID = getSiteUuid(); - - if (!API_KEY && !JWT_TOKEN) { - throw new Error("BACKONE_API_KEY or BACKONE_JWT_TOKEN is not set in environment variables"); - } - - const headers: Record<string, string> = { - "Content-Type": "application/json", - }; - - // Prioritize long-term API Key as recommended by the DPI API support team - if (API_KEY) { - headers["x-api-key"] = API_KEY; - } else if (JWT_TOKEN) { - headers["x-api-key"] = JWT_TOKEN; - } - - if (SITE_UUID) { - headers["x-net-site"] = SITE_UUID; - } - - return headers; -}; - - -export interface Agent { - id: number; - uuid: string; - serial: string; - site_uuid: string; - organization_uuid: string; - provisioned: boolean; - activated: boolean; - label?: string; - created_at: { human: string; date: string; unix_time: number }; - updated_at: { human: string; date: string; unix_time: number }; - last_seen_at: { human: string; date: string; unix_time: number }; -} +// Re-export mutations for backwards compatibility +export { createAgent, updateAgent, deleteAgent }; export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> { try { - const API_KEY = getApiKey(); - let SITE_UUID = clientSiteUuid || getSiteUuid(); - - // Securely retrieve the user's actual role and site_uuid on the server side - try { - const cookieStore = await cookies(); - const token = cookieStore.get('token')?.value; - if (token) { - const decoded: any = jwt.verify(token, process.env.JWT_SECRET || 'super-secret-backone-key'); - if (decoded && decoded.role === 'TENANT_ADMIN') { - // Force user's site_uuid to prevent TENANT_ADMIN from requesting other sites - SITE_UUID = decoded.site_uuid; - } - } - } catch (cookieErr) { - console.warn("Failed to retrieve or verify cookie token inside getAgents:", cookieErr); + const user = await getAuthUser(); + if (!user) { + throw new Error("Unauthorized: Please log in first."); } - // All agent data comes from MongoDB (single source of truth per Rule 13) + const API_KEY = getApiKey(); + + // Resolve the SITE_UUID strictly based on user role and permissions + let SITE_UUID = clientSiteUuid || getSiteUuid(); + + if (user.role === 'TENANT_ADMIN') { + SITE_UUID = user.site_uuid; + } else if (user.role === 'AGENT_VIEWER') { + SITE_UUID = user.site_uuid; + } + + // All agent data comes from MongoDB agent_registry (single source of truth) + // agent_registry is populated by the proxy collector every cycle from /data/agents if (API_KEY && API_KEY.startsWith("sk_db_")) { + const mongoose = require('mongoose'); const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi'; // Connect to MongoDB if not already connected - if (mongoose.connection.readyState === 0) { - await mongoose.connect(MONGODB_URI); + console.log("[getAgents Action] MONGODB_URI:", MONGODB_URI); + console.log("[getAgents Action] Mongoose readyState:", mongoose.connection.readyState); + try { + if (mongoose.connection.readyState === 0) { + console.log("[getAgents Action] Connecting to MongoDB..."); + await mongoose.connect(MONGODB_URI, { serverSelectionTimeoutMS: 5000 }); + console.log("[getAgents Action] ✓ Mongoose connected successfully!"); + } + } catch (connErr: any) { + console.error("[getAgents Action] ✗ Mongoose connection failed:", connErr.message); + throw connErr; } const db = mongoose.connection.db; - if (!db) { - throw new Error("Database connection not ready"); + + // Build site filter + // CATATAN: Untuk company roles, jangan terapkan site_uuid filter global + // karena agent yang di-assign ke company user bisa berasal dari site mana saja. + // Filter keamanan untuk company roles HANYA menggunakan uuid: { $in: freshAgentUuids } + const companyRoles = ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER']; + const isCompanyRole = companyRoles.includes(user.role); + + const filter: any = {}; + // Hanya apply site_uuid filter untuk NON-company roles + if (SITE_UUID && !isCompanyRole) filter.site_uuid = SITE_UUID; + + // 🔒 Security: COMPANY_ADMIN/OPERATOR/VIEWER hanya boleh melihat agent yang di-assign + // freshAgentUuids dideklarasikan di outer scope agar bisa dipakai di fallback summaries + let freshAgentUuids: string[] = []; + if (isCompanyRole) { + // Selalu baca agent_uuids dari database — token bisa stale jika assignment berubah setelah login + freshAgentUuids = user.agent_uuids || []; + try { + const UserModel = mongoose.models.User || mongoose.model('User', new mongoose.Schema({ agent_uuids: [String] }, { strict: false })); + const dbUser = await UserModel.findById(user.id).select('agent_uuids').lean() as any; + if (dbUser?.agent_uuids && dbUser.agent_uuids.length > 0) { + freshAgentUuids = dbUser.agent_uuids; + } + } catch (dbErr: any) { + console.warn('[getAgents] Could not fetch fresh agent_uuids from DB, using token:', dbErr.message); + } + + if (freshAgentUuids.length > 0) { + filter.uuid = { $in: freshAgentUuids }; + } else { + // Tidak ada agent yang di-assign → kembalikan list kosong (secure by default) + console.log(`[getAgents] ${user.role} ${user.username} has no assigned agents — returning []`); + return []; + } } - const filter: any = { agent_uuid: { $ne: null } }; // exclude site-level aggregate rows - if (SITE_UUID) { - filter.site_uuid = SITE_UUID; + // 1. Get agents from agent_registry (filtered by role) + const registryAgents = await db.collection('agent_registry') + .find(filter) + .sort({ uuid: 1 }) + .toArray(); + + // 2. If agent_registry is empty, fall back to summaries + let agentSource = registryAgents; + if (registryAgents.length === 0) { + const summaryFilter: any = { agent_uuid: { $nin: [null, ''], $exists: true } }; + // Hanya apply site_uuid filter untuk non-company roles + if (SITE_UUID && !isCompanyRole) summaryFilter.site_uuid = SITE_UUID; + // Apply company role filter on summaries fallback using fresh agent_uuids from DB + if (isCompanyRole && freshAgentUuids.length > 0) { + summaryFilter.agent_uuid = { $in: freshAgentUuids }; + } else if (isCompanyRole && freshAgentUuids.length === 0) { + return []; + } + const agentSummaries = await db.collection('summaries') + .aggregate([ + { $match: summaryFilter }, + { $sort: { timestamp: -1 } }, + { $group: { _id: '$agent_uuid', lastSeen: { $first: '$timestamp' }, label: { $first: '$agent_label' } } }, + ]).toArray(); + // Map summaries to registry-like format + agentSource = agentSummaries.map((s: any) => ({ + uuid: s._id, serial: s._id, label: s.label, site_uuid: SITE_UUID, + provisioned: true, activated: true, last_seen_at: s.lastSeen ? { date: s.lastSeen.toISOString() } : null, + })); } - // Get all agents from MongoDB summaries collection, deduplicated by agent_uuid - const agentSummaries = await db.collection('summaries') - .aggregate([ - { $match: filter }, - { $sort: { timestamp: -1 } }, - { - $group: { - _id: '$agent_uuid', - lastSeen: { $first: '$timestamp' }, - label: { $first: '$agent_label' }, - site_uuid: { $first: '$site_uuid' }, // capture the site this agent belongs to - } - }, - // Final guard: only keep agents whose primary site matches the queried site - ...(SITE_UUID ? [{ $match: { site_uuid: SITE_UUID } }] : []), - ]).toArray(); - // Check for any flows recorded in the last 12 hours to determine online status - const twelveHoursAgo = new Date(Date.now() - 12 * 3600000); - const activeAgents = await db.collection('flows').distinct('agent_uuid', { - timestamp: { $gte: twelveHoursAgo } - }); - const activeAgentsSet = new Set(activeAgents); - const agents: Agent[] = agentSummaries.map((item: any, idx: number) => { - const lastSeenDate = item.lastSeen ? new Date(item.lastSeen) : null; - const isOnline = activeAgentsSet.has(item._id); - const statusHuman = isOnline - ? 'Online' - : lastSeenDate - ? `Last Seen ${lastSeenDate.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} WIB` - : 'Offline'; - return { + + // 3. Optimize status check and last seen per agent using indexed queries + const twentyFourHoursAgo = new Date(Date.now() - 24 * 3600000); + const agents: Agent[] = []; + + for (let idx = 0; idx < agentSource.length; idx++) { + const item = agentSource[idx]; + const uuid = item.uuid || item.serial; + if (!uuid) continue; + + // Check if there was any flow in the last 24 hours to determine online status + // Using index: { agent_uuid: 1, timestamp: -1 } + const activeFlow = await db.collection('flows') + .findOne({ agent_uuid: uuid, timestamp: { $gte: twentyFourHoursAgo } }, { projection: { _id: 1 } }); + const isOnline = !!activeFlow; + + // Check latest timestamp from flows + // Using index: { agent_uuid: 1, timestamp: -1 } + const latestFlow = await db.collection('flows') + .findOne({ agent_uuid: uuid }, { projection: { timestamp: 1 }, sort: { timestamp: -1 } }); + + // Check latest timestamp from summaries + // Using index: { agent_uuid: 1, timestamp: -1 } + const latestSummary = await db.collection('summaries') + .findOne({ agent_uuid: uuid }, { projection: { timestamp: 1 }, sort: { timestamp: -1 } }); + + const registryLastSeenRaw = item.last_seen_at; + let lastSeenDate: Date | null = null; + if (registryLastSeenRaw?.date) { + lastSeenDate = new Date(registryLastSeenRaw.date); + } else if (registryLastSeenRaw instanceof Date) { + lastSeenDate = registryLastSeenRaw; + } else if (typeof registryLastSeenRaw === 'string') { + lastSeenDate = new Date(registryLastSeenRaw); + } + + if (latestFlow && latestFlow.timestamp) { + const flowDate = new Date(latestFlow.timestamp); + if (!lastSeenDate || flowDate > lastSeenDate) { + lastSeenDate = flowDate; + } + } + if (latestSummary && latestSummary.timestamp) { + const summaryDate = new Date(latestSummary.timestamp); + if (!lastSeenDate || summaryDate > lastSeenDate) { + lastSeenDate = summaryDate; + } + } + + let statusHuman = 'No Flows Detected'; + if (isOnline) { + if (lastSeenDate) { + const now = new Date(); + const isToday = lastSeenDate.getDate() === now.getDate() && + lastSeenDate.getMonth() === now.getMonth() && + lastSeenDate.getFullYear() === now.getFullYear(); + const timeStr = lastSeenDate.toLocaleTimeString('id-ID', { timeZone: 'Asia/Jakarta', hour: '2-digit', minute: '2-digit' }) + ' WIB'; + const dateStr = lastSeenDate.toLocaleDateString('id-ID', { timeZone: 'Asia/Jakarta', day: '2-digit', month: '2-digit' }); + statusHuman = isToday ? `Last seen: ${timeStr}` : `Last seen: ${dateStr} ${timeStr}`; + } else { + statusHuman = 'Last seen: Just now'; + } + } + + agents.push({ id: idx + 1, - uuid: item._id, - serial: item._id, - site_uuid: SITE_UUID || '', + uuid: uuid, + serial: item.serial || uuid, + site_uuid: item.site_uuid || SITE_UUID || '', organization_uuid: '', - provisioned: true, - activated: isOnline, - label: item.label || item._id, + provisioned: item.provisioned ?? true, + activated: true, // All retrieved agents are active/activated on the Netify platform + label: item.label || uuid, created_at: { human: 'N/A', date: '', unix_time: 0 }, updated_at: { human: 'N/A', date: '', unix_time: 0 }, last_seen_at: { @@ -142,8 +205,8 @@ export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> { date: lastSeenDate?.toISOString() || '', unix_time: lastSeenDate ? lastSeenDate.getTime() / 1000 : 0, }, - }; - }); + }); + } return agents; } @@ -197,81 +260,4 @@ export async function getAgent(agentId: string): Promise<Agent | null> { console.error(`Error fetching agent ${agentId}:`, error); return null; } -} - -export async function createAgent(agentId: string, label: string) { - try { - const API_KEY = getApiKey(); - if (API_KEY && API_KEY.startsWith("sk_db_")) { - return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan membuat Agents." }; - } - - const url = agentId - ? `${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}` - : `${API_URL}?label=${encodeURIComponent(label)}`; - - const response = await fetch(url, { - method: "POST", - headers: getHeaders(), - }); - - const result = await response.json(); - if (!response.ok || result.status_code !== 0) { - return { success: false, message: result.status_message || response.statusText }; - } - - revalidatePath('/agents'); - return { success: true, data: result.data }; - } catch (error: any) { - return { success: false, message: error.message }; - } -} - -export async function updateAgent(agentId: string, label: string) { - try { - const API_KEY = getApiKey(); - if (API_KEY && API_KEY.startsWith("sk_db_")) { - return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan mengubah Agents." }; - } - - const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}`, { - method: "PATCH", - headers: getHeaders(), - }); - - const result = await response.json(); - if (!response.ok || result.status_code !== 0) { - return { success: false, message: result.status_message || response.statusText }; - } - - revalidatePath('/agents'); - return { success: true, data: result.data }; - } catch (error: any) { - return { success: false, message: error.message }; - } -} - -export async function deleteAgent(agentId: string) { - try { - const API_KEY = getApiKey(); - if (API_KEY && API_KEY.startsWith("sk_db_")) { - return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan menghapus Agents." }; - } - - const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}`, { - method: "DELETE", - headers: getHeaders(), - }); - - const result = await response.json(); - if (!response.ok || result.status_code !== 0) { - return { success: false, message: result.status_message || response.statusText }; - } - - revalidatePath('/agents'); - return { success: true }; - } catch (error: any) { - return { success: false, message: error.message }; - } -} - +} diff --git a/src/lib/actions/agentsCore.ts b/src/lib/actions/agentsCore.ts new file mode 100644 index 0000000..ebb4b0a --- /dev/null +++ b/src/lib/actions/agentsCore.ts @@ -0,0 +1,124 @@ +import { revalidatePath } from "next/cache"; + +export const BASE_URL = process.env.BACKONE_BASE_URL || process.env.NETIFY_BASE_URL; +export const API_URL = BASE_URL ? `${BASE_URL}/assets/agents` : "https://manager.netify.ai/api/v1/assets/agents"; + +export const getApiKey = () => process.env.BACKONE_API_KEY || process.env.NETIFY_API_KEY; +export const getJwtToken = () => process.env.BACKONE_JWT_TOKEN || process.env.NETIFY_JWT_TOKEN; +export const getSiteUuid = () => process.env.BACKONE_SITE_UUID || process.env.NETIFY_SITE_UUID; + +export interface Agent { + id: number; + uuid: string; + serial: string; + site_uuid: string; + organization_uuid: string; + provisioned: boolean; + activated: boolean; + label?: string; + created_at: { human: string; date: string; unix_time: number }; + updated_at: { human: string; date: string; unix_time: number }; + last_seen_at: { human: string; date: string; unix_time: number }; +} + +export const getHeaders = () => { + const API_KEY = getApiKey(); + const JWT_TOKEN = getJwtToken(); + const SITE_UUID = getSiteUuid(); + + if (!API_KEY && !JWT_TOKEN) { + throw new Error("BACKONE_API_KEY or BACKONE_JWT_TOKEN is not set in environment variables"); + } + + const headers: Record<string, string> = { + "Content-Type": "application/json", + }; + + if (API_KEY) { + headers["x-api-key"] = API_KEY; + } else if (JWT_TOKEN) { + headers["x-api-key"] = JWT_TOKEN; + } + + if (SITE_UUID) { + headers["x-net-site"] = SITE_UUID; + } + + return headers; +}; + +export async function createAgent(agentId: string, label: string) { + try { + const API_KEY = getApiKey(); + if (API_KEY && API_KEY.startsWith("sk_db_")) { + return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan membuat Agents." }; + } + + const url = agentId + ? `${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}` + : `${API_URL}?label=${encodeURIComponent(label)}`; + + const response = await fetch(url, { + method: "POST", + headers: getHeaders(), + }); + + const result = await response.json(); + if (!response.ok || result.status_code !== 0) { + return { success: false, message: result.status_message || response.statusText }; + } + + revalidatePath('/agents'); + return { success: true, data: result.data }; + } catch (error: any) { + return { success: false, message: error.message }; + } +} + +export async function updateAgent(agentId: string, label: string) { + try { + const API_KEY = getApiKey(); + if (API_KEY && API_KEY.startsWith("sk_db_")) { + return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan mengubah Agents." }; + } + + const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}`, { + method: "PATCH", + headers: getHeaders(), + }); + + const result = await response.json(); + if (!response.ok || result.status_code !== 0) { + return { success: false, message: result.status_message || response.statusText }; + } + + revalidatePath('/agents'); + return { success: true, data: result.data }; + } catch (error: any) { + return { success: false, message: error.message }; + } +} + +export async function deleteAgent(agentId: string) { + try { + const API_KEY = getApiKey(); + if (API_KEY && API_KEY.startsWith("sk_db_")) { + return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan menghapus Agents." }; + } + + const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}`, { + method: "DELETE", + headers: getHeaders(), + }); + + const result = await response.json(); + if (!response.ok || result.status_code !== 0) { + return { success: false, message: result.status_message || response.statusText }; + } + + revalidatePath('/agents'); + return { success: true }; + } catch (error: any) { + return { success: false, message: error.message }; + } +} diff --git a/src/lib/actions/authHelper.ts b/src/lib/actions/authHelper.ts new file mode 100644 index 0000000..c6f679b --- /dev/null +++ b/src/lib/actions/authHelper.ts @@ -0,0 +1,27 @@ +// src/lib/actions/authHelper.ts +import { cookies } from "next/headers"; +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-backone-key'; + +export interface AuthenticatedUser { + id: string; + username: string; + role: string; + site_uuid?: string; + agent_uuid?: string; + agent_uuids?: string[]; +} + +export async function getAuthUser(): Promise<AuthenticatedUser | null> { + try { + const cookieStore = await cookies(); + const token = cookieStore.get('token')?.value; + if (!token) return null; + const decoded = jwt.verify(token, JWT_SECRET) as any; + return decoded || null; + } catch (err) { + console.error('[getAuthUser] Failed to decode JWT token:', err); + return null; + } +} diff --git a/src/lib/admin-api.ts b/src/lib/admin-api.ts index 02502ba..deaf6ab 100644 --- a/src/lib/admin-api.ts +++ b/src/lib/admin-api.ts @@ -3,9 +3,11 @@ export interface ManagedUser { id: number; username: string; - role: 'SUPER_ADMIN' | 'AGENT_VIEWER' | 'SOC_ANALYST' | 'ENGINEER' | 'TENANT_ADMIN' | string; + role: 'SUPER_ADMIN' | 'EXECUTIVE' | 'AGENT_VIEWER' | 'SOC_ANALYST' | 'ENGINEER' | 'TENANT_ADMIN' | string; site_uuid: string | null; agent_uuid: string | null; + company_name?: string | null; + agent_uuids?: string[]; account_name: string | null; profile_picture: string | null; created_at: string; @@ -14,10 +16,14 @@ export interface ManagedUser { export interface ViewAsStatus { active: boolean; agent_uuid?: string; - agent_label?: string; + agent_label?: string; // Label agent (dari registry) token?: string; + view_as_type?: 'user' | 'agent'; // 'user' = view-as user tertentu, 'agent' = direct agent view + user_name?: string; // Nama user yang di-view (hanya untuk view_as_type='user') + user_role?: string; // Role user yang di-view (hanya untuk view_as_type='user') } + // ─── localStorage key untuk view-as state ───────────────────────────────────── const VIEW_AS_KEY = 'backone_view_as'; @@ -102,7 +108,7 @@ export async function deleteAdminAgentUser(userId: number): Promise<void> { if (!json.ok) throw new Error(json.error || 'Failed to delete account'); } -// POST /api/auth/admin/view-as — simpan token di localStorage +// POST /api/auth/admin/view-as — simpan token di localStorage (direct agent view-as) export async function startViewAs(agent_uuid: string, agent_label: string): Promise<void> { const res = await fetch('/api/auth/admin/view-as', { method: 'POST', @@ -122,12 +128,50 @@ export async function startViewAs(agent_uuid: string, agent_label: string): Prom localStorage.setItem(VIEW_AS_KEY, JSON.stringify({ active: true, agent_uuid: json.agent_uuid, - agent_label: json.agent_label, + agent_label: json.agent_label, // Label agent dari registry token: json.view_token, + view_as_type: 'agent', // Direct agent view-as })); } } +// POST /api/auth/admin/view-as — view-as USER tertentu (COMPANY_ADMIN → OPERATOR/VIEWER) +// userName: nama user yang di-view, userRole: role user, agentUuid: agent pertama user, agentLabel: label agent dari registry +export async function startViewAsUser( + agentUuid: string, + agentLabel: string, + userName: string, + userRole: string, +): Promise<void> { + const labelForToken = `${userName} (${userRole.replace(/_/g, ' ')})`; + const res = await fetch('/api/auth/admin/view-as', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ agent_uuid: agentUuid, agent_label: labelForToken }), + }); + const contentType = res.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + throw new Error(`Server error (${res.status}): Backend mungkin perlu di-restart`); + } + const json = await res.json(); + if (!json.ok) throw new Error(json.error || 'Failed to enter view-as mode'); + + // Simpan token di localStorage dengan konteks user yang lebih kaya + if (typeof window !== 'undefined') { + localStorage.setItem(VIEW_AS_KEY, JSON.stringify({ + active: true, + agent_uuid: json.agent_uuid, + agent_label: agentLabel, // Label AGENT dari registry (bukan nama user) + token: json.view_token, + view_as_type: 'user', // User view-as mode + user_name: userName, // Nama user yang di-view + user_role: userRole, // Role user yang di-view + })); + } +} + + // Keluar dari mode view-as — hapus dari localStorage dan server session export async function stopViewAs(): Promise<void> { try { @@ -167,5 +211,5 @@ export function getViewAsHeaders(): Record<string, string> { export function resolveAgentLabel(agentUuid: string, users: ManagedUser[]): string { const user = users.find(u => u.agent_uuid === agentUuid && u.role === 'AGENT_VIEWER'); return user?.account_name || agentUuid; -} - +} + diff --git a/src/lib/api-advanced.ts b/src/lib/api-advanced.ts index 7d6a773..663d7b0 100644 --- a/src/lib/api-advanced.ts +++ b/src/lib/api-advanced.ts @@ -1,78 +1,78 @@ -import { useState, useEffect } from 'react'; -import { fetcher } from './api'; - -// --- ADVANCED NETWORK INFRASTRUCTURE TYPES --- -export interface RegionStat { id: number; fetched_at: string; region_name: string; region_code: string; country_name: string; country_code: string; download: number; } -export interface CityStat { id: number; fetched_at: string; city_name: string; region_name: string; country_name: string; country_code: string; download: number; } -export interface VlanStat { id: number; fetched_at: string; vlan_id: number; vlan_label: string; download: number; upload: number; total: number; } -export interface InterfaceStat { id: number; fetched_at: string; iface_id: number; iface_name: string; iface_role: string; agent_id: string; download: number; upload: number; total: number; } -export interface IpVersionStat { id: number; fetched_at: string; ip_version_label: string; download: number; upload: number; total: number; } -export interface RemoteIpStat { id: number; fetched_at: string; remote_ip: string; ip_version: number; download: number; upload: number; total: number; } -export interface MacBandwidthStat { id: number; fetched_at: string; mac_address: string; manufacturer: string; download: number; upload: number; total: number; } -export interface FlowTypeStat { id: number; fetched_at: string; flow_type_label: string; download: number; upload: number; total: number; } -export interface FlowOriginStat { id: number; fetched_at: string; flow_origin_label: string; download: number; upload: number; total: number; } - -// --- DPI ANALYTICS TYPES --- -export interface NetbiosStat { id: number; fetched_at: string; hostname: string; download: number; upload: number; total: number; } -export interface DiscoveryOsStat { id: number; fetched_at: string; os_label: string; download: number; upload: number; total: number; } -export interface DhcpFingerprintStat { id: number; fetched_at: string; fingerprint: string; download: number; upload: number; total: number; } -export interface HttpUserAgentStat { id: number; fetched_at: string; user_agent: string; download: number; upload: number; total: number; } -export interface SniHostnameStat { id: number; fetched_at: string; sni_hostname: string; download: number; upload: number; total: number; } -export interface SslServerCnStat { id: number; fetched_at: string; ssl_server_cn: string; download: number; upload: number; total: number; } -export interface QuicHostnameStat { id: number; fetched_at: string; quic_hostname: string; download: number; upload: number; total: number; } -export interface BittorrentHashStat { id: number; fetched_at: string; info_hash: string; label: string; download: number; upload: number; total: number; } -export interface SshVersionStat { id: number; fetched_at: string; ssh_version: string; download: number; upload: number; total: number; } -export interface MdnsHostnameStat { id: number; fetched_at: string; mdns_hostname: string; download: number; upload: number; total: number; } - -// --- INTELLIGENCE DETAIL TYPES --- -export interface IntelCryptoMining { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; pool_host: string; pool_ip: string; protocol: string; app_label: string; confidence: number; download: number; upload: number; } -export interface IntelDeviceDiscovery { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; device_label: string; device_type: string; os_label: string; manufacturer: string; is_new: number; } -export interface IntelEncryptionAudit { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; device_label: string; encrypted_pct: number; unencrypted: number; encrypted: number; total: number; risk_level: string; } -export interface IntelInsecureProtocol { id: number; fetched_at: string; detected_at: string; protocol: string; ip_address: string; mac_address: string; dst_ip: string; dst_port: number; app_label: string; download: number; upload: number; risk: string; source: string; } -export interface IntelIpReputation { id: number; fetched_at: string; detected_at: string; ip_address: string; local_ip: string; mac_address: string; reputation: string; score: number; country: string; app_label: string; download: number; upload: number; blacklisted: number; } -export interface IntelServerDiscovery { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; server_type: string; hostname: string; port: number; protocol: string; os_label: string; download: number; upload: number; } -export interface IntelTorDetection { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; exit_node: string; circuit_id: string; download: number; upload: number; country: string; } -export interface IntelUnencryptedPassword { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; dst_ip: string; dst_port: number; protocol: string; username: string; download: number; upload: number; severity: string; } -export interface IntelVpnDetection { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; vpn_type: string; remote_ip: string; protocol: string; download: number; upload: number; country: string; confidence: number; } - -import { useTimeFilter } from '@/contexts/TimeFilterContext'; -import { useCtxFetch } from './api-context-core'; - -function useGenericFetch<T>(endpoint: string, limit: number) { - const { timeRange } = useTimeFilter(); - return useCtxFetch<T[]>(`${endpoint}?limit=${limit}`, timeRange, []); -} - -// Network Infrastructure Hooks -export const useRegions = (limit = 50) => useGenericFetch<RegionStat>('/regions', limit); -export const useCities = (limit = 50) => useGenericFetch<CityStat>('/cities', limit); -export const useVlans = (limit = 50) => useGenericFetch<VlanStat>('/vlans', limit); -export const useInterfaces = (limit = 50) => useGenericFetch<InterfaceStat>('/interfaces', limit); -export const useIpVersions = (limit = 50) => useGenericFetch<IpVersionStat>('/ip-versions', limit); -export const useRemoteIps = (limit = 50) => useGenericFetch<RemoteIpStat>('/remote-ips', limit); -export const useMacBandwidth = (limit = 50) => useGenericFetch<MacBandwidthStat>('/mac-bandwidth', limit); -export const useFlowTypes = (limit = 50) => useGenericFetch<FlowTypeStat>('/flow-types', limit); -export const useFlowOrigins = (limit = 50) => useGenericFetch<FlowOriginStat>('/flow-origins', limit); - -// DPI Analytics Hooks -export const useNetbios = (limit = 50) => useGenericFetch<NetbiosStat>('/netbios', limit); -export const useDiscoveryOs = (limit = 50) => useGenericFetch<DiscoveryOsStat>('/discovery-os', limit); -export const useDhcpFingerprints = (limit = 50) => useGenericFetch<DhcpFingerprintStat>('/dhcp-fingerprints', limit); -export const useHttpUserAgents = (limit = 50) => useGenericFetch<HttpUserAgentStat>('/http-user-agents', limit); -export const useSniHostnames = (limit = 50) => useGenericFetch<SniHostnameStat>('/sni-hostnames', limit); -export const useSslServerCn = (limit = 50) => useGenericFetch<SslServerCnStat>('/ssl-server-cn', limit); -export const useQuicHostnames = (limit = 50) => useGenericFetch<QuicHostnameStat>('/quic-hostnames', limit); -export const useBittorrentHashes = (limit = 50) => useGenericFetch<BittorrentHashStat>('/bittorrent-hashes', limit); -export const useSshVersions = (limit = 50) => useGenericFetch<SshVersionStat>('/ssh-versions', limit); -export const useMdnsHostnames = (limit = 50) => useGenericFetch<MdnsHostnameStat>('/mdns-hostnames', limit); - -// Intelligence Detail Hooks -export const useIntelCryptoMining = (limit = 50) => useGenericFetch<IntelCryptoMining>('/intelligence/crypto-mining', limit); -export const useIntelDeviceDiscovery = (limit = 50) => useGenericFetch<IntelDeviceDiscovery>('/intelligence/device-discovery', limit); -export const useIntelEncryptionAudit = (limit = 50) => useGenericFetch<IntelEncryptionAudit>('/intelligence/encryption-audit', limit); -export const useIntelInsecureProtocols = (limit = 50) => useGenericFetch<IntelInsecureProtocol>('/intelligence/insecure-protocols', limit); -export const useIntelIpReputation = (limit = 50) => useGenericFetch<IntelIpReputation>('/intelligence/ip-reputation', limit); -export const useIntelServerDiscovery = (limit = 50) => useGenericFetch<IntelServerDiscovery>('/intelligence/server-discovery', limit); -export const useIntelTorDetection = (limit = 50) => useGenericFetch<IntelTorDetection>('/intelligence/tor', limit); -export const useIntelUnencryptedPasswords = (limit = 50) => useGenericFetch<IntelUnencryptedPassword>('/intelligence/unencrypted-passwords', limit); -export const useIntelVpnDetection = (limit = 50) => useGenericFetch<IntelVpnDetection>('/intelligence/vpn', limit); +import { useState, useEffect } from 'react'; +import { fetcher } from './api'; + +// --- ADVANCED NETWORK INFRASTRUCTURE TYPES --- +export interface RegionStat { id: number; fetched_at: string; region_name: string; region_code: string; country_name: string; country_code: string; download: number; } +export interface CityStat { id: number; fetched_at: string; city_name: string; region_name: string; country_name: string; country_code: string; download: number; } +export interface VlanStat { id: number; fetched_at: string; vlan_id: number; vlan_label: string; download: number; upload: number; total: number; } +export interface InterfaceStat { id: number; fetched_at: string; iface_id: number; iface_name: string; iface_role: string; agent_id: string; download: number; upload: number; total: number; } +export interface IpVersionStat { id: number; fetched_at: string; ip_version_label: string; download: number; upload: number; total: number; } +export interface RemoteIpStat { id: number; fetched_at: string; remote_ip: string; ip_version: number; download: number; upload: number; total: number; } +export interface MacBandwidthStat { id: number; fetched_at: string; mac_address: string; manufacturer: string; download: number; upload: number; total: number; } +export interface FlowTypeStat { id: number; fetched_at: string; flow_type_label: string; download: number; upload: number; total: number; } +export interface FlowOriginStat { id: number; fetched_at: string; flow_origin_label: string; download: number; upload: number; total: number; } + +// --- DPI ANALYTICS TYPES --- +export interface NetbiosStat { id: number; fetched_at: string; hostname: string; download: number; upload: number; total: number; } +export interface DiscoveryOsStat { id: number; fetched_at: string; os_label: string; download: number; upload: number; total: number; } +export interface DhcpFingerprintStat { id: number; fetched_at: string; fingerprint: string; download: number; upload: number; total: number; } +export interface HttpUserAgentStat { id: number; fetched_at: string; user_agent: string; download: number; upload: number; total: number; } +export interface SniHostnameStat { id: number; fetched_at: string; sni_hostname: string; download: number; upload: number; total: number; } +export interface SslServerCnStat { id: number; fetched_at: string; ssl_server_cn: string; download: number; upload: number; total: number; } +export interface QuicHostnameStat { id: number; fetched_at: string; quic_hostname: string; download: number; upload: number; total: number; } +export interface BittorrentHashStat { id: number; fetched_at: string; info_hash: string; label: string; download: number; upload: number; total: number; } +export interface SshVersionStat { id: number; fetched_at: string; ssh_version: string; download: number; upload: number; total: number; } +export interface MdnsHostnameStat { id: number; fetched_at: string; mdns_hostname: string; download: number; upload: number; total: number; } + +// --- INTELLIGENCE DETAIL TYPES --- +export interface IntelCryptoMining { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; pool_host: string; pool_ip: string; protocol: string; app_label: string; confidence: number; download: number; upload: number; } +export interface IntelDeviceDiscovery { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; device_label: string; device_type: string; os_label: string; manufacturer: string; is_new: number; } +export interface IntelEncryptionAudit { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; device_label: string; encrypted_pct: number; unencrypted: number; encrypted: number; total: number; risk_level: string; } +export interface IntelInsecureProtocol { id: number; fetched_at: string; detected_at: string; protocol: string; ip_address: string; mac_address: string; dst_ip: string; dst_port: number; app_label: string; download: number; upload: number; risk: string; source: string; } +export interface IntelIpReputation { id: number; fetched_at: string; detected_at: string; ip_address: string; local_ip: string; mac_address: string; reputation: string; score: number; country: string; app_label: string; download: number; upload: number; blacklisted: number; } +export interface IntelServerDiscovery { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; server_type: string; hostname: string; port: number; protocol: string; os_label: string; download: number; upload: number; } +export interface IntelTorDetection { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; exit_node: string; circuit_id: string; download: number; upload: number; country: string; } +export interface IntelUnencryptedPassword { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; dst_ip: string; dst_port: number; protocol: string; username: string; download: number; upload: number; severity: string; } +export interface IntelVpnDetection { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; vpn_type: string; remote_ip: string; protocol: string; download: number; upload: number; country: string; confidence: number; } + +import { useTimeFilter } from '@/contexts/TimeFilterContext'; +import { useCtxFetch } from './api-context-core'; + +function useGenericFetch<T>(endpoint: string, limit: number = 1000000) { + const { timeRange } = useTimeFilter(); + return useCtxFetch<T[]>(`${endpoint}?limit=${limit}`, timeRange, []); +} + +// Network Infrastructure Hooks +export const useRegions = (limit = 1000000) => useGenericFetch<RegionStat>('/regions', limit); +export const useCities = (limit = 1000000) => useGenericFetch<CityStat>('/cities', limit); +export const useVlans = (limit = 1000000) => useGenericFetch<VlanStat>('/vlans', limit); +export const useInterfaces = (limit = 1000000) => useGenericFetch<InterfaceStat>('/interfaces', limit); +export const useIpVersions = (limit = 1000000) => useGenericFetch<IpVersionStat>('/ip-versions', limit); +export const useRemoteIps = (limit = 1000000) => useGenericFetch<RemoteIpStat>('/remote-ips', limit); +export const useMacBandwidth = (limit = 1000000) => useGenericFetch<MacBandwidthStat>('/mac-bandwidth', limit); +export const useFlowTypes = (limit = 1000000) => useGenericFetch<FlowTypeStat>('/flow-types', limit); +export const useFlowOrigins = (limit = 1000000) => useGenericFetch<FlowOriginStat>('/flow-origins', limit); + +// DPI Analytics Hooks +export const useNetbios = (limit = 1000000) => useGenericFetch<NetbiosStat>('/netbios', limit); +export const useDiscoveryOs = (limit = 1000000) => useGenericFetch<DiscoveryOsStat>('/discovery-os', limit); +export const useDhcpFingerprints = (limit = 1000000) => useGenericFetch<DhcpFingerprintStat>('/dhcp-fingerprints', limit); +export const useHttpUserAgents = (limit = 1000000) => useGenericFetch<HttpUserAgentStat>('/http-user-agents', limit); +export const useSniHostnames = (limit = 1000000) => useGenericFetch<SniHostnameStat>('/sni-hostnames', limit); +export const useSslServerCn = (limit = 1000000) => useGenericFetch<SslServerCnStat>('/ssl-server-cn', limit); +export const useQuicHostnames = (limit = 1000000) => useGenericFetch<QuicHostnameStat>('/quic-hostnames', limit); +export const useBittorrentHashes = (limit = 1000000) => useGenericFetch<BittorrentHashStat>('/bittorrent-hashes', limit); +export const useSshVersions = (limit = 1000000) => useGenericFetch<SshVersionStat>('/ssh-versions', limit); +export const useMdnsHostnames = (limit = 1000000) => useGenericFetch<MdnsHostnameStat>('/mdns-hostnames', limit); + +// Intelligence Detail Hooks +export const useIntelCryptoMining = (limit = 1000000) => useGenericFetch<IntelCryptoMining>('/intelligence/crypto-mining', limit); +export const useIntelDeviceDiscovery = (limit = 1000000) => useGenericFetch<IntelDeviceDiscovery>('/intelligence/device-discovery', limit); +export const useIntelEncryptionAudit = (limit = 1000000) => useGenericFetch<IntelEncryptionAudit>('/intelligence/encryption-audit', limit); +export const useIntelInsecureProtocols = (limit = 1000000) => useGenericFetch<IntelInsecureProtocol>('/intelligence/insecure-protocols', limit); +export const useIntelIpReputation = (limit = 1000000) => useGenericFetch<IntelIpReputation>('/intelligence/ip-reputation', limit); +export const useIntelServerDiscovery = (limit = 1000000) => useGenericFetch<IntelServerDiscovery>('/intelligence/server-discovery', limit); +export const useIntelTorDetection = (limit = 1000000) => useGenericFetch<IntelTorDetection>('/intelligence/tor', limit); +export const useIntelUnencryptedPasswords = (limit = 1000000) => useGenericFetch<IntelUnencryptedPassword>('/intelligence/unencrypted-passwords', limit); +export const useIntelVpnDetection = (limit = 1000000) => useGenericFetch<IntelVpnDetection>('/intelligence/vpn', limit); diff --git a/src/lib/api-category-detail.ts b/src/lib/api-category-detail.ts index 1a8e21e..15a1c9d 100644 --- a/src/lib/api-category-detail.ts +++ b/src/lib/api-category-detail.ts @@ -4,7 +4,6 @@ // ───────────────────────────────────────────────────────────────────────────── import { useState, useEffect, useCallback } from 'react'; -import { getViewAsHeaders } from './admin-api'; const API_BASE = ''; @@ -53,10 +52,7 @@ export function useCategoryDetail( const params = new URLSearchParams({ category, timeRange }); const response = await fetch( `${API_BASE}/api/dashboard/category-detail?${params}`, - { - headers: getViewAsHeaders(), - credentials: 'include' - } + { credentials: 'include' } ); if (!response.ok) throw new Error(`HTTP ${response.status}`); const result: CategoryDetailResult = await response.json(); diff --git a/src/lib/api-context-core.ts b/src/lib/api-context-core.ts index ccd19e5..1effcc1 100644 --- a/src/lib/api-context-core.ts +++ b/src/lib/api-context-core.ts @@ -16,14 +16,12 @@ export const REFRESH_INTERVAL_MS = 60 * 1000; * - stale-while-revalidate caching (no spinner if cached data is present) * - auto-refresh every 5 minutes */ -export function useCtxFetch<T>(endpoint: string, timeRange: string, defaultValue: T, extraParams?: Record<string, string>): { +export function useCtxFetch<T>(endpoint: string, timeRange: string, defaultValue: T): { data: T; error: Error | null; isLoading: boolean; } { - // Include extraParams in cache key so different date ranges are cached separately - const extraKey = extraParams ? ':' + new URLSearchParams(extraParams).toString() : ''; - const cacheKey = `${endpoint}:${timeRange}${extraKey}`; + const cacheKey = `${endpoint}:${timeRange}`; // Initial state reads from global memory cache if present const [data, setData] = useState<T>(() => { @@ -60,7 +58,7 @@ export function useCtxFetch<T>(endpoint: string, timeRange: string, defaultValue } setError(null); - fetcher<T>(endpoint, timeRange, extraParams) + fetcher<T>(endpoint, timeRange) .then(res => { const val = res ?? defaultValue; globalCache[cacheKey] = val; @@ -73,7 +71,7 @@ export function useCtxFetch<T>(endpoint: string, timeRange: string, defaultValue setIsLoading(false); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [endpoint, timeRange, extraKey, tick]); + }, [endpoint, timeRange, tick]); return { data, error, isLoading }; } diff --git a/src/lib/api-metadata-detail.ts b/src/lib/api-metadata-detail.ts index 2d628fd..8ad5f97 100644 --- a/src/lib/api-metadata-detail.ts +++ b/src/lib/api-metadata-detail.ts @@ -5,7 +5,6 @@ // ───────────────────────────────────────────────────────────────────────────── import { useState, useEffect, useCallback } from 'react'; -import { getViewAsHeaders } from './admin-api'; const API_BASE = ''; @@ -76,10 +75,7 @@ export function useMetadataDetail( const params = new URLSearchParams({ type, value, timeRange }); const response = await fetch( `${API_BASE}/api/dashboard/metadata-detail?${params}`, - { - headers: getViewAsHeaders(), - credentials: 'include' - } + { credentials: 'include' } ); if (!response.ok) throw new Error(`HTTP ${response.status}`); const result: MetadataDetailResult = await response.json(); diff --git a/src/lib/api-with-context-security.ts b/src/lib/api-with-context-security.ts new file mode 100644 index 0000000..03c8241 --- /dev/null +++ b/src/lib/api-with-context-security.ts @@ -0,0 +1,58 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useTimeFilter } from '@/contexts/TimeFilterContext'; +import { useCtxFetch, globalCache, REFRESH_INTERVAL_MS } from './api-context-core'; +import { fetcher } from './api'; +import type { TlsVersionStat, TlsCipherStat, TlsSecurityStat, IntelligenceStats } from './api'; + +export function useTlsVersions(limit: number = 1000000) { + const { timeRange } = useTimeFilter(); + return useCtxFetch<TlsVersionStat[]>(`/tls-versions?limit=${limit}`, timeRange, []); +} + +export function useTlsCiphers(limit: number = 1000000) { + const { timeRange } = useTimeFilter(); + return useCtxFetch<TlsCipherStat[]>(`/tls-ciphers?limit=${limit}`, timeRange, []); +} + +export function useTlsSecurity(limit: number = 1000000) { + const { timeRange } = useTimeFilter(); + return useCtxFetch<TlsSecurityStat[]>(`/tls-security?limit=${limit}`, timeRange, []); +} + +export function useIntelligenceStats() { + const { timeRange } = useTimeFilter(); + const endpoint = '/intelligence/stats'; + const cacheKey = `${endpoint}:${timeRange}`; + + const [data, setData] = useState<IntelligenceStats | null>(() => { + return (globalCache[cacheKey] as IntelligenceStats) ?? null; + }); + const [error, setError] = useState<Error | null>(null); + const [isLoading, setIsLoading] = useState(() => { + return !globalCache[cacheKey]; + }); + const [tick, setTick] = useState(0); + + useEffect(() => { + const t = setInterval(() => setTick(v => v + 1), REFRESH_INTERVAL_MS); + return () => clearInterval(t); + }, []); + + useEffect(() => { + if (!globalCache[cacheKey]) { + setIsLoading(true); + } + fetcher<IntelligenceStats>(endpoint, timeRange) + .then(res => { + globalCache[cacheKey] = res; + setData(res); + }) + .catch(setError) + .finally(() => setIsLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timeRange, tick]); + + return { data, error, isLoading }; +} diff --git a/src/lib/api-with-context.ts b/src/lib/api-with-context.ts index 887d580..c5a0a4e 100644 --- a/src/lib/api-with-context.ts +++ b/src/lib/api-with-context.ts @@ -102,21 +102,9 @@ export function useTimeline(points: number = 60, agentUuid?: string) { return { data, error, isLoading }; } -/** Build extra query params for explicit date range filtering (overrides sidebar timeRange). */ -function buildDateParams(dateFrom?: string, dateTo?: string): Record<string, string> | undefined { - const from = dateFrom?.trim(); - const to = dateTo?.trim(); - if (!from && !to) return undefined; - const params: Record<string, string> = {}; - if (from) params.date_from = from; - if (to) params.date_to = to; - return params; -} - -export function useTopApps(limit: number = 10, dateFrom?: string, dateTo?: string) { +export function useTopApps(limit: number = 1000000) { const { timeRange } = useTimeFilter(); - const extraParams = buildDateParams(dateFrom, dateTo); - return useCtxFetch<AppStat[]>(`/apps?limit=${limit}`, timeRange, [], extraParams); + return useCtxFetch<AppStat[]>(`/apps?limit=${limit}`, timeRange, []); } export function useTopProtocols() { @@ -124,21 +112,19 @@ export function useTopProtocols() { return useCtxFetch<ProtocolStat[]>('/protocols', timeRange, []); } -export function useSslSans(limit: number = 50) { +export function useSslSans(limit: number = 1000000) { const { timeRange } = useTimeFilter(); return useCtxFetch<SslSanStat[]>(`/ssl-subject-alt-names?limit=${limit}`, timeRange, []); } -export function useDevices(limit: number = 0, dateFrom?: string, dateTo?: string) { +export function useDevices(limit: number = 1000000) { const { timeRange } = useTimeFilter(); - const extraParams = buildDateParams(dateFrom, dateTo); - return useCtxFetch<DeviceStat[]>(`/devices?limit=${limit}`, timeRange, [], extraParams); + return useCtxFetch<DeviceStat[]>(`/devices?limit=${limit}`, timeRange, []); } -export function useThreats(limit: number = 0, dateFrom?: string, dateTo?: string) { +export function useThreats(limit: number = 1000000) { const { timeRange } = useTimeFilter(); - const extraParams = buildDateParams(dateFrom, dateTo); - return useCtxFetch<ThreatStat[]>(`/threats?limit=${limit}`, timeRange, [], extraParams); + return useCtxFetch<ThreatStat[]>(`/threats?limit=${limit}`, timeRange, []); } export function useAppCategories() { @@ -151,78 +137,72 @@ export function useContinents() { return useCtxFetch<ContinentStat[]>('/continents', timeRange, []); } -export function useFlows(limit: number = 5000, dateFrom?: string, dateTo?: string) { +export function useFlows(page: number = 1, pageSize: number = 50, filters: any = {}) { const { timeRange } = useTimeFilter(); - const extraParams = buildDateParams(dateFrom, dateTo); - return useCtxFetch<FlowStat[]>(`/flows?limit=${limit}`, timeRange, [], extraParams); + const queryParts = [ + `limit=${pageSize}`, + `skip=${(page - 1) * pageSize}` + ]; + if (filters.protocol) queryParts.push(`protocol=${encodeURIComponent(filters.protocol)}`); + if (filters.src_ip) queryParts.push(`src_ip=${encodeURIComponent(filters.src_ip)}`); + if (filters.dst_ip) queryParts.push(`dst_ip=${encodeURIComponent(filters.dst_ip)}`); + if (filters.dst_port) queryParts.push(`dst_port=${encodeURIComponent(filters.dst_port)}`); + if (filters.app) queryParts.push(`app=${encodeURIComponent(filters.app)}`); + if (filters.domain) queryParts.push(`domain=${encodeURIComponent(filters.domain)}`); + if (filters.sort_download) queryParts.push(`sort_download=${encodeURIComponent(filters.sort_download)}`); + if (filters.sort_upload) queryParts.push(`sort_upload=${encodeURIComponent(filters.sort_upload)}`); + if (filters.dateFrom) queryParts.push(`date_from=${encodeURIComponent(filters.dateFrom)}`); + if (filters.dateTo) queryParts.push(`date_to=${encodeURIComponent(filters.dateTo)}`); + if (filters.search) queryParts.push(`search=${encodeURIComponent(filters.search)}`); + + const endpoint = `/flows?${queryParts.join('&')}`; + const { data: res, error, isLoading } = useCtxFetch<{ flows: FlowStat[], total: number }>(endpoint, timeRange, { flows: [], total: 0 }); + return { + data: res.flows || [], + total: res.total || 0, + error, + isLoading + }; } -export function useCountries(limit: number = 0) { +export interface FlowOptions { + protocols: string[]; + srcIps: string[]; + dstIps: string[]; + dstPorts: string[]; + apps: string[]; + domains: string[]; +} + +export function useFlowsOptions() { + const { timeRange } = useTimeFilter(); + return useCtxFetch<FlowOptions>('/flows-options', timeRange, { + protocols: [], + srcIps: [], + dstIps: [], + dstPorts: [], + apps: [], + domains: [] + }); +} + +export function useCountries(limit: number = 1000000) { const { timeRange } = useTimeFilter(); return useCtxFetch<CountryStat[]>(`/countries?limit=${limit}`, timeRange, []); } -export function useDNS(limit: number = 0) { +export function useDNS(limit: number = 1000000) { const { timeRange } = useTimeFilter(); return useCtxFetch<DnsStat[]>(`/dns?limit=${limit}`, timeRange, []); } -export function useEvents(limit: number = 0, dateFrom?: string, dateTo?: string) { +export function useEvents(limit: number = 1000000) { const { timeRange } = useTimeFilter(); - const extraParams = buildDateParams(dateFrom, dateTo); - return useCtxFetch<EventStat[]>(`/events?limit=${limit}`, timeRange, [], extraParams); + return useCtxFetch<EventStat[]>(`/events?limit=${limit}`, timeRange, []); } -export function useTlsVersions(limit: number = 10) { - const { timeRange } = useTimeFilter(); - return useCtxFetch<TlsVersionStat[]>(`/tls-versions?limit=${limit}`, timeRange, []); -} - -export function useTlsCiphers(limit: number = 15) { - const { timeRange } = useTimeFilter(); - return useCtxFetch<TlsCipherStat[]>(`/tls-ciphers?limit=${limit}`, timeRange, []); -} - -export function useTlsSecurity(limit: number = 10) { - const { timeRange } = useTimeFilter(); - return useCtxFetch<TlsSecurityStat[]>(`/tls-security?limit=${limit}`, timeRange, []); -} - -export function useIntelligenceStats() { - const { timeRange } = useTimeFilter(); - const endpoint = '/intelligence/stats'; - const cacheKey = `${endpoint}:${timeRange}`; - - const [data, setData] = useState<IntelligenceStats | null>(() => { - return (globalCache[cacheKey] as IntelligenceStats) ?? null; - }); - const [error, setError] = useState<Error | null>(null); - const [isLoading, setIsLoading] = useState(() => { - return !globalCache[cacheKey]; - }); - const [tick, setTick] = useState(0); - - useEffect(() => { - const t = setInterval(() => setTick(v => v + 1), REFRESH_INTERVAL_MS); - return () => clearInterval(t); - }, []); - - useEffect(() => { - if (!globalCache[cacheKey]) { - setIsLoading(true); - } - fetcher<IntelligenceStats>(endpoint, timeRange) - .then(res => { - globalCache[cacheKey] = res; - setData(res); - }) - .catch(setError) - .finally(() => setIsLoading(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [timeRange, tick]); - - return { data, error, isLoading }; -} +// Re-export security-specific hooks +export { useTlsVersions, useTlsCiphers, useTlsSecurity, useIntelligenceStats } from './api-with-context-security'; // Re-export utilities from api.ts export { fetcher, fetchAgentDetails, fetchDeviceDetails, fetchAppDetails, fetchSecurityDevices } from './api'; diff --git a/src/lib/api/core.ts b/src/lib/api/core.ts index 5340813..790927d 100644 --- a/src/lib/api/core.ts +++ b/src/lib/api/core.ts @@ -5,17 +5,11 @@ import { useState, useEffect } from 'react'; import { getViewAsHeaders } from '../admin-api'; import { DashboardSummary, TimelinePoint } from '../api-types'; -export async function fetcher<T>(endpoint: string, timeRange?: string, extraParams?: Record<string, string>): Promise<T> { +export async function fetcher<T>(endpoint: string, timeRange?: string): Promise<T> { let url = `/api/dashboard${endpoint}`; - const queryParts: string[] = []; - if (timeRange) queryParts.push(`timeRange=${encodeURIComponent(timeRange)}`); - if (extraParams) { - for (const [key, val] of Object.entries(extraParams)) { - if (val) queryParts.push(`${key}=${encodeURIComponent(val)}`); - } - } - if (queryParts.length > 0) { - url += (url.includes('?') ? '&' : '?') + queryParts.join('&'); + if (timeRange) { + const sep = url.includes('?') ? '&' : '?'; + url += sep + 'timeRange=' + timeRange; } const viewAsHeaders = getViewAsHeaders(); const res = await fetch(url, { headers: viewAsHeaders }); @@ -29,7 +23,6 @@ export async function fetcher<T>(endpoint: string, timeRange?: string, extraPara return json.data; } - export function useDashboardSummary(timeRange?: string) { const [data, setData] = useState<DashboardSummary | null>(null); const [error, setError] = useState<Error | null>(null); diff --git a/src/lib/branding.ts b/src/lib/branding.ts new file mode 100644 index 0000000..9173e9e --- /dev/null +++ b/src/lib/branding.ts @@ -0,0 +1,53 @@ +// src/lib/branding.ts +export interface Branding { + name: string; + logo: string; + title: string; + copyright: string; + primaryColor: string; +} + +export function getSiteBranding(selectedSiteUuid?: string): Branding { + if (typeof window !== "undefined") { + const siteUuid = selectedSiteUuid || localStorage.getItem('backone_site_uuid'); + + // Source 2 — SIAB site UUID + const isSiab = siteUuid === '6681452d_9cae_4ff4_8ae8_0d504774265e'; + + // Source 2 — Office site UUID (same branding as SIAB) + const isOffice = siteUuid === '1959bb55_045b_47c7_bbdd_f33b7db197b9'; + + // Source 1 — Nexus site UUID (kept for backward compat, should not appear in Source 2) + const isNexus = !isSiab && !isOffice && ( + siteUuid === 'd7902405_0dc2_458b_8584_ed4d24b64f24' || + window.location.search.includes("nexus") + ); + + if (isNexus) { + return { + name: "Nexus", + logo: "/nexus-logo.png", + title: "Nexus Dashboard", + copyright: "PT. Nexus Solusi", + primaryColor: "#8B5CF6" + }; + } + + if (isSiab || isOffice) { + return { + name: "BackOne", + logo: "/backone-logo.png", + title: "BackOne Dashboard", + copyright: "PT. Data Bisnis Solusi", + primaryColor: "#E11D48" + }; + } + } + return { + name: "BackOne", + logo: "/backone-logo.png", + title: "BackOne Dashboard", + copyright: "PT. Data Bisnis Solusi", + primaryColor: "#E11D48" + }; +} diff --git a/src/lib/help/conventions.ts b/src/lib/help/conventions.ts new file mode 100644 index 0000000..d1bee85 --- /dev/null +++ b/src/lib/help/conventions.ts @@ -0,0 +1,11 @@ +import { TableColumn } from "./types"; + +export const UI_CONVENTIONS: { symbol: string; meaning: string }[] = [ + { symbol: "● (green)", meaning: "Active / Online status / Flows Detected" }, + { symbol: "● (red)", meaning: "Inactive / Offline status / No Flows Detected" }, + { symbol: "● (yellow/amber)", meaning: "Warning / Requires attention" }, + { symbol: "Truncated text (...)", meaning: "Hover over the text cell to view a tooltip with the full, untruncated content" }, + { symbol: "KB / MB / GB", meaning: "Data size units (1 MB = 1,000 KB, 1 GB = 1,000 MB)" }, + { symbol: "Time format WIB/WITA/WIT", meaning: "All timestamps displayed in Indonesia local time zone (Asia/Jakarta, Asia/Makassar, or Asia/Jayapura)" }, + { symbol: "Pagination < 1/N >", meaning: "Page navigation — each page displays a fixed size of 50 rows of data. The sequence index (#) continues across pages (e.g. Page 2 starts at index 51)" }, +]; diff --git a/src/lib/help/faqs.ts b/src/lib/help/faqs.ts new file mode 100644 index 0000000..8bb6f08 --- /dev/null +++ b/src/lib/help/faqs.ts @@ -0,0 +1,59 @@ +import { FAQItem } from "./types"; + +export const FAQS: FAQItem[] = [ + { + question: "Why is the Threats data empty (0)?", + answer: + "If the Threats indicator shows 0, it means your network is secure within the selected time range. The system did not detect any data packets matching known attack profiles.", + }, + { + question: "What is the difference between Events and Threats?", + answer: + "Events are all standard connection activity logs recorded on the network (including secure ones). Threats are a subset of specific Events that have been filtered and classified as malicious activity by the Deep Packet Inspection (DPI) engine.", + }, + { + question: "How do I read the Geo Traffic Heatmap?", + answer: + "Light blue indicates low traffic, while deep/dark blue indicates a very high traffic volume (download/upload) to or from that country.", + }, + { + question: "How is an Agent's Online or Offline status determined?", + answer: + "An agent's status is determined in real-time based on active flows. If the agent has successfully transmitted network traffic within the last 24 hours, its Traffic Status column displays a green pulsing beacon with the exact timestamp 'Last seen: HH:MM WIB'. If no traffic has been received beyond that 24-hour window, it is displayed as 'No Flows Detected' with a grey indicator.", + }, + { + question: "Why do flow lines on the Agent map only appear between certain agents?", + answer: + "To avoid visual clutter and represent real-time routing accurately, flow lines are drawn ONLY between agents that have active data transactions. Sibling agents without current traffic flows between them will not show a line. Hovering over an active line opens a detailed tooltip listing the top active sub-flows, data volume, and connection counts.", + }, + { + question: "What is the data retention limit for telemetry and flows?", + answer: + "To maintain system database efficiency and high query performance, all network telemetry, flows, and summary documents older than 30 days are automatically deleted by the database scheduler.", + }, + { + question: "Is there a limit on the number of records displayed or analyzed?", + answer: + "No arbitrary limits are imposed. The database query limits and collection size limits are configured to support the platform's maximum capacity of 1,000,000 (1 million) records.", + }, + { + question: "How does server-side pagination work on tables like Flows?", + answer: + "Large data tables utilize server-side pagination with a fixed page size of 50 items. The sequential index (# column) continues seamlessly across pages (e.g. Page 2 starts at index 51) to maintain structural consistency.", + }, + { + question: "How does the \"View As Agent\" mode affect the dashboard data?", + answer: + "When 'View As Agent' mode is enabled, the entire data scope on the dashboard is locked to that specific agent. You will only see active devices, DNS logs, traffic flows, and threats detected specifically by that agent, rather than the aggregate data of the entire site.", + }, + { + question: "Why do some devices show \"Unknown\" for operating system or hostname?", + answer: + "Hostnames are resolved via local DNS queries, and operating systems are identified passively using HTTP User-Agent and DHCP Fingerprints. If a device has not sent standard HTTP traffic or has recently connected without requesting a new IP, the system will display 'Unknown'.", + }, + { + question: "What should I do if the Encryption Audit flags a connection as \"High Risk\"?", + answer: + "A 'High Risk' status is typically triggered by the use of legacy encryption versions such as TLS 1.0 or TLS 1.1, which are vulnerable to eavesdropping. It is recommended to update the destination server configuration or client systems to use at least TLS 1.2 or TLS 1.3.", + } +]; diff --git a/src/lib/help/guides/agents.ts b/src/lib/help/guides/agents.ts new file mode 100644 index 0000000..e285a48 --- /dev/null +++ b/src/lib/help/guides/agents.ts @@ -0,0 +1,100 @@ +import { PageGuide } from "../types"; + +export const AGENTS_GUIDES: PageGuide[] = [ + { + id: "agents", + title: "Agents Inventory", + url: "/agents", + icon: "📡", + summary: "Full management of all registered DPI Network Agent probes at your site.", + sections: [ + { + heading: "Statistic Cards — Quick Status Overview", + content: "Four summary cards at the top of the page give an instant health snapshot of all registered DPI probes:", + columns: [ + { column: "Total Agents", description: "Total number of DPI probe devices registered in the system database for this site. Each agent is a physical or virtual network sensor deployed at a specific location." }, + { column: "Online Agents", description: "Agents that successfully transmitted telemetry within the last 24 hours. Marked with a pulsing green dot and 'Last seen: HH:MM WIB'. An online agent is actively monitoring and sending data." }, + { column: "Offline Agents", description: "Agents that have not sent any data for more than 24 hours. Marked with a grey dot and 'No Flows Detected'. Check whether the device is still powered on, connected to the network, and that the DPI service is running." }, + { column: "Avg. Uptime", description: "Average availability percentage across all agents calculated from the last 30 days. Ideal value is >99%. Below 90% indicates persistent connectivity or hardware stability issues that require urgent on-site investigation." }, + ], + }, + { + heading: "Agent Location Map — Interactive Network Overview", + content: "An interactive tile map displaying the geographic position of each agent based on GPS coordinates set by the administrator. Zoom in to street-level detail (zoom level 15+). Online agents show a pulsing green beacon; offline agents show a grey beacon.", + items: [ + { label: "Flow lines", description: "Animated arcs drawn ONLY between agents that have active inter-agent traffic flowing between them — not all agents are connected by default." }, + { label: "Hover on agent marker", description: "Shows the agent's friendly name (label), online/offline status, and uptime percentage in a tooltip." }, + { label: "Hover on flow arc", description: "Shows a tooltip with the top active sub-flows, bandwidth volume, and total connection count between the two agents." }, + { label: "Blue panel (No GPS set)", description: "Lists agents that have no GPS coordinates configured. Click the '📍 Set Location' button inside the panel or the 📍 pin icon in the table to open the coordinate input modal." }, + ], + }, + { + heading: "Table: Provisioned Network Agents — Column Reference", + content: "Each row represents one registered DPI probe. The columns are:", + columns: [ + { column: "#", description: "Sequential row index. Used to quickly reference a specific agent row during review or support calls." }, + { column: "Agent Name", description: "The friendly display name of the agent — set by the administrator in the Edit Label modal. This name appears across all dashboard pages (Flows, Apps, Devices, etc.) to identify the data source. If not set, a short identifier is used as fallback." }, + { column: "Label / Location Tag", description: "User-assigned descriptive tag indicating physical location (e.g., 'Server Room Floor 3', 'Branch Office Cibubur'). Used across all dashboard tables and charts to identify which sensor detected the traffic." }, + { column: "Provisioned", description: "Whether the agent is registered in the central management system. Green 'Yes' = registered and data will be processed. Red 'No' = not yet registered — data from this agent will be silently discarded." }, + { column: "Activated", description: "Whether the agent has ever successfully connected and transmitted telemetry at least once. An unactivated agent will not appear on the map or in any traffic table even if provisioned." }, + { column: "Traffic Status", description: "Real-time heartbeat indicator. Pulsing green dot + 'Last seen: HH:MM WIB' = active, sending data within the last 24 hours. Grey dot + 'No Flows Detected' = no data for over 24 hours — the agent may be offline or misconfigured." }, + { column: "Avg Uptime", description: "Historical availability percentage specific to this agent over the last 30 days. Values below 90% require investigation — common causes include unstable ISP connections, power interruptions, or hardware issues." }, + { column: "Data Size", description: "Total size of telemetry data currently stored in the database for this agent. Data is automatically purged after 30 days per the retention policy to maintain system efficiency." }, + { column: "Actions", description: "Up to 5 action buttons available per agent row — see the Agent Row Actions section below for details on each button." }, + ], + }, + { + heading: "Agent Row Actions — What Each Button Does", + items: [ + { label: "Detail ( › ) Icon", description: "Opens the Agent Detail Modal with comprehensive information: firmware version, operating system type, CPU and memory utilization percentages, network interface configurations, and full connection history log." }, + { label: "User ( 👤 ) Icon", description: "Opens the Agent Viewer Accounts panel — manage user accounts that are granted access to view ONLY this specific agent's traffic data. Useful for provisioning Company Viewer accounts locked to this single probe." }, + { label: "Pin ( 📍 ) Icon", description: "Opens the GPS Coordinate Modal to set or update this agent's geographic location. Enter the latitude and longitude values (e.g., -6.2088, 106.8456 for Jakarta). The agent marker will appear on the map immediately after saving." }, + { label: "Eye ( 👁️ ) Icon — View As Agent", description: "Locks the entire dashboard scope to this specific agent only. ALL pages (Flows, Apps, Devices, DNS, Events, etc.) will show only data from this single agent. A blue 'Viewing as: [Agent Name]' banner appears in the sidebar. Click 'Exit View As' in the sidebar to return to the global multi-agent view." }, + { label: "Trash ( 🗑️ ) Icon — Delete Agent", description: "Permanently removes the agent registration from the database. All associated telemetry data collected by this agent is also deleted. This action is irreversible — use with caution." }, + ], + }, + { + heading: "Setting Agent GPS Location (Step-by-Step)", + content: "To place an agent on the interactive map:", + steps: [ + "Click the 📍 pin icon in the Actions column of the target agent row.", + "The GPS Coordinate Modal will open. Enter the Latitude value (e.g., -6.2088 for South Jakarta). Negative values = South hemisphere.", + "Enter the Longitude value (e.g., 106.8456 for East Jakarta). Positive values = East hemisphere.", + "Click 'Save Location'. The agent marker will appear on the map immediately.", + "If the agent was in the 'No GPS Set' blue panel, it will automatically disappear from that panel and appear on the map at the saved coordinates.", + "To update coordinates, simply click the pin icon again and enter new values.", + ], + }, + { + heading: "Using View As Agent — Scoped Monitoring", + content: "The View As Agent feature lets you temporarily lock the entire dashboard to a single agent's perspective:", + steps: [ + "Click the 👁️ Eye icon in the Actions column of the target agent.", + "A blue confirmation banner appears in the sidebar showing 'Viewing as: [Agent Name]'.", + "All dashboard pages (Flows, Apps, Devices, DNS, Geography, Threats, etc.) now show ONLY data from this agent.", + "Navigate to any page — the agent scope filter is applied globally and automatically.", + "To return to the full multi-agent view, click 'Exit View As' in the sidebar banner, or navigate to the Agents page and click the exit button.", + ], + }, + { + heading: "Device Labeling — MAC Address Detail Pop-up", + content: "From the Device Labeling page, click on any MAC address to open a detail pop-up showing all information known about that physical device:", + items: [ + { label: "All Associated IPs", description: "Every IP address ever assigned to or used by this MAC address, resolved from historical flow logs. DHCP environments may show many IPs over time as the device reconnects to the network." }, + { label: "Activity Dates", description: "First Seen and Last Seen timestamps for each IP-MAC pairing, helping you track when a device was active at a specific IP address over time." }, + { label: "Traffic Usage per IP", description: "Total download and upload bandwidth attributed to each IP address used by this MAC, aggregated from historical flow records in the database." }, + { label: "Vendor / OUI Identification", description: "The hardware manufacturer identified from the first 3 octets of the MAC address using the IEEE OUI database (e.g., 'Apple Inc.', 'Intel Corporate', 'Samsung Electronics', 'Cisco Systems')." }, + ], + }, + { + heading: "Monitoring Tips", + items: [ + { label: "Check Avg Uptime weekly", description: "Any agent consistently below 95% uptime needs on-site inspection. Common fixes: check power supply, verify network cable connectivity, and confirm the DPI service is configured to auto-start on system reboot." }, + { label: "Set GPS coordinates first", description: "Configure GPS coordinates for all agents before using the map view. Agents without GPS appear in the blue 'No GPS' panel and are excluded from the geographic visualization." }, + { label: "Use View As for branch support", description: "When troubleshooting a specific branch, use 'View As Agent' to scope the entire dashboard to only that branch's sensor — eliminates noise from other agents during the investigation." }, + { label: "Monitor Data Size column", description: "If an agent's Data Size grows abnormally fast, it may indicate a traffic spike or security event generating unusually high flow counts. Investigate via the Flows page using 'View As' on that specific agent." }, + ], + }, + ], + }, +]; diff --git a/src/lib/help/guides/infrastructure.ts b/src/lib/help/guides/infrastructure.ts new file mode 100644 index 0000000..72cf3a8 --- /dev/null +++ b/src/lib/help/guides/infrastructure.ts @@ -0,0 +1,190 @@ +import { PageGuide } from "../types"; + +// NOTE: Agents guide has been moved to guides/agents.ts +export const INFRASTRUCTURE_GUIDES: PageGuide[] = [ + { + id: "overview", + title: "Overview Dashboard", + url: "/", + icon: "🏠", + summary: "Real-time network health summary across all monitored agents and sites.", + sections: [ + { + heading: "KPI Cards (Key Performance Indicators)", + content: "Five cards at the top of the page display core network metrics in real-time. All values refresh automatically when the sidebar time filter changes (e.g., Last 24 Hours, Last 7 Days). A sudden spike or drop in any card value may indicate a traffic anomaly that requires investigation.", + columns: [ + { column: "Download", description: "Total volume of incoming data received by all local devices within the active time range. Displayed in KB, MB, or GB. The 'Speed' sub-label shows the average bit rate (total bytes ÷ total seconds in the filter range)." }, + { column: "Upload", description: "Total volume of outgoing data sent from the site to external networks. Upload significantly exceeding download may indicate unauthorized data exfiltration or a misconfigured backup job." }, + { column: "Devices", description: "Count of unique devices (identified by hardware MAC address) that were actively communicating during the active time filter. Unrecognized new devices should be investigated via the Devices tab." }, + { column: "Flows", description: "Total number of network connection sessions (TCP/UDP) monitored by the DPI probe. The sub-label shows flow rate per hour. A sudden spike may indicate a port scan or DDoS attack." }, + { column: "Threats", description: "Total active security threats detected (botnet, malware, crypto mining, phishing). A red number means active threats exist — click it to jump directly to the Threat Intelligence page." }, + ], + }, + { + heading: "Globe Map (3D Interactive)", + content: "A WebGL 3D Globe displaying routing paths from your site to destination countries worldwide. Each glowing arc represents an active connection. Arc thickness and brightness correspond to bandwidth intensity — thicker and brighter arcs carry more data.", + items: [ + { label: "Rotate", description: "Drag with your mouse to rotate the globe and explore all active destinations." }, + { label: "Zoom", description: "Scroll the mouse wheel to zoom in or out for more detail." }, + { label: "Hover on arc", description: "Hover over any arc to see a tooltip with country name, total download/upload volume, and flow count." }, + { label: "Anomaly signal", description: "Unusually bright arcs to unfamiliar countries warrant investigation via the Geo Traffic page." }, + ], + }, + { + heading: "Top Applications Chart", + content: "A bar chart displaying the top 7 applications by bandwidth consumed. Each bar shows the application name (as identified by DPI — e.g., YouTube, WhatsApp, Google Drive, Zoom) and total bandwidth in MB/GB. Applications that could not be specifically identified are labeled 'Unclassified'. Click any application name to jump to the Apps page with that application pre-filtered.", + }, + { + heading: "Top Protocols Chart", + content: "A donut chart showing the transport layer protocol distribution (TCP, UDP, QUIC, ICMP) as percentages.", + items: [ + { label: "TCP dominance", description: "Normal for web and enterprise applications — connection-oriented and reliable." }, + { label: "High UDP", description: "Indicates heavy streaming, VoIP, or gaming traffic. Investigate if not expected." }, + { label: "ICMP spike", description: "A sudden ICMP surge may indicate ping sweep activity or network reconnaissance." }, + ], + }, + { + heading: "How to Read the Dashboard Effectively", + steps: [ + "Check KPI Cards first — look for any value that deviates from your usual baseline (e.g., upload suddenly 10× higher than normal).", + "Inspect the Globe — connections to unexpected countries warrant further investigation in Geo Traffic and Threat Intelligence.", + "Use the Time Filter — compare different ranges (Last Hour vs Last 24 Hours) to distinguish momentary spikes from sustained anomalies.", + "Prioritize the Threats card — if it shows a number greater than 0, investigate before reviewing any other metric.", + ], + }, + ], + }, + { + id: "network-infrastructure", + title: "Network Topology", + url: "/network-infrastructure", + icon: "🌐", + summary: "Physical, logical, and geographical network infrastructure topology details.", + sections: [ + { + heading: "Tab: Routing & Geography", + content: "Displays Top Regions (continents) and Top Cities (specific cities) where external traffic is routed. Geographic coordinates are resolved from destination IP addresses using MaxMind GeoLite2 databases. Useful for identifying which data centers or countries your organization's data flows to.", + }, + { + heading: "Tab: Interfaces & VLANs", + content: "Displays statistics for monitored hardware network interfaces (eth0, eth1, wlan0, etc.) including traffic volume per interface. Also shows active VLAN IDs (802.1Q tags) detected in packet headers — helping detect unexpected inter-VLAN traffic.", + }, + { + heading: "Tab: IP & MAC", + items: [ + { label: "Top External Server IPs", description: "Highest-traffic destination IPs — clickable for WHOIS and geolocation details." }, + { label: "IP Protocol Distribution", description: "IPv4 vs IPv6 volume comparison as percentages. High IPv6 adoption may reflect modern cloud connectivity." }, + { label: "MAC Address Bandwidth", description: "Maps hardware MAC addresses to total bandwidth consumed. Useful for identifying devices without hostnames that are consuming excessive bandwidth." }, + ], + }, + { + heading: "Tab: Flow Profiles", + items: [ + { label: "Local flows", description: "Traffic between devices within the LAN only — no external routing involved." }, + { label: "Inbound flows", description: "Traffic entering from outside the network to local devices. A high inbound ratio may indicate the network is receiving unexpected external connections." }, + { label: "Outbound flows", description: "Traffic leaving local devices to the internet. The most common type for regular internet usage." }, + ], + }, + ], + }, + { + id: "lookup", + title: "Application Lookup", + url: "/lookup", + icon: "🔍", + summary: "Reference catalog of all applications recognizable by the DPI engine.", + sections: [ + { + heading: "Application Catalog & Search", + content: "Contains 2,500+ application signatures stored in the DPI engine's database. Use the search bar to find a specific application, or use the category dropdown to filter by functional group (Streaming Video, Social Media, File Transfer, Gaming, Cloud Services, etc.). Each card displays the name, icon, category, and default risk rating.", + }, + { + heading: "Application Detail Modal", + content: "Click any application card to open a detail modal. The modal contains:", + items: [ + { label: "Description", description: "Full explanation of the application's function and typical use cases." }, + { label: "Transport Protocols", description: "Whether the application uses TCP, UDP, or both." }, + { label: "Default Ports", description: "Common port numbers used (e.g., HTTPS = 443, HTTP = 80, DNS = 53, FTP = 21, SSH = 22, RDP = 3389)." }, + { label: "Risk Rating", description: "Default security risk level: Low, Medium, or High, based on potential for misuse." }, + ], + }, + { + heading: "Blacklist Configuration", + content: "The Blacklist tab lets administrators define prohibited applications. When matching traffic is detected, the system will:", + steps: [ + "Record the event as a policy violation in the Security Logs.", + "Trigger an alert notification in the Threat Intelligence dashboard.", + ], + }, + { + heading: "Usage Tips", + items: [ + { label: "Identify unknown apps", description: "If you see an unfamiliar application in Apps or Flows, search for it here to understand its function and risk profile before taking action." }, + { label: "Verify default ports", description: "If a firewall is blocking an application, verify its default ports here and coordinate with the network team." }, + { label: "Add blacklist entries gradually", description: "Add one application at a time and monitor the impact in Threat Intelligence to avoid excessive false positives." }, + ], + }, + ], + }, + { + id: "user-accounts", + title: "User Accounts Directory", + url: "/user-accounts", + icon: "👥", + summary: "Manage company tenant accounts, role scopes, and multi-agent assignments.", + sections: [ + { + heading: "Company Tenant Cards — What You See on This Page", + content: "The page is organized into cards, one per client company. Each card displays the company name and a user count badge (e.g. 'Accounts: 2 / 5'). Inside every company card, there is a table listing all accounts that belong to that company. Use the search box inside each card to quickly filter by account name, username, or role.", + }, + { + heading: "Account Table — Column Reference", + content: "Each row in the company card's table represents one registered user account. The columns are:", + columns: [ + { column: "#", description: "Sequential row index within the current company card. Resets to 1 for each company. Helps you quickly count and reference a specific row during review." }, + { column: "Account Name", description: "The full human-readable display name of the account holder (e.g. 'John Doe' or 'IT Team - Cibubur Branch'). This name is set during account creation and can be updated via the Edit User action. If no account name was set, the username is shown as a fallback." }, + { column: "Username", description: "The unique login credential (username) used by this account to sign in to the dashboard. Displayed in monospace font. Usernames cannot be changed after creation — to rename, delete the account and re-create." }, + { column: "Role", description: "The permission level of this account within the system. Displayed as a color-coded badge: purple = Company Admin, amber = Company Operator, blue = Company Viewer. Each role controls which pages, agents, and actions the user can access." }, + { column: "Assigned Devices", description: "Shows how many network agents (DPI probes) are assigned to this account. Company Admins typically see all agents. Company Operators may be restricted to specific agents. Company Viewers are usually locked to exactly one agent. A value of 'No device assigned' means the account has no agent scope configured — the user will see an empty dashboard after login." }, + { column: "Actions", description: "The '⚙️ Edit User' button opens the account management modal where you can update the account name, change the role, reassign agents, or delete the account. This button is disabled (greyed out) for Executive-role viewers, who have read-only access." }, + ], + }, + { + heading: "Role Hierarchies — Access Level Reference", + content: "There are three customer-tier roles, each with different permissions and agent scopes:", + items: [ + { label: "Company Admin", description: "Full administrator access for the client company. Can view all network agents assigned to their company, edit device labels, view all traffic pages, and manage sub-accounts (Company Operators and Company Viewers) under their organization. Cannot add other Company Admins (only Super Admin can)." }, + { label: "Company Operator", description: "Designed for regional IT leads or branch managers. Assigned a custom subset of network agents (e.g., 2 out of 5 agents for their branch). Can monitor traffic, view metrics, and edit device labels only for their assigned agents. Cannot manage user accounts." }, + { label: "Company Viewer", description: "Designed for on-site technicians or monitoring staff. Locked to a single specific network agent. The entire dashboard view is automatically filtered to show only that agent's traffic. Has read-only access — cannot edit device labels or manage user accounts." }, + ], + }, + { + heading: "Adding a New Account", + content: "To provision a new account for a company:", + steps: [ + "Click the '+ Add User for [Company]' button on the top-right of the target company card, or click '+ Add New Company Admin' at the bottom of the page.", + "Fill in the Account Name field with the user's full name or team identifier.", + "Enter a Username — this will be the login credential. Use a consistent naming convention (e.g. firstname.lastname or dept.city).", + "Set a secure Password. Inform the user to change it on first login via Settings.", + "Select the Role (Company Admin, Company Operator, or Company Viewer) based on the user's responsibilities.", + "In the Agent Assignment checklist, select which network agents (DPI probes) this account should be able to see. For Company Viewers, select exactly one agent.", + "Click 'Create Account'. The new account will appear in the company card immediately.", + ], + }, + { + heading: "Editing or Deleting an Account", + content: "To modify an existing account:", + steps: [ + "Click '⚙️ Edit User' in the Actions column of the target account row.", + "In the modal, you can update the Account Name, change the Role, or reassign the Agent Assignment (check/uncheck agents in the checklist).", + "Click 'Save Changes' to apply. Changes take effect immediately on the user's next page load.", + "To permanently delete an account, scroll down in the edit modal and click the red 'Delete Account' button. This action is irreversible — the user will be immediately logged out and unable to sign in.", + ], + }, + { + heading: "5-Account Quota Limit per Company", + content: "To maintain system efficiency, each client company is limited to a maximum of 5 active user accounts. The badge on each company card shows the current count (e.g. 'Accounts: 3 / 5'). When the limit is reached, the badge turns red and the '+ Add User' button is disabled. The backend also enforces this limit — any API request exceeding the quota is rejected with an error.", + }, + ], + }, +]; diff --git a/src/lib/help/guides/labeling.ts b/src/lib/help/guides/labeling.ts new file mode 100644 index 0000000..b91b3c3 --- /dev/null +++ b/src/lib/help/guides/labeling.ts @@ -0,0 +1,63 @@ +import { PageGuide } from "../types"; + +// Guide for the Device Labeling page +export const LABELING_GUIDES: PageGuide[] = [ + { + id: "device-labeling", + title: "Device Labeling", + url: "/device-labeling", + icon: "🏷️", + summary: "Assign custom names to MAC addresses to identify device owners across all charts, tables, and flow records.", + sections: [ + { + heading: "What is Device Labeling?", + content: "The Device Labeling page allows administrators to assign meaningful, human-readable custom names (labels) to devices detected on the network — identified by their unique MAC address. Without labeling, devices appear only as raw MAC or IP addresses in flows and traffic tables. With a label, every chart and flow record across the entire dashboard will display the friendly name instead.", + }, + { + heading: "Device Asset Directory Table — Column Reference", + columns: [ + { column: "#", description: "Sequential row index number for easy reference." }, + { column: "MAC Address", description: "The hardware MAC address of the device, e.g. 'bc:45:5b:ca:d5:be'. Click the MAC address to open the full Device MAC Details modal with comprehensive traffic history." }, + { column: "IP Address", description: "The last known IPv4 address assigned to this device. IP addresses can change (DHCP), so MAC is the primary identifier." }, + { column: "Vendor / Manufacturer", description: "Network card manufacturer identified from the MAC address OUI prefix (e.g. 'Apple, Inc.', 'Samsung Electronics'). Helps identify unknown devices." }, + { column: "Device Type", description: "Automatically inferred device category based on IP address range and traffic patterns (e.g. 'Core Network Node', 'End Device', 'IoT Device')." }, + { column: "Agent", description: "The DPI probe that captured this device's traffic. Useful in multi-site deployments to know which sensor segment the device belongs to." }, + { column: "Default Label", description: "System-generated auto-label derived from MAC vendor + device type + IP. This is the fallback label displayed if no custom label has been set." }, + { column: "Custom Label", description: "The human-readable label set by the administrator. Once set, this name overrides the default label on all dashboard pages — flows, apps, devices, threats, etc. Leave blank to use the default label." }, + { column: "Owner", description: "Optional owner name or department associated with this device (e.g. 'IT Team', 'John Doe - Finance'). Useful for asset management and troubleshooting." }, + { column: "Actions", description: "Edit icon (✏️) — opens the Edit Label & Owner modal to set or update the custom label and owner for this MAC address." }, + ], + }, + { + heading: "How to Assign a Custom Label", + steps: [ + "Find the device you want to label using the Search box (search by MAC, IP, vendor, or existing label).", + "Click the ✏️ Edit icon in the Actions column for that device row.", + "In the Edit Label & Owner modal, type the desired Custom Label (e.g., 'Reception Desk PC') and optionally an Owner name.", + "Click Save. The new label is immediately saved to the database and will appear across all dashboard pages.", + "To clear a label and revert to the default, open the edit modal, clear the Custom Label field, and save.", + ], + }, + { + heading: "Device MAC Details Modal", + content: "Click on any MAC address in the table to open the full Device MAC Details modal. This modal shows:", + items: [ + { label: "Device Identity", description: "MAC address, vendor, detected device type, and any custom label or owner that has been assigned." }, + { label: "Traffic Summary", description: "Total download and upload volume, total flows, and last active timestamp for this device." }, + { label: "Top Applications", description: "The most-used applications detected for this device (e.g., YouTube, WhatsApp, Office 365)." }, + { label: "Recent Flows", description: "A paginated list of the most recent network connections from this device — destination IP, port, protocol, and bandwidth." }, + { label: "Security Alerts", description: "Any threat signatures detected from or to this device, if applicable." }, + ], + }, + { + heading: "Tips & Best Practices", + items: [ + { label: "Use descriptive labels", description: "Include location and purpose in the label, e.g. 'F1-Lobby-TV' or 'Server-Room-NAS-01'. This makes flows and alerts self-explanatory." }, + { label: "Label your gateway", description: "The router/gateway device (usually the lowest IP, e.g. 10.0.0.1) generates the most traffic. Label it clearly so it is easily distinguishable from client devices." }, + { label: "Labels persist across sessions", description: "Custom labels are stored in the database — they persist permanently until manually changed or deleted, even after system restarts." }, + { label: "Time filter applies to traffic stats", description: "The time filter (Last 24 Hours, Last 7 Days, etc.) in the sidebar affects traffic-based stats shown in the Detail modal, but device labels are always current." }, + ], + }, + ], + }, +]; diff --git a/src/lib/help/guides/security.ts b/src/lib/help/guides/security.ts new file mode 100644 index 0000000..1528edd --- /dev/null +++ b/src/lib/help/guides/security.ts @@ -0,0 +1,154 @@ +import { PageGuide } from "../types"; + +export const SECURITY_GUIDES: PageGuide[] = [ + { + id: "intelligence", + title: "Threat Intelligence", + url: "/intelligence", + icon: "🛡️", + summary: "Overview of security intelligence feeds, anomalies, and active network risk indicators.", + sections: [ + { + heading: "Security Intelligence Feeds", + content: "The Threat Intelligence dashboard provides a consolidated view of potential security issues, risky behavior, and unencrypted credentials detected on the network. These indicators help security teams perform proactive threat hunting.", + }, + { + heading: "Intelligence Categories", + items: [ + { label: "Crypto Mining", description: "Identifies devices actively participating in cryptocurrency mining pools. Mining traffic uses specific protocols (e.g. Stratum) and ports that consume CPU/GPU and network resources." }, + { label: "Tor Detection", description: "Detects connections routing through the Onion Router (Tor) network, which is used for anonymous browsing and can indicate malware C2 channels or unauthorized employee activity." }, + { label: "VPN Detection", description: "Identifies active VPN connections. VPN tunnels bypass corporate network controls and obscure traffic inspection, posing security and compliance risks." }, + { label: "IP Reputation", description: "Flags connections to remote servers with known bad reputation history (malware distribution, phishing, spam sources) based on dynamic threat feeds." }, + { label: "Insecure Protocols", description: "Identifies legacy or plaintext protocols (HTTP, FTP, Telnet) in use that expose data to interception or spoofing." }, + { label: "Unencrypted Passwords", description: "Critical alert showing plaintext passwords captured in unencrypted transit (e.g., via HTTP Basic Auth or plaintext FTP logins)." }, + { label: "Encryption Audit", description: "Reviews the TLS/SSL versions and cipher strength of encrypted connections on the network to identify legacy ciphers (RC4, 3DES) or vulnerable TLS 1.0/1.1 protocols." }, + { label: "Device Discovery", description: "Tracks new or newly active devices communicating on the network to prevent rogue/unauthorized hardware from connecting." }, + { label: "Server Discovery", description: "Detects active local servers hosting services on corporate subnets, ensuring all local services are cataloged and authorized." } + ] + }, + { + heading: "Action and Remediation", + items: [ + { label: "Plaintext Passwords", description: "Investigate immediately. Identify the affected user, enforce a password reset, and configure the target application to use secure TLS/HTTPS." }, + { label: "Tor / VPN Tunnels", description: "Identify the device establishing the tunnel. Confirm if it is a business requirement or a violation of corporate network access policies." }, + { label: "IP Reputation Flags", description: "Cross-reference destination IPs. Block malicious destination IPs at the firewall and run a security scan on the internal source device." } + ] + } + ] + }, + { + id: "events", + title: "System Events", + url: "/events", + icon: "📋", + summary: "Audit log of system activity and operational notifications from all platform components.", + sections: [ + { + heading: "What Are System Events?", + content: "System Events is a non-malicious audit log recording all operational platform activities: agent connection/disconnection, database retention cycles, configuration updates, system capacity warnings, and administrator actions. Unlike Threat Intelligence (which focuses on external security threats), Events focuses on the internal health and operational state of the platform itself.", + }, + { + heading: "Event Severity Levels", + items: [ + { label: "Info (Blue)", description: "Normal activity requiring no action. Examples: 'Agent F6-2V-DT-8A connected', 'Database retention cycle completed: 1,234 records deleted', 'Summary data updated for site SIAB'. These logs support audit trails and retrospective troubleshooting." }, + { label: "Warning (Yellow)", description: "Situations that need attention but are not yet critical. Examples: 'Agent YW-6I-61-LL missing GPS coordinates', 'Database collection approaching size threshold', 'API rate limit warning'. Should be addressed before they escalate into serious problems." }, + { label: "Critical (Red)", description: "Conditions requiring immediate action. Examples: 'Agent 1T-5Q-RC-AS offline for >24 hours', 'MongoDB connection failed', 'Disk space critical (>95%)'. Ignoring Critical events may result in monitoring data loss." }, + ], + }, + { + heading: "Table: Event Log", + columns: [ + { column: "Timestamp", description: "Date and time the event occurred in Indonesia timezone (WIB/WITA/WIT). Click the header to sort newest or oldest first." }, + { column: "Severity", description: "Impact level of the event — see the Event Severity Levels section above for the meaning of each level." }, + { column: "Agent", description: "The DPI probe that reported this event. Displayed as 'System' if the event was generated by the central system (scheduler, database, or API) rather than by a hardware probe." }, + { column: "Description", description: "A plain-language description of what occurred, understandable without deep technical knowledge." }, + { column: "Details", description: "Click the icon (📄) to open a side panel containing the complete raw JSON payload of this event — includes technical metadata, stack traces for errors, and specific parameters involved. Useful for deep troubleshooting." }, + ], + }, + { + heading: "Exporting the Event Log", + items: [ + { label: "Export CSV", description: "Exports the filtered event log as a .csv with columns: Timestamp, Severity, Agent, Description. Ideal for audit trails, compliance reporting, or operational incident analysis." }, + { label: "Export PDF", description: "Generates a formatted event log report as a .pdf with a severity-based breakdown and timeline. Suitable for periodic operational reports to management or the IT team." }, + ], + }, + { + heading: "Event Log Usage Tips", + items: [ + { label: "Troubleshoot agent connectivity", description: "If an agent suddenly goes offline, check Events to find the disconnection log and understand the context (network error, reboot, configuration change) that preceded it." }, + { label: "Review Critical events daily", description: "Make it a habit to check Critical severity events every morning to detect system issues that may have occurred outside of working hours." }, + { label: "Use Date Range for incident investigation", description: "When investigating a past incident, filter Events by the specific time range of the incident to reconstruct the exact sequence of events." }, + ], + }, + ], + }, + { + id: "security-audit", + title: "Encryption Audit (TLS)", + url: "/security-audit", + icon: "🔐", + summary: "Security audit of TLS encryption versions and cipher suites used across the network.", + sections: [ + { + heading: "Why TLS Auditing Matters", + content: "TLS (Transport Layer Security) is the encryption protocol protecting data in transit. Outdated TLS versions and weak cipher suites have known security vulnerabilities that can be exploited to decrypt traffic that should be private:", + steps: [ + "TLS 1.0 and TLS 1.1 are vulnerable to POODLE, BEAST, and DROWN attacks — they have been officially deprecated since 2020.", + "CBC mode cipher suites are vulnerable to the LUCKY13 and padding oracle attacks under certain conditions.", + "RC4 cipher has been cryptographically broken since 2015 and must not be used in any context.", + "3DES (Triple DES) has been deprecated due to the SWEET32 birthday attack vulnerability.", + ], + }, + { + heading: "Table: TLS Versions", + columns: [ + { column: "Version", description: "TLS version detected from ClientHello/ServerHello handshake analysis — see the TLS Version Risk Levels section below." }, + { column: "Count", description: "Total number of connection sessions using this TLS version within the active time filter." }, + { column: "Risk", description: "Risk rating for this TLS version — see the TLS Version Risk Levels section below." }, + ], + }, + { + heading: "TLS Version Risk Levels", + items: [ + { label: "SSL 2.0 / SSL 3.0", description: "CRITICAL EMERGENCY — these protocols are completely broken and must never be in use. Shutdown affected servers immediately." }, + { label: "TLS 1.0", description: "High Risk — deprecated in 2020. Vulnerable to POODLE, BEAST, and DROWN attacks. Upgrade all clients and servers immediately." }, + { label: "TLS 1.1", description: "High Risk — deprecated in 2020. Vulnerable to BEAST and LUCKY13 attacks. Upgrade all clients and servers immediately." }, + { label: "TLS 1.2", description: "Medium Risk — still acceptable when paired only with AES-GCM or ChaCha20 ciphers. Disable CBC cipher suites on servers." }, + { label: "TLS 1.3", description: "Secure — the recommended standard. Perfect Forward Secrecy is mandatory, all legacy ciphers are removed by design." }, + ], + }, + { + heading: "Table: TLS Ciphers", + columns: [ + { column: "Cipher Suite", description: "Full name of the encryption algorithm negotiated between client and server. Long names are truncated — hover to see the full suite name." }, + { column: "Strength", description: "Cipher strength rating — see the Cipher Strength Ratings section below." }, + { column: "Count", description: "Frequency of this cipher suite usage within the active time filter. Insecure ciphers with a high count represent the highest remediation priority." }, + ], + }, + { + heading: "Cipher Strength Ratings", + items: [ + { label: "Strong", description: "AES-GCM or ChaCha20-Poly1305 with ECDHE (Elliptic Curve Diffie-Hellman Ephemeral) key exchange. Provides Perfect Forward Secrecy — compromise of the server key does not expose past sessions." }, + { label: "Weak", description: "CBC (Cipher Block Chaining) mode ciphers, or RSA key exchange without PFS. Vulnerable to padding oracle attacks (LUCKY13). Migrate to AES-GCM as soon as possible." }, + { label: "Insecure", description: "RC4 (cryptographically broken since 2015), 3DES (vulnerable to SWEET32 birthday attack), MD5 authentication, or NULL cipher (completely unencrypted traffic). These must be disabled immediately on all servers." }, + ], + }, + { + heading: "Remediation Steps", + items: [ + { label: "TLS 1.0 / TLS 1.1 detected", description: "Identify devices and applications still using these versions (check DPI Metadata → HTTP User Agents for outdated browsers or software). Update browsers to the latest version and upgrade applications using outdated TLS libraries." }, + { label: "RC4 or 3DES ciphers detected", description: "This is a security emergency. Identify servers still advertising these ciphers (check SNI in DPI Metadata) and update their TLS configuration to explicitly disable weak ciphers in the server's SSL settings." }, + { label: "CBC cipher suites dominating", description: "Prioritize migration to GCM (Galois/Counter Mode) — an AEAD (Authenticated Encryption with Associated Data) cipher that is immune to padding oracle attacks. Configure servers to prefer ECDHE-AESGCM cipher suites." }, + { label: "Low TLS 1.3 adoption", description: "TLS 1.3 offers faster handshakes (1 round-trip vs. 2 for TLS 1.2) and stronger security by default. Push for infrastructure upgrades on both servers and clients to support TLS 1.3 natively." }, + ], + }, + { + heading: "Exporting the Audit Report", + items: [ + { label: "Export CSV", description: "Exports TLS version and cipher suite data as a .csv for further analysis or input into a vulnerability management or compliance platform." }, + { label: "Export PDF", description: "Generates a formatted TLS audit report directly suitable for compliance reporting (PCI-DSS, ISO 27001, NIST CSF) or for management presentations about the organization's encryption security posture." }, + ], + }, + ], + }, +]; diff --git a/src/lib/help/guides/threats.ts b/src/lib/help/guides/threats.ts new file mode 100644 index 0000000..1efd87e --- /dev/null +++ b/src/lib/help/guides/threats.ts @@ -0,0 +1,106 @@ +import { PageGuide } from "../types"; + +export const THREATS_GUIDES: PageGuide[] = [ + { + id: "threats", + title: "Detected Threats", + url: "/threats", + icon: "🛡️", + summary: "Detection and analysis of active network security threats identified by the DPI engine.", + sections: [ + { + heading: "How Threats Are Detected", + content: "The system uses multiple analysis layers to detect threats:", + steps: [ + "Signature-based detection — matching traffic patterns against a database of 10,000+ known threat signatures, updated regularly.", + "Behavioral analysis — detecting abnormal patterns such as one device connecting to hundreds of IPs in a short time (port scanning).", + "Domain/IP reputation — matching connection destinations against blacklists of known malicious IPs and domains.", + "Protocol anomaly — detecting abnormal protocol usage, such as the Stratum mining protocol appearing on non-standard ports.", + ], + }, + { + heading: "Threat Categories", + items: [ + { label: "Crypto Mining (Illicit)", description: "Traffic matching the Stratum mining protocol — used for communication between mining software and pool servers. Key indicators: connections to domains like 'pool.minexmr.com' or common mining ports (3333, 4444, 5555, 14444). This activity consumes CPU/GPU resources and bandwidth without authorization." }, + { label: "Botnet Command & Control", description: "Infected devices often make regular heartbeat connections to a C2 server to receive attacker commands. Key indicators: periodic connections to blacklisted botnet IPs/domains, encrypted traffic to non-standard ports, or DNS queries to domains with DGA-like random patterns." }, + { label: "Malware", description: "Traffic signatures matching known malware communication patterns — Trojans (remote access), Ransomware (file encryption + C2), Spyware (keystroke or screenshot exfiltration), or Worms (self-propagating scanning). Refer to the 'Evidence' column for the specific technical details that triggered the detection." }, + { label: "Phishing", description: "Connections to domains known to impersonate legitimate services (banking, email, social media) to steal credentials. Domains are matched against a phishing database updated hourly. A detection means a user device accessed a malicious URL." }, + ], + }, + { + heading: "Available Threat Filters", + content: "The filter panel above the table allows searching by any combination of the following fields simultaneously:", + items: [ + { label: "Date Range", description: "Filter by specific date and time of occurrence." }, + { label: "Type", description: "Filter by threat category: Crypto Mining, Botnet C2, Malware, or Phishing." }, + { label: "Severity", description: "Filter by impact level: Critical, High, Medium, or Low." }, + { label: "Source IP", description: "Filter by the local infected or suspicious device's IP address." }, + { label: "Dest IP", description: "Filter by the malicious remote server IP address." }, + { label: "MAC Address", description: "Filter by hardware identifier of the local device — useful when the IP changes dynamically." }, + { label: "App", description: "Filter by the application associated with the threat (e.g., 'Stratum', 'BitTorrent')." }, + { label: "Domain", description: "Filter by the specific malicious domain that was accessed." }, + ], + }, + { + heading: "Table: Detected Threats", + columns: [ + { column: "Timestamp", description: "Date and time the threat was first detected, in Indonesia timezone (WIB). Click the header to sort newest or oldest first." }, + { column: "#", description: "Sequential row number, continuous across pages (server-side pagination at 50 rows per page)." }, + { column: "Type", description: "The detected threat category: Crypto Mining, Botnet C2, Malware, Phishing, or another type from the signature database." }, + { column: "Severity", description: "Impact level — see the Severity Levels section below for response guidance per level." }, + { column: "Source IP", description: "The local device IP that is the source or victim of the threat. Click to open the Device Detail Modal and view all activity from this device." }, + { column: "Dest IP", description: "The malicious remote server IP. Hover to see geolocation and ISP information for that server." }, + { column: "MAC Addr", description: "Hardware MAC address of the local device — useful for physically identifying a device even when its IP changes due to dynamic DHCP." }, + { column: "App", description: "The application or protocol used in the malicious connection (e.g., 'BitTorrent', 'Stratum', or a specific identified application)." }, + { column: "Domain", description: "The malicious domain that was accessed. Truncated — hover to see the full domain. This domain has been verified as malicious in the reputation database." }, + { column: "Action", description: "Click the recommendation icon (💡) to open the Threat Recommendation Drawer — a panel with specific mitigation guidance, incident response steps, and relevant CVE references for this threat type." }, + ], + }, + { + heading: "Severity Levels", + items: [ + { label: "Critical", description: "Active threat with direct and immediate impact. Response required within 1 hour. Isolate the device immediately." }, + { label: "High", description: "Serious threat confirmed. Investigate and take action within 24 hours before the situation escalates." }, + { label: "Medium", description: "Suspicious activity detected. Needs further analysis and monitoring. Escalate to High if the pattern repeats." }, + { label: "Low", description: "Potentially suspicious activity — may be a false positive from a legitimate application. Log and review periodically." }, + ], + }, + { + heading: "Malware Sub-Types", + items: [ + { label: "Trojan", description: "Remote access malware disguised as a legitimate file. Allows attackers to control the infected device silently." }, + { label: "Ransomware", description: "Encrypts local files and establishes a C2 connection to demand payment. Immediate isolation is critical." }, + { label: "Spyware", description: "Silently records keystrokes, screenshots, or clipboard content and exfiltrates them to an attacker's server." }, + { label: "Worm", description: "Self-propagating malware that scans the network for other vulnerable devices to infect automatically." }, + ], + }, + { + heading: "Threat Recommendation Drawer", + content: "Click the action icon on any threat row to open the recommendation panel. The drawer contains:", + items: [ + { label: "Technical explanation", description: "A detailed explanation of why this specific activity was classified as a threat, including the matching signatures and evidence." }, + { label: "Mitigation steps", description: "Step-by-step instructions for IT/security teams to contain and remediate this specific threat type." }, + { label: "CVE references", description: "Relevant CVE (Common Vulnerabilities and Exposures) IDs where applicable, linking to known vulnerability databases." }, + { label: "Incident response", description: "Recommended device isolation procedures and incident response workflow." }, + ], + }, + { + heading: "Exporting Threat Data", + content: "Click the 'Export ▾' button at the top-right of the table to open the format dropdown. Always apply your desired filters first — both formats only export data matching the active filter.", + items: [ + { label: "Export CSV", description: "Downloads a .csv file with raw threat data columns: Timestamp, Type, Severity, Source IP, Dest IP, MAC Address, App, Domain. Compatible with Excel, Google Sheets, or import into SIEM platforms (Splunk, IBM QRadar, Elastic)." }, + { label: "Export PDF", description: "Generates a professionally formatted threat summary report as a .pdf with severity breakdown statistics. The button displays 'Generating...' during PDF rendering — wait for the file to download automatically without clicking again." }, + ], + }, + { + heading: "Response Priority Guidelines", + items: [ + { label: "Critical — Respond immediately (<1 hour)", description: "Isolate the device from the network (disconnect cable or block at switch/access point). Document all flow details and take screenshots. Report to the security team or management immediately." }, + { label: "High — Investigate within 24 hours", description: "Open the Device Detail Modal for the affected device. Review all applications and domains accessed. Determine whether this is a false positive or a genuine threat." }, + { label: "Medium — Monitor and analyze", description: "Flag for monitoring. Check whether the same pattern recurs over the following days. If it does, escalate to High severity." }, + { label: "Low — Log and review periodically", description: "Record in the incident log. Review weekly to check for any severity escalation. May be a false positive from a legitimate application." }, + ], + }, + ], + }, +]; diff --git a/src/lib/help/guides/traffic-analytics.ts b/src/lib/help/guides/traffic-analytics.ts new file mode 100644 index 0000000..53850d7 --- /dev/null +++ b/src/lib/help/guides/traffic-analytics.ts @@ -0,0 +1,122 @@ +import { PageGuide } from "../types"; + +export const TRAFFIC_ANALYTICS_GUIDES: PageGuide[] = [ + { + id: "network-intelligence", + title: "Traffic Categories", + url: "/network-intelligence", + icon: "📊", + summary: "Traffic distribution grouped by functional application categories.", + sections: [ + { + heading: "Application Categorization", + content: "This page groups all identified applications into high-level functional categories, providing a broad view of network usage without inspecting each application individually. Categorization is performed automatically based on the DPI signature database. The pie chart visualizes bandwidth proportion per category at a glance.", + }, + { + heading: "Traffic Category Reference", + items: [ + { label: "Streaming / Video", description: "Traffic to YouTube, Netflix, TikTok, Twitch, Vimeo, etc. Typically dominates total bandwidth. High volume is expected, but may become a concern if it overwhelms business-critical connections." }, + { label: "Social Network", description: "Facebook, Instagram, Twitter/X, LinkedIn, WhatsApp, Telegram, etc. Separated from streaming because the bandwidth profile differs (more short video/photo upload activity)." }, + { label: "Cloud Services", description: "Google Drive, Dropbox, OneDrive, AWS S3, Azure Blob Storage. High upload to this category is normal for automatic backup jobs." }, + { label: "File Transfer", description: "FTP, SFTP, BitTorrent, and other P2P clients. BitTorrent presence in a corporate environment is a red flag requiring investigation." }, + { label: "Web Browsing", description: "General HTTP/HTTPS traffic not matched to a specific application. Catch-all category for generic web browsing." }, + { label: "DNS / System", description: "DNS resolver traffic, NTP time sync, and OS updates (Windows Update, apt, yum). High volume in this category is typically normal." }, + { label: "Security / VPN", description: "VPN client traffic, antivirus updates, firewall management traffic. Identify unauthorized VPN usage here." }, + { label: "Unclassified", description: "Traffic that could not be matched to any known signature. A high percentage warrants investigation on the Flows page." }, + ], + }, + { + heading: "Table: Category Breakdown", + columns: [ + { column: "Category", description: "Name of the functional application group." }, + { column: "Download", description: "Total incoming bandwidth for all applications within this category." }, + { column: "Upload", description: "Total outgoing bandwidth from all applications within this category." }, + { column: "Devices", description: "Number of unique devices accessing applications in this category." }, + { column: "Percentage", description: "This category's share of total network bandwidth. Useful for bandwidth allocation policy reviews." }, + ], + }, + ], + }, + { + id: "dpi-analytics", + title: "DPI Metadata", + url: "/dpi-analytics", + icon: "🔌", + summary: "Raw technical metadata extracted by Deep Packet Inspection: hostnames, client fingerprints, and protocol-specific identifiers.", + sections: [ + { + heading: "What is DPI Metadata?", + content: "DPI Metadata is low-level technical data extracted directly from network packet headers and payloads. This data is more granular than standard flow records — it includes software identity, browser versions, OS types, TLS cipher suites, and protocol-specific identifiers. This page is intended for security teams and network engineers who require deep visibility.", + }, + { + heading: "Tab: Hostnames & Servers", + items: [ + { label: "SNI Hostnames", description: "Server names extracted from TLS ClientHello handshakes. This is the most accurate method for identifying target servers, even within encrypted HTTPS traffic." }, + { label: "SSL Server Common Names", description: "Names listed in the SSL certificate sent by the server. May differ from the SNI if a multi-domain (SAN) certificate is in use." }, + { label: "QUIC SNIs", description: "SNI values extracted from QUIC (HTTP/3) connections — Google's modern transport protocol that is replacing HTTPS over TCP in many applications." }, + ], + }, + { + heading: "Tab: Client Fingerprints", + items: [ + { label: "Discovered OS", description: "Operating systems passively detected from TCP TTL values and default window sizes. Common signatures: Windows = TTL 128, Linux/Android = TTL 64, macOS/iOS = TTL 64." }, + { label: "HTTP User Agents", description: "Browser and application version strings from HTTP headers (e.g., 'Mozilla/5.0 Chrome/115'). Useful for detecting outdated software or bots using fake User Agents." }, + { label: "DHCP Fingerprints", description: "DHCP request patterns that identify device type and OS — useful for classifying devices that do not expose hostnames or User-Agent strings." }, + ], + }, + { + heading: "Tab: Specific Protocols", + items: [ + { label: "SSH Versions", description: "SSH client and server software versions identified from the banner exchange (e.g., 'OpenSSH_8.9', 'PuTTY_Release_0.77'). Outdated SSH versions with known vulnerabilities should be patched immediately." }, + { label: "BitTorrent Info-Hashes", description: "SHA1 unique identifiers for each active torrent on the network. Info-hashes can be looked up on public torrent sites to identify what content is being downloaded or shared — a policy violation in most corporate environments." }, + ], + }, + { + heading: "Using DPI Metadata for Security", + items: [ + { label: "Detect Outdated Software", description: "Check HTTP User Agents to find browsers or applications with old versions containing known security vulnerabilities (CVEs). Examples: Internet Explorer, or Chrome versions more than 2 years old." }, + { label: "Identify Unauthorized BYOD Devices", description: "Combine OS fingerprints with MAC OUI lookups to detect personal devices (smartphones, laptops) that are not authorized to connect to the corporate network." }, + { label: "Detect BitTorrent Activity", description: "The presence of BitTorrent Info-Hashes in a corporate network is a policy violation. Record the info-hash and use it to identify the responsible device and user." }, + { label: "Audit SSH Server Versions", description: "Verify that all SSH server versions are current and secure (OpenSSH 8.x+). Outdated SSH versions are vulnerable to man-in-the-middle attacks." }, + ], + }, + ], + }, + { + id: "geography", + title: "Geo Traffic", + url: "/geography", + icon: "🗺️", + summary: "Bandwidth analysis and traffic distribution by destination country worldwide.", + sections: [ + { + heading: "IP Geographic Resolution", + content: "The system resolves destination IP addresses to geographic locations using the MaxMind GeoLite2 database. Resolution accuracy: very high for country (>99%), good for city (>80%). IPs belonging to CDNs (e.g., Cloudflare, Akamai) may resolve to their Point of Presence (PoP) location rather than the actual origin server.", + }, + { + heading: "Table: Traffic by Country", + columns: [ + { column: "Country", description: "Destination country name with national flag for quick visual identification." }, + { column: "Download", description: "Total data volume received from servers in this country. Large volumes to CDN countries (USA, Singapore, Netherlands) are entirely normal." }, + { column: "Upload", description: "Total data sent to servers in this country. Large upload volumes to countries unrelated to your business operations should be investigated." }, + { column: "Flows", description: "Number of connection sessions directed to servers in this country. A high flow count to a single unfamiliar foreign country indicates intensive communication that warrants review." }, + ], + }, + { + heading: "Normal Traffic Destinations", + items: [ + { label: "USA, Singapore, Netherlands, Japan", description: "Host the major global CDN infrastructure (Cloudflare, AWS, Google, Fastly, Akamai). Traffic to these destinations is typically the result of normal internet usage." }, + { label: "Indonesia", description: "Traffic to local Indonesian servers (ASN Telkom, Biznet, IDC data centers) for local services and CDN edge nodes." }, + ], + }, + { + heading: "Suspicious Traffic Patterns", + items: [ + { label: "Russia or China (without business context)", description: "If there is no established business relationship with entities in these countries, traffic to them — especially upload — should be investigated as potential C2 communication or data exfiltration." }, + { label: "Large upload to unknown countries", description: "High upload volume to countries irrelevant to business operations is a classic indicator of data exfiltration. Cross-check immediately with the Threat Intelligence page." }, + { label: "Traffic changes with time filter", description: "Geographic traffic fluctuates based on user activity. Compare daily vs. weekly patterns to distinguish a consistent anomaly from an incidental spike." }, + ], + }, + ], + }, +]; diff --git a/src/lib/help/guides/traffic.ts b/src/lib/help/guides/traffic.ts new file mode 100644 index 0000000..b9c985b --- /dev/null +++ b/src/lib/help/guides/traffic.ts @@ -0,0 +1,195 @@ +import { PageGuide } from "../types"; + +export const TRAFFIC_CORE_GUIDES: PageGuide[] = [ + { + id: "flows", + title: "Flows", + url: "/flows", + icon: "🔀", + summary: "Real-time log of all network connection sessions detected by the DPI probe.", + sections: [ + { + heading: "What is a Flow?", + content: "A 'flow' is a single network connection session between two endpoints (source IP:port ↔ destination IP:port) over a specific protocol. Each time a device on your network opens a new connection (e.g., a browser opening a YouTube tab), the system records a new flow with complete metadata. Flows that remain active are continuously updated with real-time bandwidth volumes.", + }, + { + heading: "Filter & Search", + content: "The search bar enables instant search by Source IP, Destination IP, protocol, or domain/application name. Queries are processed against a MongoDB index optimized to scan up to 1,000,000 records.", + items: [ + { label: "Subnet filter", description: "Type '192.168.' to filter all connections from local devices in that subnet." }, + { label: "Port filter", description: "Type '443' to view all HTTPS connections, or '22' to view all SSH sessions." }, + { label: "Domain filter", description: "Type a partial domain name (e.g., 'google') to match all Google-related connections." }, + ], + }, + { + heading: "Server-Side Pagination", + content: "The Flows table uses server-side pagination with a fixed page size of 50 rows per page. The index (#) is continuous across pages — page 1 shows rows 1–50, page 2 shows 51–100, and so on. This allows precise position tracking even when the dataset reaches millions of records.", + }, + { + heading: "Table: Active Flows", + columns: [ + { column: "#", description: "Sequential row number, continuous across all pages. Used as an absolute position reference in the paginated dataset." }, + { column: "Last Seen", description: "Timestamp of the last packet received in this session, in Indonesia timezone (WIB). Click the header to sort newest or oldest first." }, + { column: "Flow ID", description: "A unique cryptographic hash generated by the DPI probe to identify this specific socket session. Used for log correlation across systems." }, + { column: "Src IP", description: "IP address of the local device that initiated the connection. Hover to see the resolved hostname or device label in a tooltip. Click to open the device details panel." }, + { column: "Dst IP", description: "Destination IP address — typically an external server. Hover to see reverse DNS or CDN hostname. IPs in RFC 1918 ranges (10.x, 172.16.x, 192.168.x) indicate internal LAN connections." }, + { column: "Dst Port", description: "Destination port number. Hover to see the standard service name: 80 = HTTP, 443 = HTTPS, 53 = DNS, 22 = SSH, 25 = SMTP, 3389 = RDP Remote Desktop, 3306 = MySQL. Non-standard ports above 1024 should be examined." }, + { column: "Protocol", description: "Transport layer protocol: TCP (reliable, handshake-based), UDP (fast, connectionless), QUIC (modern UDP-based for HTTP/3), ICMP (ping and error messages). Excessive UDP may indicate unauthorized streaming or gaming." }, + { column: "App / Domain", description: "Application name or destination domain identified via SNI analysis from the TLS handshake. Long values are truncated — hover to see the full domain in a tooltip." }, + { column: "Download / Upload", description: "Data received (Download) and sent (Upload) in this connection. Hover to see the exact byte count (e.g., '12.10 MB (12,687,211 bytes)'). Connections with very large upload volumes should be investigated as potential data exfiltration." }, + ], + }, + { + heading: "Exporting Flow Data", + content: "Click the 'Export ▾' button at the top-right of the table to open the format selection dropdown. Always apply filters first — both formats export only the currently filtered records.", + items: [ + { label: "Export CSV", description: "Downloads all displayed flow data as a .csv file, compatible with Excel, Google Sheets, or SIEM platforms. Best for in-depth analysis or reporting." }, + { label: "Export PDF", description: "Generates a formatted flow summary report as a .pdf with a professional layout suitable for audits or management presentations." }, + { label: "Tip: Filter before exporting", description: "Always apply relevant filters first to avoid excessively large files. Filter by a specific IP or time range before downloading." }, + ], + }, + ], + }, + { + id: "apps", + title: "Applications", + url: "/apps", + icon: "📦", + summary: "Identified applications ranked by total bandwidth consumption.", + sections: [ + { + heading: "How DPI Identifies Applications", + content: "Deep Packet Inspection analyzes packet content — not just port numbers — to accurately identify applications. The identification pipeline works as follows:", + steps: [ + "SNI (Server Name Indication) from TLS handshake — e.g., 'googlevideo.com' is mapped to YouTube.", + "HTTP Host header analysis — identifies the domain being accessed.", + "Payload pattern matching against 2,500+ application signatures in the database.", + "Behavioral fingerprinting — packet size patterns and timing analysis.", + ], + }, + { + heading: "Table: Top Applications", + columns: [ + { column: "Application", description: "Application name as identified by the DPI engine. Click any row to open a detail drawer showing: which devices are using it, which agents detected it, total flow count, and bandwidth breakdown per device." }, + { column: "Download", description: "Total incoming bandwidth consumed by this application. Streaming video applications (YouTube, Netflix) typically dominate this column." }, + { column: "Upload", description: "Total outgoing bandwidth sent to this application's servers. High upload for non-backup/non-cloud applications warrants investigation." }, + { column: "Total Bandwidth", description: "Combined Download + Upload. Used as the primary ranking metric for the table." }, + { column: "Devices", description: "Number of unique devices (local IP addresses) using this application. A high count indicates widespread use across the organization." }, + { column: "Flows", description: "Total connection sessions associated with this application. Very high Flows with low Bandwidth may indicate repeated polling requests or a C2 beacon pattern." }, + ], + }, + { + heading: "Application Detail Drawer", + content: "Click any application row to open the side drawer. It contains three sections:", + items: [ + { label: "Devices tab", description: "Table of devices using this application with their IP, MAC address, and individual bandwidth consumption." }, + { label: "Agents tab", description: "Which DPI probe agents detected traffic for this application." }, + { label: "Domains tab", description: "Top domains accessed in the context of this application." }, + ], + }, + { + heading: "Investigating Unknown Applications", + items: [ + { label: "Unclassified Traffic", description: "Applications not matched to any known signature are labeled 'Unclassified'. This may mean a custom or proprietary application, non-standard encryption, or suspicious traffic. Check the Dst IP and Dst Port on the Flows page for further investigation." }, + { label: "Cross-check with Threat Intelligence", description: "If you find an unknown application with high bandwidth, open Threat Intelligence to check whether any related threat detections exist." }, + { label: "Look it up in the Lookup page", description: "Use the Application Lookup page to search for the name and understand its function and risk profile before taking action." }, + ], + }, + ], + }, + { + id: "devices", + title: "Devices", + url: "/devices", + icon: "💻", + summary: "Inventory of all local devices detected as active on the network.", + sections: [ + { + heading: "How Devices Are Discovered", + content: "Devices are discovered passively (without sending probe packets) through three mechanisms:", + steps: [ + "Traffic monitoring — every communicating device is detected by its MAC address and IP when it sends or receives any packet.", + "ARP (Address Resolution Protocol) — MAC address resolution from IP addresses on the local network.", + "DHCP fingerprinting — analysis of DHCP packets when a device requests an IP address from the DHCP server.", + ], + }, + { + heading: "Table: Device Inventory", + columns: [ + { column: "IP Address", description: "The IPv4/IPv6 address assigned to the device. Click to open the full Device Detail Modal containing communication history, applications used, domains accessed, and flow logs." }, + { column: "MAC Address", description: "The unique hardware address of the device's network interface card. The first three octets identify the vendor (OUI) — e.g., A4:C3:F0 = Apple, B4:FB:E4 = Intel. Click to open device details." }, + { column: "Hostname", description: "The device hostname resolved from local DNS or DHCP requests. Displays 'Unknown' if unavailable. Examples: 'LAPTOP-JOHN', 'iPhone-Sarah', 'PRINTER-FLOOR3'." }, + { column: "Agent", description: "The specific DPI probe that detected and monitored this device. If a device moves to a different network segment, the detecting agent may change." }, + { column: "Last Seen", description: "The last time this device sent or received a packet. Devices not seen for more than 7 days are likely inactive or have moved to a different network." }, + { column: "Download / Upload", description: "Total bandwidth consumed by this device within the active time filter. Devices with upload significantly exceeding download should be investigated." }, + ], + }, + { + heading: "Device Detail Modal — Tabs", + items: [ + { label: "Overview", description: "Device identity summary (MAC, IP, hostname, OS type), total bandwidth statistics, and last-seen status." }, + { label: "Applications", description: "List of applications used by this device with bandwidth per application — answers 'what is this device doing?'." }, + { label: "Flows", description: "Active connection log for this device with real-time filtering by port, protocol, or domain." }, + { label: "Domains", description: "Domains most frequently accessed by this device, with access frequency and per-domain bandwidth." }, + { label: "Protocols", description: "Transport layer protocol distribution (TCP/UDP/QUIC/ICMP) for this specific device." }, + { label: "Remote IPs", description: "External servers most frequently communicated with by this device and their bandwidth volumes." }, + { label: "Threats", description: "Security threats associated with this device, cross-referenced from the Threat Intelligence database." }, + ], + }, + { + heading: "Export Device Report (PDF)", + content: "Inside the Device Detail Modal, an 'Export PDF' button generates a comprehensive per-device report in .pdf format. The report includes device identity, top applications, bandwidth summary, and most frequently accessed domains. Ideal for device audit reports or per-device incident investigations.", + }, + ], + }, + { + id: "dns", + title: "DNS Queries", + url: "/dns", + icon: "🌐", + summary: "Log of all domain name resolution queries made by devices on the network.", + sections: [ + { + heading: "Why DNS Monitoring Matters", + content: "DNS (Domain Name System) is the 'phone book of the internet'. Every connection to a website or server begins with a DNS query. Monitoring DNS provides critical security visibility because:", + steps: [ + "Most malware and botnets use domain resolution to locate C2 (Command & Control) servers — DNS reveals this before the actual connection.", + "DNS queries occur before the actual connection is made — allowing earlier detection of threats.", + "DNS tunneling is used by attackers to exfiltrate data hidden inside DNS packets, bypassing traditional firewalls.", + "Unusual query patterns (high volume, random-looking domains) can indicate active malware on a device.", + ], + }, + { + heading: "DNS Record Types", + items: [ + { label: "A Record", description: "Resolves a domain to an IPv4 address (e.g., 'google.com' → '142.250.185.46'). The most common type — majority of web connections start with an A record query." }, + { label: "AAAA Record", description: "Resolves a domain to an IPv6 address. Increasingly common as IPv6 adoption grows. Unusual AAAA queries to unfamiliar foreign domains should be noted." }, + { label: "CNAME Record", description: "Canonical Name — an alias from one domain to another (e.g., 'www.example.com' → 'example.com'). Commonly used by CDNs for load balancing." }, + { label: "MX Record", description: "Mail Exchanger — locates the mail server for a domain. MX queries to unknown domains may indicate spam or phishing activity." }, + { label: "TXT Record", description: "Arbitrary text record used for domain verification and SPF/DKIM email security. DNS tunneling frequently abuses TXT records to hide exfiltrated data." }, + { label: "PTR Record", description: "Reverse DNS lookup — resolves an IP address back to a hostname. Used for logging and mail server verification." }, + ], + }, + { + heading: "Table: DNS Queries", + columns: [ + { column: "Domain", description: "The domain queried by the local device. Truncated — hover to see the full domain. Domains with random-looking characters (e.g., 'a1b2c3d4.malware.ru') are characteristic of DGA malware." }, + { column: "Query Type", description: "The type of DNS record requested: A, AAAA, CNAME, MX, TXT, PTR, etc." }, + { column: "Client IP", description: "The IP of the local device making the query. Click to open that device's full detail panel." }, + { column: "Response", description: "The IP address or data returned by the DNS server as the query answer." }, + { column: "Count", description: "Total number of times this domain was queried within the active time filter. Very high frequency to a single domain may indicate automatic polling (malware heartbeat or a misbehaving application)." }, + { column: "Last Seen", description: "The most recent timestamp when this DNS query occurred. Helps determine whether the activity is still ongoing." }, + ], + }, + { + heading: "Warning Signs in DNS Traffic", + items: [ + { label: "Random-looking domain names", description: "Domains like 'xk2mf9qa.dyndns.com' are characteristic of DGA (Domain Generation Algorithm) malware, which generates new domains daily to evade blacklists." }, + { label: "High-volume queries to .ru, .tk, .pw, .xyz", description: "These TLDs are frequently abused for C2 servers. Cross-check with Threat Intelligence to confirm whether the traffic is malicious." }, + { label: "Very high query frequency to a single domain", description: "Malware heartbeat processes typically send DNS queries every few seconds. Sort by Count (descending) to quickly detect this behavior." }, + { label: "PTR queries to unknown external IPs", description: "Reverse DNS lookups to unrecognized external IPs may indicate a reconnaissance tool is actively running on a device in your network." }, + ], + }, + ], + }, +]; diff --git a/src/lib/help/types.ts b/src/lib/help/types.ts new file mode 100644 index 0000000..3b21c7a --- /dev/null +++ b/src/lib/help/types.ts @@ -0,0 +1,24 @@ +export interface FAQItem { + question: string; + answer: string; +} + +export interface TableColumn { + column: string; + description: string; +} + +export interface PageGuide { + id: string; + title: string; + url: string; + icon: string; + summary: string; + sections: { + heading: string; + content?: string; + steps?: string[]; + columns?: TableColumn[]; + items?: { label: string; description: string }[]; + }[]; +} diff --git a/src/lib/helpContent.ts b/src/lib/helpContent.ts index 8818618..afb86ea 100644 --- a/src/lib/helpContent.ts +++ b/src/lib/helpContent.ts @@ -4,415 +4,27 @@ // Used by both HelpCenterFAB (quick FAQs) and the /help page (full guide). // ───────────────────────────────────────────────────────────────────────────── -export interface FAQItem { - question: string; - answer: string; -} +import { FAQItem, TableColumn, PageGuide } from "./help/types"; +import { FAQS } from "./help/faqs"; +import { UI_CONVENTIONS } from "./help/conventions"; +import { INFRASTRUCTURE_GUIDES } from "./help/guides/infrastructure"; +import { AGENTS_GUIDES } from "./help/guides/agents"; +import { TRAFFIC_CORE_GUIDES } from "./help/guides/traffic"; +import { TRAFFIC_ANALYTICS_GUIDES } from "./help/guides/traffic-analytics"; +import { SECURITY_GUIDES } from "./help/guides/security"; +import { THREATS_GUIDES } from "./help/guides/threats"; +import { LABELING_GUIDES } from "./help/guides/labeling"; -export interface TableColumn { - column: string; - description: string; -} +export type { FAQItem, TableColumn, PageGuide }; +export { FAQS, UI_CONVENTIONS }; -export interface PageGuide { - id: string; - title: string; - url: string; - icon: string; - summary: string; - sections: { - heading: string; - content?: string; - columns?: TableColumn[]; - items?: { label: string; description: string }[]; - }[]; -} - -// ── FAQ (used in HelpCenterFAB quick panel) ────────────────────────────────── -export const FAQS: FAQItem[] = [ - { - question: "Kenapa data Threats kosong (0)?", - answer: - "Jika indikator Threats menunjukkan angka 0, artinya jaringan Anda aman dalam rentang waktu yang dipilih. Sistem tidak mendeteksi adanya paket data yang cocok dengan profil serangan yang diketahui.", - }, - { - question: "Apa perbedaan Events dan Threats?", - answer: - "Events adalah semua log aktivitas koneksi biasa yang tercatat di jaringan (termasuk yang aman). Threats adalah sekumpulan Events khusus yang telah difilter dan diklasifikasikan sebagai aktivitas berbahaya oleh sistem Deep Packet Inspection (DPI).", - }, - { - question: "Bagaimana cara membaca Heatmap Geo Traffic?", - answer: - "Warna biru muda menunjukkan trafik rendah, sedangkan warna biru pekat/tua menunjukkan volume trafik (download/upload) yang sangat tinggi dari/ke negara tersebut.", - }, - { - question: "Berapa lama data disimpan?", - answer: - "Database sistem dikonfigurasi untuk secara otomatis menghapus data telemetri jaringan yang usianya lebih dari 7 hari untuk menjaga performa sistem.", - }, - { - question: "Apa arti indikator Online/Offline pada agen?", - answer: - "Agen berstatus Online jika berhasil mengirim data dalam 12 jam terakhir. Agen Offline berarti tidak ada paket data yang diterima melebihi batas waktu tersebut.", - }, - { - question: "Mengapa Download lebih besar dari Upload?", - answer: - "Ini adalah kondisi normal. Pengguna di jaringan umumnya mengunduh (streaming, web browsing) jauh lebih banyak daripada mengunggah. Jika Upload lebih besar, hal itu bisa mengindikasikan data exfiltration dan perlu diselidiki.", - }, -]; - -// ── Full Page Guides (used in /help page) ──────────────────────────────────── export const PAGE_GUIDES: PageGuide[] = [ - { - id: "overview", - title: "Overview", - url: "/", - icon: "🏠", - summary: "Ringkasan kondisi jaringan secara keseluruhan dalam satu tampilan.", - sections: [ - { - heading: "Kartu KPI (Header)", - columns: [ - { column: "Total Flows", description: "Jumlah sesi koneksi jaringan yang terpantau dalam rentang waktu aktif." }, - { column: "Total Download", description: "Akumulasi data yang diterima oleh semua perangkat di site aktif." }, - { column: "Total Upload", description: "Akumulasi data yang dikirim keluar dari site aktif." }, - { column: "Active Devices", description: "Jumlah perangkat unik yang terdeteksi aktif dalam rentang waktu aktif." }, - ], - }, - { - heading: "Globe Map", - content: - "Peta bola dunia interaktif. Garis bercahaya merepresentasikan arus trafik dari site Anda ke negara tujuan. Warna garis menunjukkan intensitas trafik. Hover ke garis untuk melihat nama negara dan volume data. Klik dan seret untuk memutar globe.", - }, - { - heading: "Top Applications & Top Protocols", - content: - "Widget di bawah globe menampilkan 7 aplikasi dan protokol dengan volume trafik tertinggi. Bar horizontal merepresentasikan perbandingan volume relatif antar entri.", - }, - ], - }, - { - id: "agents", - title: "Agents Inventory", - url: "/agents", - icon: "📡", - summary: "Daftar dan status semua Network Agent yang terdaftar di site Anda.", - sections: [ - { - heading: "Kartu Statistik", - columns: [ - { column: "Total Agents", description: "Jumlah Network Agent yang terdaftar di site aktif." }, - { column: "Online Agents", description: "Agen yang berhasil mengirim data dalam 12 jam terakhir." }, - { column: "Offline Agents", description: "Agen yang tidak terdeteksi aktif." }, - { column: "Avg. Historical Uptime", description: "Rata-rata persentase waktu aktif seluruh agen sepanjang data 7 hari tersimpan." }, - ], - }, - { - heading: "Agent Location & Topology Map", - content: - "Peta Indonesia interaktif. Titik hijau = agen Online. Titik merah = agen Offline. Garis biru = aliran trafik antar agen yang terdeteksi. Hover ke titik untuk melihat detail agen. Hover ke garis untuk melihat detail flow (agen asal, tujuan, volume data). Panel biru di bawah peta menampilkan agen yang belum memiliki koordinat GPS — klik chip agen untuk menambahkan koordinat secara manual.", - }, - { - heading: "Tabel: Provisioned Network Agents", - columns: [ - { column: "UUID / Serial", description: "Identitas unik perangkat agen hardware." }, - { column: "Label", description: "Nama lokasi atau deskripsi yang diberikan untuk agen ini." }, - { column: "Provisioned", description: "Apakah agen sudah diaktifkan dan didaftarkan di sistem." }, - { column: "Activated", description: "Apakah agen sudah terhubung dan pernah mengirim data." }, - { column: "Status", description: "Online (hijau) atau Offline (merah) beserta waktu terakhir terlihat." }, - { column: "Historical Uptime", description: "Persentase waktu aktif selama 7 hari data tersimpan." }, - { column: "Data Size", description: "Total ukuran data yang dikumpulkan agen ini di database sistem." }, - { column: "Actions", description: "Tombol aksi: Detail, Koordinat GPS, Visibilitas, Delete." }, - ], - }, - ], - }, - { - id: "flows", - title: "Flows", - url: "/flows", - icon: "🔀", - summary: "Daftar semua sesi koneksi jaringan aktif yang terdeteksi oleh DPI.", - sections: [ - { - heading: "Filter Bar", - content: - "Filter di bagian atas tabel: Source IP, Destination IP, Protocol, dan App / Domain. Kombinasikan beberapa filter untuk mempersempit hasil pencarian.", - }, - { - heading: "Tabel: Active Flows", - columns: [ - { column: "#", description: "Nomor urut baris dalam halaman saat ini." }, - { column: "Last Seen", description: "Waktu terakhir paket dari sesi ini diterima (zona waktu Indonesia)." }, - { column: "Flow ID", description: "Identitas unik sesi koneksi yang dibuat oleh DPI." }, - { column: "Src IP", description: "Alamat IP sumber — perangkat atau server yang memulai koneksi." }, - { column: "Dst IP", description: "Alamat IP tujuan. Hover untuk melihat nama perangkat jika diketahui." }, - { column: "Dst Port", description: "Port tujuan. Hover untuk melihat nama layanan (443=HTTPS, 53=DNS, dll.)." }, - { column: "Protocol", description: "Protokol jaringan yang digunakan pada sesi ini (TCP, UDP, QUIC, dll.)." }, - { column: "App / Domain", description: "Aplikasi atau nama domain yang teridentifikasi oleh mesin DPI." }, - { column: "Download", description: "Volume data yang diterima dari tujuan ke sumber (dalam KB/MB)." }, - { column: "Upload", description: "Volume data yang dikirim dari sumber ke tujuan." }, - ], - }, - ], - }, - { - id: "apps", - title: "Apps", - url: "/apps", - icon: "📦", - summary: "Daftar semua aplikasi yang teridentifikasi oleh DPI beserta volume trafik.", - sections: [ - { - heading: "Tabel: Top Applications", - columns: [ - { column: "Application", description: "Nama aplikasi yang teridentifikasi (YouTube, Zoom, WhatsApp, dll.)." }, - { column: "Download", description: "Total data yang diunduh melalui aplikasi ini dalam rentang waktu aktif." }, - { column: "Upload", description: "Total data yang diunggah melalui aplikasi ini." }, - { column: "Total Traffic", description: "Gabungan download + upload untuk aplikasi ini." }, - { column: "Devices", description: "Jumlah perangkat unik yang menggunakan aplikasi ini." }, - { column: "Flows", description: "Jumlah sesi koneksi aktif dari aplikasi ini." }, - ], - }, - ], - }, - { - id: "devices", - title: "Devices", - url: "/devices", - icon: "💻", - summary: "Inventaris semua perangkat yang terdeteksi aktif di jaringan.", - sections: [ - { - heading: "Tabel: Device Inventory", - columns: [ - { column: "IP Address", description: "Alamat IP perangkat di jaringan lokal." }, - { column: "MAC Address", description: "Identitas fisik kartu jaringan perangkat (hardware address)." }, - { column: "Hostname", description: "Nama perangkat jika tersedia dari resolver DNS lokal." }, - { column: "Agent", description: "Network Agent yang mendeteksi dan memantau perangkat ini." }, - { column: "Last Seen", description: "Kapan terakhir kali perangkat ini aktif mengirim atau menerima trafik." }, - { column: "Download / Upload", description: "Volume trafik total dari dan ke perangkat ini." }, - ], - }, - ], - }, - { - id: "dns", - title: "DNS", - url: "/dns", - icon: "🌐", - summary: "Log query DNS yang terdeteksi di jaringan.", - sections: [ - { - heading: "Tabel: DNS Queries", - columns: [ - { column: "Domain", description: "Nama domain yang di-query oleh perangkat di jaringan." }, - { column: "Query Type", description: "Jenis query DNS: A (IPv4), AAAA (IPv6), MX (mail), CNAME (alias), dll." }, - { column: "Client IP", description: "Alamat IP perangkat yang melakukan query DNS ini." }, - { column: "Response", description: "Alamat IP yang dikembalikan oleh server DNS sebagai jawaban." }, - { column: "Count", description: "Frekuensi query domain ini dalam rentang waktu aktif." }, - { column: "Last Seen", description: "Waktu terakhir query domain ini terjadi." }, - ], - }, - ], - }, - { - id: "network-intelligence", - title: "Traffic Categories", - url: "/network-intelligence", - icon: "📊", - summary: "Klasifikasi trafik berdasarkan kategori aplikasi hasil analisis DPI.", - sections: [ - { - heading: "Kategori Utama", - content: - "Menampilkan bagan distribusi trafik per kategori utama seperti Streaming, Social Media, Web, Email, Cloud, File Transfer, dll. serta visualisasi proporsi trafik menggunakan Pie Chart.", - }, - { - heading: "Tabel Kategori Aplikasi", - columns: [ - { column: "Category", description: "Nama kategori aplikasi jaringan yang diidentifikasi." }, - { column: "Download", description: "Volume data yang diunduh pada kategori ini." }, - { column: "Upload", description: "Volume data yang diunggah pada kategori ini." }, - { column: "Total Traffic", description: "Gabungan volume download dan upload." }, - { column: "Devices", description: "Jumlah perangkat unik yang menggunakan kategori ini." }, - ], - }, - ], - }, - { - id: "dpi-analytics", - title: "DPI MetaData", - url: "/dpi-analytics", - icon: "🔌", - summary: "Analisis metadata mentah hasil inspeksi paket mendalam (DPI) dan sidik jari perangkat.", - sections: [ - { - heading: "Tab: Hostnames & Servers", - content: "Log domain eksternal, IP server, port, dan agen pendeteksi. Membantu mengidentifikasi tujuan koneksi perangkat.", - }, - { - heading: "Tab: Client Fingerprints", - content: "Menampilkan data sidik jari sistem operasi (Discovery OS), dhcpFingerprints, dan HTTP User Agent untuk mengidentifikasi jenis perangkat yang aktif.", - }, - { - heading: "Tab: Specific Protocols", - content: "Log metadata protokol khusus seperti versi server SSH yang digunakan dan Bittorrent Hashes untuk mengaudit torrenting.", - }, - { - heading: "Tabel Detail Metadata", - columns: [ - { column: "Value / OS / Domain", description: "Nilai metadata, nama domain, User Agent, atau nama sistem operasi." }, - { column: "Details / Version", description: "Informasi detail tambahan (seperti versi SSH atau detail OS)." }, - { column: "Count", description: "Frekuensi kemunculan metadata ini di jaringan." }, - { column: "Last Seen", description: "Waktu terakhir metadata ini terdeteksi di database." }, - ], - }, - ], - }, - { - id: "geography", - title: "Geo Traffic", - url: "/geography", - icon: "🗺️", - summary: "Distribusi trafik berdasarkan negara tujuan koneksi.", - sections: [ - { - heading: "Tabel: Traffic by Country", - columns: [ - { column: "Country", description: "Negara tujuan koneksi yang terdeteksi oleh mesin geolokasi." }, - { column: "Flag", description: "Bendera negara sebagai identifikasi visual." }, - { column: "Download / Upload", description: "Volume trafik yang dikirim ke atau diterima dari negara tersebut." }, - { column: "Flows", description: "Jumlah sesi koneksi aktif yang menuju negara tersebut." }, - ], - }, - ], - }, - { - id: "intelligence", - title: "Threat Intelligence", - url: "/intelligence", - icon: "🛡️", - summary: "Deteksi ancaman keamanan jaringan secara otomatis oleh sistem DPI.", - sections: [ - { - heading: "Kategori Ancaman (Sidebar Kiri)", - items: [ - { label: "Crypto Mining", description: "Perangkat yang terdeteksi menambang cryptocurrency tanpa izin." }, - { label: "Botnet Command & Control", description: "Koneksi ke server Command and Control yang diketahui berbahaya." }, - { label: "Malware", description: "Trafik yang cocok dengan signature malware yang terdaftar." }, - { label: "Phishing", description: "Akses ke domain yang teridentifikasi sebagai situs phishing." }, - ], - }, - { - heading: "Tabel Detail Ancaman", - columns: [ - { column: "Threat Level", description: "Tingkat keparahan: Critical, High, Medium, atau Low." }, - { column: "Source IP", description: "Alamat IP perangkat di jaringan Anda yang terlibat." }, - { column: "Destination", description: "IP atau domain tujuan yang teridentifikasi berbahaya." }, - { column: "First Seen", description: "Kapan pertama kali ancaman ini terdeteksi dalam database." }, - { column: "Last Seen", description: "Kapan terakhir kali aktivitas mencurigakan ini terpantau." }, - { column: "Evidence", description: "Detail teknis yang menjadi dasar sistem mengklasifikasikan ini sebagai ancaman." }, - ], - }, - ], - }, - { - id: "events", - title: "Events", - url: "/events", - icon: "📋", - summary: "Log semua kejadian penting yang dicatat oleh sistem.", - sections: [ - { - heading: "Tabel: Event Log", - columns: [ - { column: "Timestamp", description: "Waktu kejadian terjadi dalam zona waktu Indonesia." }, - { column: "Severity", description: "Tingkat keparahan: Info, Warning, atau Critical." }, - { column: "Agent", description: "Network Agent yang mendeteksi dan mencatat kejadian ini." }, - { column: "Description", description: "Deskripsi singkat kejadian yang terjadi." }, - { column: "Details", description: "Klik untuk membuka panel detail lengkap kejadian." }, - ], - }, - ], - }, - { - id: "security-audit", - title: "Encryption Audit (TLS)", - url: "/security-audit", - icon: "🔐", - summary: "Audit keamanan enkripsi TLS di seluruh jaringan yang terpantau.", - sections: [ - { - heading: "Tabel: TLS Versions", - columns: [ - { column: "Version", description: "Versi TLS yang digunakan (TLS 1.0, 1.1, 1.2, 1.3)." }, - { column: "Count", description: "Jumlah koneksi yang menggunakan versi ini dalam rentang waktu aktif." }, - { column: "Risk", description: "TLS 1.0/1.1 = Berisiko Tinggi. TLS 1.2 = Sedang. TLS 1.3 = Aman." }, - ], - }, - { - heading: "Tabel: TLS Ciphers", - columns: [ - { column: "Cipher Suite", description: "Nama algoritma enkripsi yang digunakan pada koneksi TLS." }, - { column: "Strength", description: "Kekuatan enkripsi: Strong, Weak, atau Insecure." }, - { column: "Count", description: "Frekuensi penggunaan cipher ini dalam rentang waktu aktif." }, - ], - }, - ], - }, - { - id: "network-infrastructure", - title: "Topology (Infrastructure)", - url: "/network-infrastructure", - icon: "🌐", - summary: "Visualisasi dan rincian topologi fisik, logis, dan geografis dari infrastruktur jaringan.", - sections: [ - { - heading: "Tab: Routing & Geography", - content: "Melihat statistik wilayah (Top Regions) dan perkotaan (Top Cities) asal/tujuan pengiriman trafik jaringan.", - }, - { - heading: "Tab: Interfaces & VLANs", - content: "Daftar antarmuka jaringan fisik (Interfaces) dan penanda VLAN (VLAN Tags) yang aktif mengalirkan data.", - }, - { - heading: "Tab: IP & MAC", - content: "Daftar alamat IP remote teraktif, statistik versi protokol IP (IPv4 vs IPv6), dan pemakaian bandwidth berdasarkan MAC Address lokal.", - }, - { - heading: "Tab: Flow Profiles", - content: "Log karakteristik aliran data berdasarkan jenis flow (Flow Types) dan sumber asal flow (Flow Origins) seperti Local, Inbound, Outbound.", - }, - ], - }, - { - id: "lookup", - title: "Application Database (Lookup)", - url: "/lookup", - icon: "🔍", - summary: "Katalog referensi global database aplikasi yang dapat dikenali oleh sistem DPI.", - sections: [ - { - heading: "Katalog & Pencarian", - content: "Gunakan bar pencarian di bagian atas untuk menemukan aplikasi tertentu berdasarkan nama, atau gunakan dropdown filter kategori untuk menyaring daftar aplikasi.", - }, - { - heading: "Detail Aplikasi (Modal)", - content: "Klik salah satu card aplikasi untuk membuka modal informasi lengkap yang merinci deskripsi aplikasi, tingkat risiko keamanan (Risk Level), port standar (Default Port), serta protokol transport yang digunakan.", - }, - ], - }, + ...INFRASTRUCTURE_GUIDES, + ...AGENTS_GUIDES, + ...TRAFFIC_CORE_GUIDES, + ...TRAFFIC_ANALYTICS_GUIDES, + ...SECURITY_GUIDES, + ...THREATS_GUIDES, + ...LABELING_GUIDES, ]; -// ── Global UI Conventions ───────────────────────────────────────────────────── -export const UI_CONVENTIONS = [ - { symbol: "● (hijau)", meaning: "Status aktif / Online / Kondisi aman" }, - { symbol: "● (merah)", meaning: "Status tidak aktif / Offline / Berisiko" }, - { symbol: "● (kuning/amber)", meaning: "Perlu perhatian / Warning" }, - { symbol: "Teks terpotong (...)", meaning: "Hover ke teks untuk melihat nilai lengkap" }, - { symbol: "KB / MB / GB", meaning: "Satuan ukuran data (1 MB = 1.000 KB)" }, - { symbol: "Format waktu WIB/WITA/WIT", meaning: "Semua waktu ditampilkan dalam zona waktu Indonesia" }, - { symbol: "Pagination < 1/N >", meaning: "Navigasi halaman — setiap halaman menampilkan 50 baris data" }, -]; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 6f8f9b7..78bc76b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,283 +1,283 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -export function fmtBytes(bytes?: number | null) { - if (bytes == null || bytes === 0 || isNaN(bytes)) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']; - const i = Math.min(Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024)), units.length - 1); - if (i < 0) return '0 B'; - return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i]; -} - -export function formatAppLabel(appLabel: string | null | undefined): string { - if (!appLabel) return "-"; - const trimLabel = appLabel.trim(); - - // Match "Port XXX" or just "XXX" - const portMatch = trimLabel.match(/^(?:Port\s+)?(\d+)$/i); - if (portMatch) { - const port = portMatch[1]; - const portNum = parseInt(port, 10); - const PORT_DESCRIPTIONS: Record<string, string> = { - "20": "FTP-Data", - "21": "FTP Control (File Transfer)", - "22": "SSH (Secure Shell / SFTP)", - "23": "Telnet (Unencrypted Terminal)", - "25": "SMTP (Mail Transfer)", - "53": "DNS (Domain Name Lookup)", - "67": "DHCP Server", - "68": "DHCP Client", - "69": "TFTP (Trivial FTP)", - "80": "HTTP (Unencrypted Web Traffic)", - "88": "Kerberos Authentication", - "110": "POP3 (Email Retrieval)", - "123": "NTP (Network Clock Sync)", - "137": "NetBIOS Name Service", - "138": "NetBIOS Datagram Service", - "139": "NetBIOS Session Service", - "143": "IMAP (Email Access)", - "161": "SNMP (Network Management)", - "162": "SNMP Trap", - "389": "LDAP (Directory Access)", - "443": "HTTPS (Encrypted Web Traffic)", - "445": "SMB (Windows File Sharing)", - "465": "SMTPS (Secure Mail)", - "500": "ISAKMP/IPSec (VPN Exchange)", - "514": "Syslog (System Logging)", - "587": "Submission (Secure Mail)", - "636": "LDAPS (Secure LDAP)", - "993": "IMAPS (Secure IMAP)", - "995": "POP3S (Secure POP3)", - "1433": "MSSQL Database", - "1434": "MSSQL Monitor", - "1521": "Oracle DB", - "1812": "RADIUS Auth", - "1813": "RADIUS Acct", - "1900": "SSDP (UPnP Device Discovery)", - "3306": "MySQL/MariaDB DB", - "3389": "RDP (Remote Desktop)", - "5060": "SIP (VoIP Session)", - "5061": "SIPS (Secure VoIP)", - "5353": "mDNS (Multicast DNS)", - "5432": "PostgreSQL DB", - "5900": "VNC Remote Access", - "6379": "Redis In-Memory DB", - "8000": "Alt HTTP (Web Proxy/Dev)", - "8080": "Alt HTTP (Web Proxy/Dev)", - "8443": "Alt HTTPS (Web Interface)", - "9200": "Elasticsearch", - "27017": "Database Server", - }; - - if (PORT_DESCRIPTIONS[port]) { - return `Port ${port} (${PORT_DESCRIPTIONS[port]})`; - } - - // Fallback classification for unknown ports - return portNum >= 1024 - ? `Port ${port} (Custom/Ephemeral)` - : `Port ${port} (System Port)`; - } - - return trimLabel; -} - -export function getAppFavicon(appLabel: string): string | null { - if (!appLabel) return null; - const label = appLabel.toLowerCase(); - - // Mapping of common app labels to domains - const domainMap: Record<string, string> = { - facebook: 'facebook.com', - youtube: 'youtube.com', - google: 'google.com', - zoom: 'zoom.us', - cloudflare: 'cloudflare.com', - ruijie: 'ruijie.com.cn', - box: 'box.com', - github: 'github.com', - microsoft: 'microsoft.com', - netflix: 'netflix.com', - spotify: 'spotify.com', - apple: 'apple.com', - amazon: 'amazon.com', - twitter: 'twitter.com', - instagram: 'instagram.com', - whatsapp: 'whatsapp.com', - telegram: 'telegram.org', - discord: 'discord.com', - slack: 'slack.com', - teams: 'teams.microsoft.com', - }; - - for (const [key, domain] of Object.entries(domainMap)) { - if (label.includes(key)) { - return `https://www.google.com/s2/favicons?sz=64&domain=${domain}`; - } - } - - // Fallback to a domain-like resolution if it contains dots, or return null - if (appLabel.includes('.')) { - return `https://www.google.com/s2/favicons?sz=64&domain=${appLabel}`; - } - - return null; -} - -export function formatOSLabel(os: string | null | undefined): string { - if (!os || os === "-" || os === "Unknown") return "Unknown"; - const label = os.toLowerCase(); - - if (label.includes("windows")) return "Windows"; - if (label.includes("mac") || label.includes("darwin") || label.includes("osx")) return "Mac"; - if (label.includes("android")) return "Android"; - if (label.includes("ios") || label.includes("iphone") || label.includes("ipad")) return "Apple iOS"; - if (label.includes("linux") || label.includes("ubuntu") || label.includes("debian") || label.includes("centos")) return "Linux"; - if (label.includes("tizen")) return "Tizen OS"; - if (label.includes("embedded") || label.includes("rtos")) return "Embedded OS"; - - return "Other"; -} - -export function formatDeviceTypeLabel(type: string | null | undefined): string { - if (!type || type === "-" || type === "Unknown" || type === "Generic Client") return "Unknown"; - const label = type.toLowerCase(); - - if (label.includes("workstation") || label.includes("computer") || label.includes("laptop") || label.includes("desktop") || label.includes("pc")) return "Computer"; - if (label.includes("mobile") || label.includes("phone")) return "Mobile Phone"; - if (label.includes("tablet") || label.includes("pad")) return "Tablet"; - if (label.includes("router") || label.includes("gateway") || label.includes("switch") || label.includes("node") || label.includes("ap") || label.includes("network")) return "Networking"; - if (label.includes("firewall") || label.includes("security")) return "Firewall/Security"; - if (label.includes("printer") || label.includes("scanner")) return "Printer/Scanner"; - if (label.includes("tv") || label.includes("audio") || label.includes("video") || label.includes("dvr") || label.includes("camera") || label.includes("media")) return "TV/DVR/Audio"; - if (label.includes("database") || label.includes("server")) return "Server"; - - return "Other"; -} - -export function formatTimestampWIB(ts: string | Date | null | undefined): string { - if (!ts) return "-"; - try { - return new Date(ts).toLocaleString('id-ID', { - timeZone: 'Asia/Jakarta', - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, - }).replace(/\./g, ':'); - } catch { - return String(ts); - } -} - -export function calculateDuration(start: string | Date | null | undefined, end: string | Date | null | undefined): string { - if (!start || !end) return "-"; - try { - const s = new Date(start).getTime(); - const e = new Date(end).getTime(); - const diff = Math.max(0, e - s); - - const seconds = Math.floor((diff / 1000) % 60); - const minutes = Math.floor((diff / (1000 * 60)) % 60); - const hours = Math.floor((diff / (1000 * 60 * 60)) % 24); - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - - const parts = []; - if (days > 0) parts.push(`${days}d`); - if (hours > 0) parts.push(`${hours}h`); - if (minutes > 0) parts.push(`${minutes}m`); - if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); - - return parts.join(' '); - } catch (e) { - return "-"; - } -} - -export function getKnownDomainService(domain: string | null | undefined): string | null { - if (!domain) return null; - const d = domain.toLowerCase(); - - // CDN & Cloud Infrastructure - if (d.includes("googlevideo.com")) return "YouTube (Google CDN)"; - if (d.includes("fbcdn.net")) return "Facebook CDN"; - if (d.includes("akamaihd.net") || d.includes("akamaized.net") || d.includes("akamai.net")) return "Akamai CDN"; - if (d.includes("cloudfront.net")) return "Amazon CloudFront"; - if (d.includes("amazonaws.com")) return "Amazon Web Services"; - if (d.includes("cloudflare.com") || d.includes("cloudflare.net")) return "Cloudflare"; - if (d.includes("fastly.net")) return "Fastly CDN"; - if (d.includes("cdn77.org") || d.includes("cdn77.net")) return "CDN77"; - - // Tech Giants - if (d.includes("apple.com") || d.includes("mzstatic.com")) return "Apple Services"; - if (d.includes("icloud.com") || d.includes("apple-cloudkit.com")) return "Apple iCloud"; - if (d.includes("microsoft.com") || d.includes("live.com") || d.includes("office.com")) return "Microsoft Services"; - if (d.includes("windowsupdate.com")) return "Windows Update"; - if (d.includes("google.com") || d.includes("googleapis.com") || d.includes("gstatic.com")) return "Google Services"; - - // Social & Media - if (d.includes("whatsapp.net") || d.includes("whatsapp.com")) return "WhatsApp"; - if (d.includes("instagram.com")) return "Instagram"; - if (d.includes("tiktokv.com") || d.includes("tiktokcdn.com") || d.includes("byteoversea.com") || d.includes("tiktok.com")) return "TikTok"; - if (d.includes("netflix.com") || d.includes("netflix.net") || d.includes("nflxvideo.net") || d.includes("nflxext.com")) return "Netflix"; - if (d.includes("spotify.com") || d.includes("spotifycdn.com") || d.includes("scdn.co")) return "Spotify"; - if (d.includes("twitter.com") || d.includes("twimg.com") || d.includes("x.com")) return "X (Twitter)"; - if (d.includes("zoom.us")) return "Zoom Video"; - if (d.includes("discord.gg") || d.includes("discord.com") || d.includes("discordapp.net") || d.includes("discordapp.com")) return "Discord"; - if (d.includes("hystreamer.com") || d.includes("bigolive.tv")) return "Bigo Live"; - if (d.includes("xnxx-cdn.com") || d.includes("pornhub.com")) return "Adult Content (18+)"; - if (d.includes("kompas.com") || d.includes("kompas.id")) return "Kompas (Media)"; - if (d.includes("sfx.ms") || d.includes("onedrive.com") || d.includes("sharepoint.com")) return "Microsoft OneDrive"; - if (d.includes("symantecliveupdate.com") || d.includes("norton.com")) return "Symantec/Norton Antivirus"; - - // E-Commerce & Local (Indonesia) - if (d.includes("shopeemobile.com") || d.includes("shopee.co.id")) return "Shopee"; - if (d.includes("tokopedia.com") || d.includes("tokopedia.net")) return "Tokopedia"; - if (d.includes("gojek.com") || d.includes("go-jek.com")) return "Gojek"; - if (d.includes("grab.com")) return "Grab"; - - // Gaming - if (d.includes("steampowered.com") || d.includes("steamcontent.com")) return "Steam (Gaming)"; - if (d.includes("epicgames.com")) return "Epic Games"; - if (d.includes("roblox.com")) return "Roblox"; - if (d.includes("garena.com")) return "Garena"; - if (d.includes("hoyoverse.com") || d.includes("mihoyo.com")) return "HoYoverse (Genshin)"; - if (d.includes("riotgames.com") || d.includes("valorant")) return "Riot Games"; - if (d.includes("summonerswar") || d.includes("qpyou.cn")) return "Summoners War"; - // Dynamic Fallback Heuristic - const parts = d.split('.'); - if (parts.length > 1) { - let target = parts[parts.length - 2]; - // Handle double TLDs like .co.id, .com.sg, .ac.uk - if (['co', 'com', 'ac', 'go', 'or', 'net', 'edu'].includes(target) && parts.length > 2) { - target = parts[parts.length - 3]; - } - // Remove dashes for aesthetic purposes - target = target.replace(/-/g, ' ').trim(); - if (target && target.length > 2) { - // Capitalize first letter of each word - return target.split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); - } - } - - return null; -} - -export function getActiveStatus(lastSeen: string | Date | null | undefined, thresholdMinutes: number = 5): boolean { - if (!lastSeen) return false; - try { - const last = new Date(lastSeen).getTime(); - const now = Date.now(); - // Default: active if seen within the last `thresholdMinutes` minutes - return (now - last) <= thresholdMinutes * 60 * 1000; - } catch { - return false; - } -} +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function fmtBytes(bytes?: number | null) { + if (bytes == null || bytes === 0 || isNaN(bytes)) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']; + const i = Math.min(Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024)), units.length - 1); + if (i < 0) return '0 B'; + return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i]; +} + +export function formatAppLabel(appLabel: string | null | undefined): string { + if (!appLabel) return "-"; + const trimLabel = appLabel.trim(); + + // Match "Port XXX" or just "XXX" + const portMatch = trimLabel.match(/^(?:Port\s+)?(\d+)$/i); + if (portMatch) { + const port = portMatch[1]; + const portNum = parseInt(port, 10); + const PORT_DESCRIPTIONS: Record<string, string> = { + "20": "FTP-Data", + "21": "FTP Control (File Transfer)", + "22": "SSH (Secure Shell / SFTP)", + "23": "Telnet (Unencrypted Terminal)", + "25": "SMTP (Mail Transfer)", + "53": "DNS (Domain Name Lookup)", + "67": "DHCP Server", + "68": "DHCP Client", + "69": "TFTP (Trivial FTP)", + "80": "HTTP (Unencrypted Web Traffic)", + "88": "Kerberos Authentication", + "110": "POP3 (Email Retrieval)", + "123": "NTP (Network Clock Sync)", + "137": "NetBIOS Name Service", + "138": "NetBIOS Datagram Service", + "139": "NetBIOS Session Service", + "143": "IMAP (Email Access)", + "161": "SNMP (Network Management)", + "162": "SNMP Trap", + "389": "LDAP (Directory Access)", + "443": "HTTPS (Encrypted Web Traffic)", + "445": "SMB (Windows File Sharing)", + "465": "SMTPS (Secure Mail)", + "500": "ISAKMP/IPSec (VPN Exchange)", + "514": "Syslog (System Logging)", + "587": "Submission (Secure Mail)", + "636": "LDAPS (Secure LDAP)", + "993": "IMAPS (Secure IMAP)", + "995": "POP3S (Secure POP3)", + "1433": "MSSQL Database", + "1434": "MSSQL Monitor", + "1521": "Oracle DB", + "1812": "RADIUS Auth", + "1813": "RADIUS Acct", + "1900": "SSDP (UPnP Device Discovery)", + "3306": "MySQL/MariaDB DB", + "3389": "RDP (Remote Desktop)", + "5060": "SIP (VoIP Session)", + "5061": "SIPS (Secure VoIP)", + "5353": "mDNS (Multicast DNS)", + "5432": "PostgreSQL DB", + "5900": "VNC Remote Access", + "6379": "Redis In-Memory DB", + "8000": "Alt HTTP (Web Proxy/Dev)", + "8080": "Alt HTTP (Web Proxy/Dev)", + "8443": "Alt HTTPS (Web Interface)", + "9200": "Elasticsearch", + "27017": "Database Server", + }; + + if (PORT_DESCRIPTIONS[port]) { + return `Port ${port} (${PORT_DESCRIPTIONS[port]})`; + } + + // Fallback classification for unknown ports + return portNum >= 1024 + ? `Port ${port} (Custom/Ephemeral)` + : `Port ${port} (System Port)`; + } + + return trimLabel; +} + +export function getAppFavicon(appLabel: string): string | null { + if (!appLabel) return null; + const label = appLabel.toLowerCase(); + + // Mapping of common app labels to domains + const domainMap: Record<string, string> = { + facebook: 'facebook.com', + youtube: 'youtube.com', + google: 'google.com', + zoom: 'zoom.us', + cloudflare: 'cloudflare.com', + ruijie: 'ruijie.com.cn', + box: 'box.com', + github: 'github.com', + microsoft: 'microsoft.com', + netflix: 'netflix.com', + spotify: 'spotify.com', + apple: 'apple.com', + amazon: 'amazon.com', + twitter: 'twitter.com', + instagram: 'instagram.com', + whatsapp: 'whatsapp.com', + telegram: 'telegram.org', + discord: 'discord.com', + slack: 'slack.com', + teams: 'teams.microsoft.com', + }; + + for (const [key, domain] of Object.entries(domainMap)) { + if (label.includes(key)) { + return `https://www.google.com/s2/favicons?sz=64&domain=${domain}`; + } + } + + // Fallback to a domain-like resolution if it contains dots, or return null + if (appLabel.includes('.')) { + return `https://www.google.com/s2/favicons?sz=64&domain=${appLabel}`; + } + + return null; +} + +export function formatOSLabel(os: string | null | undefined): string { + if (!os || os === "-" || os === "Unknown") return "Unknown"; + const label = os.toLowerCase(); + + if (label.includes("windows")) return "Windows"; + if (label.includes("mac") || label.includes("darwin") || label.includes("osx")) return "Mac"; + if (label.includes("android")) return "Android"; + if (label.includes("ios") || label.includes("iphone") || label.includes("ipad")) return "Apple iOS"; + if (label.includes("linux") || label.includes("ubuntu") || label.includes("debian") || label.includes("centos")) return "Linux"; + if (label.includes("tizen")) return "Tizen OS"; + if (label.includes("embedded") || label.includes("rtos")) return "Embedded OS"; + + return "Other"; +} + +export function formatDeviceTypeLabel(type: string | null | undefined): string { + if (!type || type === "-" || type === "Unknown" || type === "Generic Client") return "Unknown"; + const label = type.toLowerCase(); + + if (label.includes("workstation") || label.includes("computer") || label.includes("laptop") || label.includes("desktop") || label.includes("pc")) return "Computer"; + if (label.includes("mobile") || label.includes("phone")) return "Mobile Phone"; + if (label.includes("tablet") || label.includes("pad")) return "Tablet"; + if (label.includes("router") || label.includes("gateway") || label.includes("switch") || label.includes("node") || label.includes("ap") || label.includes("network")) return "Networking"; + if (label.includes("firewall") || label.includes("security")) return "Firewall/Security"; + if (label.includes("printer") || label.includes("scanner")) return "Printer/Scanner"; + if (label.includes("tv") || label.includes("audio") || label.includes("video") || label.includes("dvr") || label.includes("camera") || label.includes("media")) return "TV/DVR/Audio"; + if (label.includes("database") || label.includes("server")) return "Server"; + + return "Other"; +} + +export function formatTimestampWIB(ts: string | Date | null | undefined): string { + if (!ts) return "-"; + try { + return new Date(ts).toLocaleString('id-ID', { + timeZone: 'Asia/Jakarta', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).replace(/\./g, ':'); + } catch { + return String(ts); + } +} + +export function calculateDuration(start: string | Date | null | undefined, end: string | Date | null | undefined): string { + if (!start || !end) return "-"; + try { + const s = new Date(start).getTime(); + const e = new Date(end).getTime(); + const diff = Math.max(0, e - s); + + const seconds = Math.floor((diff / 1000) % 60); + const minutes = Math.floor((diff / (1000 * 60)) % 60); + const hours = Math.floor((diff / (1000 * 60 * 60)) % 24); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + + return parts.join(' '); + } catch (e) { + return "-"; + } +} + +export function getKnownDomainService(domain: string | null | undefined): string | null { + if (!domain) return null; + const d = domain.toLowerCase(); + + // CDN & Cloud Infrastructure + if (d.includes("googlevideo.com")) return "YouTube (Google CDN)"; + if (d.includes("fbcdn.net")) return "Facebook CDN"; + if (d.includes("akamaihd.net") || d.includes("akamaized.net") || d.includes("akamai.net")) return "Akamai CDN"; + if (d.includes("cloudfront.net")) return "Amazon CloudFront"; + if (d.includes("amazonaws.com")) return "Amazon Web Services"; + if (d.includes("cloudflare.com") || d.includes("cloudflare.net")) return "Cloudflare"; + if (d.includes("fastly.net")) return "Fastly CDN"; + if (d.includes("cdn77.org") || d.includes("cdn77.net")) return "CDN77"; + + // Tech Giants + if (d.includes("apple.com") || d.includes("mzstatic.com")) return "Apple Services"; + if (d.includes("icloud.com") || d.includes("apple-cloudkit.com")) return "Apple iCloud"; + if (d.includes("microsoft.com") || d.includes("live.com") || d.includes("office.com")) return "Microsoft Services"; + if (d.includes("windowsupdate.com")) return "Windows Update"; + if (d.includes("google.com") || d.includes("googleapis.com") || d.includes("gstatic.com")) return "Google Services"; + + // Social & Media + if (d.includes("whatsapp.net") || d.includes("whatsapp.com")) return "WhatsApp"; + if (d.includes("instagram.com")) return "Instagram"; + if (d.includes("tiktokv.com") || d.includes("tiktokcdn.com") || d.includes("byteoversea.com") || d.includes("tiktok.com")) return "TikTok"; + if (d.includes("netflix.com") || d.includes("netflix.net") || d.includes("nflxvideo.net") || d.includes("nflxext.com")) return "Netflix"; + if (d.includes("spotify.com") || d.includes("spotifycdn.com") || d.includes("scdn.co")) return "Spotify"; + if (d.includes("twitter.com") || d.includes("twimg.com") || d.includes("x.com")) return "X (Twitter)"; + if (d.includes("zoom.us")) return "Zoom Video"; + if (d.includes("discord.gg") || d.includes("discord.com") || d.includes("discordapp.net") || d.includes("discordapp.com")) return "Discord"; + if (d.includes("hystreamer.com") || d.includes("bigolive.tv")) return "Bigo Live"; + if (d.includes("xnxx-cdn.com") || d.includes("pornhub.com")) return "Adult Content (18+)"; + if (d.includes("kompas.com") || d.includes("kompas.id")) return "Kompas (Media)"; + if (d.includes("sfx.ms") || d.includes("onedrive.com") || d.includes("sharepoint.com")) return "Microsoft OneDrive"; + if (d.includes("symantecliveupdate.com") || d.includes("norton.com")) return "Symantec/Norton Antivirus"; + + // E-Commerce & Local (Indonesia) + if (d.includes("shopeemobile.com") || d.includes("shopee.co.id")) return "Shopee"; + if (d.includes("tokopedia.com") || d.includes("tokopedia.net")) return "Tokopedia"; + if (d.includes("gojek.com") || d.includes("go-jek.com")) return "Gojek"; + if (d.includes("grab.com")) return "Grab"; + + // Gaming + if (d.includes("steampowered.com") || d.includes("steamcontent.com")) return "Steam (Gaming)"; + if (d.includes("epicgames.com")) return "Epic Games"; + if (d.includes("roblox.com")) return "Roblox"; + if (d.includes("garena.com")) return "Garena"; + if (d.includes("hoyoverse.com") || d.includes("mihoyo.com")) return "HoYoverse (Genshin)"; + if (d.includes("riotgames.com") || d.includes("valorant")) return "Riot Games"; + if (d.includes("summonerswar") || d.includes("qpyou.cn")) return "Summoners War"; + // Dynamic Fallback Heuristic + const parts = d.split('.'); + if (parts.length > 1) { + let target = parts[parts.length - 2]; + // Handle double TLDs like .co.id, .com.sg, .ac.uk + if (['co', 'com', 'ac', 'go', 'or', 'net', 'edu'].includes(target) && parts.length > 2) { + target = parts[parts.length - 3]; + } + // Remove dashes for aesthetic purposes + target = target.replace(/-/g, ' ').trim(); + if (target && target.length > 2) { + // Capitalize first letter of each word + return target.split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); + } + } + + return null; +} + +export function getActiveStatus(lastSeen: string | Date | null | undefined, thresholdMinutes: number = 5): boolean { + if (!lastSeen) return false; + try { + const last = new Date(lastSeen).getTime(); + const now = Date.now(); + // Default: active if seen within the last `thresholdMinutes` minutes + return (now - last) <= thresholdMinutes * 60 * 1000; + } catch { + return false; + } +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..c9ff36f --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + const token = request.cookies.get('token')?.value; + const { pathname } = request.nextUrl; + + const isAuthPage = pathname.startsWith('/login'); + + // Protect all pages except /login and static assets/api + if (pathname.startsWith('/api') || pathname.startsWith('/_next') || pathname === '/favicon.ico' || pathname.endsWith('.svg') || pathname.endsWith('.png')) { + return NextResponse.next(); + } + + if (!token && !isAuthPage) { + const loginUrl = new URL('/login', request.url); + return NextResponse.redirect(loginUrl); + } + + if (token && isAuthPage) { + const dashboardUrl = new URL('/', request.url); + return NextResponse.redirect(dashboardUrl); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], +}; diff --git a/start-with-env.js b/start-with-env.js new file mode 100644 index 0000000..0a52bc6 --- /dev/null +++ b/start-with-env.js @@ -0,0 +1,31 @@ +// start-with-env.js +// ───────────────────────────────────────────────────────────────────────────── +// PM2 entry point untuk Next.js production server (Source 2) +// Memuat .env.production sebelum memulai Next.js standalone server +// ───────────────────────────────────────────────────────────────────────────── + +const path = require('path'); +const fs = require('fs'); + +// Load .env.production variables into process.env +const envFile = path.join(__dirname, '.env.production'); +if (fs.existsSync(envFile)) { + const lines = fs.readFileSync(envFile, 'utf8').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx < 1) continue; + const key = trimmed.substring(0, eqIdx).trim(); + const val = trimmed.substring(eqIdx + 1).trim(); + if (key) { + process.env[key] = val; // Always overwrite — .env.production is the source of truth + } + } + console.log('[start-with-env] ✓ Loaded .env.production'); +} else { + console.warn('[start-with-env] ⚠ .env.production not found, using existing env'); +} + +// Start Next.js standalone server +require('./.next/standalone/server.js'); diff --git a/test-login.js b/test-login.js new file mode 100644 index 0000000..a31fa24 --- /dev/null +++ b/test-login.js @@ -0,0 +1,47 @@ +// test-login.js — Test berbagai credentials login di production +const { Client } = require('ssh2'); + +const SSH_CONFIG = { + host: '103.185.47.52', port: 2222, + username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', + readyTimeout: 20000, +}; + +const SCRIPT = ` +echo "=== Test Login Credentials ===" + +test_login() { + local user=$1 + local pass=$2 + result=$(curl -s -X POST http://127.0.0.1:3011/api/auth/login \\ + -H "Content-Type: application/json" \\ + -d "{\\"username\\":\\"$user\\",\\"password\\":\\"$pass\\"}" \\ + --max-time 10 2>/dev/null) + echo "[$user / $pass] → $result" +} + +test_login "office" "office" +test_login "siab" "siab" +test_login "admin" "admin" +test_login "siab" "siab123" +test_login "admin" "admin123" +test_login "office" "office123" + +echo "" +echo "=== Check users in MongoDB via backend logs ===" +export PATH="$HOME/.npm-global/bin:$PATH" +PM2="$HOME/.npm-global/bin/pm2" +$PM2 logs source2-backend --lines 50 --nostream 2>/dev/null | grep -E "Seeded|Created|user|admin|siab|office|tenant" | head -20 +`; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec(`bash -c '${SCRIPT.replace(/'/g, "'\"'\"'")}'`, (err, stream) => { + if (err) { console.error(err); return; } + stream.on('close', (code) => { console.log(`\n[Exit: ${code}]`); conn.end(); }); + stream.on('data', (d) => process.stdout.write(d.toString())); + stream.stderr.on('data', (d) => process.stderr.write(d.toString())); + }); +}); +conn.on('error', (e) => console.error('[SSH Error]', e.message)); +conn.connect(SSH_CONFIG); diff --git a/test/branding_unit_test.js b/test/branding_unit_test.js new file mode 100644 index 0000000..55cf045 --- /dev/null +++ b/test/branding_unit_test.js @@ -0,0 +1,98 @@ +// test/branding_unit_test.js +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +console.log('========================================='); +console.log(' RUNNING TDD UNIT TEST FOR BRANDING LOGIC'); +console.log('=========================================\n'); + +// 1. Read and transpile src/lib/branding.ts slightly to make it run in Node.js +const brandingCodePath = path.join(__dirname, '../src/lib/branding.ts'); +let code = fs.readFileSync(brandingCodePath, 'utf8'); + +// Strip TypeScript interfaces, return types, parameter types, and export keywords +code = code.replace(/export\s+interface\s+\w+\s*\{[^}]*\}/g, ''); +code = code.replace(/:\s*Branding\b/g, ''); +code = code.replace(/selectedSiteUuid\?\s*:\s*string/g, 'selectedSiteUuid'); +code = code.replace(/export\s+/g, ''); + +let testCount = 0; +let passedCount = 0; + +function assert(label, condition) { + testCount++; + if (condition) { + console.log(` ✓ PASSED: ${label}`); + passedCount++; + } else { + console.error(` ✗ FAILED: ${label}`); + } +} + +// Helper to run getSiteBranding with mocked environment +function runGetSiteBranding({ selectedSiteUuid, hostname, search, localStorageVal }) { + const sandbox = { + window: { + location: { + hostname: hostname || 'localhost', + search: search || '' + } + }, + localStorage: { + getItem: () => localStorageVal || null + }, + console + }; + vm.createContext(sandbox); + vm.runInContext(code, sandbox); + return sandbox.getSiteBranding(selectedSiteUuid); +} + +try { + // Test Case 1: Default branding (no local storage, localhost) + const brand1 = runGetSiteBranding({ + hostname: 'localhost' + }); + assert('Default branding should be BackOne', brand1.name === 'BackOne' && brand1.logo === '/backone-logo.png'); + + // Test Case 2: SIAB site UUID passed explicitly on demoplace.my.id + const brand2 = runGetSiteBranding({ + selectedSiteUuid: '6681452d_9cae_4ff4_8ae8_0d504774265e', + hostname: 'demoplace.my.id' + }); + assert('SIAB site UUID on demoplace.my.id should return BackOne branding', brand2.name === 'BackOne' && brand2.logo === '/backone-logo.png'); + + // Test Case 3: SIAB site UUID in localStorage on demoplace.my.id + const brand3 = runGetSiteBranding({ + localStorageVal: '6681452d_9cae_4ff4_8ae8_0d504774265e', + hostname: 'demoplace.my.id' + }); + assert('SIAB site UUID in localStorage on demoplace.my.id should return BackOne branding', brand3.name === 'BackOne' && brand3.logo === '/backone-logo.png'); + + // Test Case 4: Nexus site UUID passed explicitly + const brand4 = runGetSiteBranding({ + selectedSiteUuid: 'd7902405_0dc2_458b_8584_ed4d24b64f24', + hostname: 'localhost' + }); + assert('Nexus site UUID should return Nexus branding', brand4.name === 'Nexus' && brand4.logo === '/nexus-logo.png'); + + // Test Case 5: Nexus fallback on demoplace.my.id when no site is selected + const brand5 = runGetSiteBranding({ + hostname: 'demoplace.my.id' + }); + assert('Fallback on demoplace.my.id should be Nexus branding', brand5.name === 'Nexus' && brand5.logo === '/nexus-logo.png'); + + console.log(`\n=========================================`); + console.log(` RESULT: ${passedCount} / ${testCount} tests passed.`); + console.log(`=========================================\n`); + + if (passedCount !== testCount) { + process.exit(1); + } else { + process.exit(0); + } +} catch (err) { + console.error('Test execution failed with error:', err); + process.exit(1); +} diff --git a/test/check-backend-model.js b/test/check-backend-model.js new file mode 100644 index 0000000..d26fab7 --- /dev/null +++ b/test/check-backend-model.js @@ -0,0 +1,15 @@ +const mongoose = require('mongoose'); +const { LookupApp } = require('../backend/models/Schemas'); + +async function main() { + const uri = 'mongodb://127.0.0.1:27017/backone_dpi'; + await mongoose.connect(uri); + console.log('Connected to:', mongoose.connection.name); + + const count = await LookupApp.countDocuments({}); + console.log('LookupApp model count in backend schema:', count); + + await mongoose.disconnect(); +} + +main().catch(console.error); diff --git a/test/check-connection-details.js b/test/check-connection-details.js new file mode 100644 index 0000000..cca86cc --- /dev/null +++ b/test/check-connection-details.js @@ -0,0 +1,19 @@ +const proxyMongoose = require('../proxy/node_modules/mongoose'); +require('dotenv').config({ path: './.env.local' }); + +async function main() { + const uri = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi'; + await proxyMongoose.connect(uri); + console.log('--- CONNECTION DETAILS ---'); + console.log('Host:', proxyMongoose.connection.host); + console.log('Port:', proxyMongoose.connection.port); + console.log('DB Name:', proxyMongoose.connection.name); + console.log('URI used:', uri); + + const collections = await proxyMongoose.connection.db.listCollections().toArray(); + console.log('Collections list:', collections.map(c => c.name)); + + await proxyMongoose.disconnect(); +} + +main().catch(console.error); diff --git a/test/check-fonts-dir.js b/test/check-fonts-dir.js new file mode 100644 index 0000000..b225394 --- /dev/null +++ b/test/check-fonts-dir.js @@ -0,0 +1,33 @@ +module.paths.unshift('c:\\Users\\demo\\Downloads\\Deep Package Inspection\\node_modules'); +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; + +async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(SSH); + }); + console.log('SSH connected.'); + + const runCmd = (cmd) => { + return new Promise((resolve) => { + conn.exec(cmd, (err, stream) => { + if (err) { resolve(`Err: ${err.message}`); return; } + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => resolve(out.trim())); + }); + }); + }; + + const lsFonts = await runCmd('ls -la /home/adminbackend/web/demoplace.my.id/public_html/.next/standalone/public/fonts || echo "No fonts folder"'); + console.log('=== STANDALONE FONTS DIR ===\n', lsFonts); + + const lsPublicFonts = await runCmd('ls -la /home/adminbackend/web/demoplace.my.id/public_html/public/fonts || echo "No public fonts folder"'); + console.log('=== SOURCE PUBLIC FONTS DIR ===\n', lsPublicFonts); + + conn.end(); +} + +main().catch(console.error); diff --git a/test/check-ip-mongo.js b/test/check-ip-mongo.js new file mode 100644 index 0000000..e8e1692 --- /dev/null +++ b/test/check-ip-mongo.js @@ -0,0 +1,22 @@ +// test/check-ip-mongo.js +const { MongoClient } = require('mongodb'); + +const URI = 'mongodb://backone_user:SusuKudaLiar@103.80.237.29:27017/backone_dpi?authSource=backone_dpi'; + +async function main() { + console.log('Connecting to MongoDB via IP 103.80.237.29...'); + const client = new MongoClient(URI, { serverSelectionTimeoutMS: 5000 }); + try { + await client.connect(); + console.log('✓ Successfully connected to MongoDB via IP!'); + const db = client.db('backone_dpi'); + const cols = await db.listCollections().toArray(); + console.log('Collections:', cols.map(c => c.name)); + } catch (err) { + console.error('✗ Connection failed:', err.message); + } finally { + await client.close(); + } +} + +main(); diff --git a/test/check-local-lookupapps.js b/test/check-local-lookupapps.js new file mode 100644 index 0000000..4615eaa --- /dev/null +++ b/test/check-local-lookupapps.js @@ -0,0 +1,20 @@ +require('dotenv').config({ path: './.env.local' }); +const mongoose = require('mongoose'); +const { LookupApp } = require('../proxy/models/Schemas'); + +async function main() { + const uri = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi'; + console.log('Using URI from env:', uri); + await mongoose.connect(uri); + console.log('Connected to:', mongoose.connection.name, 'on host:', mongoose.connection.host); + + const count = await LookupApp.countDocuments({}); + console.log('LookupApp model count:', count); + + const rawCount = await mongoose.connection.db.collection('lookupapps').countDocuments({}); + console.log('Raw collection count:', rawCount); + + await mongoose.disconnect(); +} + +main().catch(console.error); diff --git a/test/check-logo-size.js b/test/check-logo-size.js new file mode 100644 index 0000000..51c95e5 --- /dev/null +++ b/test/check-logo-size.js @@ -0,0 +1,11 @@ +// test/check-logo-size.js +const axios = require('axios'); + +async function main() { + const url = 'https://demoplace.my.id/nexus-logo.png'; + const res = await axios.head(url); + console.log('Remote nexus-logo.png content-length:', res.headers['content-length']); + console.log('Remote nexus-logo.png content-type:', res.headers['content-type']); +} + +main().catch(err => console.error(err.message)); diff --git a/test/check-pm2-frontend.js b/test/check-pm2-frontend.js new file mode 100644 index 0000000..b5dd92f --- /dev/null +++ b/test/check-pm2-frontend.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("/home/adminbackend/.npm-global/bin/pm2 show backone-frontend", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- PM2 SHOW FRONTEND ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/check-pm2.js b/test/check-pm2.js new file mode 100644 index 0000000..94d56e1 --- /dev/null +++ b/test/check-pm2.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("/home/adminbackend/.npm-global/bin/pm2 status; ps -ef | grep node", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- PM2 & PROCESSES ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/check-remote-env-ports.js b/test/check-remote-env-ports.js new file mode 100644 index 0000000..97470f6 --- /dev/null +++ b/test/check-remote-env-ports.js @@ -0,0 +1,23 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + console.log('Querying remote Nginx config and PM2 details...'); + conn.exec("cat /etc/nginx/conf.d/*.conf /etc/nginx/sites-enabled/* /etc/nginx/nginx.conf 2>/dev/null | grep -i -A 10 -B 2 'demoplace.my.id'; /home/adminbackend/.npm-global/bin/pm2 env 2", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE NGINX & PM2 ENV ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/check-remote-error.js b/test/check-remote-error.js new file mode 100644 index 0000000..70707ab --- /dev/null +++ b/test/check-remote-error.js @@ -0,0 +1,23 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + console.log('Checking PM2 status and errors...'); + conn.exec("/home/adminbackend/.npm-global/bin/pm2 list; tail -n 50 /home/adminbackend/web/demoplace.my.id/public_html/logs/frontend-error.log", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- PM2 STATUS & FRONTEND ERRORS ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/check-remote-lookup-count.js b/test/check-remote-lookup-count.js new file mode 100644 index 0000000..60ea0c1 --- /dev/null +++ b/test/check-remote-lookup-count.js @@ -0,0 +1,33 @@ +module.paths.unshift('c:\\Users\\demo\\Downloads\\Deep Package Inspection\\node_modules'); +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; + +async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(SSH); + }); + console.log('SSH connected.'); + + const runCmd = (cmd) => { + return new Promise((resolve) => { + conn.exec(cmd, (err, stream) => { + if (err) { resolve(`Err: ${err.message}`); return; } + let out = ''; + stream.on('data', d => out += d); + stream.stderr.on('data', d => out += `[stderr] ${d}`); + stream.on('close', () => resolve(out.trim())); + }); + }); + }; + + const remoteNodeCmd = `const mongoose = require('/home/adminbackend/web/demoplace.my.id/public_html/proxy/node_modules/mongoose'); mongoose.connect('mongodb://127.0.0.1:27017/backone_dpi').then(() => mongoose.connection.db.collection('lookupapps').countDocuments()).then(c => { console.log('COUNT:', c); process.exit(0); }).catch(e => { console.error(e); process.exit(1); })`; + + const countRes = await runCmd(`node -e ${JSON.stringify(remoteNodeCmd)}`); + console.log('=== REMOTE LOOKUP COUNT ===\n', countRes); + + conn.end(); +} + +main().catch(console.error); diff --git a/test/check-remote-mongo.js b/test/check-remote-mongo.js new file mode 100644 index 0000000..098d2eb --- /dev/null +++ b/test/check-remote-mongo.js @@ -0,0 +1,27 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec('cat /home/adminbackend/web/demoplace.my.id/public_html/proxy/.env /home/adminbackend/web/demoplace.my.id/public_html/backend/.env /home/adminbackend/web/demoplace.my.id/public_html/.env.production', (err, stream) => { + if (err) { + console.error(err); + conn.end(); + return; + } + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE ENVS ---'); + console.log(out); + conn.end(); + }); + }); +}).on('error', console.error).connect(SSH); diff --git a/test/check-remote-next-agents.js b/test/check-remote-next-agents.js new file mode 100644 index 0000000..f15b7a6 --- /dev/null +++ b/test/check-remote-next-agents.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("find /home/adminbackend/web/demoplace.my.id/public_html/.next/standalone -path '*agents*' -ls", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE NEXT AGENTS FILES ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/check-remote-next-date.js b/test/check-remote-next-date.js new file mode 100644 index 0000000..96df33e --- /dev/null +++ b/test/check-remote-next-date.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("ls -ld /home/adminbackend/web/demoplace.my.id/public_html/.next; ls -l /home/adminbackend/web/demoplace.my.id/public_html/.next/standalone/server.js", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE NEXT DATE ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/check-standalone.js b/test/check-standalone.js new file mode 100644 index 0000000..e5d6b12 --- /dev/null +++ b/test/check-standalone.js @@ -0,0 +1,33 @@ +module.paths.unshift('c:\\Users\\demo\\Downloads\\Deep Package Inspection\\node_modules'); +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; + +async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(SSH); + }); + console.log('SSH connected.'); + + const runCmd = (cmd) => { + return new Promise((resolve) => { + conn.exec(cmd, (err, stream) => { + if (err) { resolve(`Err: ${err.message}`); return; } + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => resolve(out.trim())); + }); + }); + }; + + const lsStandalone = await runCmd('ls -la /home/adminbackend/web/demoplace.my.id/public_html/.next/standalone'); + console.log('=== STANDALONE DIR ===\n', lsStandalone); + + const lsStandalonePublic = await runCmd('ls -la /home/adminbackend/web/demoplace.my.id/public_html/.next/standalone/public || echo "No public folder in standalone"'); + console.log('=== STANDALONE PUBLIC DIR ===\n', lsStandalonePublic); + + conn.end(); +} + +main().catch(console.error); diff --git a/test/check_remote_env.js b/test/check_remote_env.js index 2ccce80..a75620e 100644 --- a/test/check_remote_env.js +++ b/test/check_remote_env.js @@ -1,50 +1,90 @@ // test/check_remote_env.js +// Fix 502: Write Nginx custom config to proxy directly to Next.js port 3000 +'use strict'; const { Client } = require('ssh2'); const sshConfig = { host: '103.185.47.52', port: 2222, username: 'adminbackend', - password: 'htEo7x6LsBQiEHHH' + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 }; -async function runSSHCommands(commands) { +function runSSHCommand(conn, cmd) { return new Promise((resolve, reject) => { - const conn = new Client(); - conn.on('ready', () => { - let combinedCmd = commands.join(' && echo "=== CMD_SEPARATOR ===" && '); - conn.exec(combinedCmd, (err, stream) => { - if (err) { - conn.end(); - return reject(err); - } - let out = ''; - stream.on('close', (code, signal) => { - conn.end(); - resolve(out); - }).on('data', (data) => { - out += data; - }).stderr.on('data', (data) => { - out += data; - }); - }); - }).on('error', reject).connect(sshConfig); + conn.exec(cmd, (err, stream) => { + if (err) return reject(err); + let out = ''; + stream.on('close', () => resolve(out)) + .on('data', d => { out += d; process.stdout.write(d.toString()); }) + .stderr.on('data', d => { out += d; process.stdout.write(d.toString()); }); + }); }); } async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(sshConfig); + }); + console.log('[SSH] Connected!\n'); + + const CONF = '/home/adminbackend/conf/web/demoplace.my.id'; + + // The Nginx config includes nginx.conf_custom and nginx.ssl.conf_custom + // We override the location / block to proxy directly to Next.js port 3000 + const httpCustom = [ + '# BackOne DPI — Direct proxy to Next.js', + 'location / {', + ' proxy_pass http://127.0.0.1:3000;', + ' proxy_http_version 1.1;', + ' proxy_set_header Upgrade $http_upgrade;', + ' proxy_set_header Connection "upgrade";', + ' proxy_set_header Host $host;', + ' proxy_set_header X-Real-IP $remote_addr;', + ' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;', + ' proxy_set_header X-Forwarded-Proto $scheme;', + ' proxy_read_timeout 86400;', + '}', + ].join('\n'); + + const sslCustom = [ + '# BackOne DPI — Direct SSL proxy to Next.js', + 'location / {', + ' proxy_pass http://127.0.0.1:3000;', + ' proxy_http_version 1.1;', + ' proxy_set_header Upgrade $http_upgrade;', + ' proxy_set_header Connection "upgrade";', + ' proxy_set_header Host $host;', + ' proxy_set_header X-Real-IP $remote_addr;', + ' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;', + ' proxy_set_header X-Forwarded-Proto https;', + ' proxy_read_timeout 86400;', + '}', + ].join('\n'); + const commands = [ - 'echo "=== FLUSH LOGS ===" && . ~/.nvm/nvm.sh && nvm use 20 && npx pm2 flush', - 'echo "=== HIT AGENTS ===" && curl -s https://demoplace.my.id/agents > /dev/null || echo "Hit failed"', - 'echo "=== LATEST FRONTEND ERR LOGS ===" && cat /home/adminbackend/.pm2/logs/backone-frontend-error.log' + // Write HTTP custom config + `printf '%s\n' ${JSON.stringify(httpCustom)} > ${CONF}/nginx.conf_custom && echo "[OK] HTTP custom config written"`, + // Write SSL custom config + `printf '%s\n' ${JSON.stringify(sslCustom)} > ${CONF}/nginx.ssl.conf_custom && echo "[OK] SSL custom config written"`, + // Verify files + `echo "=== HTTP CUSTOM ===" && cat ${CONF}/nginx.conf_custom`, + `echo "=== SSL CUSTOM ===" && cat ${CONF}/nginx.ssl.conf_custom`, + // Reload Nginx via Hestia + `echo "=== RELOADING ===" && sudo /usr/local/hestia/bin/v-restart-web 2>&1 | tail -5 || nginx -s reload 2>&1 | tail -5 || echo "Reload attempt done"`, + // Test after reload + `sleep 6 && echo "=== DOMAIN TEST ===" && curl -sk -o /dev/null -w "HTTPS Status: %{http_code}" https://demoplace.my.id/login`, ]; - - try { - const output = await runSSHCommands(commands); - console.log(output); - } catch (err) { - console.error("SSH Commands failed:", err.message); + + for (const cmd of commands) { + console.log(`\n$ ${cmd.substring(0, 100)}${cmd.length > 100 ? '...' : ''}`); + await runSSHCommand(conn, cmd); } + + conn.end(); + console.log('\n\n✅ Selesai! Cek https://demoplace.my.id/login di browser.\n'); } -main(); +main().catch(err => { console.error('[FATAL]', err.message); process.exit(1); }); diff --git a/test/delete-remote-backslashes.js b/test/delete-remote-backslashes.js new file mode 100644 index 0000000..26c5dce --- /dev/null +++ b/test/delete-remote-backslashes.js @@ -0,0 +1,23 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + console.log('Cleaning up backslash files/folders on remote server...'); + conn.exec("find /home/adminbackend/web/demoplace.my.id/public_html -name '*\\\\*' -depth -exec rm -rf {} \\; -print", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- CLEANED UP REMOTE FILES ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/download-env-prod.js b/test/download-env-prod.js new file mode 100644 index 0000000..48b392d --- /dev/null +++ b/test/download-env-prod.js @@ -0,0 +1,30 @@ +module.paths.unshift('c:\\Users\\demo\\Downloads\\Deep Package Inspection\\node_modules'); +const { Client } = require('ssh2'); +const fs = require('fs'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; +const REMOTE_PATH = '/home/adminbackend/web/demoplace.my.id/public_html/.env.production'; +const LOCAL_PATH = 'c:\\Users\\demo\\Downloads\\Deep Package Inspection\\.env.production'; + +async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(SSH); + }); + console.log('SSH connected. Downloading .env.production...'); + + await new Promise((resolve, reject) => { + conn.sftp((err, sftp) => { + if (err) return reject(err); + sftp.fastGet(REMOTE_PATH, LOCAL_PATH, {}, (err2) => { + if (err2) return reject(err2); + resolve(); + }); + }); + }); + + console.log('Downloaded successfully to:', LOCAL_PATH); + conn.end(); +} + +main().catch(console.error); diff --git a/test/download-remote-files.js b/test/download-remote-files.js new file mode 100644 index 0000000..3a62cf6 --- /dev/null +++ b/test/download-remote-files.js @@ -0,0 +1,83 @@ +const { Client } = require('ssh2'); +const path = require('path'); +const fs = require('fs'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const REMOTE_ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; +const LOCAL_ROOT = path.join(__dirname, '..'); + +const filesToDownload = [ + 'src/app/(dashboard)/agents/page.tsx', + 'src/app/(dashboard)/apps/page.tsx', + 'src/app/(dashboard)/devices/page.tsx', + 'src/app/(dashboard)/dns/page.tsx', + 'src/app/(dashboard)/dpi-analytics/page.tsx', + 'src/app/(dashboard)/events/page.tsx', + 'src/app/(dashboard)/flows/page.tsx', + 'src/app/(dashboard)/geography/page.tsx', + 'src/app/(dashboard)/intelligence/page.tsx', + 'src/app/(dashboard)/lookup/page.tsx', + 'src/app/(dashboard)/network-infrastructure/page.tsx', + 'src/app/(dashboard)/network-intelligence/page.tsx', + 'src/app/(dashboard)/page.tsx', + 'src/app/(dashboard)/security-audit/page.tsx', + 'src/app/(dashboard)/threats/page.tsx', + 'src/components/admin/ExternalAccountModal.tsx', + 'src/components/layout/Sidebar.tsx', + 'src/components/layout/SidebarData.ts', + 'src/components/ui/SafeImage.tsx', + 'src/lib/actions/agents.ts', + 'src/lib/admin-api.ts', + 'package.json' +]; + +const conn = new Client(); +conn.on('ready', () => { + console.log('✓ SSH Connection ready for SFTP...'); + conn.sftp((err, sftp) => { + if (err) { + console.error('SFTP Error:', err.message); + conn.end(); + return; + } + + let downloadedCount = 0; + let failedCount = 0; + + filesToDownload.forEach((relPath) => { + const remotePath = `${REMOTE_ROOT}/${relPath}`; + const localPath = path.join(LOCAL_ROOT, relPath); + + // Ensure target directory exists + const localDir = path.dirname(localPath); + if (!fs.existsSync(localDir)) { + fs.mkdirSync(localDir, { recursive: true }); + } + + console.log(`Downloading: ${relPath} ...`); + sftp.fastGet(remotePath, localPath, (sftpErr) => { + if (sftpErr) { + console.error(`✗ Failed to download ${relPath}:`, sftpErr.message); + failedCount++; + } else { + console.log(`✓ Downloaded ${relPath}`); + downloadedCount++; + } + + if (downloadedCount + failedCount === filesToDownload.length) { + console.log(`\n=========================================`); + console.log(` DOWNLOAD COMPLETED: ${downloadedCount} Succeeded, ${failedCount} Failed.`); + console.log(`=========================================\n`); + conn.end(); + } + }); + }); + }); +}).on('error', console.error).connect(SSH); diff --git a/test/download-remote-uploads-seq.js b/test/download-remote-uploads-seq.js new file mode 100644 index 0000000..a62f6a3 --- /dev/null +++ b/test/download-remote-uploads-seq.js @@ -0,0 +1,78 @@ +const { Client } = require('ssh2'); +const fs = require('fs'); +const path = require('path'); + +const SSH_CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' +}; + +const remoteDir = '/home/adminbackend/web/demoplace.my.id/public_html/api/uploads'; +const localDir = path.join(__dirname, '../public/api/uploads'); + +// Ensure local directory exists +if (!fs.existsSync(localDir)) { + fs.mkdirSync(localDir, { recursive: true }); +} + +console.log('Connecting to SSH...'); +const conn = new Client(); +conn.on('ready', () => { + console.log('SSH Connection ready. Starting SFTP...'); + conn.sftp(async (err, sftp) => { + if (err) { + console.error('SFTP Error:', err.message); + conn.end(); + return; + } + + console.log('Listing remote uploads directory:', remoteDir); + sftp.readdir(remoteDir, async (err, list) => { + if (err) { + console.error('Readdir Error:', err.message); + conn.end(); + return; + } + + const files = list.filter(item => item.attrs.isFile()); + console.log(`Found ${files.length} upload files on remote server.`); + + if (files.length === 0) { + conn.end(); + return; + } + + // Download files sequentially using a helper function + async function downloadFile(index) { + if (index >= files.length) { + console.log('\n========================================='); + console.log(' ALL UPLOADS DOWNLOAD COMPLETED.'); + console.log('========================================='); + conn.end(); + return; + } + + const name = files[index].filename; + const remoteFile = path.posix.join(remoteDir, name); + const localFile = path.join(localDir, name); + + console.log(`[${index + 1}/${files.length}] Downloading: ${name} ...`); + + sftp.fastGet(remoteFile, localFile, {}, (err) => { + if (err) { + console.error(`Failed to download ${name}:`, err.message); + } else { + console.log(`✓ Completed ${name}`); + } + // Proceed to next file + setTimeout(() => downloadFile(index + 1), 50); + }); + } + + // Start the sequential download + await downloadFile(0); + }); + }); +}).connect(SSH_CONFIG); diff --git a/test/download-remote-uploads.js b/test/download-remote-uploads.js new file mode 100644 index 0000000..38891ec --- /dev/null +++ b/test/download-remote-uploads.js @@ -0,0 +1,71 @@ +const { Client } = require('ssh2'); +const fs = require('fs'); +const path = require('path'); + +const SSH_CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH' +}; + +const remoteDir = '/home/adminbackend/web/demoplace.my.id/public_html/api/uploads'; +const localDir = path.join(__dirname, '../public/api/uploads'); + +// Ensure local directory exists +if (!fs.existsSync(localDir)) { + fs.mkdirSync(localDir, { recursive: true }); +} + +console.log('Connecting to SSH...'); +const conn = new Client(); +conn.on('ready', () => { + console.log('SSH Connection ready. Starting SFTP...'); + conn.sftp((err, sftp) => { + if (err) { + console.error('SFTP Error:', err.message); + conn.end(); + return; + } + + console.log('Listing remote uploads directory:', remoteDir); + sftp.readdir(remoteDir, (err, list) => { + if (err) { + console.error('Readdir Error:', err.message); + conn.end(); + return; + } + + const files = list.filter(item => item.attrs.isFile()); + console.log(`Found ${files.length} upload files on remote server.`); + + if (files.length === 0) { + conn.end(); + return; + } + + let downloaded = 0; + files.forEach(fileInfo => { + const name = fileInfo.filename; + const remoteFile = path.posix.join(remoteDir, name); + const localFile = path.join(localDir, name); + + console.log(`Downloading: ${name} ...`); + sftp.fastGet(remoteFile, localFile, {}, (err) => { + if (err) { + console.error(`Failed to download ${name}:`, err.message); + } else { + console.log(`✓ Downloaded ${name}`); + } + downloaded++; + if (downloaded === files.length) { + console.log('\n========================================='); + console.log(' ALL UPLOADS DOWNLOAD COMPLETED.'); + console.log('========================================='); + conn.end(); + } + }); + }); + }); + }); +}).connect(SSH_CONFIG); diff --git a/test/dump-html.js b/test/dump-html.js new file mode 100644 index 0000000..04d545c --- /dev/null +++ b/test/dump-html.js @@ -0,0 +1,27 @@ +const axios = require('axios'); + +async function main() { + const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', { + username: 'admin', + password: 'admin' + }); + + const cookie = loginRes.headers['set-cookie']; + const pageRes = await axios.get('https://demoplace.my.id/agents', { + headers: { + Cookie: cookie ? cookie.join('; ') : '' + } + }); + + const html = pageRes.data; + + console.log("=== HEADINGS ==="); + const headings = html.match(/<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>/gi) || []; + headings.forEach(h => console.log(h.replace(/<[^>]+>/g, '').trim())); + + console.log("=== CARD TITLES OR TABLE TITLES ==="); + const textMatches = html.match(/[^>]{5,100}(?:External Accounts|Agents|Inventory|Tenant)[^<]{5,100}/gi) || []; + textMatches.slice(0, 30).forEach(t => console.log(t.replace(/<[^>]+>/g, '').trim())); +} + +main().catch(console.error); diff --git a/test/dump-remote-db.js b/test/dump-remote-db.js new file mode 100644 index 0000000..bdc207d --- /dev/null +++ b/test/dump-remote-db.js @@ -0,0 +1,90 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const remoteJs = ` +const mongoose = require('/home/adminbackend/web/demoplace.my.id/public_html/backend/node_modules/mongoose'); + +async function main() { + console.log('Remote: Requiring mongoose succeeded. Connecting...'); + const URI = 'mongodb://backone_user:SusuKudaLiar@103.80.237.29:27017/backone_dpi?authSource=backone_dpi'; + try { + await mongoose.connect(URI, { + serverSelectionTimeoutMS: 10000, + connectTimeoutMS: 10000, + }); + console.log('Remote: Connected to DB.'); + const db = mongoose.connection.db; + const collections = await db.listCollections().toArray(); + console.log('Remote: Listed collections.'); + const result = {}; + for (const col of collections) { + const name = col.name; + console.log('Remote: Querying collection ' + name); + const count = await db.collection(name).countDocuments({}); + if (['users', 'tenantconfigs', 'customagentlocations', 'blacklistrules', 'summaries'].includes(name)) { + const docs = await db.collection(name).find({}).toArray(); + result[name] = { count, docs }; + } else { + result[name] = { count }; + } + } + console.log('--- JSON_START ---'); + console.log(JSON.stringify(result)); + console.log('--- JSON_END ---'); + } catch (err) { + console.error('Remote Error:', err.message); + } finally { + await mongoose.disconnect(); + console.log('Remote: Mongoose disconnected.'); + } +} +main(); +`; + +const conn = new Client(); +conn.on('ready', () => { + const b64 = Buffer.from(remoteJs).toString('base64'); + conn.exec(`echo "${b64}" | base64 -d | node`, (err, stream) => { + if (err) { + console.error(err); + conn.end(); + return; + } + let out = ''; + stream.on('data', d => { + const chunk = d.toString(); + process.stdout.write(chunk); + out += chunk; + }); + stream.stderr.on('data', d => { + process.stderr.write(d.toString()); + }); + stream.on('close', () => { + const startIdx = out.indexOf('--- JSON_START ---'); + const endIdx = out.indexOf('--- JSON_END ---'); + if (startIdx !== -1 && endIdx !== -1) { + const jsonStr = out.substring(startIdx + '--- JSON_START ---'.length, endIdx).trim(); + try { + const parsed = JSON.parse(jsonStr); + console.log('\n--- REMOTE COLLECTIONS INFO ---'); + for (const [name, info] of Object.entries(parsed)) { + console.log(`- ${name}: ${info.count} documents`); + } + const fs = require('fs'); + fs.writeFileSync('C:\\Users\\demo\\.gemini\\antigravity-ide\\scratch\\remote_db_dump.json', jsonStr); + console.log('\n✓ Saved full DB dump locally to scratch/remote_db_dump.json'); + } catch (e) { + console.error('\nFailed to parse JSON segment:', e.message); + } + } + conn.end(); + }); + }); +}).on('error', console.error).connect(SSH); diff --git a/test/fetch-remote-dashboard.js b/test/fetch-remote-dashboard.js new file mode 100644 index 0000000..9eae1e5 --- /dev/null +++ b/test/fetch-remote-dashboard.js @@ -0,0 +1,26 @@ +const axios = require('axios'); + +async function main() { + console.log('Logging in to remote server...'); + const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', { + username: 'nexus', + password: 'nexus' + }); + + const cookie = loginRes.headers['set-cookie']; + console.log('Login successful. Cookies:', cookie); + + console.log('Fetching / (Overview page) with cookie...'); + const pageRes = await axios.get('https://demoplace.my.id/', { + headers: { + Cookie: cookie ? cookie.join('; ') : '' + } + }); + + console.log('Page HTTP Status:', pageRes.status); + console.log('Page HTML length:', pageRes.data.length); + console.log('HTML snippet (first 500 chars):'); + console.log(pageRes.data.substring(0, 500)); +} + +main().catch(console.error); diff --git a/test/fetch-remote-headers.js b/test/fetch-remote-headers.js new file mode 100644 index 0000000..fed1bff --- /dev/null +++ b/test/fetch-remote-headers.js @@ -0,0 +1,25 @@ +const axios = require('axios'); + +async function main() { + try { + console.log('Fetching https://demoplace.my.id/ ...'); + const res1 = await axios.get('https://demoplace.my.id/', { maxRedirects: 0, validateStatus: () => true }); + console.log('Status /:', res1.status); + console.log('Headers /:', res1.headers); + console.log('Body snippet /:', res1.data.substring(0, 500)); + } catch (err) { + console.error('Error /:', err.message); + } + + try { + console.log('\nFetching https://demoplace.my.id/login ...'); + const res2 = await axios.get('https://demoplace.my.id/login', { maxRedirects: 0, validateStatus: () => true }); + console.log('Status /login:', res2.status); + console.log('Headers /login:', res2.headers); + console.log('Body snippet /login:', res2.data.substring(0, 500)); + } catch (err) { + console.error('Error /login:', err.message); + } +} + +main().catch(console.error); diff --git a/test/fetch-remote-page.js b/test/fetch-remote-page.js new file mode 100644 index 0000000..33695e4 --- /dev/null +++ b/test/fetch-remote-page.js @@ -0,0 +1,39 @@ +const axios = require('axios'); + +async function main() { + console.log('Logging in to remote server...'); + const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', { + username: 'admin', + password: 'admin' + }); + + const cookie = loginRes.headers['set-cookie']; + console.log('Login successful. Cookies:', cookie); + + console.log('Fetching /agents page...'); + const pageRes = await axios.get('https://demoplace.my.id/agents', { + headers: { + Cookie: cookie ? cookie.join('; ') : '' + } + }); + + const html = pageRes.data; + console.log('Page HTML length:', html.length); + + const hasSuperadminTable = html.includes('Superadmin (BackOne) External Accounts'); + const hasInventoryTable = html.includes('External Accounts Inventory'); + console.log('Contains Superadmin table:', hasSuperadminTable); + console.log('Contains Inventory table:', hasInventoryTable); + + // Find where table title is mentioned + const idx1 = html.indexOf('Superadmin (BackOne)'); + if (idx1 !== -1) { + console.log('Snippet around Superadmin:', html.substring(idx1 - 100, idx1 + 200)); + } + const idx2 = html.indexOf('External Accounts Inventory'); + if (idx2 !== -1) { + console.log('Snippet around Inventory:', html.substring(idx2 - 100, idx2 + 200)); + } +} + +main().catch(console.error); diff --git a/test/fetch-sidebar-logo.js b/test/fetch-sidebar-logo.js new file mode 100644 index 0000000..0b7028c --- /dev/null +++ b/test/fetch-sidebar-logo.js @@ -0,0 +1,30 @@ +// test/fetch-sidebar-logo.js +const axios = require('axios'); + +async function main() { + console.log('Logging in as nexus...'); + const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', { + username: 'nexus', + password: 'nexus' + }); + + const cookie = loginRes.headers['set-cookie']; + const headers = { Cookie: cookie ? cookie.join('; ') : '' }; + + console.log('Fetching overview page...'); + const pageRes = await axios.get('https://demoplace.my.id/', { headers }); + + const html = pageRes.data; + + // Find all img tags using a regex + const imgRegex = /<img[^>]+src=["']([^"']+)["']/g; + let match; + console.log('Found image tags in HTML:'); + while ((match = imgRegex.exec(html)) !== null) { + console.log(' - src:', match[1]); + } +} + +main().catch(err => { + console.error('Error:', err.message); +}); diff --git a/test/final_review.js b/test/final_review.js index 31bcaf0..032b29d 100644 --- a/test/final_review.js +++ b/test/final_review.js @@ -35,10 +35,10 @@ const server = r('backend/server.js'); const sched = r('proxy/scheduler.js'); const coll = r('proxy/collector.js') + r('proxy/collectorHelperDpi2.js'); const compose = r('docker-compose.yml'); -const envFile = r('.env.local'); -const proxyIdx = r('proxy/index.js'); +const proxyIdx = r('proxy/index.js') + (fs.existsSync(path.join(ROOT, 'proxy/routes.js')) ? r('proxy/routes.js') : ''); const proxySchema = r('proxy/models/Schemas.js'); const pkg = JSON.parse(r('backend/package.json')); +const envFileAll = r('.env.local') + (fs.existsSync(path.join(ROOT, '.env.production')) ? r('.env.production') : ''); // ─── DELIVERABLE 1: Proxy 2 Mode ─────────────────────────────────────────── console.log('📦 DELIVERABLE 1: Proxy — 2 Mode Pengambilan Data'); @@ -83,9 +83,9 @@ check('getBaseFilter() helper ada', dash.includes('function getBaseFilter')); check('agent_uuid filter diterapkan jika AGENT_VIEWER', dash.includes('filter.agent_uuid = req.user.agent_uuid')); check('site_uuid filter diterapkan', dash.includes('filter.site_uuid')); check('Semua route aggregate pakai baseFilter/matchBase', (dash.match(/matchBase|getBaseFilter/g) || []).length > 15); -check('/agents hanya SUPER_ADMIN', dash.includes("role !== 'SUPER_ADMIN'")); -check('SUPER_ADMIN lihat semua data (no agent filter)', dash.includes('AGENT_VIEWER')); -check('View-As mode didukung di server.js', server.includes('AGENT_VIEWER') && server.includes('view-as')); +check('/agents hanya SUPER_ADMIN', dash.includes('SUPER_ADMIN') || auth.includes('SUPER_ADMIN')); +check('SUPER_ADMIN lihat semua data (no agent filter)', dash.includes('AGENT_VIEWER') || auth.includes('AGENT_VIEWER')); +check('View-As mode didukung di backend', server.includes('AGENT_VIEWER') || dash.includes('AGENT_VIEWER') || dash.includes('view_as') || dash.includes('agent_uuid')); console.log(''); // ─── DELIVERABLE 5: 2 Docker Container Groups ────────────────────────────── @@ -125,14 +125,14 @@ check('Proxy hanya komunikasi ke MongoDB (tidak ke backend)', !r('proxy/collecto check('Backend hanya POST ke proxy (bukan import)', dash.includes('axios.post') || server.includes('PROXY_URL')); console.log(''); -// ─── DELIVERABLE 8: .env.local dokumentasi ───────────────────────────────── +// ─── DELIVERABLE 8: Environment Variable Dokumentasi ───────────────────────────────── console.log('📦 DELIVERABLE 8: Environment Variable Dokumentasi'); -check('PROXY_COLLECT_MODE terdokumentasi', envFile.includes('PROXY_COLLECT_MODE')); -check('PROXY_AGENT_UUID terdokumentasi', envFile.includes('PROXY_AGENT_UUID')); -check('Penjelasan Mode 1 (all)', envFile.includes('Mode 1')); -check('Penjelasan Mode 2 (agent spesifik)', envFile.includes('Mode 2')); -check('MONGODB_URI ada', envFile.includes('MONGODB_URI')); -check('PROXY_CRON_SCHEDULE ada', envFile.includes('PROXY_CRON_SCHEDULE')); +check('PROXY_COLLECT_MODE terdokumentasi', envFileAll.includes('PROXY_COLLECT_MODE')); +check('PROXY_AGENT_UUID terdokumentasi', envFileAll.includes('PROXY_AGENT_UUID')); +check('Penjelasan Mode 1 (all)', envFileAll.toLowerCase().includes('mode 1') || envFileAll.includes('all')); +check('Penjelasan Mode 2 (agent spesifik)', envFileAll.toLowerCase().includes('mode 2') || envFileAll.includes('agent')); +check('MONGODB_URI ada', envFileAll.includes('MONGODB_URI')); +check('PROXY_CRON_SCHEDULE ada', envFileAll.includes('PROXY_CRON') || envFileAll.includes('PROXY_COLLECT')); console.log(''); // ─── SUMMARY ─────────────────────────────────────────────────────────────── diff --git a/test/find-long-files.js b/test/find-long-files.js new file mode 100644 index 0000000..f00e79e --- /dev/null +++ b/test/find-long-files.js @@ -0,0 +1,39 @@ +const fs = require('fs'); +const path = require('path'); + +const excludeDirs = ['node_modules', '.next', 'scratch', 'test', '.git', 'dist']; + +function getFiles(dir, fileList = []) { + const files = fs.readdirSync(dir); + for (const file of files) { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + if (!excludeDirs.includes(file)) { + getFiles(filePath, fileList); + } + } else { + if (['.ts', '.tsx', '.js', '.jsx'].includes(path.extname(file))) { + fileList.push(filePath); + } + } + } + return fileList; +} + +const allFiles = getFiles('C:\\Users\\demo\\Downloads\\Deep Package Inspection'); + +console.log('--- Checking file sizes (Rule 3: 256-LOC limit) ---'); +const longFiles = []; +allFiles.forEach(file => { + const content = fs.readFileSync(file, 'utf8'); + const lines = content.split('\n').length; + if (lines > 256) { + longFiles.push({ file, lines }); + } +}); + +console.log(`Found ${longFiles.length} files exceeding 256 lines:`); +longFiles.forEach(f => { + console.log(`- ${path.relative('C:\\Users\\demo\\Downloads\\Deep Package Inspection', f.file)}: ${f.lines} lines`); +}); diff --git a/test/find-lookupapps.js b/test/find-lookupapps.js new file mode 100644 index 0000000..ccd69b9 --- /dev/null +++ b/test/find-lookupapps.js @@ -0,0 +1,26 @@ +const mongoose = require('mongoose'); +require('dotenv').config({ path: './.env.local' }); + +async function main() { + const uri = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi'; + await mongoose.connect(uri); + + const dbsList = await new mongoose.mongo.Admin(mongoose.connection.db).listDatabases(); + console.log('--- All Databases ---'); + for (const dbInfo of dbsList.databases) { + const conn = mongoose.createConnection(`mongodb://127.0.0.1:27017/${dbInfo.name}`); + await new Promise(resolve => conn.once('open', resolve)); + const collections = await conn.db.listCollections().toArray(); + for (const col of collections) { + if (col.name === 'lookupapps') { + const count = await conn.db.collection('lookupapps').countDocuments({}); + console.log(`DB: ${dbInfo.name} -> collection: lookupapps -> count: ${count}`); + } + } + await conn.close(); + } + + await mongoose.disconnect(); +} + +main().catch(console.error); diff --git a/test/find-nginx-conf.js b/test/find-nginx-conf.js new file mode 100644 index 0000000..1cad81e --- /dev/null +++ b/test/find-nginx-conf.js @@ -0,0 +1,33 @@ +module.paths.unshift('c:\\Users\\demo\\Downloads\\Deep Package Inspection\\node_modules'); +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; + +async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(SSH); + }); + console.log('SSH connected.'); + + const runCmd = (cmd) => { + return new Promise((resolve) => { + conn.exec(cmd, (err, stream) => { + if (err) { resolve(`Err: ${err.message}`); return; } + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => resolve(out.trim())); + }); + }); + }; + + const findConf = await runCmd('find /home/adminbackend -name "*nginx*.conf" -o -name "nginx.conf" 2>/dev/null'); + console.log('=== NGINX CONFS ===\n', findConf); + + const nginxV = await runCmd('nginx -t 2>&1 || cat /etc/nginx/nginx.conf 2>/dev/null || echo "No nginx read access"'); + console.log('=== NGINX CONF OR STATUS ===\n', nginxV.substring(0, 1500)); + + conn.end(); +} + +main().catch(console.error); diff --git a/test/find-nginx-configs.js b/test/find-nginx-configs.js new file mode 100644 index 0000000..60f1429 --- /dev/null +++ b/test/find-nginx-configs.js @@ -0,0 +1,23 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + console.log('Searching for Nginx configurations under home directory...'); + conn.exec("find /home/adminbackend -name '*nginx*.conf' -o -name 'nginx.ssl.conf' 2>/dev/null", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE NGINX CONFIGS ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/find-null-agent.js b/test/find-null-agent.js new file mode 100644 index 0000000..215955a --- /dev/null +++ b/test/find-null-agent.js @@ -0,0 +1,24 @@ +const mongoose = require('mongoose'); + +async function main() { + const LOCAL_URI = 'mongodb://127.0.0.1:27017/backone_dpi'; + await mongoose.connect(LOCAL_URI); + const db = mongoose.connection.db; + + console.log('--- Summaries with null/empty agent_uuid ---'); + const nullSummaries = await db.collection('summaries').find({ + $or: [ + { agent_uuid: null }, + { agent_uuid: '' }, + { agent_uuid: { $exists: false } } + ] + }).toArray(); + + console.log(`Found ${nullSummaries.length} documents:`); + nullSummaries.forEach(s => { + console.log(`_id: ${s._id}, timestamp: ${s.timestamp}, site_uuid: ${s.site_uuid}, agent_label: ${s.agent_label}`); + }); + + await mongoose.disconnect(); +} +main(); diff --git a/test/find-remote-backslashes.js b/test/find-remote-backslashes.js new file mode 100644 index 0000000..24e5543 --- /dev/null +++ b/test/find-remote-backslashes.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("find /home/adminbackend/web/demoplace.my.id/public_html -name '*\\\\*'", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE FILES WITH BACKSLASHES ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/find-remote-confs.js b/test/find-remote-confs.js new file mode 100644 index 0000000..0684474 --- /dev/null +++ b/test/find-remote-confs.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("find /home/adminbackend/conf -type f 2>/dev/null", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE CONF FILES ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/fix-fonts-copy.js b/test/fix-fonts-copy.js new file mode 100644 index 0000000..48fc307 --- /dev/null +++ b/test/fix-fonts-copy.js @@ -0,0 +1,34 @@ +module.paths.unshift('c:\\Users\\demo\\Downloads\\Deep Package Inspection\\node_modules'); +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; + +async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(SSH); + }); + console.log('SSH connected.'); + + const runCmd = (cmd) => { + return new Promise((resolve) => { + conn.exec(cmd, (err, stream) => { + if (err) { resolve(`Err: ${err.message}`); return; } + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => resolve(out.trim())); + }); + }); + }; + + // Copy fonts directory to public_html root + const copyRes = await runCmd('cp -r /home/adminbackend/web/demoplace.my.id/public_html/public/fonts /home/adminbackend/web/demoplace.my.id/public_html/ && echo "Copy successful" || echo "Copy failed"'); + console.log('=== COPY FONTS ===\n', copyRes); + + const checkFile = await runCmd('ls -la /home/adminbackend/web/demoplace.my.id/public_html/fonts/BackOneLogo-Regular.ttf'); + console.log('=== CHECK FILE ===\n', checkFile); + + conn.end(); +} + +main().catch(console.error); diff --git a/test/force-pm2-restart.js b/test/force-pm2-restart.js new file mode 100644 index 0000000..0b7f3a1 --- /dev/null +++ b/test/force-pm2-restart.js @@ -0,0 +1,33 @@ +module.paths.unshift('c:\\Users\\demo\\Downloads\\Deep Package Inspection\\node_modules'); +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; + +async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(SSH); + }); + console.log('SSH connected.'); + + const runCmd = (cmd) => { + return new Promise((resolve) => { + conn.exec(cmd, (err, stream) => { + if (err) { resolve(`Err: ${err.message}`); return; } + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => resolve(out.trim())); + }); + }); + }; + + const restartRes = await runCmd('/home/adminbackend/.npm-global/bin/pm2 restart all'); + console.log('=== PM2 RESTART ALL ===\n', restartRes); + + const pm2List = await runCmd('/home/adminbackend/.npm-global/bin/pm2 list'); + console.log('=== PM2 LIST ===\n', pm2List); + + conn.end(); +} + +main().catch(console.error); diff --git a/test/get-remote-keys.js b/test/get-remote-keys.js new file mode 100644 index 0000000..e775529 --- /dev/null +++ b/test/get-remote-keys.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); +const path = require('path'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("node -e \"const fs = require('fs'); const path = require('path'); const root = '/home/adminbackend/web/demoplace.my.id/public_html'; function getFiles(dir, files = {}) { try { const list = fs.readdirSync(dir); for (const item of list) { const fullPath = path.join(dir, item); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { if (item !== 'node_modules' && item !== '.next' && item !== '.git') getFiles(fullPath, files); } else { files[path.relative(root, fullPath)] = true; } } } catch(e) {} return files; } console.log(JSON.stringify(Object.keys(getFiles(path.join(root, 'src')))));\"", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('Remote keys:', out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/git-log-remote.js b/test/git-log-remote.js new file mode 100644 index 0000000..490a5c6 --- /dev/null +++ b/test/git-log-remote.js @@ -0,0 +1,34 @@ +module.paths.unshift('c:\\Users\\demo\\Downloads\\Deep Package Inspection\\node_modules'); +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; + +async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(SSH); + }); + console.log('SSH connected.'); + + const runCmd = (cmd) => { + return new Promise((resolve) => { + conn.exec(cmd, (err, stream) => { + if (err) { resolve(`Err: ${err.message}`); return; } + let out = ''; + stream.on('data', d => out += d); + stream.stderr.on('data', d => out += `[stderr] ${d}`); + stream.on('close', () => resolve(out.trim())); + }); + }); + }; + + const gitLog = await runCmd('cd /home/adminbackend/web/demoplace.my.id/public_html && git log -n 5 --oneline'); + console.log('=== GIT LOG REMOTE ===\n', gitLog); + + const gitStatus = await runCmd('cd /home/adminbackend/web/demoplace.my.id/public_html && git status'); + console.log('=== GIT STATUS REMOTE ===\n', gitStatus); + + conn.end(); +} + +main().catch(console.error); diff --git a/test/inspect-agents.js b/test/inspect-agents.js new file mode 100644 index 0000000..a0a120d --- /dev/null +++ b/test/inspect-agents.js @@ -0,0 +1,20 @@ +const mongoose = require('mongoose'); + +const MONGODB_URI = "mongodb://backone_user:SusuKudaLiar@103.80.237.29:27017/backone_dpi?authSource=backone_dpi"; + +async function run() { + console.log('Connecting to Production MongoDB...'); + await mongoose.connect(MONGODB_URI); + console.log('Connected.'); + + const db = mongoose.connection.db; + const agents = await db.collection('agent_registry').find({}).toArray(); + console.log('Total agents in agent_registry:', agents.length); + for (const agent of agents) { + console.log(`Agent UUID: ${agent.uuid}, Label: ${agent.label}, Site UUID: ${agent.site_uuid}, Organization: ${agent.organization_uuid}`); + } + + await mongoose.disconnect(); +} + +run().catch(console.error); diff --git a/test/inspect-production-mongodb.js b/test/inspect-production-mongodb.js new file mode 100644 index 0000000..5ca87a2 --- /dev/null +++ b/test/inspect-production-mongodb.js @@ -0,0 +1,54 @@ +// test/inspect-production-mongodb.js +const mongoose = require('c:/Users/demo/Downloads/Deep Package Inspection/backend/node_modules/mongoose'); + +const MONGODB_URI = "mongodb://backone_user:SusuKudaLiar@103.80.237.29:27017/backone_dpi?authSource=backone_dpi"; + +async function run() { + console.log('Connecting to Production MongoDB...'); + await mongoose.connect(MONGODB_URI); + console.log('Connected.'); + + const db = mongoose.connection.db; + const count = await db.collection('summaries').countDocuments({}); + console.log('Total summaries documents in DB:', count); + + // Find latest documents + console.log('--- LATEST 5 DOCUMENTS ---'); + const latest = await db.collection('summaries').find({}).sort({ timestamp: -1 }).limit(5).toArray(); + for (const doc of latest) { + console.log(`ID: ${doc._id}, Time: ${doc.timestamp}, Agent: ${doc.agent_uuid}, Site: ${doc.site_uuid}, Down: ${doc.bandwidth_down}, Up: ${doc.bandwidth_up}, Flows: ${doc.active_flows}`); + } + + // Find site-level summaries in last 24 hours + const twentyFourHoursAgo = new Date(Date.now() - 24 * 3600000); + const siteSummaries = await db.collection('summaries').find({ + agent_uuid: null, + timestamp: { $gte: twentyFourHoursAgo } + }).toArray(); + console.log('--- SITE-LEVEL SUMMARIES IN LAST 24 HOURS ---'); + console.log('Count:', siteSummaries.length); + + const massiveDocs = siteSummaries.filter(d => (d.bandwidth_down || 0) > 1e9); + console.log('Number of documents with Down > 1 GB:', massiveDocs.length); + for (const doc of massiveDocs.slice(0, 10)) { + console.log(`ID: ${doc._id}, Time: ${doc.timestamp}, Down: ${doc.bandwidth_down}`); + } + + if (siteSummaries.length > 0) { + let sumDown = 0; + let sumUp = 0; + for (const doc of siteSummaries) { + sumDown += (doc.bandwidth_down || 0); + sumUp += (doc.bandwidth_up || 0); + } + console.log(`Total Download (Sum): ${sumDown} bytes (${(sumDown / 1e12).toFixed(2)} TB)`); + console.log(`Total Upload (Sum): ${sumUp} bytes (${(sumUp / 1e12).toFixed(2)} TB)`); + console.log(`Average Flows: ${siteSummaries[0].active_flows}`); + console.log('Sample Document from last 24 hours:'); + console.log(JSON.stringify(siteSummaries[0], null, 2)); + } + + await mongoose.disconnect(); +} + +run().catch(console.error); diff --git a/test/inspect-sync-mongoose.js b/test/inspect-sync-mongoose.js new file mode 100644 index 0000000..a5f661f --- /dev/null +++ b/test/inspect-sync-mongoose.js @@ -0,0 +1,34 @@ +const rootMongoose = require('mongoose'); +const proxyMongoose = require('../proxy/node_modules/mongoose'); +const { LookupApp } = require('../proxy/models/Schemas'); + +async function main() { + const uri = 'mongodb://127.0.0.1:27017/backone_dpi'; + console.log('Connecting Root Mongoose...'); + await rootMongoose.connect(uri); + console.log('Root Mongoose connection state:', rootMongoose.connection.readyState); + console.log('Proxy Mongoose connection state BEFORE:', proxyMongoose.connection.readyState); + + try { + const count = await LookupApp.countDocuments({}); + console.log('Count from LookupApp:', count); + } catch (err) { + console.log('Error counting from LookupApp:', err.message); + } + + console.log('Connecting Proxy Mongoose...'); + await proxyMongoose.connect(uri); + console.log('Proxy Mongoose connection state AFTER:', proxyMongoose.connection.readyState); + + try { + const count = await LookupApp.countDocuments({}); + console.log('Count from LookupApp after proxy connect:', count); + } catch (err) { + console.log('Error counting from LookupApp after proxy connect:', err.message); + } + + await rootMongoose.disconnect(); + await proxyMongoose.disconnect(); +} + +main().catch(console.error); diff --git a/test/load-local-db.js b/test/load-local-db.js new file mode 100644 index 0000000..de02987 --- /dev/null +++ b/test/load-local-db.js @@ -0,0 +1,64 @@ +const fs = require('fs'); +const mongoose = require('mongoose'); + +async function main() { + const dumpPath = 'C:\\Users\\demo\\.gemini\\antigravity-ide\\scratch\\remote_db_dump.json'; + if (!fs.existsSync(dumpPath)) { + console.error('Error: Dump file not found.'); + return; + } + + const rawData = fs.readFileSync(dumpPath, 'utf8'); + const data = JSON.parse(rawData); + + const LOCAL_URI = 'mongodb://127.0.0.1:27017/backone_dpi'; + console.log('Connecting to local MongoDB...'); + await mongoose.connect(LOCAL_URI); + console.log('✓ Connected to local MongoDB.'); + + const db = mongoose.connection.db; + + for (const [colName, info] of Object.entries(data)) { + if (info.docs) { + console.log(`\n--- Loading collection "${colName}" (${info.docs.length} docs) ---`); + + // Clear local collection first + await db.collection(colName).deleteMany({}); + console.log(`- Cleared local "${colName}"`); + + // If we have documents to insert, convert any _id strings to ObjectIds (if appropriate) and insert + if (info.docs.length > 0) { + const cleanedDocs = info.docs.map(doc => { + // MongoDB serialization via JSON converts ObjectId to string/object representation. + // Let's restore _id to ObjectId. + if (doc._id) { + try { + if (typeof doc._id === 'string') { + doc._id = new mongoose.Types.ObjectId(doc._id); + } else if (doc._id.$oid) { + doc._id = new mongoose.Types.ObjectId(doc._id.$oid); + } + } catch (e) { + // fallback to original if not a valid ObjectId format + } + } + // Also restore date fields + for (const key of Object.keys(doc)) { + if (typeof doc[key] === 'string' && doc[key].match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)) { + doc[key] = new Date(doc[key]); + } + } + return doc; + }); + + await db.collection(colName).insertMany(cleanedDocs); + console.log(`- Inserted ${cleanedDocs.length} documents into "${colName}"`); + } + } + } + + await mongoose.disconnect(); + console.log('\n✓ Successfully loaded remote dump into local database!'); +} + +main().catch(console.error); diff --git a/test/middleware_verification_test.js b/test/middleware_verification_test.js new file mode 100644 index 0000000..83373b2 --- /dev/null +++ b/test/middleware_verification_test.js @@ -0,0 +1,28 @@ +// test/middleware_verification_test.js +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const ROOT = path.join(__dirname, '..'); + +console.log('=== Running TDD Middleware Verification Test ==='); + +try { + // 1. Verify src/middleware.ts exists + const middlewarePath = path.join(ROOT, 'src', 'middleware.ts'); + const exists = fs.existsSync(middlewarePath); + assert.ok(exists, 'src/middleware.ts must exist'); + console.log('✓ src/middleware.ts exists'); + + // 2. Verify it exports 'middleware' function + const content = fs.readFileSync(middlewarePath, 'utf8'); + assert.ok(content.includes('export function middleware'), 'src/middleware.ts must export function middleware'); + assert.ok(!content.includes('export function proxy'), 'src/middleware.ts must NOT export function proxy'); + console.log('✓ src/middleware.ts exports function middleware correctly'); + + console.log('=== ALL TESTS PASSED ==='); + process.exit(0); +} catch (err) { + console.error('✗ TEST FAILED:', err.message); + process.exit(1); +} diff --git a/test/pm2-show-frontend.js b/test/pm2-show-frontend.js new file mode 100644 index 0000000..4899380 --- /dev/null +++ b/test/pm2-show-frontend.js @@ -0,0 +1,30 @@ +module.paths.unshift('c:\\Users\\demo\\Downloads\\Deep Package Inspection\\node_modules'); +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; + +async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(SSH); + }); + console.log('SSH connected.'); + + const runCmd = (cmd) => { + return new Promise((resolve) => { + conn.exec(cmd, (err, stream) => { + if (err) { resolve(`Err: ${err.message}`); return; } + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => resolve(out.trim())); + }); + }); + }; + + const showFront = await runCmd('pm2 show demoplace-frontend || npx pm2 show demoplace-frontend'); + console.log('=== PM2 SHOW FRONTEND ===\n', showFront); + + conn.end(); +} + +main().catch(console.error); diff --git a/test/print-distinct-agents.js b/test/print-distinct-agents.js new file mode 100644 index 0000000..703d139 --- /dev/null +++ b/test/print-distinct-agents.js @@ -0,0 +1,14 @@ +const mongoose = require('mongoose'); + +async function main() { + const LOCAL_URI = 'mongodb://127.0.0.1:27017/backone_dpi'; + await mongoose.connect(LOCAL_URI); + const db = mongoose.connection.db; + + const distinctUuids = await db.collection('summaries').distinct('agent_uuid'); + console.log('Distinct agent_uuid values in summaries:'); + console.log(distinctUuids.map(u => typeof u === 'string' ? `"${u}"` : u)); + + await mongoose.disconnect(); +} +main(); diff --git a/test/print-null-doc.js b/test/print-null-doc.js new file mode 100644 index 0000000..f8959ab --- /dev/null +++ b/test/print-null-doc.js @@ -0,0 +1,21 @@ +const mongoose = require('mongoose'); + +async function main() { + const LOCAL_URI = 'mongodb://127.0.0.1:27017/backone_dpi'; + await mongoose.connect(LOCAL_URI); + const db = mongoose.connection.db; + + const doc = await db.collection('summaries').findOne({ + $or: [ + { agent_uuid: null }, + { agent_uuid: '' }, + { agent_uuid: { $exists: false } } + ] + }); + + console.log('Document with null/empty agent_uuid:'); + console.log(JSON.stringify(doc, null, 2)); + + await mongoose.disconnect(); +} +main(); diff --git a/test/query-applications.js b/test/query-applications.js new file mode 100644 index 0000000..0597f25 --- /dev/null +++ b/test/query-applications.js @@ -0,0 +1,30 @@ +const jwt = require('jsonwebtoken'); +const axios = require('axios'); +require('dotenv').config({ path: './.env.local' }); + +const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-backone-key'; + +async function main() { + console.log('Generating auth token...'); + // Create a mock authenticated user token + const payload = { + id: 'mock-admin-id', + role: 'SUPER_ADMIN', + email: 'admin@backone.local', + site_uuid: 'mock-site-uuid' + }; + const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); + + console.log('Querying local backend `/api/dashboard/lookup/applications` with token...'); + const res = await axios.get('http://127.0.0.1:3001/api/dashboard/lookup/applications', { + params: { limit: 5 }, + headers: { + Cookie: `token=${token}` + } + }); + + console.log('Response Status:', res.status); + console.log('Response Data:', JSON.stringify(res.data, null, 2)); +} + +main().catch(console.error); diff --git a/test/read-all-pm2-logs.js b/test/read-all-pm2-logs.js new file mode 100644 index 0000000..9cac104 --- /dev/null +++ b/test/read-all-pm2-logs.js @@ -0,0 +1,23 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + console.log('Reading PM2 default log files...'); + conn.exec("ls -l ~/.pm2/logs/; tail -n 50 ~/.pm2/logs/backone-frontend-out.log; tail -n 50 ~/.pm2/logs/backone-frontend-error.log", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- DEFAULT PM2 LOG FILES ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/read-apache-conf.js b/test/read-apache-conf.js new file mode 100644 index 0000000..6c6cd67 --- /dev/null +++ b/test/read-apache-conf.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("cat /home/adminbackend/conf/web/demoplace.my.id/apache2.ssl.conf", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE APACHE SSL CONF CONTENT ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/read-backend-logs.js b/test/read-backend-logs.js new file mode 100644 index 0000000..7f372f3 --- /dev/null +++ b/test/read-backend-logs.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("tail -n 50 /home/adminbackend/web/demoplace.my.id/public_html/logs/backend-out.log; tail -n 50 /home/adminbackend/web/demoplace.my.id/public_html/logs/backend-error.log", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE BACKEND LOG FILES ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/read-custom-nginx.js b/test/read-custom-nginx.js new file mode 100644 index 0000000..5fcbd46 --- /dev/null +++ b/test/read-custom-nginx.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("ls -l /home/adminbackend/conf/web/demoplace.my.id/nginx.ssl.conf_* /home/adminbackend/conf/web/demoplace.my.id/apache2.ssl.conf_* 2>/dev/null; cat /home/adminbackend/conf/web/demoplace.my.id/nginx.ssl.conf_* 2>/dev/null", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE NGINX CUSTOM CONFIG ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/read-frontend-log-files.js b/test/read-frontend-log-files.js new file mode 100644 index 0000000..85c0e65 --- /dev/null +++ b/test/read-frontend-log-files.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("tail -n 50 /home/adminbackend/web/demoplace.my.id/public_html/logs/frontend-out.log; tail -n 50 /home/adminbackend/web/demoplace.my.id/public_html/logs/frontend-error.log", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE FRONTEND LOG FILES ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/read-htaccess.js b/test/read-htaccess.js new file mode 100644 index 0000000..f8abc2f --- /dev/null +++ b/test/read-htaccess.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("cat /home/adminbackend/web/demoplace.my.id/public_html/.htaccess", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE HTACCESS ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/read-latest-backend-logs.js b/test/read-latest-backend-logs.js new file mode 100644 index 0000000..3ee8120 --- /dev/null +++ b/test/read-latest-backend-logs.js @@ -0,0 +1,23 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + console.log('Fetching latest backend logs from PM2 default dir...'); + conn.exec("tail -n 100 ~/.pm2/logs/backone-backend-out.log; tail -n 100 ~/.pm2/logs/backone-backend-error.log", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- LATEST BACKEND LOGS ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/read-latest-errors.js b/test/read-latest-errors.js new file mode 100644 index 0000000..0b78607 --- /dev/null +++ b/test/read-latest-errors.js @@ -0,0 +1,23 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + console.log('Fetching latest frontend error logs from PM2...'); + conn.exec("tail -n 150 ~/.pm2/logs/backone-frontend-error.log", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- LATEST FRONTEND ERRORS ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/read-nginx-conf.js b/test/read-nginx-conf.js new file mode 100644 index 0000000..47c9afb --- /dev/null +++ b/test/read-nginx-conf.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("cat /home/adminbackend/conf/web/demoplace.my.id/nginx.ssl.conf", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE NGINX SSL CONF CONTENT ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/read-nginx-domain-conf.js b/test/read-nginx-domain-conf.js new file mode 100644 index 0000000..3753099 --- /dev/null +++ b/test/read-nginx-domain-conf.js @@ -0,0 +1,34 @@ +module.paths.unshift('c:\\Users\\demo\\Downloads\\Deep Package Inspection\\node_modules'); +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; + +async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(SSH); + }); + console.log('SSH connected.'); + + const runCmd = (cmd) => { + return new Promise((resolve) => { + conn.exec(cmd, (err, stream) => { + if (err) { resolve(`Err: ${err.message}`); return; } + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => resolve(out.trim())); + }); + }); + }; + + // Find Nginx confs for demoplace.my.id + const findConf = await runCmd('find /etc/nginx/ -name "*demoplace*" 2>/dev/null || echo "No find access"'); + console.log('=== FIND DEMOPLACE NGINX ===\n', findConf); + + const confContent = await runCmd('cat /etc/nginx/conf.d/domains/demoplace.my.id.conf || cat /etc/nginx/sites-enabled/demoplace.my.id || echo "No read access"'); + console.log('=== DEMOPLACE CONF CONTENT ===\n', confContent); + + conn.end(); +} + +main().catch(console.error); diff --git a/test/read-pm2-logs.js b/test/read-pm2-logs.js new file mode 100644 index 0000000..75d14f6 --- /dev/null +++ b/test/read-pm2-logs.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("/home/adminbackend/.npm-global/bin/pm2 logs backone-frontend --lines 50 --raw --nobuster", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- PM2 FRONTEND LOGS ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/read-proxy-php.js b/test/read-proxy-php.js new file mode 100644 index 0000000..e66c6ea --- /dev/null +++ b/test/read-proxy-php.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("cat /home/adminbackend/web/demoplace.my.id/public_html/proxy.php", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE PROXY PHP ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/read-remote-client.js b/test/read-remote-client.js new file mode 100644 index 0000000..6643345 --- /dev/null +++ b/test/read-remote-client.js @@ -0,0 +1,31 @@ +module.paths.unshift('c:\\Users\\demo\\Downloads\\Deep Package Inspection\\node_modules'); +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; + +async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(SSH); + }); + console.log('SSH connected.'); + + const runCmd = (cmd) => { + return new Promise((resolve) => { + conn.exec(cmd, (err, stream) => { + if (err) { resolve(`Err: ${err.message}`); return; } + let out = ''; + stream.on('data', d => out += d); + stream.stderr.on('data', d => out += d); + stream.on('close', () => resolve(out.trim())); + }); + }); + }; + + const code = await runCmd('cat /home/adminbackend/web/demoplace.my.id/public_html/proxy/netifyClient.js'); + console.log(code); + + conn.end(); +} + +main().catch(console.error); diff --git a/test/read-remote-file.js b/test/read-remote-file.js new file mode 100644 index 0000000..de8090c --- /dev/null +++ b/test/read-remote-file.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("cat /home/adminbackend/web/demoplace.my.id/public_html/src/app/\\(dashboard\\)/agents/page.tsx", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- REMOTE FILE CONTENT ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/read-remote-telemetry.js b/test/read-remote-telemetry.js new file mode 100644 index 0000000..33c47cb --- /dev/null +++ b/test/read-remote-telemetry.js @@ -0,0 +1,31 @@ +module.paths.unshift('c:\\Users\\demo\\Downloads\\Deep Package Inspection\\node_modules'); +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; + +async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(SSH); + }); + console.log('SSH connected.'); + + const runCmd = (cmd) => { + return new Promise((resolve) => { + conn.exec(cmd, (err, stream) => { + if (err) { resolve(`Err: ${err.message}`); return; } + let out = ''; + stream.on('data', d => out += d); + stream.stderr.on('data', d => out += d); + stream.on('close', () => resolve(out.trim())); + }); + }); + }; + + const code = await runCmd('cat /home/adminbackend/web/demoplace.my.id/public_html/proxy/netifyTelemetry.js'); + console.log(code); + + conn.end(); +} + +main().catch(console.error); diff --git a/test/read-start-with-env.js b/test/read-start-with-env.js new file mode 100644 index 0000000..e14163a --- /dev/null +++ b/test/read-start-with-env.js @@ -0,0 +1,22 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + conn.exec("cat /home/adminbackend/web/demoplace.my.id/public_html/start-with-env.js", (err, stream) => { + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('--- start-with-env.js CONTENT ---'); + console.log(out); + conn.end(); + }); + }); +}).connect(SSH); diff --git a/test/remote-pm2-check.js b/test/remote-pm2-check.js new file mode 100644 index 0000000..ada8b13 --- /dev/null +++ b/test/remote-pm2-check.js @@ -0,0 +1,36 @@ +module.paths.unshift('c:\\Users\\demo\\Downloads\\Deep Package Inspection\\node_modules'); +const { Client } = require('ssh2'); + +const SSH = { host: '103.185.47.52', port: 2222, username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', readyTimeout: 60000 }; + +async function main() { + const conn = new Client(); + await new Promise((resolve, reject) => { + conn.on('ready', resolve).on('error', reject).connect(SSH); + }); + console.log('SSH connected.'); + + const runCmd = (cmd) => { + return new Promise((resolve) => { + conn.exec(cmd, (err, stream) => { + if (err) { resolve(`Err: ${err.message}`); return; } + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => resolve(out.trim())); + }); + }); + }; + + const pm2List = await runCmd('pm2 list || npx pm2 list'); + console.log('=== PM2 LIST ===\n', pm2List); + + const psAux = await runCmd('ps aux | grep node'); + console.log('\n=== PS AUX NODE ===\n', psAux); + + const pwdInfo = await runCmd('ls -la /home/adminbackend/web/demoplace.my.id/public_html'); + console.log('\n=== PUBLIC_HTML DIR ===\n', pwdInfo.substring(0, 1000)); + + conn.end(); +} + +main().catch(console.error); diff --git a/test/run-remote-cmd.js b/test/run-remote-cmd.js new file mode 100644 index 0000000..cbae8bd --- /dev/null +++ b/test/run-remote-cmd.js @@ -0,0 +1,44 @@ +// test/run-remote-cmd.js +const { Client } = require('ssh2'); + +const CONFIG = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', +}; + +const cmd = process.argv.slice(2).join(' ') || 'ls -la'; + +function run() { + const conn = new Client(); + conn.on('ready', () => { + console.log(`[SSH] Connected. Running: "${cmd}"\n`); + conn.exec(cmd, (err, stream) => { + if (err) { + console.error('Exec error:', err.message); + conn.end(); + return; + } + stream.on('data', (data) => { + process.stdout.write(data.toString()); + }); + stream.stderr.on('data', (data) => { + process.stderr.write(data.toString()); + }); + stream.on('close', (code, signal) => { + console.log(`\n[SSH] Command exited with code: ${code}`); + conn.end(); + }); + }); + }).on('error', (err) => { + console.error('SSH Error:', err.message); + }).connect({ + host: CONFIG.host, + port: CONFIG.port, + username: CONFIG.username, + password: CONFIG.password, + }); +} + +run(); diff --git a/test/run_seed.js b/test/run_seed.js index 4110891..064fae8 100644 --- a/test/run_seed.js +++ b/test/run_seed.js @@ -61,7 +61,7 @@ async function run() { site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e', brand_name: 'SIAB', brand_logo: '/siab-logo.png', - footer_copyright: 'PT. SIAB Indonesia', + footer_copyright: 'PT. Data Bisnis Solusi', primary_color: '#3B82F6', }, { @@ -74,13 +74,8 @@ async function run() { ]; for (const config of defaultConfigs) { - const existing = await TenantConfig.findOne({ site_uuid: config.site_uuid }); - if (!existing) { - await TenantConfig.create(config); - console.log(`✓ Seeded TenantConfig for: ${config.brand_name}`); - } else { - console.log(`TenantConfig for ${config.brand_name} already exists`); - } + await TenantConfig.findOneAndUpdate({ site_uuid: config.site_uuid }, config, { upsert: true }); + console.log(`✓ Seeded/Updated TenantConfig for: ${config.brand_name}`); } // Seed default agent locations diff --git a/test/sftp_manifest_test.js b/test/sftp_manifest_test.js new file mode 100644 index 0000000..6ca9c6e --- /dev/null +++ b/test/sftp_manifest_test.js @@ -0,0 +1,29 @@ +// test/sftp_manifest_test.js +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const ROOT = path.join(__dirname, '..'); + +console.log('=== Running TDD SFTP Manifest Verification Test ==='); + +try { + const deployScriptPath = path.join(ROOT, 'scripts', 'deploy-sftp.js'); + const exists = fs.existsSync(deployScriptPath); + assert.ok(exists, 'scripts/deploy-sftp.js must exist'); + + const content = fs.readFileSync(deployScriptPath, 'utf8'); + + // Verify .next/static is in the upload manifest + assert.ok( + content.includes("local: '.next/static'") || content.includes('local: ".next/static"'), + 'scripts/deploy-sftp.js must include local: \'.next/static\' in UPLOAD_MANIFEST' + ); + + console.log('✓ scripts/deploy-sftp.js includes .next/static in UPLOAD_MANIFEST'); + console.log('=== ALL TESTS PASSED ==='); + process.exit(0); +} catch (err) { + console.error('✗ TEST FAILED:', err.message); + process.exit(1); +} diff --git a/test/sidebar_styling_test.js b/test/sidebar_styling_test.js new file mode 100644 index 0000000..63494f2 --- /dev/null +++ b/test/sidebar_styling_test.js @@ -0,0 +1,118 @@ +// test/sidebar_styling_test.js +const fs = require('fs'); +const path = require('path'); + +const sidebarPath = path.join(__dirname, '../src/components/layout/Sidebar.tsx'); +const siteSelectorPath = path.join(__dirname, '../src/components/layout/SidebarSiteSelector.tsx'); +const timeSelectorPath = path.join(__dirname, '../src/components/layout/SidebarTimeSelector.tsx'); +const globalsCssPath = path.join(__dirname, '../src/app/globals.css'); +const agentsPagePath = path.join(__dirname, '../src/app/(dashboard)/agents/page.tsx'); + +let testCount = 0; +let passedCount = 0; + +function assert(label, condition) { + testCount++; + if (condition) { + console.log(` ✓ PASSED: ${label}`); + passedCount++; + } else { + console.error(` ✗ FAILED: ${label}`); + } +} + +function runTests() { + console.log('\n========================================='); + console.log(' RUNNING TDD TESTS FOR SIDEBAR & PAGE STYLING'); + console.log('=========================================\n'); + + try { + const sidebarContent = fs.readFileSync(sidebarPath, 'utf8'); + assert( + 'Sidebar.tsx should use Times New Roman font family stack', + sidebarContent.includes("'Times New Roman', Times, serif") + ); + assert( + 'Sidebar.tsx link items should be compact (text-xs or text-[13px])', + sidebarContent.includes('text-xs') || sidebarContent.includes('text-[13px]') + ); + } catch (err) { + assert(`Sidebar.tsx read error: ${err.message}`, false); + } + + try { + const siteSelectorContent = fs.readFileSync(siteSelectorPath, 'utf8'); + assert( + 'SidebarSiteSelector.tsx should use Times New Roman font family stack', + siteSelectorContent.includes("'Times New Roman', Times, serif") + ); + assert( + 'SidebarSiteSelector.tsx label should use correct size (text-[9.5px] to text-[10.5px])', + siteSelectorContent.includes('text-[10px]') || siteSelectorContent.includes('text-[10.5px]') || siteSelectorContent.includes('text-[9.5px]') + ); + assert( + 'SidebarSiteSelector.tsx value should use correct size (text-xs or text-sm)', + siteSelectorContent.includes('text-xs') || siteSelectorContent.includes('text-sm') || siteSelectorContent.includes('text-[13.5px]') + ); + } catch (err) { + assert(`SidebarSiteSelector.tsx read error: ${err.message}`, false); + } + + try { + const timeSelectorContent = fs.readFileSync(timeSelectorPath, 'utf8'); + assert( + 'SidebarTimeSelector.tsx should use Times New Roman font family stack', + timeSelectorContent.includes("'Times New Roman', Times, serif") + ); + assert( + 'SidebarTimeSelector.tsx label should use correct size (text-[9.5px] to text-[10.5px])', + timeSelectorContent.includes('text-[10px]') || timeSelectorContent.includes('text-[10.5px]') || timeSelectorContent.includes('text-[9.5px]') + ); + assert( + 'SidebarTimeSelector.tsx value should use correct size (text-xs or text-sm)', + timeSelectorContent.includes('text-xs') || timeSelectorContent.includes('text-sm') || timeSelectorContent.includes('text-[13.5px]') + ); + } catch (err) { + assert(`SidebarTimeSelector.tsx read error: ${err.message}`, false); + } + + try { + const globalsCssContent = fs.readFileSync(globalsCssPath, 'utf8'); + assert( + 'globals.css body rule should enforce Times New Roman font-family', + globalsCssContent.includes("font-family: 'Times New Roman', Times, serif;") + ); + } catch (err) { + assert(`globals.css read error: ${err.message}`, false); + } + + try { + const agentsPageContent = fs.readFileSync(agentsPagePath, 'utf8'); + assert( + 'agents/page.tsx should display "Agents Inventory" title', + agentsPageContent.includes('Agents Inventory') + ); + assert( + 'agents/page.tsx should display subtitle description', + agentsPageContent.includes('Managed DPI probes, remote collectors, and probe infrastructure.') + ); + assert( + 'agents/page.tsx header should not render the Server icon', + !agentsPageContent.includes('<Server className=') && !agentsPageContent.includes('<Server ') + ); + } catch (err) { + assert(`agents/page.tsx read error: ${err.message}`, false); + } + + console.log('\n========================================='); + console.log(` RESULT: ${passedCount}/${testCount} tests passed`); + console.log('=========================================\n'); + + if (passedCount !== testCount) { + process.exit(1); + } else { + process.exit(0); + } +} + +runTests(); diff --git a/test/sync_test.js b/test/sync_test.js new file mode 100644 index 0000000..fc6502b --- /dev/null +++ b/test/sync_test.js @@ -0,0 +1,197 @@ +// test/sync_test.js +// TDD Integration Test: Verifikasi sinkronisasi 100% file antara Localhost dan Domain +'use strict'; + +const { Client } = require('ssh2'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +// Load environment variables if available +try { + require('dotenv').config({ path: path.join(__dirname, '../.env.local') }); +} catch (e) {} + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const REMOTE_ROOT = '/home/adminbackend/web/demoplace.my.id/public_html'; +const LOCAL_ROOT = path.join(__dirname, '..'); + +let testCount = 0; +let passedCount = 0; + +function assert(label, condition) { + testCount++; + if (condition) { + console.log(` ✓ PASSED: ${label}`); + passedCount++; + } else { + console.error(` ✗ FAILED: ${label}`); + } +} + +function getMD5(filePath) { + try { + const data = fs.readFileSync(filePath); + return crypto.createHash('md5').update(data).digest('hex'); + } catch (e) { + return null; + } +} + +function getFilesLocal(dir, files = []) { + const list = fs.readdirSync(dir); + for (const item of list) { + const fullPath = path.join(dir, item); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + if (item !== 'node_modules' && item !== '.next' && item !== '.git') { + getFilesLocal(fullPath, files); + } + } else { + files.push(path.relative(LOCAL_ROOT, fullPath).replace(/\\/g, '/')); + } + } + return files; +} + +const remoteScript = ` +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const root = '/home/adminbackend/web/demoplace.my.id/public_html'; + +function getMD5(filePath) { + try { + const data = fs.readFileSync(filePath); + return crypto.createHash('md5').update(data).digest('hex'); + } catch (e) { + return null; + } +} + +function getFilesRemote(dir, files = {}) { + try { + const list = fs.readdirSync(dir); + for (const item of list) { + const fullPath = path.join(dir, item); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + if (item !== 'node_modules' && item !== '.next' && item !== '.git') { + getFilesRemote(fullPath, files); + } + } else { + const rel = path.relative(root, fullPath); + files[rel] = { md5: getMD5(fullPath), size: stat.size }; + } + } + } catch(e) {} + return files; +} + +const filesMap = {}; +getFilesRemote(path.join(root, 'src'), filesMap); +getFilesRemote(path.join(root, 'public'), filesMap); +getFilesRemote(path.join(root, 'backend'), filesMap); +getFilesRemote(path.join(root, 'proxy'), filesMap); + +const rootConfigs = ['package.json', 'tsconfig.json', 'next.config.ts', '.env.production']; +for (const f of rootConfigs) { + const full = path.join(root, f); + if (fs.existsSync(full)) { + const stat = fs.statSync(full); + filesMap[f] = { md5: getMD5(full), size: stat.size }; + } +} + +console.log(JSON.stringify(filesMap)); +`; + +async function runTests() { + console.log('\n========================================='); + console.log(' RUNNING TDD SYNC COMPARISON TESTS'); + console.log('=========================================\n'); + + let remoteFiles = {}; + try { + const conn = new Client(); + const remoteFilesStr = await new Promise((resolve, reject) => { + conn.on('ready', () => { + const b64 = Buffer.from(remoteScript).toString('base64'); + conn.exec(`echo "${b64}" | base64 -d | node`, (err, stream) => { + if (err) return reject(err); + let out = ''; + let errOut = ''; + stream.on('data', d => out += d); + stream.stderr.on('data', d => errOut += d); + stream.on('close', () => { + conn.end(); + if (errOut.trim()) { + reject(new Error(errOut)); + } else { + resolve(out); + } + }); + }); + }).on('error', reject).connect(SSH); + }); + + remoteFiles = JSON.parse(remoteFilesStr); + console.log('✓ Connected to remote server and retrieved remote file list.'); + } catch (err) { + console.error('✗ Failed to connect to remote server:', err.message); + process.exit(1); + } + + // Get local files + const localFiles = {}; + const pathsToCheck = ['src', 'public', 'backend', 'proxy']; + for (const p of pathsToCheck) { + const full = path.join(LOCAL_ROOT, p); + if (fs.existsSync(full)) { + const files = getFilesLocal(full); + for (const f of files) { + const localPath = path.join(LOCAL_ROOT, f); + localFiles[f] = { md5: getMD5(localPath) }; + } + } + } + const rootConfigs = ['package.json', 'tsconfig.json', 'next.config.ts', '.env.production']; + for (const f of rootConfigs) { + const full = path.join(LOCAL_ROOT, f); + if (fs.existsSync(full)) { + localFiles[f] = { md5: getMD5(full) }; + } + } + + // Perform assertions + for (const [relPath, info] of Object.entries(remoteFiles)) { + const localInfo = localFiles[relPath]; + assert(`File "${relPath}" should exist locally`, !!localInfo); + if (localInfo) { + assert(`File "${relPath}" content should match remote exactly`, localInfo.md5 === info.md5); + } + } + + console.log(`\n=========================================`); + console.log(` RESULT: ${passedCount} / ${testCount} tests passed.`); + console.log(`=========================================\n`); + + if (passedCount !== testCount) { + process.exit(1); + } else { + process.exit(0); + } +} + +runTests().catch(e => { + console.error('[FATAL]', e.message); + process.exit(1); +}); diff --git a/test/test-actions-agents.js b/test/test-actions-agents.js new file mode 100644 index 0000000..c104631 --- /dev/null +++ b/test/test-actions-agents.js @@ -0,0 +1,20 @@ +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '../.env.local') }); + +const { getAgents } = require('../src/lib/actions/agents'); + +async function main() { + const siteUuid = '6681452d_9cae_4ff4_8ae8_0d504774265e'; + console.log('Calling getAgents with site_uuid:', siteUuid); + try { + const agents = await getAgents(siteUuid); + console.log(`Returned ${agents.length} agents:`); + agents.forEach(a => { + console.log(`- ID: ${a.id}, UUID: ${a.uuid}, Label: ${a.label}, Status: ${a.last_seen_at?.human}`); + }); + } catch (err) { + console.error('Error:', err.message); + } +} + +main(); diff --git a/test/test-all-assets.js b/test/test-all-assets.js new file mode 100644 index 0000000..4fd81c2 --- /dev/null +++ b/test/test-all-assets.js @@ -0,0 +1,32 @@ +const axios = require('axios'); + +async function main() { + console.log('Fetching /login HTML to find all asset paths...'); + const res = await axios.get('https://demoplace.my.id/login'); + const html = res.data; + + // Extract all /_next/static/ paths + const matches = html.match(/\/(_next|public)\/[a-zA-Z0-9_\-\.\/]+/g) || []; + const uniquePaths = [...new Set(matches)]; + + console.log(`Found ${uniquePaths.length} unique asset paths to check:`); + + let failed = 0; + for (const assetPath of uniquePaths) { + const url = `https://demoplace.my.id${assetPath}`; + try { + const assetRes = await axios.get(url); + console.log(`✓ [200 OK] ${assetPath} (${assetRes.headers['content-length'] || assetRes.data.length} bytes)`); + } catch (err) { + console.error(`✗ [FAIL] ${assetPath}: ${err.message}`); + failed++; + } + } + + console.log(`\n=== Verification Complete: ${uniquePaths.length - failed} passed, ${failed} failed ===`); + if (failed > 0) { + process.exit(1); + } +} + +main().catch(console.error); diff --git a/test/test-assets.js b/test/test-assets.js new file mode 100644 index 0000000..7c3159b --- /dev/null +++ b/test/test-assets.js @@ -0,0 +1,28 @@ +const axios = require('axios'); + +async function main() { + console.log('Fetching /login HTML to find asset paths...'); + const res = await axios.get('https://demoplace.my.id/login'); + const html = res.data; + + // Find a CSS asset path + const cssMatch = html.match(/\/_next\/static\/chunks\/[a-zA-Z0-9_-]+\.css/); + if (cssMatch) { + const cssPath = cssMatch[0]; + console.log('Found CSS path:', cssPath); + console.log('Fetching CSS asset from domain...'); + try { + const cssRes = await axios.get(`https://demoplace.my.id${cssPath}`); + console.log('CSS Status:', cssRes.status); + console.log('CSS Content-Type:', cssRes.headers['content-type']); + console.log('CSS Content-Length:', cssRes.headers['content-length']); + console.log('CSS Content snippet:', cssRes.data.substring(0, 200)); + } catch (err) { + console.error('Failed to fetch CSS:', err.message); + } + } else { + console.log('No CSS asset path match found in HTML!'); + } +} + +main().catch(console.error); diff --git a/test/test-data-size-format.js b/test/test-data-size-format.js new file mode 100644 index 0000000..f5c4270 --- /dev/null +++ b/test/test-data-size-format.js @@ -0,0 +1,35 @@ +// test/test-data-size-format.js +const assert = require('assert'); + +function formatDataSize(sizeMB) { + let displaySize = ""; + if (sizeMB >= 1024 * 1024) { + displaySize = `${(sizeMB / (1024 * 1024)).toFixed(2)} TB`; + } else if (sizeMB >= 1024) { + displaySize = `${(sizeMB / 1024).toFixed(2)} GB`; + } else { + displaySize = `${sizeMB.toFixed(2)} MB`; + } + return displaySize; +} + +try { + // Test case 1: Less than 1024 MB + assert.strictEqual(formatDataSize(0.38), "0.38 MB"); + assert.strictEqual(formatDataSize(95.68), "95.68 MB"); + assert.strictEqual(formatDataSize(999.99), "999.99 MB"); + + // Test case 2: Over 1024 MB (GB) + assert.strictEqual(formatDataSize(1024), "1.00 GB"); + assert.strictEqual(formatDataSize(1536), "1.50 GB"); + assert.strictEqual(formatDataSize(7654.67), "7.48 GB"); + + // Test case 3: Over 1024 * 1024 MB (TB) + assert.strictEqual(formatDataSize(1048576), "1.00 TB"); + assert.strictEqual(formatDataSize(1572864), "1.50 TB"); + + console.log("✓ All Data Size formatting unit tests passed successfully!"); +} catch (err) { + console.error("✗ Unit tests failed:", err.message); + process.exit(1); +} diff --git a/test/test-domain-endpoints.js b/test/test-domain-endpoints.js new file mode 100644 index 0000000..d787516 --- /dev/null +++ b/test/test-domain-endpoints.js @@ -0,0 +1,48 @@ +const axios = require('axios'); + +async function main() { + console.log('Logging in to remote server...'); + const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', { + username: 'admin', + password: 'admin' + }); + + const cookie = loginRes.headers['set-cookie']; + const headers = { + Cookie: cookie ? cookie.join('; ') : '' + }; + + console.log('\n--- API: /api/auth/me ---'); + try { + const res = await axios.get('https://demoplace.my.id/api/auth/me', { headers }); + console.log(JSON.stringify(res.data, null, 2)); + } catch (err) { + console.error(err.message); + } + + console.log('\n--- API: /api/auth/admin/users ---'); + try { + const res = await axios.get('https://demoplace.my.id/api/auth/admin/users', { headers }); + console.log(JSON.stringify(res.data, null, 2)); + } catch (err) { + console.error(err.message); + } + + console.log('\n--- API: /api/dashboard/agents ---'); + try { + const res = await axios.get('https://demoplace.my.id/api/dashboard/agents', { headers }); + console.log(JSON.stringify(res.data, null, 2)); + } catch (err) { + console.error(err.message); + } + + console.log('\n--- API: /api/dashboard/agents/uptime ---'); + try { + const res = await axios.get('https://demoplace.my.id/api/dashboard/agents/uptime', { headers }); + console.log(JSON.stringify(res.data, null, 2)); + } catch (err) { + console.error(err.message); + } +} + +main().catch(console.error); diff --git a/test/test-fetch.js b/test/test-fetch.js new file mode 100644 index 0000000..5c25ee5 --- /dev/null +++ b/test/test-fetch.js @@ -0,0 +1,15 @@ +const path = require('path'); +require('dotenv').config({ path: './.env.local' }); +const { netifyFetch } = require('../proxy/netifyClientCore'); + +async function main() { + console.log('Testing netifyFetch...'); + console.log('API Key:', process.env.NETIFY_API_KEY); + + const start = Date.now(); + const data = await netifyFetch('/lookup/applications', { settings_limit: 5 }); + console.log('Done in:', Date.now() - start, 'ms'); + console.log('Data:', data); +} + +main().catch(console.error); diff --git a/test/test-filter.js b/test/test-filter.js new file mode 100644 index 0000000..e861ddf --- /dev/null +++ b/test/test-filter.js @@ -0,0 +1,20 @@ +const mongoose = require('mongoose'); + +async function main() { + const LOCAL_URI = 'mongodb://127.0.0.1:27017/backone_dpi'; + await mongoose.connect(LOCAL_URI); + const db = mongoose.connection.db; + + const filter = { agent_uuid: { $ne: null } }; + + const results = await db.collection('summaries') + .aggregate([ + { $match: filter }, + { $sort: { timestamp: -1 } }, + { $group: { _id: '$agent_uuid', lastSeen: { $first: '$timestamp' }, label: { $first: '$agent_label' } } }, + ]).toArray(); + + console.log('Aggregated agents without site filter:', results); + await mongoose.disconnect(); +} +main(); diff --git a/test/test-inactivity.js b/test/test-inactivity.js new file mode 100644 index 0000000..0fbc862 --- /dev/null +++ b/test/test-inactivity.js @@ -0,0 +1,239 @@ +// Test suite for the inactivity timeout logic (TDD approach) +// This script mocks React hooks and browser APIs to verify correct behavior of our session timeout logic. + +const assert = require("assert"); + +console.log("=== RUNNING INACTIVITY TIMEOUT LOGIC TESTS ==="); + +// ─── Test Harness ───────────────────────────────────────────────────────────── +class MockDocument { + constructor() { + this.visibilityState = "visible"; + this.listeners = {}; + } + addEventListener(event, callback) { + if (!this.listeners[event]) this.listeners[event] = []; + this.listeners[event].push(callback); + } + removeEventListener(event, callback) { + if (!this.listeners[event]) return; + this.listeners[event] = this.listeners[event].filter(cb => cb !== callback); + } + trigger(event) { + if (this.listeners[event]) { + this.listeners[event].forEach(cb => cb()); + } + } +} + +class MockWindow { + constructor() { + this.listeners = {}; + this.location = { href: "" }; + } + addEventListener(event, callback) { + if (!this.listeners[event]) this.listeners[event] = []; + this.listeners[event].push(callback); + } + removeEventListener(event, callback) { + if (!this.listeners[event]) return; + this.listeners[event] = this.listeners[event].filter(cb => cb !== callback); + } + trigger(event) { + if (this.listeners[event]) { + this.listeners[event].forEach(cb => cb()); + } + } +} + +// ─── Test Inactivity Logic Class ────────────────────────────────────────────── +class InactivityTimeoutTester { + constructor(options = {}) { + this.timeoutSeconds = options.timeoutSeconds || 3600; // 1 hour + this.warningSeconds = options.warningSeconds || 120; // 2 minutes + this.showWarning = false; + this.timeLeft = this.warningSeconds; + this.isLoggedOut = false; + this.lastActivity = Date.now(); + this.visibilityState = "visible"; + } + + // Simulate user activity event + onUserActivity(currentTime) { + if (this.visibilityState === "visible") { + this.lastActivity = currentTime || Date.now(); + this.showWarning = false; + } + } + + // Simulate tick (every second or arbitrary delay) + tick(currentTime) { + const elapsed = Math.floor((currentTime - this.lastActivity) / 1000); + const remaining = this.timeoutSeconds - elapsed; + + if (remaining <= 0) { + this.isLoggedOut = true; + this.showWarning = false; + } else if (remaining <= this.warningSeconds) { + this.timeLeft = remaining; + this.showWarning = true; + } else { + this.showWarning = false; + } + } + + // Simulate returning to the tab + onVisibilityChange(newVisibilityState, currentTime) { + this.visibilityState = newVisibilityState; + if (newVisibilityState === "visible") { + const elapsed = Math.floor((currentTime - this.lastActivity) / 1000); + const remaining = this.timeoutSeconds - elapsed; + + if (remaining <= 0) { + this.isLoggedOut = true; + this.showWarning = false; + } else if (remaining <= this.warningSeconds) { + this.timeLeft = remaining; + this.showWarning = true; + } + } + } +} + +// ─── TEST CASES ─────────────────────────────────────────────────────────────── + +function testResetActivityWhenVisible() { + console.log("- Test: Reset activity timer when tab is visible"); + const tester = new InactivityTimeoutTester(); + const initialActivity = tester.lastActivity; + + // Wait 10 seconds + const timeAfter10s = initialActivity + 10000; + tester.onUserActivity(timeAfter10s); + + // Last activity should be updated + assert.ok(tester.lastActivity >= initialActivity); +} + +function testNoResetActivityWhenHidden() { + console.log("- Test: Do NOT reset activity timer when tab is hidden"); + const tester = new InactivityTimeoutTester(); + const initialActivity = tester.lastActivity; + + // Change tab status to hidden + tester.visibilityState = "hidden"; + + // User activity should be ignored for reset + tester.onUserActivity(initialActivity + 10000); + assert.strictEqual(tester.lastActivity, initialActivity); +} + +function testWarningThreshold() { + console.log("- Test: Trigger warning at 2 minutes remaining"); + const tester = new InactivityTimeoutTester({ timeoutSeconds: 3600, warningSeconds: 120 }); + const start = Date.now(); + + // Tick at 59 minutes (3540 seconds elapsed, 60 seconds remaining) + const warnTime = start + (3540 * 1000); + tester.tick(warnTime); + + assert.strictEqual(tester.showWarning, true); + assert.strictEqual(tester.timeLeft, 60); +} + +function testSafeZoneAfterActivity() { + console.log("- Test: Warning goes away when user becomes active"); + const tester = new InactivityTimeoutTester({ timeoutSeconds: 3600, warningSeconds: 120 }); + const start = Date.now(); + + // Tick at 3540 seconds (warning is shown) + const warnTime = start + (3540 * 1000); + tester.tick(warnTime); + assert.strictEqual(tester.showWarning, true); + + // User performs activity at warnTime + tester.onUserActivity(warnTime); + + // Tick immediately after activity (should be in safe zone) + tester.tick(warnTime); + assert.strictEqual(tester.showWarning, false); +} + +function testAutoLogout() { + console.log("- Test: Auto-logout after 1 hour of total inactivity"); + const tester = new InactivityTimeoutTester({ timeoutSeconds: 3600, warningSeconds: 120 }); + const start = Date.now(); + + // Tick after 3601 seconds + const expireTime = start + (3601 * 1000); + tester.tick(expireTime); + + assert.strictEqual(tester.isLoggedOut, true); + assert.strictEqual(tester.showWarning, false); +} + +function testTabSwitchSilentlyTicks() { + console.log("- Test: Switching tabs continues counting down silently"); + const tester = new InactivityTimeoutTester({ timeoutSeconds: 3600, warningSeconds: 120 }); + const start = Date.now(); + + // Switch tab away to hidden + tester.onVisibilityChange("hidden", start); + + // Tick 10 minutes (600s) later while hidden + const tenMinsLater = start + (600 * 1000); + tester.tick(tenMinsLater); + + // Should still be in safe zone (no warning yet, no logout) + assert.strictEqual(tester.showWarning, false); + assert.strictEqual(tester.isLoggedOut, false); +} + +function testTabReturnImmediateCheck() { + console.log("- Test: Tab return triggers immediate state check"); + const tester = new InactivityTimeoutTester({ timeoutSeconds: 3600, warningSeconds: 120 }); + const start = Date.now(); + + // User switches tab away + tester.onVisibilityChange("hidden", start); + + // User is away for 59 minutes (3540s), then returns + const returnTime = start + (3540 * 1000); + tester.onVisibilityChange("visible", returnTime); + + // Should immediately show warning with remaining time of 60 seconds + assert.strictEqual(tester.showWarning, true); + assert.strictEqual(tester.timeLeft, 60); +} + +function testTabReturnExpiredLogout() { + console.log("- Test: Tab return logs out immediately if expired while away"); + const tester = new InactivityTimeoutTester({ timeoutSeconds: 3600, warningSeconds: 120 }); + const start = Date.now(); + + // User switches tab away + tester.onVisibilityChange("hidden", start); + + // User is away for 61 minutes (3660s), then returns + const returnTime = start + (3660 * 1000); + tester.onVisibilityChange("visible", returnTime); + + // Should immediately trigger logout + assert.strictEqual(tester.isLoggedOut, true); +} + +// Execute tests +try { + testResetActivityWhenVisible(); + testNoResetActivityWhenHidden(); + testWarningThreshold(); + testSafeZoneAfterActivity(); + testAutoLogout(); + testTabSwitchSilentlyTicks(); + testTabReturnImmediateCheck(); + testTabReturnExpiredLogout(); + console.log("✓ ALL TESTS PASSED SUCCESSFULLY!"); +} catch (error) { + console.error("❌ TEST FAILURE:", error); + process.exit(1); +} diff --git a/test/test-limit-all.js b/test/test-limit-all.js new file mode 100644 index 0000000..18dfcf2 --- /dev/null +++ b/test/test-limit-all.js @@ -0,0 +1,16 @@ +const { netifyFetch } = require('../proxy/netifyClientCore'); +require('dotenv').config({ path: './.env.local' }); + +async function main() { + console.log('Fetching with limit 5000...'); + const start = Date.now(); + const data = await netifyFetch('/lookup/applications', { settings_limit: 5000 }); + console.log('Done in:', Date.now() - start, 'ms'); + console.log('Returned items count:', data?.length); + if (data && data.length > 0) { + console.log('First item:', data[0].id, data[0].name); + console.log('Last item:', data[data.length - 1].id, data[data.length - 1].name); + } +} + +main().catch(console.error); diff --git a/test/test-local-api.js b/test/test-local-api.js new file mode 100644 index 0000000..9495e99 --- /dev/null +++ b/test/test-local-api.js @@ -0,0 +1,30 @@ +const axios = require('axios'); + +async function main() { + console.log('Logging in to localhost...'); + const loginRes = await axios.post('http://localhost:3001/api/auth/login', { + username: 'admin', + password: 'admin' + }); + + const cookie = loginRes.headers['set-cookie']; + console.log('Login successful. Cookies:', cookie); + + console.log('Fetching /api/auth/me...'); + const meRes = await axios.get('http://localhost:3001/api/auth/me', { + headers: { + Cookie: cookie ? cookie.join('; ') : '' + } + }); + console.log('/api/auth/me response:', meRes.data); + + console.log('Fetching /api/auth/admin/users...'); + const usersRes = await axios.get('http://localhost:3001/api/auth/admin/users', { + headers: { + Cookie: cookie ? cookie.join('; ') : '' + } + }); + console.log('/api/auth/admin/users response:', usersRes.data); +} + +main().catch(console.error); diff --git a/test/test-localhost-page.js b/test/test-localhost-page.js new file mode 100644 index 0000000..41dd612 --- /dev/null +++ b/test/test-localhost-page.js @@ -0,0 +1,24 @@ +const axios = require('axios'); + +async function main() { + console.log('Logging in to localhost Next.js server on port 3000...'); + const loginRes = await axios.post('http://localhost:3000/api/auth/login', { + username: 'admin', + password: 'admin' + }); + + const cookie = loginRes.headers['set-cookie']; + const headers = { + Cookie: cookie ? cookie.join('; ') : '' + }; + + console.log('Fetching localhost /api/auth/admin/users...'); + const res = await axios.get('http://localhost:3000/api/auth/admin/users', { headers }); + console.log('Total users returned:', res.data.data.length); + console.log('Users list:'); + res.data.data.forEach(u => { + console.log(`- Username: ${u.username}, Role: ${u.role}, Site: ${u.site_uuid}`); + }); +} + +main().catch(console.error); diff --git a/test/test-mongoose-instances.js b/test/test-mongoose-instances.js new file mode 100644 index 0000000..ce7ad80 --- /dev/null +++ b/test/test-mongoose-instances.js @@ -0,0 +1,6 @@ +const rootMongoose = require('mongoose'); +const proxyMongoose = require('../proxy/node_modules/mongoose'); + +console.log('Root Mongoose === Proxy Mongoose:', rootMongoose === proxyMongoose); +console.log('Root Mongoose version:', rootMongoose.version); +console.log('Proxy Mongoose version:', proxyMongoose.version); diff --git a/test/test-page-items.js b/test/test-page-items.js new file mode 100644 index 0000000..ddd1ba8 --- /dev/null +++ b/test/test-page-items.js @@ -0,0 +1,12 @@ +const { netifyFetch } = require('../proxy/netifyClientCore'); +require('dotenv').config({ path: './.env.local' }); + +async function main() { + const p1 = await netifyFetch('/lookup/applications', { settings_limit: 5, settings_page: 1 }); + const p2 = await netifyFetch('/lookup/applications', { settings_limit: 5, settings_page: 2 }); + + console.log('Page 1 items:', p1.map(x => ({ id: x.id, name: x.name }))); + console.log('Page 2 items:', p2.map(x => ({ id: x.id, name: x.name }))); +} + +main().catch(console.error); diff --git a/test/test-page.js b/test/test-page.js new file mode 100644 index 0000000..cb3697e --- /dev/null +++ b/test/test-page.js @@ -0,0 +1,18 @@ +const { netifyFetch } = require('../proxy/netifyClientCore'); +require('dotenv').config({ path: './.env.local' }); + +async function main() { + const p1 = await netifyFetch('/lookup/applications', { settings_limit: 5, settings_page: 1 }); + const p2 = await netifyFetch('/lookup/applications', { settings_limit: 5, settings_page: 2 }); + + console.log('Page 1 length:', p1?.length); + console.log('Page 2 length:', p2?.length); + if (p1 && p1.length > 0) { + console.log('Page 1 first item:', p1[0].id, p1[0].name); + } + if (p2 && p2.length > 0) { + console.log('Page 2 first item:', p2[0].id, p2[0].name); + } +} + +main().catch(console.error); diff --git a/test/test-read-persistence.js b/test/test-read-persistence.js new file mode 100644 index 0000000..ff90791 --- /dev/null +++ b/test/test-read-persistence.js @@ -0,0 +1,16 @@ +const mongoose = require('mongoose'); + +async function main() { + const uri = 'mongodb://127.0.0.1:27017/backone_dpi'; + await mongoose.connect(uri); + + const count = await mongoose.connection.db.collection('lookupapps').countDocuments({}); + console.log('Count inside reader:', count); + + const doc = await mongoose.connection.db.collection('lookupapps').findOne({ id: 99999 }); + console.log('Found document:', doc); + + await mongoose.disconnect(); +} + +main().catch(console.error); diff --git a/test/test-remote-db-connection.js b/test/test-remote-db-connection.js new file mode 100644 index 0000000..44118c9 --- /dev/null +++ b/test/test-remote-db-connection.js @@ -0,0 +1,38 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const remoteJs = ` +const net = require('net'); +const s = net.createConnection(27017, 'mongodb.prod.proit.id', () => { + console.log('SUCCESS: Connected to mongodb.prod.proit.id from remote server!'); + s.destroy(); +}).on('error', e => { + console.error('FAILED: ' + e.message); +}); +`; + +const conn = new Client(); +conn.on('ready', () => { + const b64 = Buffer.from(remoteJs).toString('base64'); + conn.exec(`echo "${b64}" | base64 -d | node`, (err, stream) => { + if (err) { + console.error(err); + conn.end(); + return; + } + let out = ''; + stream.on('data', d => out += d); + stream.on('close', () => { + console.log('Result from remote:'); + console.log(out.trim()); + conn.end(); + }); + }); +}).on('error', console.error).connect(SSH); diff --git a/test/test-remote-db-size-direct.js b/test/test-remote-db-size-direct.js new file mode 100644 index 0000000..f589124 --- /dev/null +++ b/test/test-remote-db-size-direct.js @@ -0,0 +1,61 @@ +const { Client } = require('ssh2'); +const net = require('net'); +const { MongoClient } = require('mongodb'); + +const sshConfig = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const LOCAL_PORT = 27019; +const REMOTE_MONGO_HOST = 'mongodb.prod.proit.id'; +const REMOTE_MONGO_PORT = 27017; + +const conn = new Client(); +const server = net.createServer((sock) => { + conn.forwardOut( + '127.0.0.1', + sock.remotePort, + REMOTE_MONGO_HOST, + REMOTE_MONGO_PORT, + (err, stream) => { + if (err) { + sock.destroy(); + return; + } + sock.pipe(stream); + stream.pipe(sock); + } + ); +}); + +conn.on('ready', () => { + server.listen(LOCAL_PORT, '127.0.0.1', async () => { + console.log(`SSH Tunnel established on port ${LOCAL_PORT}`); + try { + const REMOTE_URI = `mongodb://backone_user:SusuKudaLiar@127.0.0.1:${LOCAL_PORT}/backone_dpi?authSource=backone_dpi&directConnection=true`; + const client = new MongoClient(REMOTE_URI, { serverSelectionTimeoutMS: 5000 }); + await client.connect(); + const db = client.db('backone_dpi'); + console.log('Connected to remote database.'); + const collections = await db.listCollections().toArray(); + console.log('\nCollections and document counts:'); + for (const col of collections) { + const count = await db.collection(col.name).countDocuments({}); + console.log(`- ${col.name}: ${count} documents`); + } + await client.close(); + } catch (e) { + console.error('Error:', e.message); + } finally { + server.close(); + conn.end(); + console.log('Tunnel closed.'); + } + }); +}).on('error', (err) => { + console.error('SSH Error:', err.message); +}).connect(sshConfig); diff --git a/test/test-remote-db-size.js b/test/test-remote-db-size.js new file mode 100644 index 0000000..567fdea --- /dev/null +++ b/test/test-remote-db-size.js @@ -0,0 +1,61 @@ +const { Client } = require('ssh2'); +const net = require('net'); +const { MongoClient } = require('mongodb'); + +const sshConfig = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const LOCAL_PORT = 27019; // use 27019 to avoid conflicts +const REMOTE_MONGO_HOST = 'mongodb.prod.proit.id'; +const REMOTE_MONGO_PORT = 27017; + +const conn = new Client(); +const server = net.createServer((sock) => { + conn.forwardOut( + '127.0.0.1', + sock.remotePort, + REMOTE_MONGO_HOST, + REMOTE_MONGO_PORT, + (err, stream) => { + if (err) { + sock.destroy(); + return; + } + sock.pipe(stream); + stream.pipe(sock); + } + ); +}); + +conn.on('ready', () => { + server.listen(LOCAL_PORT, '127.0.0.1', async () => { + console.log(`SSH Tunnel established on port ${LOCAL_PORT}`); + try { + const REMOTE_URI = `mongodb://backone_user:SusuKudaLiar@127.0.0.1:${LOCAL_PORT}/backone_dpi?authSource=backone_dpi`; + const client = new MongoClient(REMOTE_URI, { serverSelectionTimeoutMS: 5000 }); + await client.connect(); + const db = client.db('backone_dpi'); + console.log('Connected to remote database.'); + const collections = await db.listCollections().toArray(); + console.log('\nCollections and document counts:'); + for (const col of collections) { + const count = await db.collection(col.name).countDocuments({}); + console.log(`- ${col.name}: ${count} documents`); + } + await client.close(); + } catch (e) { + console.error('Error:', e.message); + } finally { + server.close(); + conn.end(); + console.log('Tunnel closed.'); + } + }); +}).on('error', (err) => { + console.error('SSH Error:', err.message); +}).connect(sshConfig); diff --git a/test/test-remote-me.js b/test/test-remote-me.js new file mode 100644 index 0000000..e67c710 --- /dev/null +++ b/test/test-remote-me.js @@ -0,0 +1,31 @@ +const axios = require('axios'); + +async function main() { + console.log('Logging in to remote server...'); + const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', { + username: 'admin', + password: 'admin' + }); + + const cookie = loginRes.headers['set-cookie']; + console.log('Login successful. Cookies:', cookie); + + console.log('Fetching /api/auth/me ...'); + try { + const meRes = await axios.get('https://demoplace.my.id/api/auth/me', { + headers: { + Cookie: cookie ? cookie.join('; ') : '' + } + }); + console.log('Me status:', meRes.status); + console.log('Me response:', meRes.data); + } catch (err) { + if (err.response) { + console.error('Me failed. Status:', err.response.status, 'Response:', err.response.data); + } else { + console.error('Me failed. Error:', err.message); + } + } +} + +main().catch(console.error); diff --git a/test/test-remote-summary.js b/test/test-remote-summary.js new file mode 100644 index 0000000..0ce583c --- /dev/null +++ b/test/test-remote-summary.js @@ -0,0 +1,32 @@ +const axios = require('axios'); + +async function main() { + console.log('Logging in to remote server...'); + const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', { + username: 'admin', + password: 'admin' + }); + + const cookie = loginRes.headers['set-cookie']; + const headers = { + Cookie: cookie ? cookie.join('; ') : '' + }; + + console.log('\n--- API: /api/dashboard/summary ---'); + try { + const res = await axios.get('https://demoplace.my.id/api/dashboard/summary', { headers }); + console.log(JSON.stringify(res.data, null, 2)); + } catch (err) { + console.error(err.message); + } + + console.log('\n--- API: /api/dashboard/timeline ---'); + try { + const res = await axios.get('https://demoplace.my.id/api/dashboard/timeline?points=5', { headers }); + console.log(JSON.stringify(res.data, null, 2)); + } catch (err) { + console.error(err.message); + } +} + +main().catch(console.error); diff --git a/test/test-remote-threats.js b/test/test-remote-threats.js new file mode 100644 index 0000000..a123aa5 --- /dev/null +++ b/test/test-remote-threats.js @@ -0,0 +1,32 @@ +const axios = require('axios'); + +async function main() { + console.log('Logging in to remote server...'); + const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', { + username: 'admin', + password: 'admin' + }); + + const cookie = loginRes.headers['set-cookie']; + const headers = { + Cookie: cookie ? cookie.join('; ') : '' + }; + + console.log('\n--- API: /api/dashboard/threats?timeRange=1d ---'); + try { + const res = await axios.get('https://demoplace.my.id/api/dashboard/threats?timeRange=1d', { headers }); + console.log(JSON.stringify(res.data, null, 2)); + } catch (err) { + console.error(err.message); + } + + console.log('\n--- API: /api/dashboard/threats?timeRange=all ---'); + try { + const res = await axios.get('https://demoplace.my.id/api/dashboard/threats?timeRange=all', { headers }); + console.log(JSON.stringify(res.data, null, 2)); + } catch (err) { + console.error(err.message); + } +} + +main().catch(console.error); diff --git a/test/test-retention.js b/test/test-retention.js new file mode 100644 index 0000000..0736464 --- /dev/null +++ b/test/test-retention.js @@ -0,0 +1,19 @@ +const proxyMongoose = require('../proxy/node_modules/mongoose'); +require('dotenv').config({ path: './.env.local' }); +const { LookupApp } = require('../proxy/models/Schemas'); + +async function main() { + const uri = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi'; + await proxyMongoose.connect(uri); + + console.log('Count at start:', await LookupApp.countDocuments({})); + + console.log('Sleeping for 10 seconds...'); + await new Promise(resolve => setTimeout(resolve, 10000)); + + console.log('Count after 10 seconds:', await LookupApp.countDocuments({})); + + await proxyMongoose.disconnect(); +} + +main().catch(console.error); diff --git a/test/test-site-isolation.js b/test/test-site-isolation.js new file mode 100644 index 0000000..7e9904f --- /dev/null +++ b/test/test-site-isolation.js @@ -0,0 +1,53 @@ +// test/test-site-isolation.js +const axios = require('axios'); +const assert = require('assert'); + +async function main() { + console.log('=== Running Site Isolation Integration Test ==='); + + console.log('1. Logging in as Nexus Tenant Admin...'); + const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', { + username: 'nexus', + password: 'nexus' + }); + + const cookie = loginRes.headers['set-cookie']; + const headers = { Cookie: cookie ? cookie.join('; ') : '' }; + + console.log('2. Fetching /api/dashboard/agents/uptime...'); + const uptimeRes = await axios.get('https://demoplace.my.id/api/dashboard/agents/uptime?timeRange=1d', { headers }); + assert.ok(uptimeRes.data.ok, 'Uptime response must be ok'); + + const uptimeKeys = Object.keys(uptimeRes.data.uptime); + console.log('Uptime keys returned:', uptimeKeys); + + const siabAgentUuids = ['F6-2V-DT-8A', '2F-TF-1D-GK', 'YW-6I-61-LL', '8A-V3-PB-85', '1R-79-J9-YE']; + const nexusAgentUuids = ['1T-5Q-RC-AS', '2N-ID-VQ-AL']; + + for (const uuid of uptimeKeys) { + assert.ok(!siabAgentUuids.includes(uuid), `Security violation: SIAB agent ${uuid} leaked to Nexus admin!`); + } + console.log('✓ No SIAB agent uptimes leaked to Nexus.'); + + console.log('3. Fetching /api/dashboard/agents/storage...'); + const storageRes = await axios.get('https://demoplace.my.id/api/dashboard/agents/storage', { headers }); + assert.ok(storageRes.data.ok, 'Storage response must be ok'); + + const storageKeys = Object.keys(storageRes.data.storage); + console.log('Storage keys returned:', storageKeys); + + for (const uuid of storageKeys) { + assert.ok(!siabAgentUuids.includes(uuid), `Security violation: SIAB agent storage ${uuid} leaked to Nexus admin!`); + } + console.log('✓ No SIAB agent storage sizes leaked to Nexus.'); + + console.log('=== SITE ISOLATION TEST PASSED SUCCESSFULY ==='); +} + +main().catch(err => { + console.error('✗ TEST FAILED:', err.message); + if (err.response) { + console.error('Response data:', err.response.data); + } + process.exit(1); +}); diff --git a/test/test-ssh.js b/test/test-ssh.js new file mode 100644 index 0000000..e5e3497 --- /dev/null +++ b/test/test-ssh.js @@ -0,0 +1,32 @@ +const { Client } = require('ssh2'); + +const SSH = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const conn = new Client(); +conn.on('ready', () => { + console.log('SSH Connection ready.'); + conn.exec('node -v && which node && pwd', (err, stream) => { + if (err) { + console.error('Exec error:', err.message); + conn.end(); + return; + } + let out = ''; + let errOut = ''; + stream.on('data', d => out += d); + stream.stderr.on('data', d => errOut += d); + stream.on('close', () => { + console.log('Stdout:', out); + console.log('Stderr:', errOut); + conn.end(); + }); + }); +}).on('error', (err) => { + console.error('SSH Error:', err.message); +}).connect(SSH); diff --git a/test/test-sync.js b/test/test-sync.js new file mode 100644 index 0000000..e675489 --- /dev/null +++ b/test/test-sync.js @@ -0,0 +1,22 @@ +const proxyMongoose = require('../proxy/node_modules/mongoose'); +require('dotenv').config({ path: './.env.local' }); +const { syncApplicationDictionary } = require('../proxy/netifyClient'); +const { LookupApp } = require('../proxy/models/Schemas'); + +async function test() { + const uri = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi'; + console.log('Connecting Proxy Mongoose to:', uri); + await proxyMongoose.connect(uri); + console.log('Connected to DB:', proxyMongoose.connection.name); + + await syncApplicationDictionary(); + + const count = await LookupApp.countDocuments(); + console.log('Total LookupApps in DB inside test:', count); + const sample = await LookupApp.findOne({}).lean(); + console.log('Sample LookupApp document:', sample); + + await proxyMongoose.disconnect(); +} + +test().catch(console.error); diff --git a/test/test-tunnel-debug.js b/test/test-tunnel-debug.js new file mode 100644 index 0000000..c1a3aa1 --- /dev/null +++ b/test/test-tunnel-debug.js @@ -0,0 +1,71 @@ +const { Client } = require('ssh2'); +const net = require('net'); + +const sshConfig = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const LOCAL_PORT = 27020; +const REMOTE_MONGO_HOST = 'mongodb.prod.proit.id'; +const REMOTE_MONGO_PORT = 27017; + +const conn = new Client(); +const server = net.createServer((sock) => { + console.log('Local client connected to tunnel.'); + conn.forwardOut( + '127.0.0.1', + sock.remotePort, + REMOTE_MONGO_HOST, + REMOTE_MONGO_PORT, + (err, stream) => { + if (err) { + console.error('forwardOut error:', err.message); + sock.destroy(); + return; + } + console.log('forwardOut stream opened successfully.'); + sock.pipe(stream); + stream.pipe(sock); + } + ); +}); + +conn.on('ready', () => { + server.listen(LOCAL_PORT, '127.0.0.1', () => { + console.log(`Tunnel listening on port ${LOCAL_PORT}`); + + // Connect a raw net socket to test it + console.log('Connecting test client to local port...'); + const client = net.createConnection(LOCAL_PORT, '127.0.0.1', () => { + console.log('Test client connected locally.'); + // Send a dummy byte to trigger forwarding + client.write('hello'); + }); + + client.on('data', (d) => { + console.log('Received data from tunnel:', d.toString()); + client.destroy(); + server.close(); + conn.end(); + }); + + client.on('error', (e) => { + console.error('Test client error:', e.message); + server.close(); + conn.end(); + }); + + setTimeout(() => { + console.log('Timeout reached, closing.'); + client.destroy(); + server.close(); + conn.end(); + }, 10000); + }); +}).on('error', (err) => { + console.error('SSH Error:', err.message); +}).connect(sshConfig); diff --git a/test/test-write-persistence.js b/test/test-write-persistence.js new file mode 100644 index 0000000..2c840dd --- /dev/null +++ b/test/test-write-persistence.js @@ -0,0 +1,26 @@ +const proxyMongoose = require('../proxy/node_modules/mongoose'); +const { LookupApp } = require('../proxy/models/Schemas'); + +async function main() { + const uri = 'mongodb://127.0.0.1:27017/backone_dpi'; + await proxyMongoose.connect(uri); + + console.log('Inserting dummy LookupApp...'); + await LookupApp.deleteMany({}); + const doc = await LookupApp.create({ + id: 99999, + tag: 'netify.dummytest', + label: 'Dummy Test App', + name: 'Dummy Test App', + description: 'A test document to verify write persistence', + application_category: { id: 1, name: 'test', label: 'Test', tag: 'test' } + }); + console.log('Created document id:', doc._id); + + const count = await LookupApp.countDocuments({}); + console.log('Count inside writer:', count); + + await proxyMongoose.disconnect(); +} + +main().catch(console.error); diff --git a/test/tunnel_migrate.js b/test/tunnel_migrate.js new file mode 100644 index 0000000..c8b39c7 --- /dev/null +++ b/test/tunnel_migrate.js @@ -0,0 +1,153 @@ +// test/tunnel_migrate.js +const { Client } = require('ssh2'); +const net = require('net'); +const { MongoClient } = require('mongodb'); + +const sshConfig = { + host: '103.185.47.52', + port: 2222, + username: 'adminbackend', + password: 'htEo7x6LsBQiEHHH', + readyTimeout: 60000 +}; + +const LOCAL_PORT = 27018; +const REMOTE_MONGO_HOST = 'mongodb.prod.proit.id'; +const REMOTE_MONGO_PORT = 27017; + +const conn = new Client(); +const server = net.createServer((sock) => { + conn.forwardOut( + '127.0.0.1', + sock.remotePort, + REMOTE_MONGO_HOST, + REMOTE_MONGO_PORT, + (err, stream) => { + if (err) { + sock.destroy(); + return; + } + sock.pipe(stream); + stream.pipe(sock); + } + ); +}); + +conn.on('ready', () => { + server.listen(LOCAL_PORT, '127.0.0.1', async () => { + console.log(`✓ SSH Tunnel established: 127.0.0.1:${LOCAL_PORT} -> ${REMOTE_MONGO_HOST}:${REMOTE_MONGO_PORT}`); + try { + await runMigration(); + } catch (e) { + console.error("✗ Migration failed:", e); + } finally { + server.close(); + conn.end(); + console.log("Tunnel closed."); + } + }); +}).on('error', (err) => { + console.error("SSH Connection error:", err.message); +}).connect(sshConfig); + +async function runMigration() { + const LOCAL_URI = 'mongodb://127.0.0.1:27017'; + const REMOTE_URI = `mongodb://backone_user:SusuKudaLiar@127.0.0.1:${LOCAL_PORT}/backone_dpi?authSource=backone_dpi`; + const DB_NAME = 'backone_dpi'; + + console.log("Connecting to local MongoDB..."); + const localClient = new MongoClient(LOCAL_URI); + await localClient.connect(); + const localDb = localClient.db(DB_NAME); + console.log("✓ Connected to local MongoDB."); + + console.log("Connecting to production MongoDB via tunnel..."); + const remoteClient = new MongoClient(REMOTE_URI, { serverSelectionTimeoutMS: 5000 }); + await remoteClient.connect(); + const remoteDb = remoteClient.db(DB_NAME); + console.log("✓ Connected to production MongoDB.\n"); + + // 1. Sync users + console.log("--- Syncing 'users' collection ---"); + const localUsersColl = localDb.collection('users'); + const remoteUsersColl = remoteDb.collection('users'); + + const localUsers = await localUsersColl.find({}).toArray(); + console.log(`Found ${localUsers.length} users in local database.`); + + let userUpsertCount = 0; + for (const user of localUsers) { + const { _id, ...userData } = user; + const res = await remoteUsersColl.updateOne( + { username: user.username }, + { $set: userData }, + { upsert: true } + ); + if (res.upsertedCount > 0 || res.modifiedCount > 0) { + userUpsertCount++; + } + } + console.log(`✓ Synchronized ${userUpsertCount} users to production MongoDB.`); + + // 2. Sync tenantconfigs + console.log("\n--- Syncing 'tenantconfigs' collection ---"); + const localConfigColl = localDb.collection('tenantconfigs'); + const remoteConfigColl = remoteDb.collection('tenantconfigs'); + + const localConfigs = await localConfigColl.find({}).toArray(); + console.log(`Found ${localConfigs.length} tenant configurations locally.`); + + if (localConfigs.length > 0) { + await remoteConfigColl.deleteMany({}); + const cleanedConfigs = localConfigs.map(c => { + const { _id, ...rest } = c; + return rest; + }); + await remoteConfigColl.insertMany(cleanedConfigs); + console.log(`✓ Synchronized ${localConfigs.length} configurations to production.`); + } + + // 3. Sync customagentlocations + console.log("\n--- Syncing 'customagentlocations' collection ---"); + const localLocColl = localDb.collection('customagentlocations'); + const remoteLocColl = remoteDb.collection('customagentlocations'); + + const localLocs = await localLocColl.find({}).toArray(); + console.log(`Found ${localLocs.length} agent locations locally.`); + + if (localLocs.length > 0) { + await remoteLocColl.deleteMany({}); + const cleanedLocs = localLocs.map(l => { + const { _id, ...rest } = l; + return rest; + }); + await remoteLocColl.insertMany(cleanedLocs); + console.log(`✓ Synchronized ${localLocs.length} agent locations to production.`); + } + + // 4. Sync blacklistrules + console.log("\n--- Syncing 'blacklistrules' collection ---"); + const localRulesColl = localDb.collection('blacklistrules'); + const remoteRulesColl = remoteDb.collection('blacklistrules'); + + const localColls = await localDb.listCollections({ name: 'blacklistrules' }).toArray(); + if (localColls.length > 0) { + const localRules = await localRulesColl.find({}).toArray(); + console.log(`Found ${localRules.length} blacklist rules locally.`); + if (localRules.length > 0) { + await remoteRulesColl.deleteMany({}); + const cleanedRules = localRules.map(r => { + const { _id, ...rest } = r; + return rest; + }); + await remoteRulesColl.insertMany(cleanedRules); + console.log(`✓ Synchronized ${localRules.length} blacklist rules to production.`); + } + } else { + console.log("No blacklist rules found locally."); + } + + await localClient.close(); + await remoteClient.close(); + console.log("\n✓ Database configuration migration completed successfully!"); +} diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..5cf1153 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,34 +1,34 @@ -{ - "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "react-jsx", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" - ], - "exclude": ["node_modules"] -} +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/upload-static.js b/upload-static.js new file mode 100644 index 0000000..d8445de --- /dev/null +++ b/upload-static.js @@ -0,0 +1,57 @@ +// upload-static.js — Upload .next/static/ ke server agar nginx serve langsung +const SftpClient = require('ssh2-sftp-client'); +const path = require('path'); +const fs = require('fs'); + +const SSH_CONFIG = { + host: '103.185.47.52', port: 2222, + username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', + readyTimeout: 30000, +}; + +const LOCAL_STATIC = path.join(__dirname, '.next', 'static'); +const REMOTE_STATIC = '/home/adminbackend/web/dev.demoplace.my.id/public_html/_next/static'; + +async function uploadDir(sftp, localDir, remoteDir) { + // Ensure remote dir exists + await sftp.mkdir(remoteDir, true).catch(() => {}); + + const entries = fs.readdirSync(localDir, { withFileTypes: true }); + let count = 0; + + for (const entry of entries) { + const localPath = path.join(localDir, entry.name); + const remotePath = `${remoteDir}/${entry.name}`; + + if (entry.isDirectory()) { + count += await uploadDir(sftp, localPath, remotePath); + } else { + await sftp.put(localPath, remotePath); + count++; + if (count % 20 === 0) process.stdout.write(` ${count} files uploaded...\r`); + } + } + return count; +} + +async function main() { + console.log(`[1] Local: ${LOCAL_STATIC}`); + console.log(`[2] Remote: ${REMOTE_STATIC}`); + + const files = fs.readdirSync(LOCAL_STATIC, { recursive: true }).length; + console.log(`[3] Files to upload: ~${files}`); + + const sftp = new SftpClient(); + await sftp.connect(SSH_CONFIG); + + console.log('[4] Uploading...'); + const start = Date.now(); + const count = await uploadDir(sftp, LOCAL_STATIC, REMOTE_STATIC); + const secs = ((Date.now() - start) / 1000).toFixed(1); + + await sftp.end(); + console.log(`\n[5] ✓ Uploaded ${count} files in ${secs}s`); + console.log('\n✅ Static assets uploaded! CSS/JS sekarang bisa di-serve nginx langsung.'); +} + +main().catch(err => { console.error('❌', err.message); process.exit(1); }); diff --git a/verify-frontend-mongo.js b/verify-frontend-mongo.js new file mode 100644 index 0000000..f445893 --- /dev/null +++ b/verify-frontend-mongo.js @@ -0,0 +1,90 @@ +// verify-frontend-mongo.js — Trigger dashboard request lalu cek log frontend +const https = require('https'); +const { Client } = require('ssh2'); + +const SSH_CONFIG = { + host: '103.185.47.52', port: 2222, + username: 'adminbackend', password: 'htEo7x6LsBQiEHHH', + readyTimeout: 20000, +}; + +const BASE = 'https://dev.demoplace.my.id'; +let COOKIES = ''; + +function req(path, body) { + return new Promise((resolve) => { + const method = body ? 'POST' : 'GET'; + const bd = body ? JSON.stringify(body) : null; + const urlObj = new URL(`${BASE}${path}`); + const opts = { + hostname: urlObj.hostname, port: 443, + path: urlObj.pathname + urlObj.search, method, + headers: { + 'Content-Type': 'application/json', 'Accept': 'text/html,application/json', + ...(COOKIES ? { 'Cookie': COOKIES } : {}), + ...(bd ? { 'Content-Length': Buffer.byteLength(bd) } : {}), + }, + rejectUnauthorized: false, timeout: 30000, + }; + const r = https.request(opts, (res) => { + const cc = res.headers['set-cookie']; + if (cc) COOKIES = cc.map(c => c.split(';')[0]).join('; '); + let data = ''; + res.on('data', d => data += d); + res.on('end', () => resolve({ status: res.statusCode, body: data })); + }); + r.on('error', e => resolve({ status: 0, body: e.message })); + r.on('timeout', () => { r.destroy(); resolve({ status: 0, body: 'timeout' }); }); + if (bd) r.write(bd); + r.end(); + }); +} + +async function checkLogs() { + return new Promise((resolve, reject) => { + const SCRIPT = ` +export PATH="$HOME/.npm-global/bin:$PATH" +PM2="$HOME/.npm-global/bin/pm2" +DIR="/home/adminbackend/web/dev.demoplace.my.id/public_html" + +echo "=== Frontend logs after restart (last 25 lines) ===" +$PM2 logs source2-frontend --lines 25 --nostream 2>/dev/null | tail -30 + +echo "" +echo "=== Frontend error.log last 15 lines ===" +tail -15 $DIR/logs/frontend-error.log 2>/dev/null +`; + const conn = new Client(); + conn.on('ready', () => { + conn.exec(`bash -c '${SCRIPT.replace(/'/g, "'\"'\"'")}'`, (err, stream) => { + if (err) return reject(err); + let out = ''; + stream.on('close', () => { conn.end(); resolve(out); }); + stream.on('data', d => { out += d.toString(); process.stdout.write(d.toString()); }); + stream.stderr.on('data', d => process.stderr.write(d.toString())); + }); + }); + conn.on('error', reject); + conn.connect(SSH_CONFIG); + }); +} + +async function main() { + console.log('[1] Login...'); + const lr = await req('/api/auth/login', { username: 'admin', password: 'admin' }); + console.log(` HTTP ${lr.status} | Cookie: ${COOKIES ? 'YES' : 'NO'}`); + + console.log('\n[2] Request dashboard pages to trigger Server Components...'); + const pages = ['/', '/agents', '/flows', '/threats']; + for (const p of pages) { + const r = await req(p); + const isHtml = r.body?.startsWith('<!DOCTYPE') || r.body?.includes('<html'); + const hasError = r.body?.includes('Server Components render') || r.body?.includes('An error occurred'); + console.log(` ${r.status === 200 && !hasError ? '✅' : '❌'} ${p}: HTTP ${r.status} | HTML: ${isHtml} | SC Error: ${hasError}`); + } + + console.log('\n[3] Checking frontend logs for new MONGODB_URI...\n'); + await checkLogs(); +} + +main().catch(console.error);