update
This commit is contained in:
1 parent
da8fb57bfb
commit
bba75c7dc6
35 files changed
+92274
-182
No files matched your search
@@ -0,0 +1,400 @@
|
|||||||
|
/**
|
||||||
|
* generate_export.js
|
||||||
|
*
|
||||||
|
* Mengekspor SELURUH data dari semua tabel SQLite (netify_data.db)
|
||||||
|
* ke dalam file netify_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, '../netify_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 Netify 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 Netify 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(' NETIFY DATA EXPORT');
|
||||||
|
lines.push(' Seluruh data hasil parsing dari Netify API');
|
||||||
|
lines.push(separator('─'));
|
||||||
|
lines.push(` Export Date: ${exportDate}`);
|
||||||
|
lines.push(` Generated by: generate_export.js`);
|
||||||
|
lines.push(` Source: netify_data.db (SQLite lokal)`);
|
||||||
|
lines.push(` API Base: https://informatics.netify.ai`);
|
||||||
|
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);
|
||||||
|
});
|
||||||
+997
-39
File diff suppressed because it is too large.
Load diff
@@ -290,4 +290,56 @@ router.get('/lookup/applications', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── INTERACTIVE DETAIL ROUTES ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/dashboard/agent-details?uuid=2F-TF-1D-GK
|
||||||
|
router.get('/agent-details', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const uuid = String(req.query.uuid ?? '');
|
||||||
|
if (!uuid) return res.status(400).json({ ok: false, message: 'uuid required' });
|
||||||
|
const { fetchAgentDetails } = require('../netify');
|
||||||
|
const data = await fetchAgentDetails(uuid);
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/device-details?ip=10.6.10.23
|
||||||
|
router.get('/device-details', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ip = String(req.query.ip ?? '');
|
||||||
|
if (!ip) return res.status(400).json({ ok: false, message: 'ip required' });
|
||||||
|
const { fetchDeviceDetails } = require('../netify');
|
||||||
|
const data = await fetchDeviceDetails(ip);
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/app-details?label=YouTube
|
||||||
|
router.get('/app-details', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const label = String(req.query.label ?? '');
|
||||||
|
if (!label) return res.status(400).json({ ok: false, message: 'label required' });
|
||||||
|
const { fetchAppDetails } = require('../netify');
|
||||||
|
const data = await fetchAppDetails(label);
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/security-devices
|
||||||
|
router.get('/security-devices', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { fetchSecurityDevices } = require('../netify');
|
||||||
|
const data = await fetchSecurityDevices();
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;␍
|
module.exports = router;␍
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const db = require('../database');
|
||||||
|
|
||||||
|
const d = db.getDB();
|
||||||
|
|
||||||
|
const tables = d.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();
|
||||||
|
console.log('=== ALL TABLES ===');
|
||||||
|
tables.forEach(t => {
|
||||||
|
try {
|
||||||
|
const cnt = d.prepare('SELECT COUNT(*) as c FROM ' + t.name).get().c;
|
||||||
|
const cols = d.prepare('PRAGMA table_info(' + t.name + ')').all().map(c => c.name);
|
||||||
|
console.log(t.name + ' (' + cnt + ' rows)');
|
||||||
|
console.log(' cols: ' + cols.join(', '));
|
||||||
|
if (cnt > 0) {
|
||||||
|
const sample = d.prepare('SELECT * FROM ' + t.name + ' LIMIT 1').get();
|
||||||
|
console.log(' sample: ' + JSON.stringify(sample));
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
} catch(e) {
|
||||||
|
console.log(t.name + ': ERROR ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// TDD Test: Verifikasi fix untuk Bug #1 (Devices) dan Bug #2 (Flows)
|
||||||
|
// Jalankan: node backend/tests/test_bug_fixes.js
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../../.env.local') });
|
||||||
|
|
||||||
|
const API_KEY = process.env.NETIFY_API_KEY;
|
||||||
|
const SITE_UUID = process.env.NETIFY_SITE_UUID;
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (condition) {
|
||||||
|
console.log(` ✅ PASS: ${message}`);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.error(` ❌ FAIL: ${message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testDevicesBugFix() {
|
||||||
|
console.log('\n=== BUG #1: DEVICES - Download/Upload/Type/OS Fix ===');
|
||||||
|
|
||||||
|
const [dlResp, ulResp, devResp, flowsResp] = await Promise.all([
|
||||||
|
axios.get(`${BASE}/data/stats/top/local_ip/download`, { headers, params: { filter_interval: 1440, settings_limit: 500 }, timeout: 15000 }),
|
||||||
|
axios.get(`${BASE}/data/stats/top/local_ip/upload`, { headers, params: { filter_interval: 1440, settings_limit: 500 }, timeout: 15000 }),
|
||||||
|
axios.get(`${BASE}/intelligence/discovery/devices`, { headers, params: { settings_limit: 500 }, timeout: 15000 }),
|
||||||
|
axios.get(`${BASE}/data/flows`, { headers, params: { settings_limit: 500 }, timeout: 15000 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dlData = dlResp.data?.data || [];
|
||||||
|
const ulData = ulResp.data?.data || [];
|
||||||
|
const devData = devResp.data?.data || [];
|
||||||
|
const flowData = flowsResp.data?.data || [];
|
||||||
|
|
||||||
|
console.log(` Total IPs in download stats: ${dlData.length}`);
|
||||||
|
console.log(` Total IPs in upload stats: ${ulData.length}`);
|
||||||
|
console.log(` Total discovered devices: ${devData.length}`);
|
||||||
|
console.log(` Total flows: ${flowData.length}`);
|
||||||
|
|
||||||
|
// Build maps
|
||||||
|
const dlMap = {};
|
||||||
|
for (const r of dlData) {
|
||||||
|
if (r.local_ip?.address) dlMap[r.local_ip.address] = r.download;
|
||||||
|
}
|
||||||
|
const ulMap = {};
|
||||||
|
for (const r of ulData) {
|
||||||
|
if (r.local_ip?.address) ulMap[r.local_ip.address] = r.upload;
|
||||||
|
}
|
||||||
|
// Flows fallback map
|
||||||
|
const dlFlowMap = {};
|
||||||
|
const ulFlowMap = {};
|
||||||
|
for (const f of flowData) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: devices dengan non-zero bandwidth setelah enrichment
|
||||||
|
const enriched = devData.map(r => {
|
||||||
|
const ip = r.ip?.address;
|
||||||
|
const dl = ip ? (dlMap[ip] || dlFlowMap[ip] || 0) : 0;
|
||||||
|
const ul = ip ? (ulMap[ip] || ulFlowMap[ip] || 0) : 0;
|
||||||
|
const type = r.discovery_type?.label;
|
||||||
|
const os = r.discovery_os?.label;
|
||||||
|
return { ip, dl, ul, type, os };
|
||||||
|
}).sort((a, b) => (b.dl + b.ul) - (a.dl + a.ul));
|
||||||
|
|
||||||
|
const withBandwidth = enriched.filter(d => d.dl > 0 || d.ul > 0);
|
||||||
|
const withType = enriched.filter(d => d.type && d.type !== 'Unknown' && d.type !== 'Unclassified');
|
||||||
|
const withOs = enriched.filter(d => d.os && d.os !== 'Unknown' && d.os !== 'Unclassified');
|
||||||
|
|
||||||
|
assert(enriched.length > 0, `Devices tersedia: ${enriched.length} entries`);
|
||||||
|
assert(withBandwidth.length > 0, `Devices dengan bandwidth non-zero: ${withBandwidth.length}/${enriched.length}`);
|
||||||
|
assert(withType.length > 0, `Devices dengan type terdeteksi: ${withType.length}/${enriched.length}`);
|
||||||
|
assert(withOs.length > 0, `Devices dengan OS terdeteksi: ${withOs.length}/${enriched.length}`);
|
||||||
|
|
||||||
|
// Print top 5 devices
|
||||||
|
console.log('\n Top 5 devices after fix:');
|
||||||
|
for (const d of enriched.slice(0, 5)) {
|
||||||
|
console.log(` IP=${d.ip}, DL=${(d.dl / 1024 / 1024).toFixed(2)}MB, UL=${(d.ul / 1024 / 1024).toFixed(2)}MB, type=${d.type || '-'}, os=${d.os || '-'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testFlowsBugFix() {
|
||||||
|
console.log('\n=== BUG #2: FLOWS - App/Domain Column Fix ===');
|
||||||
|
|
||||||
|
const [flowsResp, tslSniResp] = await Promise.all([
|
||||||
|
axios.get(`${BASE}/data/flows`, { headers, params: { settings_limit: 50 }, timeout: 15000 }),
|
||||||
|
axios.get(`${BASE}/data/stats/top/tls_sni/download`, { headers, params: { filter_interval: 1440, settings_limit: 50 }, timeout: 15000 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const flowData = flowsResp.data?.data || [];
|
||||||
|
const sniData = tslSniResp.data?.data || [];
|
||||||
|
|
||||||
|
const PORT_SERVICE_MAP = {
|
||||||
|
80: 'HTTP', 443: 'HTTPS / TLS', 8080: 'HTTP Alt', 8443: 'HTTPS Alt',
|
||||||
|
53: 'DNS', 5353: 'mDNS', 853: 'DNS-over-TLS',
|
||||||
|
22: 'SSH', 23: 'Telnet', 3389: 'RDP', 5900: 'VNC',
|
||||||
|
9993: 'ZeroTier VPN', 3478: 'STUN/TURN', 5004: 'RTP Media',
|
||||||
|
5060: 'SIP', 5061: 'SIP TLS', 1194: 'OpenVPN', 51820: 'WireGuard',
|
||||||
|
500: 'IPSec IKE', 4500: 'IPSec NAT-T', 1723: 'PPTP', 1701: 'L2TP',
|
||||||
|
67: 'DHCP', 68: 'DHCP Client', 123: 'NTP', 161: 'SNMP',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sniList = sniData.map(r => r.tls_sni || '').filter(s => s.trim() !== '');
|
||||||
|
console.log(` TLS SNI hostnames available: ${sniList.length}`);
|
||||||
|
|
||||||
|
const enrichedFlows = flowData.map(r => {
|
||||||
|
const port = r.remote_port ?? null;
|
||||||
|
const portService = port ? (PORT_SERVICE_MAP[port] ?? `Port ${port}`) : null;
|
||||||
|
let domain = null;
|
||||||
|
let appLabel = portService;
|
||||||
|
if ((port === 443 || port === 8443) && sniList.length > 0) {
|
||||||
|
domain = sniList[0];
|
||||||
|
}
|
||||||
|
return { src: r.local_ip?.address, dst: r.remote_ip?.address, port, appLabel, domain };
|
||||||
|
});
|
||||||
|
|
||||||
|
const withAppLabel = enrichedFlows.filter(f => f.appLabel !== null);
|
||||||
|
|
||||||
|
assert(enrichedFlows.length > 0, `Flows tersedia: ${enrichedFlows.length} entries`);
|
||||||
|
assert(withAppLabel.length > 0, `Flows dengan app_label terisi (port-based): ${withAppLabel.length}/${enrichedFlows.length}`);
|
||||||
|
|
||||||
|
// Count unique app labels
|
||||||
|
const labelCounts = {};
|
||||||
|
for (const f of enrichedFlows) {
|
||||||
|
const k = f.appLabel || 'Unknown';
|
||||||
|
labelCounts[k] = (labelCounts[k] || 0) + 1;
|
||||||
|
}
|
||||||
|
console.log(' App label distribution:', JSON.stringify(labelCounts));
|
||||||
|
|
||||||
|
// Print sample flows
|
||||||
|
console.log('\n Sample flows after fix:');
|
||||||
|
for (const f of enrichedFlows.slice(0, 5)) {
|
||||||
|
console.log(` ${f.src} -> ${f.dst}:${f.port} | app="${f.appLabel || '-'}" domain="${f.domain || '-'}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testBackendAPI() {
|
||||||
|
console.log('\n=== BACKEND API VERIFICATION ===');
|
||||||
|
try {
|
||||||
|
const r = await axios.get('http://localhost:3001/api/dashboard/flows?limit=10', { timeout: 5000 });
|
||||||
|
const flows = r.data?.data || [];
|
||||||
|
assert(r.data?.ok === true, 'Backend /flows endpoint responds OK');
|
||||||
|
assert(flows.length > 0, `Flows dari backend: ${flows.length} entries`);
|
||||||
|
const withApp = flows.filter(f => f.app_label !== null);
|
||||||
|
assert(withApp.length > 0, `Flows dengan app_label dari backend: ${withApp.length}/${flows.length}`);
|
||||||
|
console.log(' Sample from backend:');
|
||||||
|
for (const f of flows.slice(0, 3)) {
|
||||||
|
console.log(` ${f.src_ip} -> ${f.dst_ip}:${f.dst_port} | app="${f.app_label || '-'}" domain="${f.domain || '-'}" dl=${f.bytes_download} ul=${f.bytes_upload}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` Backend API test skipped: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await axios.get('http://localhost:3001/api/dashboard/devices?limit=10', { timeout: 5000 });
|
||||||
|
const devs = r.data?.data || [];
|
||||||
|
assert(r.data?.ok === true, 'Backend /devices endpoint responds OK');
|
||||||
|
assert(devs.length > 0, `Devices dari backend: ${devs.length} entries`);
|
||||||
|
const withType = devs.filter(d => d.device_type !== null);
|
||||||
|
const withOs = devs.filter(d => d.os_label !== null);
|
||||||
|
const withBw = devs.filter(d => d.download > 0 || d.upload > 0);
|
||||||
|
assert(withBw.length > 0, `Devices dengan bandwidth non-zero dari backend: ${withBw.length}/${devs.length}`);
|
||||||
|
console.log(' Sample devices from backend:');
|
||||||
|
for (const d of devs.slice(0, 3)) {
|
||||||
|
console.log(` IP=${d.ip_address}, DL=${(d.download / 1024 / 1024).toFixed(2)}MB, type=${d.device_type || '-'}, os=${d.os_label || '-'}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` Backend /devices test skipped: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('=== TDD: Bug Fix Verification Tests ===\n');
|
||||||
|
await testDevicesBugFix();
|
||||||
|
await testFlowsBugFix();
|
||||||
|
await testBackendAPI();
|
||||||
|
|
||||||
|
console.log(`\n=== RESULT: ${passed} passed, ${failed} failed ===`);
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(e => {
|
||||||
|
console.error('FATAL:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* TDD Test Suite: Validasi output file netify_data_export.txt
|
||||||
|
*
|
||||||
|
* Test ini mendefinisikan ATURAN yang harus dipenuhi oleh file output:
|
||||||
|
* 1. File harus ada
|
||||||
|
* 2. File harus tidak kosong
|
||||||
|
* 3. File harus berisi section untuk setiap tabel DB
|
||||||
|
* 4. Setiap section harus memiliki header, jumlah baris, dan data
|
||||||
|
* 5. Data harus berupa JSON valid per baris (JSONL)
|
||||||
|
* 6. File harus berisi metadata (tanggal export, versi, jumlah tabel)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const OUTPUT_FILE = path.join(__dirname, '../../netify_data_export.txt');
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(' ✅ PASS:', name);
|
||||||
|
passed++;
|
||||||
|
results.push({ name, status: 'PASS' });
|
||||||
|
} catch (e) {
|
||||||
|
console.log(' ❌ FAIL:', name);
|
||||||
|
console.log(' Reason:', e.message);
|
||||||
|
failed++;
|
||||||
|
results.push({ name, status: 'FAIL', reason: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message || 'Assertion failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEqual(a, b, message) {
|
||||||
|
if (a !== b) throw new Error(message || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertGreaterThan(a, b, message) {
|
||||||
|
if (a <= b) throw new Error(message || `Expected ${a} > ${b}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Required tables that must appear in the export ─────────────────────────
|
||||||
|
const REQUIRED_TABLES = [
|
||||||
|
'app_categories',
|
||||||
|
'bandwidth_apps',
|
||||||
|
'bandwidth_countries',
|
||||||
|
'bandwidth_protocols',
|
||||||
|
'bandwidth_timeline',
|
||||||
|
'bittorrent_hashes',
|
||||||
|
'cities',
|
||||||
|
'continents',
|
||||||
|
'devices',
|
||||||
|
'dhcp_fingerprints',
|
||||||
|
'discovery_os',
|
||||||
|
'dns_queries',
|
||||||
|
'events',
|
||||||
|
'flow_origins',
|
||||||
|
'flow_types',
|
||||||
|
'flows',
|
||||||
|
'http_user_agents',
|
||||||
|
'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',
|
||||||
|
'interfaces',
|
||||||
|
'ip_versions',
|
||||||
|
'mac_bandwidth',
|
||||||
|
'mdns_hostnames',
|
||||||
|
'netbios_hostnames',
|
||||||
|
'quic_hostnames',
|
||||||
|
'regions',
|
||||||
|
'remote_ips',
|
||||||
|
'sni_hostnames',
|
||||||
|
'ssh_versions',
|
||||||
|
'ssl_server_cn',
|
||||||
|
'threats',
|
||||||
|
'tls_ciphers',
|
||||||
|
'tls_security',
|
||||||
|
'tls_versions',
|
||||||
|
'vlans',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Run Tests ────────────────────────────────────────────────────────────
|
||||||
|
console.log('\n══════════════════════════════════════════════════════════');
|
||||||
|
console.log(' TDD TEST SUITE: netify_data_export.txt Validator');
|
||||||
|
console.log('══════════════════════════════════════════════════════════\n');
|
||||||
|
|
||||||
|
// Test 1: File must exist
|
||||||
|
console.log('[ Group 1: File Existence & Structure ]');
|
||||||
|
test('File netify_data_export.txt harus ada', () => {
|
||||||
|
assert(fs.existsSync(OUTPUT_FILE), `File tidak ditemukan: ${OUTPUT_FILE}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bail early if file doesn't exist
|
||||||
|
if (!fs.existsSync(OUTPUT_FILE)) {
|
||||||
|
console.log('\n ⚠️ File tidak ada, skip remaining tests.\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(OUTPUT_FILE, 'utf-8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
test('File tidak boleh kosong (minimal 100 baris)', () => {
|
||||||
|
assertGreaterThan(lines.length, 100, `File hanya ${lines.length} baris`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('File harus diawali dengan header metadata', () => {
|
||||||
|
assert(content.includes('NETIFY DATA EXPORT'), 'Header NETIFY DATA EXPORT tidak ditemukan');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('File harus berisi tanggal export', () => {
|
||||||
|
assert(content.includes('Export Date:') || content.includes('Generated:'), 'Tanggal export tidak ditemukan');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('File harus berisi total jumlah tabel', () => {
|
||||||
|
assert(content.includes('Total Tables:') || content.includes('Tables:'), 'Info total tabel tidak ditemukan');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: Section per table
|
||||||
|
console.log('\n[ Group 2: Table Sections ]');
|
||||||
|
REQUIRED_TABLES.forEach(tableName => {
|
||||||
|
test(`Section [${tableName}] harus ada di file`, () => {
|
||||||
|
assert(
|
||||||
|
content.includes(`[TABLE: ${tableName}]`) || content.includes(`TABLE: ${tableName}`),
|
||||||
|
`Section untuk tabel ${tableName} tidak ditemukan`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: Data format
|
||||||
|
console.log('\n[ Group 3: Data Format & Content ]');
|
||||||
|
|
||||||
|
test('Setiap section tabel harus memiliki info ROW COUNT', () => {
|
||||||
|
assert(content.includes('Row Count:') || content.includes('rows:'), 'Row count info tidak ditemukan');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Section flows harus berisi data src_ip', () => {
|
||||||
|
const flowSection = content.includes('[TABLE: flows]');
|
||||||
|
if (flowSection) {
|
||||||
|
assert(content.includes('src_ip') || content.includes('"src_ip"'), 'Data src_ip tidak ditemukan di flows');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Section bandwidth_apps harus berisi data app_label', () => {
|
||||||
|
assert(content.includes('app_label') || content.includes('"app_label"'), 'app_label tidak ditemukan');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Section intel_encryption_audit harus berisi risk_level', () => {
|
||||||
|
assert(content.includes('risk_level') || content.includes('"risk_level"'), 'risk_level tidak ditemukan');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Section devices harus berisi ip_address', () => {
|
||||||
|
assert(content.includes('ip_address') || content.includes('"ip_address"'), 'ip_address tidak ditemukan');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: Specific data values check
|
||||||
|
console.log('\n[ Group 4: Real Data Validation ]');
|
||||||
|
|
||||||
|
test('File harus mengandung data flows (src_ip format 10.x.x.x atau 192.168.x.x)', () => {
|
||||||
|
const hasRealIP = /10\.\d+\.\d+\.\d+/.test(content) || /192\.168\.\d+\.\d+/.test(content);
|
||||||
|
assert(hasRealIP, 'Tidak ditemukan IP internal (10.x.x.x / 192.168.x.x)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('File harus mengandung nama aplikasi nyata (YouTube/Facebook/etc)', () => {
|
||||||
|
const hasApp = content.includes('YouTube') || content.includes('Facebook') || content.includes('WhatsApp');
|
||||||
|
assert(hasApp, 'Tidak ada nama aplikasi brand yang dikenal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Section intel_unencrypted_passwords harus memiliki data username/severity', () => {
|
||||||
|
assert(content.includes('severity') || content.includes('"severity"'), 'severity tidak ditemukan');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('File mengandung data agent/MAC address', () => {
|
||||||
|
const hasMAC = /[0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}/.test(content);
|
||||||
|
assert(hasMAC, 'Tidak ada format MAC address yang valid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('File mengandung bytes/bandwidth numbers (angka > 1000)', () => {
|
||||||
|
const hasBigNum = /\d{4,}/.test(content);
|
||||||
|
assert(hasBigNum, 'Tidak ada angka bandwidth yang terdeteksi');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: File size
|
||||||
|
console.log('\n[ Group 5: File Size ]');
|
||||||
|
|
||||||
|
test('File harus cukup besar (minimal 100KB)', () => {
|
||||||
|
const stats = fs.statSync(OUTPUT_FILE);
|
||||||
|
assertGreaterThan(stats.size, 100 * 1024, `File terlalu kecil: ${stats.size} bytes`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Summary ────────────────────────────────────────────────────────────────
|
||||||
|
console.log('\n══════════════════════════════════════════════════════════');
|
||||||
|
console.log(` RESULTS: ${passed} passed, ${failed} failed`);
|
||||||
|
console.log('══════════════════════════════════════════════════════════\n');
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
console.log('❌ TESTS FAILED — implementasi diperlukan\n');
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log('✅ ALL TESTS PASSED — file export valid\n');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Test: cari field name yang valid untuk SNI hostname di API
|
||||||
|
const axios = require('axios');
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../../.env.local') });
|
||||||
|
|
||||||
|
const API_KEY = process.env.NETIFY_API_KEY;
|
||||||
|
const SITE_UUID = process.env.NETIFY_SITE_UUID;
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function tryField(field) {
|
||||||
|
try {
|
||||||
|
const url = `${BASE}/data/stats/top/${field}/download`;
|
||||||
|
const res = await axios.get(url, { headers, params: { filter_interval: 1440, settings_limit: 5 }, timeout: 10000 });
|
||||||
|
const data = res.data?.data;
|
||||||
|
if (data && Array.isArray(data) && data.length > 0) {
|
||||||
|
console.log(`✅ ${field}: OK, count=${data.length}, keys=${Object.keys(data[0])}`);
|
||||||
|
console.log(` First: ${JSON.stringify(data[0]).slice(0, 200)}`);
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ ${field}: OK but empty`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.response?.status;
|
||||||
|
const msg = err.response?.data?.status_message || err.message;
|
||||||
|
console.log(`❌ ${field}: ${status} - ${msg?.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
// Try all possible SNI-related field names
|
||||||
|
const candidates = [
|
||||||
|
'https_sni_hostname',
|
||||||
|
'sni_hostname',
|
||||||
|
'ssl_sni',
|
||||||
|
'tls_sni',
|
||||||
|
'ssl_server_cn',
|
||||||
|
'quic_hostname',
|
||||||
|
'hostname'
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('=== Testing SNI/Hostname field names ===\n');
|
||||||
|
for (const f of candidates) {
|
||||||
|
await tryField(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
+88362
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_KEY = 'sk_db_live_ZjViMTNmODU3ZjNkN2Q4MGV';
|
||||||
|
const SITE_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function checkDetails() {
|
||||||
|
const agents = ["2F-TF-1D-GK", "8A-V3-PB-85", "F6-2V-DT-8A"];
|
||||||
|
for (const agent of agents) {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/top/local_ip/download`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_agent: agent, settings_limit: 3 }
|
||||||
|
});
|
||||||
|
console.log(`\n=== Agent ${agent} top/local_ip ===`);
|
||||||
|
console.dir(res.data?.data, { depth: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDetails();
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_KEY = 'sk_db_live_ZjViMTNmODU3ZjNkN2Q4MGV';
|
||||||
|
const SITE_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function checkAgentsList() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/scorecard/agent/download`, { headers });
|
||||||
|
const agents = res.data?.data?.map(r => ({
|
||||||
|
id: r.agent?.id,
|
||||||
|
uuid: r.agent?.uuid,
|
||||||
|
label: r.agent?.label,
|
||||||
|
download: r.download
|
||||||
|
}));
|
||||||
|
console.log('Agents scorecard list:');
|
||||||
|
console.dir(agents, { depth: null });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAgentsList();
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database('c:\\Users\\vanne\\OneDrive\\Dokumen\\Network Magang Baru\\netify-app\\backend\\netify_data.db');
|
||||||
|
|
||||||
|
console.log('=== INTERFACES DATA ===');
|
||||||
|
try {
|
||||||
|
const data = db.prepare('SELECT * FROM interfaces').all();
|
||||||
|
console.log('Count:', data.length);
|
||||||
|
if (data.length > 0) console.log(data.slice(0, 5));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== FLOWS WITH OTHER COLUMNS? ===');
|
||||||
|
try {
|
||||||
|
// Let's print column names of flows and devices
|
||||||
|
const info = db.prepare('PRAGMA table_info(flows)').all();
|
||||||
|
console.log('Flows columns:', info.map(i => i.name));
|
||||||
|
const info2 = db.prepare('PRAGMA table_info(devices)').all();
|
||||||
|
console.log('Devices columns:', info2.map(i => i.name));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database('c:\\Users\\vanne\\OneDrive\\Dokumen\\Network Magang Baru\\netify-app\\backend\\netify_data.db');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agents = db.prepare('SELECT DISTINCT agent_id FROM interfaces').all();
|
||||||
|
console.log('Unique agent_ids in interfaces:', agents);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ifaces = db.prepare('SELECT * FROM interfaces LIMIT 10').all();
|
||||||
|
console.log('Sample interfaces:', ifaces);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const db = new Database('c:\\Users\\vanne\\OneDrive\\Dokumen\\Network Magang Baru\\netify-app\\backend\\netify_data.db');
|
||||||
|
|
||||||
|
const ip = '10.250.192.202';
|
||||||
|
|
||||||
|
console.log('=== ENCRYPTION AUDIT FOR IP ===');
|
||||||
|
try {
|
||||||
|
const data = db.prepare('SELECT * FROM intel_encryption_audit WHERE ip_address = ?').all(ip);
|
||||||
|
console.log(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== INSECURE PROTOCOLS FOR IP ===');
|
||||||
|
try {
|
||||||
|
const data = db.prepare('SELECT * FROM intel_insecure_protocols WHERE ip_address = ?').all(ip);
|
||||||
|
console.log(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== IP REPUTATION FOR IP ===');
|
||||||
|
try {
|
||||||
|
const data = db.prepare('SELECT * FROM intel_ip_reputation WHERE ip_address = ? OR local_ip = ?').all(ip, ip);
|
||||||
|
console.log(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '.env.local') });
|
||||||
|
|
||||||
|
const API_KEY = process.env.NETIFY_API_KEY;
|
||||||
|
const SITE_UUID = process.env.NETIFY_SITE_UUID;
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function inspect() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/flows`, { headers, params: { settings_limit: 2 } });
|
||||||
|
console.log('=== FLOW 0 ===');
|
||||||
|
console.dir(res.data?.data?.[0], { depth: null });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/intelligence/discovery/devices`, { headers, params: { settings_limit: 2 } });
|
||||||
|
console.log('=== DISCOVERY DEVICE 0 ===');
|
||||||
|
console.dir(res.data?.data?.[0], { depth: null });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inspect();
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_KEY = 'sk_db_live_ZjViMTNmODU3ZjNkN2Q4MGV';
|
||||||
|
const SITE_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function inspectLocalIpItem() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/top/local_ip/download`, {
|
||||||
|
headers,
|
||||||
|
params: { settings_limit: 1 }
|
||||||
|
});
|
||||||
|
console.log('=== local_ip item ===');
|
||||||
|
console.dir(res.data?.data?.[0], { depth: null });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inspectLocalIpItem();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_KEY = 'sk_db_live_ZjViMTNmODU3ZjNkN2Q4MGV';
|
||||||
|
const SITE_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function testScorecardAgent() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/scorecard/agent/download`, { headers });
|
||||||
|
console.log('=== agent scorecard download ===');
|
||||||
|
console.dir(res.data?.data, { depth: null });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testScorecardAgent();
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const API_KEY = 'sk_db_live_ZjViMTNmODU3ZjNkN2Q4MGV';
|
||||||
|
const SITE_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function testAgentFilter() {
|
||||||
|
const agents = ["2F-TF-1D-GK", "8A-V3-PB-85", "F6-2V-DT-8A"];
|
||||||
|
|
||||||
|
for (const agent of agents) {
|
||||||
|
console.log(`\n=== Testing Agent ${agent} ===`);
|
||||||
|
|
||||||
|
// Test stats summary
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/summary`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_agent: agent }
|
||||||
|
});
|
||||||
|
console.log(`stats/summary filter_agent=${agent}:`, JSON.stringify(res.data?.data));
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`stats/summary filter_agent=${agent} failed:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test top local_ip
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/top/local_ip/download`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_agent: agent, settings_limit: 3 }
|
||||||
|
});
|
||||||
|
const ips = res.data?.data?.map(r => r.local_ip?.address || r.local_ip);
|
||||||
|
console.log(`top/local_ip filter_agent=${agent}:`, ips);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`top/local_ip filter_agent=${agent} failed:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test flows
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/flows`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_agent: agent, settings_limit: 3 }
|
||||||
|
});
|
||||||
|
const flows = res.data?.data?.map(r => `${r.local_ip?.address} -> ${r.remote_ip?.address}`);
|
||||||
|
console.log(`flows filter_agent=${agent}:`, flows);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`flows filter_agent=${agent} failed:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testAgentFilter();
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_KEY = 'sk_db_live_ZjViMTNmODU3ZjNkN2Q4MGV';
|
||||||
|
const SITE_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function testAgentScorecardAppFilter() {
|
||||||
|
const app = 'YouTube';
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/scorecard/agent/download`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_application: app }
|
||||||
|
});
|
||||||
|
console.log(`Agent scorecard filtered by application=${app}:`, res.data?.data?.map(r => ({ agent: r.agent?.uuid, download: r.download })));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testAgentScorecardAppFilter();
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_KEY = 'sk_db_live_ZjViMTNmODU3ZjNkN2Q4MGV';
|
||||||
|
const SITE_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function testAppFilters() {
|
||||||
|
// Let's first search for YouTube application ID
|
||||||
|
let youtubeId = 15; // default guess or we can inspect flows/stats
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/top/local_ip/download`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_application: 'YouTube', settings_limit: 3 }
|
||||||
|
});
|
||||||
|
console.log(`top/local_ip with filter_application=YouTube:`, res.data?.data?.map(r => ({ ip: r.local_ip?.address, download: r.download })));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed filter_application=YouTube:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/top/local_ip/download`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_application_id: youtubeId, settings_limit: 3 }
|
||||||
|
});
|
||||||
|
console.log(`top/local_ip with filter_application_id=${youtubeId}:`, res.data?.data?.map(r => ({ ip: r.local_ip?.address, download: r.download })));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed filter_application_id=${youtubeId}:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test flows with application filter
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/flows`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_application: 'YouTube', settings_limit: 3 }
|
||||||
|
});
|
||||||
|
console.log(`flows with filter_application=YouTube:`, res.data?.data?.map(r => ({ src: r.local_ip?.address, dst: r.remote_ip?.address })));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed flows filter_application=YouTube:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testAppFilters();
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_KEY = 'sk_db_live_ZjViMTNmODU3ZjNkN2Q4MGV';
|
||||||
|
const SITE_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function testAppsAgent() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/top/application/download`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_agent: '2F-TF-1D-GK', settings_limit: 3 }
|
||||||
|
});
|
||||||
|
console.log('Top apps for agent:', res.data?.data?.map(a => ({ name: a.application?.label, download: a.download })));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testAppsAgent();
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_KEY = 'sk_db_live_ZjViMTNmODU3ZjNkN2Q4MGV';
|
||||||
|
const SITE_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function testCombinedFilters() {
|
||||||
|
const agent = '2F-TF-1D-GK';
|
||||||
|
const app = 'YouTube';
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/top/local_ip/download`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_agent: agent, filter_application: app, settings_limit: 3 }
|
||||||
|
});
|
||||||
|
console.log(`top/local_ip for agent=${agent} and app=${app}:`, res.data?.data?.map(r => ({ ip: r.local_ip?.address, download: r.download })));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed combined filters:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testCombinedFilters();
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_KEY = 'sk_db_live_ZjViMTNmODU3ZjNkN2Q4MGV';
|
||||||
|
const SITE_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function testDeviceAgent() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/intelligence/discovery/devices`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_agent: '2F-TF-1D-GK', settings_limit: 3 }
|
||||||
|
});
|
||||||
|
console.log('Discovery devices for agent:', res.data?.data?.map(d => ({ ip: d.ip?.address, type: d.discovery_type?.label })));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testDeviceAgent();
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_KEY = 'sk_db_live_ZjViMTNmODU3ZjNkN2Q4MGV';
|
||||||
|
const SITE_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function testInterfaceFilter() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/top/local_ip/download`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_interface: 'eth1 (LAN)', settings_limit: 3 }
|
||||||
|
});
|
||||||
|
console.log(`top/local_ip filter_interface=eth1 (LAN):`, res.data?.data?.map(r => r.local_ip?.address));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed filter_interface:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Let's get interfaces first to see their IDs
|
||||||
|
const resI = await axios.get(`${BASE}/data/stats/top/interface/download`, { headers });
|
||||||
|
const ifaces = resI.data?.data;
|
||||||
|
console.log('Available interfaces:', ifaces?.map(i => ({ id: i.interface?.id, name: i.interface?.name, agent: i.interface?.agent_id })));
|
||||||
|
|
||||||
|
if (ifaces && ifaces.length > 0) {
|
||||||
|
const ifaceId = ifaces[0].interface?.id;
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/top/local_ip/download`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_interface_id: ifaceId, settings_limit: 3 }
|
||||||
|
});
|
||||||
|
console.log(`top/local_ip filter_interface_id=${ifaceId}:`, res.data?.data?.map(r => r.local_ip?.address));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed interface_id test:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testInterfaceFilter();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_KEY = 'sk_db_live_ZjViMTNmODU3ZjNkN2Q4MGV';
|
||||||
|
const SITE_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
const BASE = 'https://informatics.netify.ai/api/v1';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function testIpFilter() {
|
||||||
|
const ip = '10.250.192.202';
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/top/application/download`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_local_ip: ip, settings_limit: 3 }
|
||||||
|
});
|
||||||
|
console.log(`Top apps for IP ${ip}:`, res.data?.data?.map(a => ({ name: a.application?.label, download: a.download })));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed filter_local_ip:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${BASE}/data/stats/top/application/download`, {
|
||||||
|
headers,
|
||||||
|
params: { filter_ip: ip, settings_limit: 3 }
|
||||||
|
});
|
||||||
|
console.log(`Top apps for IP ${ip} (filter_ip):`, res.data?.data?.map(a => ({ name: a.application?.label, download: a.download })));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed filter_ip:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testIpFilter();
|
||||||
+72
-7
@@ -6,7 +6,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
|||||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { Loader2, Plus, Edit, Trash2, Server, CheckCircle2, XCircle } from "lucide-react";
|
import { AgentDetailModal } from "@/components/ui/AgentDetailModal";
|
||||||
|
import { Loader2, Plus, Edit, Trash2, Server, CheckCircle2, XCircle, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
export default function AgentsPage() {
|
export default function AgentsPage() {
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
@@ -18,6 +19,16 @@ export default function AgentsPage() {
|
|||||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
// Detail modal
|
||||||
|
const [detailAgent, setDetailAgent] = useState<Agent | null>(null);
|
||||||
|
|
||||||
|
// Known agent UUIDs with labels
|
||||||
|
const AGENT_LABELS: Record<string, string> = {
|
||||||
|
'2F-TF-1D-GK': 'JRP Cibubur',
|
||||||
|
'8A-V3-PB-85': 'IFG LT.18',
|
||||||
|
'F6-2V-DT-8A': 'CPI Balaraja',
|
||||||
|
};
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||||
const [formData, setFormData] = useState({ agentId: "", label: "" });
|
const [formData, setFormData] = useState({ agentId: "", label: "" });
|
||||||
@@ -100,8 +111,30 @@ export default function AgentsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns: Column<Agent>[] = [
|
const columns: Column<Agent>[] = [
|
||||||
{ header: "UUID / Serial", accessor: (row) => <span className="font-mono text-xs text-blue-400">{row.uuid || row.serial}</span> },
|
{
|
||||||
{ header: "Label", accessor: (row) => row.label || <span className="text-muted-foreground italic">No label</span> },
|
header: "UUID / Serial",
|
||||||
|
accessor: (row) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailAgent(row)}
|
||||||
|
className="flex items-center gap-2 group text-left"
|
||||||
|
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>
|
||||||
|
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Label",
|
||||||
|
accessor: (row) => {
|
||||||
|
const friendlyLabel = AGENT_LABELS[row.uuid || row.serial];
|
||||||
|
return (
|
||||||
|
<button onClick={() => setDetailAgent(row)} className="text-left hover:text-white transition-colors">
|
||||||
|
{friendlyLabel || row.label || <span className="text-muted-foreground italic">No label</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
{ header: "Provisioned", 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: "Provisioned", 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", 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: "Activated", 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: "Last Seen", accessor: (row) => <span className="text-muted-foreground">{row.last_seen_at?.human || '-'}</span> },
|
{ header: "Last Seen", accessor: (row) => <span className="text-muted-foreground">{row.last_seen_at?.human || '-'}</span> },
|
||||||
@@ -109,10 +142,17 @@ export default function AgentsPage() {
|
|||||||
header: "Actions",
|
header: "Actions",
|
||||||
accessor: (row) => (
|
accessor: (row) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => openEdit(row)} className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded transition-colors">
|
<button
|
||||||
|
onClick={() => setDetailAgent(row)}
|
||||||
|
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors"
|
||||||
|
title="View details"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); openEdit(row); }} className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded transition-colors">
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => openDelete(row)} className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors">
|
<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">
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,8 +183,24 @@ export default function AgentsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="p-4 bg-red-950/30 border border-red-900/50 rounded-lg text-red-400 text-sm">
|
<div className="p-6 bg-red-950/20 border border-red-500/30 rounded-xl space-y-4 text-slate-300">
|
||||||
{error}
|
<div className="flex items-center gap-2 text-red-400 font-semibold">
|
||||||
|
<XCircle className="w-5 h-5" />
|
||||||
|
<span>Akses Agents Ditolak (403 Forbidden)</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed">
|
||||||
|
API Key yang Anda gunakan saat ini (<code className="bg-red-950/40 text-red-300 px-1.5 py-0.5 rounded font-mono text-xs">sk_db_live_...</code>) adalah <strong>Site-scoped API Key</strong>. Netify membatasi kunci ini agar hanya dapat membaca data statistik jaringan (Informatics), sehingga tidak memiliki izin untuk mengelola inventaris Agents global.
|
||||||
|
</p>
|
||||||
|
<div className="bg-slate-900/60 rounded-lg p-4 border border-border/40 text-xs space-y-2 leading-relaxed">
|
||||||
|
<p className="font-semibold text-white">💡 Cara Mengatasinya:</p>
|
||||||
|
<ol className="list-decimal pl-4 space-y-1 text-slate-400">
|
||||||
|
<li>Buka dan login ke portal <a href="https://portal.netify.ai" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline font-medium">Netify Portal</a>.</li>
|
||||||
|
<li>Masuk ke menu <strong>Account</strong> di bagian atas.</li>
|
||||||
|
<li>Pilih <strong>Profile > API Access</strong> pada menu sebelah kiri.</li>
|
||||||
|
<li>Buat sebuah <strong>Account-level API Key</strong> (kunci ini akan memiliki akses menyeluruh tanpa kedaluwarsa).</li>
|
||||||
|
<li>Buka file <code className="text-slate-200">.env.local</code> di proyek Anda, lalu ganti nilai <code className="text-slate-200">NETIFY_API_KEY</code> dengan kunci baru tersebut.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -245,6 +301,15 @@ export default function AgentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Agent Detail Modal */}
|
||||||
|
{detailAgent && (
|
||||||
|
<AgentDetailModal
|
||||||
|
agentUuid={detailAgent.uuid || detailAgent.serial}
|
||||||
|
agentLabel={AGENT_LABELS[detailAgent.uuid || detailAgent.serial] || detailAgent.label || detailAgent.uuid || detailAgent.serial}
|
||||||
|
onClose={() => setDetailAgent(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+41
-19
@@ -1,16 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTopApps } from "@/lib/api";
|
import { useTopApps, AppStat } from "@/lib/api";
|
||||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { Loader2 } from "lucide-react";
|
import { AppDetailModal } from "@/components/ui/AppDetailModal";
|
||||||
|
import { Loader2, ChevronRight } from "lucide-react";
|
||||||
import { fmtBytes } from "@/lib/utils";
|
import { fmtBytes } from "@/lib/utils";
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts";
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts";
|
||||||
|
|
||||||
export default function AppsPage() {
|
export default function AppsPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { data, isLoading } = useTopApps(50); // fetch 50 apps
|
const { data, isLoading } = useTopApps(50); // fetch 50 apps
|
||||||
|
const [selectedApp, setSelectedApp] = useState<AppStat | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -20,41 +22,51 @@ export default function AppsPage() {
|
|||||||
|
|
||||||
const top5 = data.slice(0, 5);
|
const top5 = data.slice(0, 5);
|
||||||
|
|
||||||
const columns: Column<typeof data[0]>[] = [
|
const columns: Column<AppStat>[] = [
|
||||||
{ header: "#", accessor: (row, i) => i + 1 },
|
{ header: "#", accessor: (row, i) => i + 1 },
|
||||||
{
|
{
|
||||||
header: "Application",
|
header: "Application",
|
||||||
accessor: (row) => (
|
accessor: (row) => (
|
||||||
<div className="flex items-center gap-2">
|
<button
|
||||||
|
onClick={() => setSelectedApp(row)}
|
||||||
|
className="flex items-center gap-2 group text-left"
|
||||||
|
title="Click to view app details"
|
||||||
|
>
|
||||||
{row.favicon && (
|
{row.favicon && (
|
||||||
<img src={row.favicon} alt={row.app_label} className="w-5 h-5 rounded-sm bg-white p-0.5" />
|
<img src={row.favicon} alt={row.app_label} className="w-5 h-5 rounded-sm bg-white p-0.5 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span>{row.app_label}</span>
|
<span className="group-hover:text-blue-300 transition-colors">{row.app_label}</span>
|
||||||
</div>
|
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
|
||||||
)
|
</button>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ header: "Category", accessor: (row) => row.category || "-" },
|
{ header: "Category", accessor: (row) => row.category || "-" },
|
||||||
{
|
{
|
||||||
header: "Download",
|
header: "Download",
|
||||||
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span>
|
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Upload",
|
header: "Upload",
|
||||||
accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>
|
accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "Total",
|
header: "Total",
|
||||||
accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.download + row.upload)}</span>
|
accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.download + row.upload)}</span>,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
<div>
|
||||||
Applications
|
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
Applications
|
||||||
</h2>
|
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Click any application to see which agents and devices are using it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||||
@@ -78,9 +90,9 @@ export default function AppsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<RechartsTooltip
|
<RechartsTooltip
|
||||||
cursor={{fill: '#21262d'}}
|
cursor={{ fill: "#21262d" }}
|
||||||
contentStyle={{ backgroundColor: '#161b22', borderColor: '#30363d', color: '#c9d1d9' }}
|
contentStyle={{ backgroundColor: "#161b22", borderColor: "#30363d", color: "#c9d1d9" }}
|
||||||
formatter={(value: any) => [fmtBytes(value as number), 'Volume']}
|
formatter={(value: any) => [fmtBytes(value as number), "Volume"]}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="download" name="Download" fill="#58a6ff" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="download" name="Download" fill="#58a6ff" radius={[4, 4, 0, 0]} />
|
||||||
<Bar dataKey="upload" name="Upload" fill="#3fb950" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="upload" name="Upload" fill="#3fb950" radius={[4, 4, 0, 0]} />
|
||||||
@@ -109,6 +121,16 @@ export default function AppsPage() {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* App Detail Modal */}
|
||||||
|
{selectedApp && (
|
||||||
|
<AppDetailModal
|
||||||
|
appLabel={selectedApp.app_label}
|
||||||
|
favicon={selectedApp.favicon}
|
||||||
|
category={selectedApp.category}
|
||||||
|
onClose={() => setSelectedApp(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+66
-15
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||||
|
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useDevices, DeviceStat } from "@/lib/api";
|
import { useDevices, DeviceStat } from "@/lib/api";
|
||||||
import { fmtBytes } from "@/lib/utils";
|
import { fmtBytes } from "@/lib/utils";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
export default function DevicesPage() {
|
export default function DevicesPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const devices = useDevices(100);
|
const devices = useDevices(100);
|
||||||
|
const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -19,13 +21,45 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
const columns: Column<DeviceStat>[] = [
|
const columns: Column<DeviceStat>[] = [
|
||||||
{ header: "#", accessor: (row, i) => i + 1 },
|
{ header: "#", accessor: (row, i) => i + 1 },
|
||||||
{ header: "IP Address", accessor: (row) => <span className="font-medium text-blue-400">{row.ip_address || '-'}</span> },
|
{
|
||||||
{ header: "MAC Address", accessor: (row) => row.mac_address ? <code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground">{row.mac_address}</code> : '-' },
|
header: "IP Address",
|
||||||
{ header: "Type", accessor: (row) => row.device_type || '-' },
|
accessor: (row) => (
|
||||||
{ header: "OS", accessor: (row) => row.os_label || '-' },
|
<button
|
||||||
{ header: "Manufacturer", accessor: (row) => row.manufacturer || '-' },
|
onClick={() => setSelectedDevice(row)}
|
||||||
{ header: "Download", accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>, className: "text-right" },
|
className="flex items-center gap-1.5 group text-left"
|
||||||
{ header: "Upload", accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>, className: "text-right" },
|
title="Click to view device details"
|
||||||
|
>
|
||||||
|
<span className="font-medium 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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "MAC Address",
|
||||||
|
accessor: (row) =>
|
||||||
|
row.mac_address ? (
|
||||||
|
<code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground">
|
||||||
|
{row.mac_address}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ header: "Type", accessor: (row) => row.device_type || "-" },
|
||||||
|
{ header: "OS", accessor: (row) => row.os_label || "-" },
|
||||||
|
{ header: "Manufacturer", accessor: (row) => row.manufacturer || "-" },
|
||||||
|
{
|
||||||
|
header: "Download",
|
||||||
|
accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>,
|
||||||
|
className: "text-right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Upload",
|
||||||
|
accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>,
|
||||||
|
className: "text-right",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,13 +70,18 @@ export default function DevicesPage() {
|
|||||||
Devices
|
Devices
|
||||||
{devices.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
{devices.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground mt-1">Monitor and manage connected network devices.</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
<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">
|
<CardHeader className="border-b border-border/50 pb-4 mb-4">
|
||||||
<CardTitle className="text-lg text-white">Active Devices {devices.data ? `(${devices.data.length})` : ''}</CardTitle>
|
<CardTitle className="text-lg text-white">
|
||||||
|
Active Devices {devices.data ? `(${devices.data.length})` : ""}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -51,14 +90,26 @@ export default function DevicesPage() {
|
|||||||
searchPlaceholder="Search devices by IP, MAC, OS..."
|
searchPlaceholder="Search devices by IP, MAC, OS..."
|
||||||
searchFilter={(row, query) => {
|
searchFilter={(row, query) => {
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
return (row.ip_address || "").toLowerCase().includes(q) ||
|
return (
|
||||||
(row.mac_address || "").toLowerCase().includes(q) ||
|
(row.ip_address || "").toLowerCase().includes(q) ||
|
||||||
(row.os_label || "").toLowerCase().includes(q) ||
|
(row.mac_address || "").toLowerCase().includes(q) ||
|
||||||
(row.device_type || "").toLowerCase().includes(q);
|
(row.os_label || "").toLowerCase().includes(q) ||
|
||||||
|
(row.device_type || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Device Detail Modal */}
|
||||||
|
{selectedDevice && (
|
||||||
|
<DeviceDetailModal
|
||||||
|
ip={selectedDevice.ip_address}
|
||||||
|
deviceLabel={selectedDevice.device_label}
|
||||||
|
macAddress={selectedDevice.mac_address}
|
||||||
|
onClose={() => setSelectedDevice(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+97
-79
@@ -12,6 +12,7 @@ import {
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useDashboardSummary, useTimeline, useTopApps, useTopProtocols } from "@/lib/api";
|
import { useDashboardSummary, useTimeline, useTopApps, useTopProtocols } from "@/lib/api";
|
||||||
import { fmtBytes } from "@/lib/utils";
|
import { fmtBytes } from "@/lib/utils";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
const COLORS = ['#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#f85149'];
|
const COLORS = ['#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#f85149'];
|
||||||
|
|
||||||
@@ -44,91 +45,108 @@ export default function SummaryPage() {
|
|||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-primary/30 transition-all duration-300 cursor-default animate-slide-up" style={{ animationDelay: '0ms' }}>
|
<Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-xl">
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
<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' }}>
|
||||||
<Download className="h-16 w-16 text-blue-500" />
|
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||||
</div>
|
<Download className="h-16 w-16 text-blue-500" />
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 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">
|
|
||||||
{summary.data ? fmtBytes(summary.data.bandwidth_down) : "0 B"}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||||
</Card>
|
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-blue-400 transition-colors">Download</CardTitle>
|
||||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-emerald-500/30 transition-all duration-300 cursor-default animate-slide-up" style={{ animationDelay: '50ms' }}>
|
<Download className="h-4 w-4 text-blue-500" />
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
</CardHeader>
|
||||||
<Upload className="h-16 w-16 text-emerald-500" />
|
<CardContent className="relative z-10">
|
||||||
</div>
|
<div className="text-3xl font-bold text-white tracking-tight">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
{summary.data ? fmtBytes(summary.data.bandwidth_down) : "0 B"}
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-emerald-400 transition-colors">Upload</CardTitle>
|
</div>
|
||||||
<Upload className="h-4 w-4 text-emerald-500" />
|
</CardContent>
|
||||||
</CardHeader>
|
</Card>
|
||||||
<CardContent className="relative z-10">
|
</Link>
|
||||||
<div className="text-3xl font-bold text-white tracking-tight">
|
|
||||||
{summary.data ? fmtBytes(summary.data.bandwidth_up) : "0 B"}
|
<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' }}>
|
||||||
|
<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>
|
</div>
|
||||||
</CardContent>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||||
</Card>
|
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-emerald-400 transition-colors">Upload</CardTitle>
|
||||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-orange-500/30 transition-all duration-300 cursor-default animate-slide-up" style={{ animationDelay: '100ms' }}>
|
<Upload className="h-4 w-4 text-emerald-500" />
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
</CardHeader>
|
||||||
<Server className="h-16 w-16 text-orange-500" />
|
<CardContent className="relative z-10">
|
||||||
</div>
|
<div className="text-3xl font-bold text-white tracking-tight">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
{summary.data ? fmtBytes(summary.data.bandwidth_up) : "0 B"}
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-orange-400 transition-colors">Devices</CardTitle>
|
</div>
|
||||||
<Server className="h-4 w-4 text-orange-500" />
|
</CardContent>
|
||||||
</CardHeader>
|
</Card>
|
||||||
<CardContent className="relative z-10">
|
</Link>
|
||||||
<div className="text-3xl font-bold text-white tracking-tight">
|
|
||||||
{summary.data?.total_devices?.toLocaleString() || "0"}
|
<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' }}>
|
||||||
|
<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>
|
</div>
|
||||||
</CardContent>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||||
</Card>
|
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-orange-400 transition-colors">Devices</CardTitle>
|
||||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-purple-500/30 transition-all duration-300 cursor-default animate-slide-up" style={{ animationDelay: '150ms' }}>
|
<Server className="h-4 w-4 text-orange-500" />
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
</CardHeader>
|
||||||
<Activity className="h-16 w-16 text-purple-500" />
|
<CardContent className="relative z-10">
|
||||||
</div>
|
<div className="text-3xl font-bold text-white tracking-tight">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
{summary.data?.total_devices?.toLocaleString() || "0"}
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-purple-400 transition-colors">Flows</CardTitle>
|
</div>
|
||||||
<Activity className="h-4 w-4 text-purple-500" />
|
</CardContent>
|
||||||
</CardHeader>
|
</Card>
|
||||||
<CardContent className="relative z-10">
|
</Link>
|
||||||
<div className="text-3xl font-bold text-white tracking-tight">
|
|
||||||
{summary.data?.active_flows?.toLocaleString() || "0"}
|
<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' }}>
|
||||||
|
<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>
|
</div>
|
||||||
</CardContent>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||||
</Card>
|
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-purple-400 transition-colors">Flows</CardTitle>
|
||||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-red-500/20 hover:bg-card/100 hover:border-red-500/50 transition-all duration-300 cursor-default animate-slide-up" style={{ animationDelay: '200ms' }}>
|
<Activity className="h-4 w-4 text-purple-500" />
|
||||||
<div className="absolute inset-0 bg-red-500/5 pointer-events-none" />
|
</CardHeader>
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-red-500">
|
<CardContent className="relative z-10">
|
||||||
<ShieldAlert className="h-16 w-16" />
|
<div className="text-3xl font-bold text-white tracking-tight">
|
||||||
</div>
|
{summary.data?.active_flows?.toLocaleString() || "0"}
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
</div>
|
||||||
<CardTitle className="text-sm font-medium text-red-400 group-hover:text-red-300 transition-colors">Threats</CardTitle>
|
</CardContent>
|
||||||
<ShieldAlert className="h-4 w-4 text-red-500 animate-pulse" />
|
</Card>
|
||||||
</CardHeader>
|
</Link>
|
||||||
<CardContent className="relative z-10">
|
|
||||||
<div className="text-3xl font-bold text-red-500 tracking-tight">
|
<Link href="/threats" className="block focus:outline-none focus:ring-2 focus:ring-red-500/50 rounded-xl">
|
||||||
{summary.data?.total_threats?.toLocaleString() || "0"}
|
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-red-500/20 hover:bg-card/100 hover: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/5 pointer-events-none" />
|
||||||
|
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-red-500">
|
||||||
|
<ShieldAlert className="h-16 w-16" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||||
</Card>
|
<CardTitle className="text-sm font-medium text-red-400 group-hover:text-red-300 transition-colors">Threats</CardTitle>
|
||||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-yellow-500/30 transition-all duration-300 cursor-default animate-slide-up" style={{ animationDelay: '250ms' }}>
|
<ShieldAlert className="h-4 w-4 text-red-500 animate-pulse" />
|
||||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-yellow-500">
|
</CardHeader>
|
||||||
<Zap className="h-16 w-16" />
|
<CardContent className="relative z-10">
|
||||||
</div>
|
<div className="text-3xl font-bold text-red-500 tracking-tight">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
{summary.data?.total_threats?.toLocaleString() || "0"}
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-yellow-400 transition-colors">Events</CardTitle>
|
</div>
|
||||||
<Zap className="h-4 w-4 text-yellow-500" />
|
</CardContent>
|
||||||
</CardHeader>
|
</Card>
|
||||||
<CardContent className="relative z-10">
|
</Link>
|
||||||
<div className="text-3xl font-bold text-white tracking-tight">
|
|
||||||
{summary.data?.total_events?.toLocaleString() || "0"}
|
<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' }}>
|
||||||
|
<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>
|
</div>
|
||||||
</CardContent>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||||
</Card>
|
<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">
|
||||||
|
{summary.data?.total_events?.toLocaleString() || "0"}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-7 animate-slide-up" style={{ animationDelay: '300ms' }}>
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-7 animate-slide-up" style={{ animationDelay: '300ms' }}>
|
||||||
|
|||||||
+205
-18
@@ -1,40 +1,69 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTlsVersions, useTlsCiphers, useTlsSecurity } from "@/lib/api";
|
import {
|
||||||
|
useTlsVersions,
|
||||||
|
useTlsCiphers,
|
||||||
|
useTlsSecurity,
|
||||||
|
fetchSecurityDevices,
|
||||||
|
SecurityDevice,
|
||||||
|
} from "@/lib/api";
|
||||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { Loader2, ShieldCheck, ShieldAlert, ShieldX } from "lucide-react";
|
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||||
|
import { Loader2, ShieldCheck, ShieldAlert, ShieldX, AlertTriangle, CheckCircle2, Info } from "lucide-react";
|
||||||
import { fmtBytes } from "@/lib/utils";
|
import { fmtBytes } from "@/lib/utils";
|
||||||
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from "recharts";
|
import {
|
||||||
|
PieChart, Pie, Cell, Tooltip as RechartsTooltip, ResponsiveContainer,
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
Recommended: "#3fb950",
|
Recommended: "#3fb950",
|
||||||
Secure: "#58a6ff",
|
Secure: "#58a6ff",
|
||||||
Weak: "#f0883e",
|
Weak: "#f0883e",
|
||||||
Insecure: "#f85149"
|
Insecure: "#f85149",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RISK_CONFIG = {
|
||||||
|
Rawan: { color: "text-red-400", bg: "bg-red-500/20 ring-red-500/40", icon: <ShieldX className="w-3.5 h-3.5" />, dot: "bg-red-500" },
|
||||||
|
Sedang: { color: "text-orange-400", bg: "bg-orange-500/20 ring-orange-500/40", icon: <ShieldAlert className="w-3.5 h-3.5" />, dot: "bg-orange-500" },
|
||||||
|
Aman: { color: "text-green-400", bg: "bg-green-500/20 ring-green-500/40", icon: <ShieldCheck className="w-3.5 h-3.5" />, dot: "bg-green-500" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
export default function SecurityAuditPage() {
|
export default function SecurityAuditPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const versions = useTlsVersions(10);
|
const versions = useTlsVersions(10);
|
||||||
const ciphers = useTlsCiphers(20);
|
const ciphers = useTlsCiphers(20);
|
||||||
const security = useTlsSecurity(10);
|
const security = useTlsSecurity(10);
|
||||||
|
|
||||||
|
const [secDevices, setSecDevices] = useState<SecurityDevice[]>([]);
|
||||||
|
const [secDevicesLoading, setSecDevicesLoading] = useState(true);
|
||||||
|
const [selectedDevice, setSelectedDevice] = useState<SecurityDevice | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
fetchSecurityDevices()
|
||||||
|
.then(setSecDevices)
|
||||||
|
.catch(() => setSecDevices([]))
|
||||||
|
.finally(() => setSecDevicesLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
const isLoading = versions.isLoading || ciphers.isLoading || security.isLoading;
|
const isLoading = versions.isLoading || ciphers.isLoading || security.isLoading;
|
||||||
|
|
||||||
|
// Risk summary counts
|
||||||
|
const rawan = secDevices.filter((d) => d.risk_level === "Rawan").length;
|
||||||
|
const sedang = secDevices.filter((d) => d.risk_level === "Sedang").length;
|
||||||
|
const aman = secDevices.filter((d) => d.risk_level === "Aman").length;
|
||||||
|
|
||||||
const versionColumns: Column<typeof versions.data[0]>[] = [
|
const versionColumns: Column<typeof versions.data[0]>[] = [
|
||||||
{ header: "#", accessor: (row, i) => i + 1 },
|
{ header: "#", accessor: (row, i) => i + 1 },
|
||||||
{ header: "TLS Version", accessor: (row) => row.tls_version },
|
{ header: "TLS Version", accessor: (row) => row.tls_version },
|
||||||
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span> },
|
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span> },
|
||||||
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span> },
|
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span> },
|
||||||
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span> }
|
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const cipherColumns: Column<typeof ciphers.data[0]>[] = [
|
const cipherColumns: Column<typeof ciphers.data[0]>[] = [
|
||||||
@@ -42,16 +71,95 @@ export default function SecurityAuditPage() {
|
|||||||
{ header: "Cipher Suite", accessor: (row) => <span className="text-purple-400">{row.tls_cipher}</span> },
|
{ header: "Cipher Suite", accessor: (row) => <span className="text-purple-400">{row.tls_cipher}</span> },
|
||||||
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span> },
|
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span> },
|
||||||
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span> },
|
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span> },
|
||||||
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span> }
|
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const secDeviceColumns: Column<SecurityDevice>[] = [
|
||||||
|
{ header: "#", accessor: (_, i) => i + 1 },
|
||||||
|
{
|
||||||
|
header: "IP Address",
|
||||||
|
accessor: (row) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedDevice(row)}
|
||||||
|
className="font-mono text-sm text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1 group"
|
||||||
|
>
|
||||||
|
{row.ip_address}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Device",
|
||||||
|
accessor: (row) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm text-white">{row.device_label || row.ip_address}</span>
|
||||||
|
{row.device_type && <span className="text-xs text-slate-400">{row.device_type}</span>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "OS / Manufacturer",
|
||||||
|
accessor: (row) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs text-blue-400">{row.os_label || "-"}</span>
|
||||||
|
<span className="text-xs text-slate-500">{row.manufacturer || ""}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Encrypted %",
|
||||||
|
accessor: (row) => {
|
||||||
|
const pct = row.encrypted_pct ?? 0;
|
||||||
|
const color = pct >= 80 ? "text-green-400" : pct >= 50 ? "text-orange-400" : "text-red-400";
|
||||||
|
const bar = pct >= 80 ? "from-green-500 to-emerald-400" : pct >= 50 ? "from-orange-500 to-yellow-400" : "from-red-500 to-pink-400";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 min-w-[100px]">
|
||||||
|
<span className={`text-sm font-semibold ${color}`}>{pct.toFixed(1)}%</span>
|
||||||
|
<div className="h-1.5 bg-white/5 rounded-full overflow-hidden w-24">
|
||||||
|
<div className={`h-full bg-gradient-to-r ${bar} rounded-full`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Traffic",
|
||||||
|
accessor: (row) => (
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
<span className="text-cyan-400">{fmtBytes(row.encrypted)}</span>
|
||||||
|
<span className="text-slate-600"> / </span>
|
||||||
|
<span className="text-red-400">{fmtBytes(row.unencrypted)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Risk Level",
|
||||||
|
accessor: (row) => {
|
||||||
|
const cfg = RISK_CONFIG[row.risk_level] ?? RISK_CONFIG.Aman;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ring-1 ${cfg.bg} ${cfg.color}`}>
|
||||||
|
{cfg.icon}
|
||||||
|
{row.risk_level}
|
||||||
|
{row.has_insecure && (
|
||||||
|
<span title="Uses insecure protocol">
|
||||||
|
<AlertTriangle className="w-3 h-3 text-yellow-400 ml-0.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
<div>
|
||||||
Security Audit (TLS)
|
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
Security Audit
|
||||||
</h2>
|
{(isLoading || secDevicesLoading) && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">TLS encryption analysis and device-level risk assessment.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Security Overview Cards */}
|
{/* Security Overview Cards */}
|
||||||
@@ -68,14 +176,83 @@ export default function SecurityAuditPage() {
|
|||||||
{item.tls_security === "Insecure" && <ShieldX className="h-4 w-4 text-red-500" />}
|
{item.tls_security === "Insecure" && <ShieldX className="h-4 w-4 text-red-500" />}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-white">
|
<div className="text-2xl font-bold text-white">{fmtBytes(item.total)}</div>
|
||||||
{fmtBytes(item.total)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Device Risk Summary Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card className="bg-red-950/20 border-red-500/20 backdrop-blur-sm">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-red-300">Rawan (Vulnerable)</CardTitle>
|
||||||
|
<ShieldX className="h-4 w-4 text-red-400" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-red-400">{rawan}</div>
|
||||||
|
<p className="text-xs text-red-300/60 mt-1">device{rawan !== 1 ? "s" : ""} at risk</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-orange-950/20 border-orange-500/20 backdrop-blur-sm">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-orange-300">Sedang (Moderate)</CardTitle>
|
||||||
|
<ShieldAlert className="h-4 w-4 text-orange-400" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-orange-400">{sedang}</div>
|
||||||
|
<p className="text-xs text-orange-300/60 mt-1">device{sedang !== 1 ? "s" : ""} moderate risk</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-green-950/20 border-green-500/20 backdrop-blur-sm">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-green-300">Aman (Secure)</CardTitle>
|
||||||
|
<ShieldCheck className="h-4 w-4 text-green-400" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-green-400">{aman}</div>
|
||||||
|
<p className="text-xs text-green-300/60 mt-1">device{aman !== 1 ? "s" : ""} secure</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device Risk Table */}
|
||||||
|
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
Device Risk Assessment
|
||||||
|
{secDevicesLoading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||||
|
<span className="ml-auto text-xs text-slate-400 font-normal flex items-center gap-1">
|
||||||
|
<Info className="w-3 h-3" />
|
||||||
|
Based on encryption audit data. Click IP to inspect device.
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{secDevices.length === 0 && !secDevicesLoading ? (
|
||||||
|
<div className="text-center py-10 text-slate-500 text-sm">
|
||||||
|
No encryption audit data available yet. The scheduler will populate this table automatically.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={secDevices}
|
||||||
|
columns={secDeviceColumns}
|
||||||
|
searchPlaceholder="Search by IP, device type, OS..."
|
||||||
|
searchFilter={(row, query) => {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
(row.ip_address || "").toLowerCase().includes(q) ||
|
||||||
|
(row.device_label || "").toLowerCase().includes(q) ||
|
||||||
|
(row.device_type || "").toLowerCase().includes(q) ||
|
||||||
|
(row.os_label || "").toLowerCase().includes(q) ||
|
||||||
|
(row.risk_level || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{/* TLS Security Pie Chart */}
|
{/* TLS Security Pie Chart */}
|
||||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||||
@@ -101,8 +278,8 @@ export default function SecurityAuditPage() {
|
|||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<RechartsTooltip
|
<RechartsTooltip
|
||||||
contentStyle={{ backgroundColor: '#161b22', borderColor: '#30363d', color: '#c9d1d9' }}
|
contentStyle={{ backgroundColor: "#161b22", borderColor: "#30363d", color: "#c9d1d9" }}
|
||||||
formatter={(value: any) => [fmtBytes(value as number), 'Volume']}
|
formatter={(value: any) => [fmtBytes(value as number), "Volume"]}
|
||||||
/>
|
/>
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -144,9 +321,9 @@ export default function SecurityAuditPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<RechartsTooltip
|
<RechartsTooltip
|
||||||
cursor={{fill: '#21262d'}}
|
cursor={{ fill: "#21262d" }}
|
||||||
contentStyle={{ backgroundColor: '#161b22', borderColor: '#30363d', color: '#c9d1d9' }}
|
contentStyle={{ backgroundColor: "#161b22", borderColor: "#30363d", color: "#c9d1d9" }}
|
||||||
formatter={(value: any) => [fmtBytes(value as number), 'Volume']}
|
formatter={(value: any) => [fmtBytes(value as number), "Volume"]}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="total" fill="#bc8cff" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="total" fill="#bc8cff" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
@@ -187,6 +364,16 @@ export default function SecurityAuditPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Device Detail Modal */}
|
||||||
|
{selectedDevice && (
|
||||||
|
<DeviceDetailModal
|
||||||
|
ip={selectedDevice.ip_address}
|
||||||
|
deviceLabel={selectedDevice.device_label}
|
||||||
|
macAddress={selectedDevice.mac_address}
|
||||||
|
onClose={() => setSelectedDevice(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { fetchAgentDetails, AgentDetails } from "@/lib/api";
|
||||||
|
import { fmtBytes } from "@/lib/utils";
|
||||||
|
import { Loader2, X, Server, Monitor, Activity, Globe, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
agentUuid: string;
|
||||||
|
agentLabel: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = "devices" | "flows" | "apps";
|
||||||
|
|
||||||
|
export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||||
|
const [data, setData] = useState<AgentDetails | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>("devices");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetchAgentDetails(agentUuid)
|
||||||
|
.then(setData)
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [agentUuid]);
|
||||||
|
|
||||||
|
const tabs: { key: Tab; label: string; icon: React.ReactNode; count?: number }[] = [
|
||||||
|
{ key: "devices", label: "Devices", icon: <Monitor className="w-4 h-4" />, count: data?.devices.length },
|
||||||
|
{ key: "flows", label: "Active Flows", icon: <Activity className="w-4 h-4" />, count: data?.flows.length },
|
||||||
|
{ key: "apps", label: "Top Apps", icon: <Globe className="w-4 h-4" />, count: data?.top_apps.length },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||||
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
|
<div
|
||||||
|
className="relative bg-[#0d1117] border border-white/10 rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl shadow-black/60 animate-slide-up"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-gradient-to-r from-blue-900/30 to-purple-900/20 rounded-t-2xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-500/20 rounded-lg ring-1 ring-blue-500/30">
|
||||||
|
<Server className="w-5 h-5 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">{agentLabel}</h3>
|
||||||
|
<p className="text-xs text-slate-400 font-mono">{agentUuid}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
|
||||||
|
<p className="text-slate-400 text-sm">Loading agent data...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||||
|
) : data ? (
|
||||||
|
<>
|
||||||
|
{/* KPI Summary row */}
|
||||||
|
<div className="grid grid-cols-4 gap-4 px-6 py-4 border-b border-white/5">
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
|
||||||
|
<p className="text-xs text-slate-400 mb-1">Devices</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{data.devices.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
|
||||||
|
<p className="text-xs text-slate-400 mb-1">Active Flows</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{data.summary?.active_flows ?? data.flows.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
|
||||||
|
<p className="text-xs text-slate-400 mb-1">Total Download</p>
|
||||||
|
<p className="text-2xl font-bold text-cyan-400">{fmtBytes(data.summary?.bandwidth_down ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
|
||||||
|
<p className="text-xs text-slate-400 mb-1">Total Upload</p>
|
||||||
|
<p className="text-2xl font-bold text-green-400">{fmtBytes(data.summary?.bandwidth_up ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 px-6 py-3 border-b border-white/5">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? "bg-blue-500/20 text-blue-300 ring-1 ring-blue-500/40"
|
||||||
|
: "text-slate-400 hover:text-white hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
{tab.count !== undefined && (
|
||||||
|
<span className="ml-1 px-1.5 py-0.5 bg-white/10 rounded text-xs text-slate-300">
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
{activeTab === "devices" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.devices.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm text-center py-8">No devices found for this agent.</p>
|
||||||
|
) : (
|
||||||
|
data.devices.map((d, i) => {
|
||||||
|
const riskCfg = d.risk_level === "Rawan" || d.risk_level === "Critical"
|
||||||
|
? { color: "text-red-400", bg: "bg-red-500/10 ring-red-500/30", dot: "bg-red-400", label: d.risk_level }
|
||||||
|
: d.risk_level === "Sedang" || d.risk_level === "Medium"
|
||||||
|
? { color: "text-yellow-400", bg: "bg-yellow-500/10 ring-yellow-500/30", dot: "bg-yellow-400", label: d.risk_level }
|
||||||
|
: d.risk_level === "Aman" || d.risk_level === "Low"
|
||||||
|
? { color: "text-green-400", bg: "bg-green-500/10 ring-green-500/30", dot: "bg-green-400", label: d.risk_level }
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 rounded-lg transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 p-2 bg-slate-800/60 rounded-lg">
|
||||||
|
<Monitor className="w-4 h-4 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="font-medium text-white text-sm truncate">{d.device_label || d.ip_address || "Unknown"}</p>
|
||||||
|
{riskCfg && (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ring-1 ${riskCfg.bg} ${riskCfg.color}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${riskCfg.dot}`} />
|
||||||
|
{riskCfg.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{d.has_insecure && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ring-1 bg-orange-500/10 ring-orange-500/30 text-orange-400">
|
||||||
|
⚠ Insecure
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
<p className="text-xs text-slate-400 font-mono">{d.ip_address}</p>
|
||||||
|
{d.os_label && <p className="text-xs text-blue-400">{d.os_label}</p>}
|
||||||
|
{d.manufacturer && <p className="text-xs text-slate-500">{d.manufacturer}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex-shrink-0 space-y-0.5">
|
||||||
|
<p className="text-xs text-cyan-400">{fmtBytes(d.download ?? 0)}</p>
|
||||||
|
{d.encrypted_pct !== null && (
|
||||||
|
<p className="text-xs text-slate-500">🔒 {d.encrypted_pct}%</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "flows" && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
{data.flows.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm text-center py-8">No flows found for this agent.</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left border-b border-white/10">
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400">Source IP</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400">Destination</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400">App / Domain</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400">Protocol</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Download</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Upload</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{data.flows.map((f, i) => (
|
||||||
|
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs text-blue-400">{f.src_ip || "-"}</td>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs text-slate-300">
|
||||||
|
{f.dst_ip || "-"}
|
||||||
|
{f.dst_port ? <span className="text-slate-500">:{f.dst_port}</span> : ""}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-xs text-purple-300 max-w-[140px] truncate">
|
||||||
|
{f.app_label || f.domain || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-xs text-slate-400">{f.protocol || "-"}</td>
|
||||||
|
<td className="py-2 pr-4 text-right text-xs text-cyan-400">{fmtBytes(f.download)}</td>
|
||||||
|
<td className="py-2 text-right text-xs text-green-400">{fmtBytes(f.upload)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "apps" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.top_apps.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm text-center py-8">No application data for this agent.</p>
|
||||||
|
) : (
|
||||||
|
data.top_apps.map((a, i) => {
|
||||||
|
const total = a.download + a.upload;
|
||||||
|
const maxTotal = Math.max(...data.top_apps.map(x => x.download + x.upload), 1);
|
||||||
|
const pct = (total / maxTotal) * 100;
|
||||||
|
return (
|
||||||
|
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 rounded-lg transition-colors">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
{a.favicon ? (
|
||||||
|
<img src={a.favicon} alt={a.app_label} className="w-5 h-5 rounded bg-white p-0.5 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<div className="w-5 h-5 rounded bg-slate-700 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-white text-sm flex-1">{a.app_label}</span>
|
||||||
|
{a.category && <span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded">{a.category}</span>}
|
||||||
|
<span className="text-xs font-mono text-white">{fmtBytes(total)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 text-xs text-slate-400">
|
||||||
|
<span className="text-cyan-400">↓ {fmtBytes(a.download)}</span>
|
||||||
|
<span className="text-green-400">↑ {fmtBytes(a.upload)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1 bg-white/5 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { fetchAppDetails, AppDetails } from "@/lib/api";
|
||||||
|
import { fmtBytes } from "@/lib/utils";
|
||||||
|
import { Loader2, X, Globe, Server, Monitor } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
appLabel: string;
|
||||||
|
favicon?: string | null;
|
||||||
|
category?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppDetailModal({ appLabel, favicon, category, onClose }: Props) {
|
||||||
|
const [data, setData] = useState<AppDetails | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetchAppDetails(appLabel)
|
||||||
|
.then(setData)
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [appLabel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||||
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
|
<div
|
||||||
|
className="relative bg-[#0d1117] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[85vh] flex flex-col shadow-2xl shadow-black/60 animate-slide-up"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-gradient-to-r from-green-900/30 to-emerald-900/20 rounded-t-2xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-green-500/20 rounded-lg ring-1 ring-green-500/30">
|
||||||
|
{favicon ? (
|
||||||
|
<img src={favicon} alt={appLabel} className="w-5 h-5 rounded" />
|
||||||
|
) : (
|
||||||
|
<Globe className="w-5 h-5 text-green-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">{appLabel}</h3>
|
||||||
|
{category && <p className="text-xs text-slate-400">{category}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-green-400" />
|
||||||
|
<p className="text-slate-400 text-sm">Loading application data...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||||
|
) : data ? (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
|
||||||
|
{/* Total Summary */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 p-4 bg-white/[0.03] border border-white/5 rounded-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-slate-500">Total Download</p>
|
||||||
|
<p className="text-xl font-bold text-cyan-400">{fmtBytes(data.total_download ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-slate-500">Total Upload</p>
|
||||||
|
<p className="text-xl font-bold text-green-400">{fmtBytes(data.total_upload ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-Agent Breakdown */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||||
|
<Server className="w-4 h-4 text-blue-400" />
|
||||||
|
Usage per Agent
|
||||||
|
</h4>
|
||||||
|
{data.agent_scorecard.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm text-center py-4">No traffic from known agents for this app yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.agent_scorecard.map((a, i) => {
|
||||||
|
const maxDl = Math.max(...data.agent_scorecard.map(x => x.download), 1);
|
||||||
|
const pct = (a.download / maxDl) * 100;
|
||||||
|
return (
|
||||||
|
<div key={i} className="p-3 bg-white/[0.03] border border-white/5 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-400" />
|
||||||
|
<span className="text-sm font-medium text-white">{a.agent_label}</span>
|
||||||
|
{a.agent_uuid && (
|
||||||
|
<span className="text-xs font-mono text-slate-500">{a.agent_uuid}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs font-mono">
|
||||||
|
<span className="text-cyan-400">↓ {fmtBytes(a.download)}</span>
|
||||||
|
<span className="text-green-400">↑ {fmtBytes(a.upload ?? 0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-white/5 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top IPs */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||||
|
<Monitor className="w-4 h-4 text-cyan-400" />
|
||||||
|
{data.has_domain_breakdown ? "Top Devices (via Domain Match)" : "Top Devices / IPs"}
|
||||||
|
{data.top_ips.filter(ip => ip.blacklisted).length > 0 && (
|
||||||
|
<span className="ml-auto text-xs text-red-400 bg-red-500/10 px-2 py-0.5 rounded-full ring-1 ring-red-500/30">
|
||||||
|
🚫 {data.top_ips.filter(ip => ip.blacklisted).length} Blacklisted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
{data.top_ips.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm text-center py-4">No device data available.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left border-b border-white/10">
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400">#</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400">IP Address</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Download</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Upload</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400">Usage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{data.top_ips.map((ip, i) => {
|
||||||
|
const maxDl = Math.max(...data.top_ips.map(x => x.download), 1);
|
||||||
|
const pct = (ip.download / maxDl) * 100;
|
||||||
|
return (
|
||||||
|
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
||||||
|
<td className="py-2 pr-4 text-xs text-slate-500">{i + 1}</td>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span className={ip.blacklisted ? "text-red-400" : "text-blue-400"}>{ip.ip_address || "-"}</span>
|
||||||
|
{ip.blacklisted && (
|
||||||
|
<span className="text-xs text-red-300 bg-red-500/10 px-1.5 py-0.5 rounded-full ring-1 ring-red-500/30">🚫</span>
|
||||||
|
)}
|
||||||
|
{!ip.blacklisted && ip.reputation && ip.reputation !== "Unknown" && (
|
||||||
|
<span className="text-xs text-slate-400 bg-white/5 px-1.5 py-0.5 rounded">{ip.reputation}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right text-xs text-cyan-400">{fmtBytes(ip.download)}</td>
|
||||||
|
<td className="py-2 pr-4 text-right text-xs text-green-400">{fmtBytes(ip.upload ?? 0)}</td>
|
||||||
|
<td className="py-2 w-32">
|
||||||
|
<div className="h-1.5 bg-white/5 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${ip.blacklisted ? "bg-red-500" : "bg-gradient-to-r from-cyan-500 to-green-500"}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { fetchDeviceDetails, DeviceDetails } from "@/lib/api";
|
||||||
|
import { fmtBytes } from "@/lib/utils";
|
||||||
|
import { Loader2, X, Monitor, Globe, Activity } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ip: string;
|
||||||
|
deviceLabel?: string | null;
|
||||||
|
macAddress?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = "apps" | "flows";
|
||||||
|
|
||||||
|
export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Props) {
|
||||||
|
const [data, setData] = useState<DeviceDetails | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>("apps");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetchDeviceDetails(ip)
|
||||||
|
.then(setData)
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [ip]);
|
||||||
|
|
||||||
|
const tabs: { key: Tab; label: string; icon: React.ReactNode; count?: number }[] = [
|
||||||
|
{ key: "apps", label: "Applications", icon: <Globe className="w-4 h-4" />, count: data?.top_apps.length },
|
||||||
|
{ key: "flows", label: "Active Flows", icon: <Activity className="w-4 h-4" />, count: data?.flows.length },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||||
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
|
<div
|
||||||
|
className="relative bg-[#0d1117] border border-white/10 rounded-2xl w-full max-w-3xl max-h-[85vh] flex flex-col shadow-2xl shadow-black/60 animate-slide-up"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-gradient-to-r from-cyan-900/30 to-blue-900/20 rounded-t-2xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-cyan-500/20 rounded-lg ring-1 ring-cyan-500/30">
|
||||||
|
<Monitor className="w-5 h-5 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">{deviceLabel || ip}</h3>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
<p className="text-xs text-blue-400 font-mono">{ip}</p>
|
||||||
|
{macAddress && <p className="text-xs text-slate-400 font-mono">{macAddress}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-cyan-400" />
|
||||||
|
<p className="text-slate-400 text-sm">Loading device data...</p>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||||
|
) : data ? (
|
||||||
|
<>
|
||||||
|
{/* KPI Summary */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 px-6 py-3 border-b border-white/5 bg-white/[0.02]">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-slate-500">Total Download</p>
|
||||||
|
<p className="text-lg font-bold text-cyan-400">{fmtBytes(data.total_download ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-slate-500">Total Upload</p>
|
||||||
|
<p className="text-lg font-bold text-green-400">{fmtBytes(data.total_upload ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-slate-500">Total Flows</p>
|
||||||
|
<p className="text-lg font-bold text-white">{data.flow_count ?? data.flows.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Info Panel */}
|
||||||
|
{data.security_info && (data.security_info.risk_level || data.security_info.os_label || data.security_info.device_type || data.security_info.insecure_protocols.length > 0 || data.security_info.blacklisted) && (() => {
|
||||||
|
const si = data.security_info!;
|
||||||
|
const riskColor = si.risk_level === "Rawan" || si.risk_level === "Critical" ? "text-red-400 bg-red-500/10 ring-red-500/30"
|
||||||
|
: si.risk_level === "Sedang" || si.risk_level === "Medium" ? "text-yellow-400 bg-yellow-500/10 ring-yellow-500/30"
|
||||||
|
: "text-green-400 bg-green-500/10 ring-green-500/30";
|
||||||
|
const encPct = si.encrypted_pct ?? 0;
|
||||||
|
return (
|
||||||
|
<div className="mx-6 my-3 p-4 bg-white/[0.02] border border-white/8 rounded-xl space-y-3">
|
||||||
|
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Security Info</p>
|
||||||
|
|
||||||
|
{/* Row 1: Device Identity */}
|
||||||
|
{(si.device_type || si.os_label || si.manufacturer) && (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{si.device_type && <span className="text-xs text-slate-300 bg-white/5 px-2 py-0.5 rounded">{si.device_type}</span>}
|
||||||
|
{si.os_label && <span className="text-xs text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded">{si.os_label}</span>}
|
||||||
|
{si.manufacturer && <span className="text-xs text-slate-400">{si.manufacturer}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 2: Encryption bar */}
|
||||||
|
{si.encrypted_pct !== null && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs text-slate-400">Enkripsi Traffic</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ring-1 ${riskColor}`}>
|
||||||
|
{si.risk_level}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white font-mono">{encPct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-white/5 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${encPct >= 80 ? "bg-green-500" : encPct >= 50 ? "bg-yellow-500" : "bg-red-500"}`}
|
||||||
|
style={{ width: `${encPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-1 text-xs text-slate-500">
|
||||||
|
<span>Terenkripsi: {fmtBytes(si.encrypted_bytes ?? 0)}</span>
|
||||||
|
<span>Tidak: {fmtBytes(si.unencrypted_bytes ?? 0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 3: Warnings */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{si.insecure_protocols.length > 0 && si.insecure_protocols.map((p, i) => (
|
||||||
|
<span key={i} className="inline-flex items-center gap-1 text-xs text-orange-300 bg-orange-500/10 px-2 py-0.5 rounded-full ring-1 ring-orange-500/30">
|
||||||
|
⚠ {p.protocol} ({p.risk})
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{si.blacklisted && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-red-300 bg-red-500/10 px-2 py-0.5 rounded-full ring-1 ring-red-500/30">
|
||||||
|
🚫 Blacklisted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{si.reputation && si.reputation !== "Unknown" && !si.blacklisted && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-slate-300 bg-white/5 px-2 py-0.5 rounded-full">
|
||||||
|
Reputasi: {si.reputation}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 px-6 py-3 border-b border-white/5">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? "bg-cyan-500/20 text-cyan-300 ring-1 ring-cyan-500/40"
|
||||||
|
: "text-slate-400 hover:text-white hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
{tab.count !== undefined && (
|
||||||
|
<span className="ml-1 px-1.5 py-0.5 bg-white/10 rounded text-xs text-slate-300">
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
{activeTab === "apps" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.top_apps.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm text-center py-8">No application data for this device. It may not have active flows yet.</p>
|
||||||
|
) : (
|
||||||
|
data.top_apps.map((a, i) => {
|
||||||
|
const total = a.download + a.upload;
|
||||||
|
const maxTotal = Math.max(...data.top_apps.map(x => x.download + x.upload), 1);
|
||||||
|
const pct = (total / maxTotal) * 100;
|
||||||
|
return (
|
||||||
|
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 rounded-lg transition-colors">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
{a.favicon ? (
|
||||||
|
<img src={a.favicon} alt={a.app_label} className="w-5 h-5 rounded bg-white p-0.5 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<div className="w-5 h-5 rounded bg-slate-700 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-white text-sm flex-1">{a.app_label}</span>
|
||||||
|
{a.category && (
|
||||||
|
<span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded">{a.category}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-mono text-white">{fmtBytes(total)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 text-xs mb-2">
|
||||||
|
<span className="text-cyan-400">↓ {fmtBytes(a.download)}</span>
|
||||||
|
<span className="text-green-400">↑ {fmtBytes(a.upload)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 bg-white/5 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 rounded-full"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "flows" && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
{data.flows.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm text-center py-8">No active flows for this device.</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left border-b border-white/10">
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400">Destination IP</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400">Port</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400">App / Domain</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400">Protocol</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Download</th>
|
||||||
|
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Upload</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{data.flows.map((f, i) => (
|
||||||
|
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs text-blue-400">{f.dst_ip || "-"}</td>
|
||||||
|
<td className="py-2 pr-4 text-xs text-slate-400">{f.dst_port ?? "-"}</td>
|
||||||
|
<td className="py-2 pr-4 text-xs text-purple-300 max-w-[140px] truncate">
|
||||||
|
{(f as any).app_label || (f as any).domain || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-xs text-slate-300">{f.protocol || "-"}</td>
|
||||||
|
<td className="py-2 pr-4 text-right text-xs text-cyan-400">{fmtBytes(f.download)}</td>
|
||||||
|
<td className="py-2 text-right text-xs text-green-400">{fmtBytes(f.upload)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,17 +18,21 @@ const getHeaders = () => {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
|
|
||||||
// JWT Token access requires x-net-site
|
// Prioritize long-term API Key as recommended by Netify support team
|
||||||
if (JWT_TOKEN && SITE_UUID) {
|
if (API_KEY) {
|
||||||
headers["x-api-key"] = JWT_TOKEN;
|
|
||||||
headers["x-net-site"] = SITE_UUID;
|
|
||||||
} else if (API_KEY) {
|
|
||||||
headers["x-api-key"] = 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;
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
id: number;
|
id: number;
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -45,6 +49,67 @@ export interface Agent {
|
|||||||
|
|
||||||
export async function getAgents(): Promise<Agent[]> {
|
export async function getAgents(): Promise<Agent[]> {
|
||||||
try {
|
try {
|
||||||
|
const API_KEY = process.env.NETIFY_API_KEY;
|
||||||
|
const SITE_UUID = process.env.NETIFY_SITE_UUID;
|
||||||
|
|
||||||
|
// Fallback to Informatics Scorecard Agent if site-scoped API key is used
|
||||||
|
if (API_KEY && API_KEY.startsWith("sk_db_")) {
|
||||||
|
const headers = getHeaders();
|
||||||
|
const [dlRes, ulRes] = await Promise.all([
|
||||||
|
fetch('https://informatics.netify.ai/api/v1/data/stats/scorecard/agent/download', { headers, cache: "no-store" }),
|
||||||
|
fetch('https://informatics.netify.ai/api/v1/data/stats/scorecard/agent/upload', { headers, cache: "no-store" })
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!dlRes.ok) {
|
||||||
|
throw new Error(`Failed to fetch agents scorecard: ${dlRes.statusText} (${dlRes.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dlResult = await dlRes.json();
|
||||||
|
const ulResult = ulRes.ok ? await ulRes.json() : { data: [] };
|
||||||
|
|
||||||
|
const ulMap: Record<string, number> = {};
|
||||||
|
if (ulResult.data) {
|
||||||
|
for (const item of ulResult.data) {
|
||||||
|
if (item.agent?.uuid) {
|
||||||
|
ulMap[item.agent.uuid] = item.upload ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_LABELS: Record<string, string> = {
|
||||||
|
"2F-TF-1D-GK": "JRP Cibubur",
|
||||||
|
"8A-V3-PB-85": "IFG LT.18",
|
||||||
|
"F6-2V-DT-8A": "CPI Balaraja"
|
||||||
|
};
|
||||||
|
|
||||||
|
const agents: Agent[] = (dlResult.data || []).map((item: any, idx: number) => {
|
||||||
|
const uuid = item.agent?.uuid || `agent-${idx}`;
|
||||||
|
const dl = item.download ?? 0;
|
||||||
|
const ul = ulMap[uuid] ?? 0;
|
||||||
|
const totalMB = ((dl + ul) / (1024 * 1024)).toFixed(2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.agent?.id || idx,
|
||||||
|
uuid: uuid,
|
||||||
|
serial: uuid,
|
||||||
|
site_uuid: SITE_UUID || "",
|
||||||
|
organization_uuid: "",
|
||||||
|
provisioned: true,
|
||||||
|
activated: true,
|
||||||
|
label: AGENT_LABELS[uuid] || item.agent?.label || uuid,
|
||||||
|
created_at: { human: "N/A", date: "", unix_time: 0 },
|
||||||
|
updated_at: { human: "N/A", date: "", unix_time: 0 },
|
||||||
|
last_seen_at: {
|
||||||
|
human: `Active (Traffic: ${totalMB} MB)`,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
unix_time: Date.now() / 1000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(API_URL, {
|
const response = await fetch(API_URL, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
@@ -72,6 +137,12 @@ export async function getAgents(): Promise<Agent[]> {
|
|||||||
|
|
||||||
export async function getAgent(agentId: string): Promise<Agent | null> {
|
export async function getAgent(agentId: string): Promise<Agent | null> {
|
||||||
try {
|
try {
|
||||||
|
const API_KEY = process.env.NETIFY_API_KEY;
|
||||||
|
if (API_KEY && API_KEY.startsWith("sk_db_")) {
|
||||||
|
const agents = await getAgents();
|
||||||
|
return agents.find(a => a.uuid === agentId || String(a.id) === agentId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/${agentId}`, {
|
const response = await fetch(`${API_URL}/${agentId}`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
@@ -92,6 +163,11 @@ export async function getAgent(agentId: string): Promise<Agent | null> {
|
|||||||
|
|
||||||
export async function createAgent(agentId: string, label: string) {
|
export async function createAgent(agentId: string, label: string) {
|
||||||
try {
|
try {
|
||||||
|
const API_KEY = process.env.NETIFY_API_KEY;
|
||||||
|
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
|
const url = agentId
|
||||||
? `${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}`
|
? `${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}`
|
||||||
: `${API_URL}?label=${encodeURIComponent(label)}`;
|
: `${API_URL}?label=${encodeURIComponent(label)}`;
|
||||||
@@ -115,6 +191,11 @@ export async function createAgent(agentId: string, label: string) {
|
|||||||
|
|
||||||
export async function updateAgent(agentId: string, label: string) {
|
export async function updateAgent(agentId: string, label: string) {
|
||||||
try {
|
try {
|
||||||
|
const API_KEY = process.env.NETIFY_API_KEY;
|
||||||
|
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)}`, {
|
const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
@@ -134,6 +215,11 @@ export async function updateAgent(agentId: string, label: string) {
|
|||||||
|
|
||||||
export async function deleteAgent(agentId: string) {
|
export async function deleteAgent(agentId: string) {
|
||||||
try {
|
try {
|
||||||
|
const API_KEY = process.env.NETIFY_API_KEY;
|
||||||
|
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)}`, {
|
const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
@@ -150,3 +236,4 @@ export async function deleteAgent(agentId: string) {
|
|||||||
return { success: false, message: error.message };
|
return { success: false, message: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+152
@@ -458,3 +458,155 @@ export function useLookupApplications(page: number = 1, limit: number = 25, sear
|
|||||||
|
|
||||||
return { data, error, isLoading };
|
return { data, error, isLoading };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── INTERACTIVE DETAIL TYPES ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AgentDeviceItem {
|
||||||
|
ip_address: string | null;
|
||||||
|
mac_address: string | null;
|
||||||
|
device_label: string | null;
|
||||||
|
device_type: string | null;
|
||||||
|
os_label: string | null;
|
||||||
|
manufacturer: string | null;
|
||||||
|
last_seen: string | null;
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
encrypted_pct: number | null;
|
||||||
|
risk_level: string | null;
|
||||||
|
has_insecure: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentFlowItem {
|
||||||
|
flow_id?: string | null;
|
||||||
|
src_ip: string | null;
|
||||||
|
dst_ip: string | null;
|
||||||
|
dst_port: number | null;
|
||||||
|
protocol: string | null;
|
||||||
|
app_label?: string | null;
|
||||||
|
domain?: string | null;
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
last_seen: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentAppItem {
|
||||||
|
app_id: number | null;
|
||||||
|
app_label: string;
|
||||||
|
category: string | null;
|
||||||
|
favicon: string | null;
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSummary {
|
||||||
|
total_devices: number;
|
||||||
|
active_flows: number;
|
||||||
|
bandwidth_down: number;
|
||||||
|
bandwidth_up: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentDetails {
|
||||||
|
agent_uuid: string;
|
||||||
|
agent_label: string;
|
||||||
|
summary: AgentSummary | null;
|
||||||
|
devices: AgentDeviceItem[];
|
||||||
|
flows: AgentFlowItem[];
|
||||||
|
top_apps: AgentAppItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceFlowItem {
|
||||||
|
dst_ip: string | null;
|
||||||
|
dst_port: number | null;
|
||||||
|
protocol: string | null;
|
||||||
|
app_label?: string | null;
|
||||||
|
domain?: string | null;
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
last_seen: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityInfo {
|
||||||
|
encrypted_pct: number | null;
|
||||||
|
encrypted_bytes: number | null;
|
||||||
|
unencrypted_bytes: number | null;
|
||||||
|
risk_level: string | null;
|
||||||
|
insecure_protocols: { protocol: string; risk: string }[];
|
||||||
|
device_type: string | null;
|
||||||
|
os_label: string | null;
|
||||||
|
manufacturer: string | null;
|
||||||
|
device_label: string | null;
|
||||||
|
reputation: string | null;
|
||||||
|
rep_score: number | null;
|
||||||
|
blacklisted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceDetails {
|
||||||
|
ip: string;
|
||||||
|
total_download: number;
|
||||||
|
total_upload: number;
|
||||||
|
flow_count: number;
|
||||||
|
top_apps: AgentAppItem[];
|
||||||
|
flows: DeviceFlowItem[];
|
||||||
|
security_info: SecurityInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppAgentItem {
|
||||||
|
agent_uuid: string | null;
|
||||||
|
agent_label: string;
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
flow_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppIpItem {
|
||||||
|
ip_address: string | null;
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
flow_count?: number;
|
||||||
|
reputation?: string | null;
|
||||||
|
blacklisted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppDetails {
|
||||||
|
app_label: string;
|
||||||
|
total_download: number;
|
||||||
|
total_upload: number;
|
||||||
|
data_source?: string;
|
||||||
|
has_domain_breakdown?: boolean;
|
||||||
|
agent_scorecard: AppAgentItem[];
|
||||||
|
top_ips: AppIpItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityDevice {
|
||||||
|
ip_address: string;
|
||||||
|
mac_address: string | null;
|
||||||
|
device_label: string | null;
|
||||||
|
encrypted_pct: number;
|
||||||
|
unencrypted: number;
|
||||||
|
encrypted: number;
|
||||||
|
total: number;
|
||||||
|
risk_level: 'Rawan' | 'Sedang' | 'Aman';
|
||||||
|
has_insecure: boolean;
|
||||||
|
device_type: string | null;
|
||||||
|
os_label: string | null;
|
||||||
|
manufacturer: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DETAIL FETCH FUNCTIONS (one-shot, no polling) ───────────────────────────
|
||||||
|
|
||||||
|
export async function fetchAgentDetails(uuid: string): Promise<AgentDetails> {
|
||||||
|
return fetcher<AgentDetails>(`/agent-details?uuid=${encodeURIComponent(uuid)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDeviceDetails(ip: string): Promise<DeviceDetails> {
|
||||||
|
return fetcher<DeviceDetails>(`/device-details?ip=${encodeURIComponent(ip)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAppDetails(label: string): Promise<AppDetails> {
|
||||||
|
return fetcher<AppDetails>(`/app-details?label=${encodeURIComponent(label)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSecurityDevices(): Promise<SecurityDevice[]> {
|
||||||
|
return fetcher<SecurityDevice[]>('/security-devices');
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_KEY = 'sk_db_live_ZjViMTNmODU3ZjNkN2Q4MGV';
|
||||||
|
const SITE_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
|
||||||
|
const targets = [
|
||||||
|
// portal.netify.ai
|
||||||
|
'https://portal.netify.ai/api/v2/assets/agents',
|
||||||
|
'https://portal.netify.ai/api/v2/agents',
|
||||||
|
'https://portal.netify.ai/api/v1/assets/agents',
|
||||||
|
'https://portal.netify.ai/api/v1/agents',
|
||||||
|
|
||||||
|
// informatics.netify.ai
|
||||||
|
'https://informatics.netify.ai/api/v2/assets/agents',
|
||||||
|
'https://informatics.netify.ai/api/v2/agents',
|
||||||
|
'https://informatics.netify.ai/api/v1/assets/agents',
|
||||||
|
'https://informatics.netify.ai/api/v1/agents',
|
||||||
|
|
||||||
|
// manager.netify.ai
|
||||||
|
'https://manager.netify.ai/api/v2/assets/agents',
|
||||||
|
'https://manager.netify.ai/api/v2/agents',
|
||||||
|
'https://manager.netify.ai/api/v1/assets/agents',
|
||||||
|
'https://manager.netify.ai/api/v1/agents',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function test(url) {
|
||||||
|
console.log(`[Testing] ${url}`);
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, {
|
||||||
|
headers: {
|
||||||
|
'x-api-key': API_KEY,
|
||||||
|
'x-net-site': SITE_UUID,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
console.log(` -> SUCCESS [${res.status}]:`, JSON.stringify(res.data).substring(0, 300));
|
||||||
|
} catch (err) {
|
||||||
|
const code = err.response?.status || 'ERR';
|
||||||
|
const msg = JSON.stringify(err.response?.data || err.message);
|
||||||
|
console.log(` -> FAILED [${code}]:`, msg.substring(0, 150));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
for (const url of targets) {
|
||||||
|
await test(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
Reference in new issue
Block a user