feat: standardisasi ukuran modal, animasi buka-tutup, transisi 0.3s, dan penghapusan file tak terpakai
No files matched your search
@@ -71,6 +71,8 @@ function initSchema() {
|
||||
role TEXT NOT NULL DEFAULT 'AGENT_VIEWER',
|
||||
site_uuid TEXT DEFAULT NULL,
|
||||
agent_uuid TEXT DEFAULT NULL,
|
||||
account_name TEXT DEFAULT NULL,
|
||||
profile_picture TEXT DEFAULT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
|
||||
@@ -92,6 +94,13 @@ function initSchema() {
|
||||
d.exec("ALTER TABLE bandwidth_timeline ADD COLUMN flow_speed INTEGER DEFAULT 0");
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
d.exec("ALTER TABLE users ADD COLUMN account_name TEXT DEFAULT NULL");
|
||||
d.exec("ALTER TABLE users ADD COLUMN profile_picture TEXT DEFAULT NULL");
|
||||
} catch (e) {
|
||||
// Columns already exist, safe to ignore
|
||||
}
|
||||
|
||||
const adminExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'admin'").get();
|
||||
if (adminExists.count === 0) {
|
||||
const hash = bcrypt.hashSync('admin123', 10);
|
||||
@@ -99,36 +108,6 @@ function initSchema() {
|
||||
console.log('[DB] Created default admin user (admin / admin123)');
|
||||
}
|
||||
|
||||
const agentExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'agent1'").get();
|
||||
if (agentExists.count === 0) {
|
||||
const hash = bcrypt.hashSync('agent123', 10);
|
||||
const site_uuid = process.env.NETIFY_SITE_UUID || '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||
d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)").run('agent1', hash, 'AGENT_VIEWER', site_uuid, '8A-V3-PB-85');
|
||||
console.log('[DB] Created default agent user (agent1 / agent123)');
|
||||
}
|
||||
|
||||
// Seed specific agent accounts for testing isolation
|
||||
const agentJrpExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'agent_jrp'").get();
|
||||
if (agentJrpExists.count === 0) {
|
||||
const hash = bcrypt.hashSync('agent123', 10);
|
||||
d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)").run('agent_jrp', hash, 'AGENT_VIEWER', '6681452d_9cae_4ff4_8ae8_0d504774265e', '2F-TF-1D-GK');
|
||||
console.log('[DB] Created JRP Cibubur agent user (agent_jrp / agent123)');
|
||||
}
|
||||
|
||||
const agentIfgExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'agent_ifg'").get();
|
||||
if (agentIfgExists.count === 0) {
|
||||
const hash = bcrypt.hashSync('agent123', 10);
|
||||
d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)").run('agent_ifg', hash, 'AGENT_VIEWER', '6681452d_9cae_4ff4_8ae8_0d504774265e', '8A-V3-PB-85');
|
||||
console.log('[DB] Created IFG LT.18 agent user (agent_ifg / agent123)');
|
||||
}
|
||||
|
||||
const agentCpiExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'agent_cpi'").get();
|
||||
if (agentCpiExists.count === 0) {
|
||||
const hash = bcrypt.hashSync('agent123', 10);
|
||||
d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)").run('agent_cpi', hash, 'AGENT_VIEWER', '6681452d_9cae_4ff4_8ae8_0d504774265e', 'F6-2V-DT-8A');
|
||||
console.log('[DB] Created CPI Balaraja agent user (agent_cpi / agent123)');
|
||||
}
|
||||
|
||||
d.exec(`CREATE TABLE IF NOT EXISTS tls_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_uuid TEXT,
|
||||
@@ -2421,6 +2400,9 @@ function getDataInterval() {
|
||||
module.exports = {
|
||||
getUserByUsername,
|
||||
updateUserPassword,
|
||||
updateUserUsername,
|
||||
updateUserAccountName,
|
||||
updateUserProfilePicture,
|
||||
syncAgentUsers,
|
||||
getDB,
|
||||
getDataInterval,
|
||||
@@ -2457,6 +2439,18 @@ function updateUserPassword(userId, newPasswordHash) {
|
||||
return getDB().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(newPasswordHash, userId);
|
||||
}
|
||||
|
||||
function updateUserUsername(userId, newUsername) {
|
||||
return getDB().prepare('UPDATE users SET username = ? WHERE id = ?').run(newUsername, userId);
|
||||
}
|
||||
|
||||
function updateUserAccountName(userId, newAccountName) {
|
||||
return getDB().prepare('UPDATE users SET account_name = ? WHERE id = ?').run(newAccountName, userId);
|
||||
}
|
||||
|
||||
function updateUserProfilePicture(userId, filename) {
|
||||
return getDB().prepare('UPDATE users SET profile_picture = ? WHERE id = ?').run(filename, userId);
|
||||
}
|
||||
|
||||
function syncAgentUsers(agents) {
|
||||
const d = getDB();
|
||||
const bcrypt = require('bcryptjs');
|
||||
@@ -2482,7 +2476,7 @@ function syncAgentUsers(agents) {
|
||||
if (exists1.count === 0) {
|
||||
d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(usernameUuidLower, hash, 'AGENT_VIEWER', siteUuid, uuid);
|
||||
console.log(`[DB] Created default user: ${usernameUuidLower} / agent123`);
|
||||
console.log(`[DB] Created default user: ${usernameUuidLower} / 123`);
|
||||
}
|
||||
|
||||
// Create username = uppercase uuid (e.g. '1R-79-J9-YE')
|
||||
@@ -2491,7 +2485,7 @@ function syncAgentUsers(agents) {
|
||||
if (exists1u.count === 0) {
|
||||
d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(usernameUuidUpper, hash, 'AGENT_VIEWER', siteUuid, uuid);
|
||||
console.log(`[DB] Created default user: ${usernameUuidUpper} / agent123`);
|
||||
console.log(`[DB] Created default user: ${usernameUuidUpper} / 123`);
|
||||
}
|
||||
|
||||
// Create username = sanitized lowercase label (e.g. 'agent_cpi_balaraja_wan')
|
||||
@@ -2501,7 +2495,7 @@ function syncAgentUsers(agents) {
|
||||
if (exists2.count === 0) {
|
||||
d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(usernameLabel, hash, 'AGENT_VIEWER', siteUuid, uuid);
|
||||
console.log(`[DB] Created default user: ${usernameLabel} / agent123`);
|
||||
console.log(`[DB] Created default user: ${usernameLabel} / 123`);
|
||||
}
|
||||
|
||||
// Create username = name/label directly (e.g. 'CPI Balaraja WAN' -> 'CPI Balaraja WAN' or 'JRP Cibubur')
|
||||
@@ -2509,7 +2503,7 @@ function syncAgentUsers(agents) {
|
||||
if (exists3.count === 0) {
|
||||
d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)")
|
||||
.run(label, hash, 'AGENT_VIEWER', siteUuid, uuid);
|
||||
console.log(`[DB] Created default user: ${label} / agent123`);
|
||||
console.log(`[DB] Created default user: ${label} / 123`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,7 +260,8 @@ router.post('/upload-profile-picture', upload.single('profile_picture'), (req, r
|
||||
|
||||
res.json({ ok: true, message: 'Foto profil berhasil diperbarui', profile_picture: req.file.filename });
|
||||
} catch (err) {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
console.error('[Upload Error]', err);
|
||||
res.status(401).json({ error: 'Invalid token', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
@@ -1,25 +0,0 @@
|
||||
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();
|
||||
@@ -1,29 +0,0 @@
|
||||
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();
|
||||
@@ -1,22 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
const db = require('../backend/database').getDB();
|
||||
|
||||
// Check all flows - how many have domain vs just port
|
||||
const stats = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN domain IS NOT NULL THEN 1 ELSE 0 END) as with_domain,
|
||||
SUM(CASE WHEN app_label IS NOT NULL AND app_label NOT LIKE 'Port %' THEN 1 ELSE 0 END) as named_app,
|
||||
SUM(CASE WHEN app_label LIKE 'Port %' THEN 1 ELSE 0 END) as port_only,
|
||||
SUM(CASE WHEN app_label IS NULL AND domain IS NULL THEN 1 ELSE 0 END) as both_null
|
||||
FROM flows
|
||||
`).get();
|
||||
console.log('Flow stats:', stats);
|
||||
|
||||
// What are the unique domain values?
|
||||
const uniqueDomains = db.prepare(`SELECT DISTINCT domain FROM flows WHERE domain IS NOT NULL LIMIT 20`).all();
|
||||
console.log('\nUnique domains in DB:', uniqueDomains.length);
|
||||
uniqueDomains.forEach(r => console.log(' ', r.domain));
|
||||
|
||||
// What devices have domain data, and how many?
|
||||
console.log('\n\nDevices with domain+app data:');
|
||||
const devDomains = db.prepare(`
|
||||
SELECT src_ip, src_mac,
|
||||
COUNT(*) total_flows,
|
||||
SUM(CASE WHEN domain IS NOT NULL THEN 1 ELSE 0 END) domain_flows,
|
||||
SUM(CASE WHEN app_label IS NOT NULL AND app_label NOT LIKE 'Port %' THEN 1 ELSE 0 END) named_flows,
|
||||
GROUP_CONCAT(DISTINCT domain) sample_domains
|
||||
FROM flows
|
||||
WHERE domain IS NOT NULL OR (app_label IS NOT NULL AND app_label NOT LIKE 'Port %')
|
||||
GROUP BY src_ip
|
||||
ORDER BY domain_flows DESC
|
||||
LIMIT 10
|
||||
`).all();
|
||||
devDomains.forEach(r => {
|
||||
const {sample_domains, ...rest} = r;
|
||||
console.log(JSON.stringify(rest));
|
||||
if (sample_domains) console.log(' domains:', sample_domains.split(',').slice(0,3).join(', '));
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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();
|
||||
@@ -1,26 +0,0 @@
|
||||
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();
|
||||
@@ -1,23 +0,0 @@
|
||||
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();
|
||||
@@ -1,325 +0,0 @@
|
||||
// patch_device_details.js — replaces fetchDeviceDetails in netify.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const filePath = path.join(__dirname, '..', 'backend', 'netify.js');
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
const startMarker = '// Fetch data for a specific device IP — from local DB flows + intel tables\r\nasync function fetchDeviceDetails(ip) {';
|
||||
const endMarker = '}\r\n\r\n// Fetch data for a specific application';
|
||||
|
||||
const startIdx = content.indexOf(startMarker);
|
||||
const endIdx = content.indexOf('// Fetch data for a specific application');
|
||||
|
||||
if (startIdx === -1) { console.error('START not found'); process.exit(1); }
|
||||
if (endIdx === -1) { console.error('END not found'); process.exit(1); }
|
||||
|
||||
console.log(`Found fetchDeviceDetails: char ${startIdx} → ${endIdx}`);
|
||||
|
||||
const replacement = `// Fetch data for a specific device IP — from local DB (all 10 correlated tables)
|
||||
async function fetchDeviceDetails(ip) {
|
||||
const db = require('./database');
|
||||
const d = db.getDB();
|
||||
|
||||
// ── 0. Resolve MAC from flows (most recent) ──────────────────────────────
|
||||
const macRow = d.prepare(\`SELECT src_mac FROM flows WHERE src_ip = ? AND src_mac IS NOT NULL ORDER BY last_seen DESC LIMIT 1\`).get(ip);
|
||||
const mac = macRow?.src_mac || null;
|
||||
|
||||
// ── 1. Device info from devices table ────────────────────────────────────
|
||||
const deviceRow = d.prepare(\`
|
||||
SELECT device_label, device_type, os_label, manufacturer, download, upload, last_seen
|
||||
FROM devices
|
||||
WHERE ip_address = ?
|
||||
ORDER BY fetched_at DESC
|
||||
LIMIT 1
|
||||
\`).get(ip);
|
||||
|
||||
// ── 2. Discovery info (may differ from devices table) ────────────────────
|
||||
const discRow = d.prepare(\`
|
||||
SELECT device_type, os_label, manufacturer, device_label, is_new
|
||||
FROM intel_device_discovery
|
||||
WHERE ip_address = ?
|
||||
ORDER BY fetched_at DESC
|
||||
LIMIT 1
|
||||
\`).get(ip);
|
||||
|
||||
const device_info = {
|
||||
device_label : deviceRow?.device_label || discRow?.device_label || null,
|
||||
device_type : deviceRow?.device_type || discRow?.device_type || null,
|
||||
os_label : deviceRow?.os_label || discRow?.os_label || null,
|
||||
manufacturer : deviceRow?.manufacturer || discRow?.manufacturer || null,
|
||||
mac_address : mac,
|
||||
is_new : discRow?.is_new ?? null,
|
||||
};
|
||||
|
||||
// ── 3. Top apps (flows GROUP BY app_label) ─────────────────────────────
|
||||
const appRows = d.prepare(\`
|
||||
SELECT app_label,
|
||||
SUM(bytes_download) AS download,
|
||||
SUM(bytes_upload) AS upload,
|
||||
COUNT(*) AS flow_count
|
||||
FROM flows
|
||||
WHERE src_ip = ?
|
||||
AND app_label IS NOT NULL
|
||||
GROUP BY app_label
|
||||
ORDER BY download DESC
|
||||
LIMIT 20
|
||||
\`).all(ip);
|
||||
|
||||
const top_apps = appRows.map(r => ({
|
||||
app_label : r.app_label,
|
||||
download : r.download ?? 0,
|
||||
upload : r.upload ?? 0,
|
||||
flow_count : r.flow_count,
|
||||
}));
|
||||
|
||||
// ── 4. Top domains (flows GROUP BY domain) ─────────────────────────────
|
||||
const domainRows = d.prepare(\`
|
||||
SELECT domain,
|
||||
SUM(bytes_download) AS download,
|
||||
SUM(bytes_upload) AS upload,
|
||||
COUNT(*) AS flow_count
|
||||
FROM flows
|
||||
WHERE src_ip = ?
|
||||
AND domain IS NOT NULL
|
||||
GROUP BY domain
|
||||
ORDER BY download DESC
|
||||
LIMIT 20
|
||||
\`).all(ip);
|
||||
|
||||
// ── 5. Recent flows ────────────────────────────────────────────────────
|
||||
const flowRows = d.prepare(\`
|
||||
SELECT dst_ip, dst_port, protocol, app_label, domain,
|
||||
bytes_download AS download, bytes_upload AS upload, last_seen
|
||||
FROM flows
|
||||
WHERE src_ip = ?
|
||||
ORDER BY last_seen DESC
|
||||
LIMIT 100
|
||||
\`).all(ip);
|
||||
|
||||
const flows = flowRows.map(r => ({
|
||||
dst_ip : r.dst_ip,
|
||||
dst_port : r.dst_port,
|
||||
protocol : r.protocol,
|
||||
app_label : r.app_label,
|
||||
domain : r.domain,
|
||||
download : r.download ?? 0,
|
||||
upload : r.upload ?? 0,
|
||||
last_seen : r.last_seen,
|
||||
}));
|
||||
|
||||
// ── 6. Totals ─────────────────────────────────────────────────────────
|
||||
const sumRow = d.prepare(\`
|
||||
SELECT SUM(bytes_download) AS total_download,
|
||||
SUM(bytes_upload) AS total_upload,
|
||||
COUNT(*) AS flow_count
|
||||
FROM flows
|
||||
WHERE src_ip = ?
|
||||
\`).get(ip);
|
||||
|
||||
// ── 7. Encryption audit (latest snapshot) ─────────────────────────────
|
||||
const latestAudit = d.prepare(\`SELECT MAX(fetched_at) AS t FROM intel_encryption_audit\`).get()?.t;
|
||||
const encRow = latestAudit
|
||||
? d.prepare(\`
|
||||
SELECT encrypted_pct, encrypted, unencrypted, total, risk_level, mac_address
|
||||
FROM intel_encryption_audit
|
||||
WHERE fetched_at = ? AND ip_address = ?
|
||||
LIMIT 1
|
||||
\`).get(latestAudit, ip)
|
||||
: null;
|
||||
|
||||
// Also try fallback by MAC if not found by IP
|
||||
const encRowMac = (!encRow && mac && latestAudit)
|
||||
? d.prepare(\`
|
||||
SELECT encrypted_pct, encrypted, unencrypted, total, risk_level, ip_address
|
||||
FROM intel_encryption_audit
|
||||
WHERE fetched_at = ? AND mac_address = ?
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT 1
|
||||
\`).get(latestAudit, mac)
|
||||
: null;
|
||||
|
||||
const encFinal = encRow || encRowMac;
|
||||
|
||||
const encryption = encFinal ? {
|
||||
encrypted_pct : encFinal.encrypted_pct ?? null,
|
||||
encrypted_bytes : encFinal.encrypted ?? null,
|
||||
unencrypted_bytes: encFinal.unencrypted ?? null,
|
||||
total_bytes : encFinal.total ?? null,
|
||||
risk_level : encFinal.risk_level ?? null,
|
||||
} : null;
|
||||
|
||||
// ── 8. Server discovery (servers this device accessed) ─────────────────
|
||||
const serverRows = d.prepare(\`
|
||||
SELECT DISTINCT server_type, hostname, port, protocol, os_label,
|
||||
MAX(download) AS download, MAX(upload) AS upload, MAX(detected_at) AS detected_at
|
||||
FROM intel_server_discovery
|
||||
WHERE ip_address = ?
|
||||
GROUP BY server_type, port, protocol
|
||||
ORDER BY download DESC
|
||||
LIMIT 50
|
||||
\`).all(ip);
|
||||
|
||||
// fallback by MAC if no rows by IP
|
||||
const serverRowsMac = (serverRows.length === 0 && mac)
|
||||
? d.prepare(\`
|
||||
SELECT DISTINCT server_type, hostname, port, protocol, os_label,
|
||||
MAX(download) AS download, MAX(upload) AS upload, MAX(detected_at) AS detected_at
|
||||
FROM intel_server_discovery
|
||||
WHERE mac_address = ?
|
||||
GROUP BY server_type, port, protocol
|
||||
ORDER BY download DESC
|
||||
LIMIT 50
|
||||
\`).all(mac)
|
||||
: [];
|
||||
|
||||
const server_discovery = (serverRows.length > 0 ? serverRows : serverRowsMac).map(r => ({
|
||||
server_type : r.server_type,
|
||||
hostname : r.hostname || null,
|
||||
port : r.port,
|
||||
protocol : r.protocol,
|
||||
os_label : r.os_label || null,
|
||||
download : r.download ?? 0,
|
||||
upload : r.upload ?? 0,
|
||||
detected_at : r.detected_at,
|
||||
}));
|
||||
|
||||
// ── 9. Unencrypted passwords ───────────────────────────────────────────
|
||||
let pwdRows = d.prepare(\`
|
||||
SELECT dst_ip, dst_port, protocol, username, severity, download, upload, detected_at
|
||||
FROM intel_unencrypted_passwords
|
||||
WHERE ip_address = ?
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT 50
|
||||
\`).all(ip);
|
||||
|
||||
if (pwdRows.length === 0 && mac) {
|
||||
pwdRows = d.prepare(\`
|
||||
SELECT dst_ip, dst_port, protocol, username, severity, download, upload, detected_at
|
||||
FROM intel_unencrypted_passwords
|
||||
WHERE mac_address = ?
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT 50
|
||||
\`).all(mac);
|
||||
}
|
||||
|
||||
const unencrypted_passwords = pwdRows.map(r => ({
|
||||
dst_ip : r.dst_ip,
|
||||
dst_port : r.dst_port,
|
||||
protocol : r.protocol,
|
||||
username : r.username,
|
||||
severity : r.severity,
|
||||
download : r.download ?? 0,
|
||||
upload : r.upload ?? 0,
|
||||
detected_at : r.detected_at,
|
||||
}));
|
||||
|
||||
// ── 10. IP Reputation (latest snapshot, this device's local_ip) ─────────
|
||||
const latestRepSnap = d.prepare(\`SELECT MAX(fetched_at) AS t FROM intel_ip_reputation\`).get()?.t;
|
||||
const repRows = latestRepSnap
|
||||
? d.prepare(\`
|
||||
SELECT ip_address, local_ip, reputation, score, country, app_label, blacklisted, download, upload
|
||||
FROM intel_ip_reputation
|
||||
WHERE fetched_at = ? AND (local_ip = ? OR ip_address = ?)
|
||||
ORDER BY score DESC NULLS LAST
|
||||
LIMIT 30
|
||||
\`).all(latestRepSnap, ip, ip)
|
||||
: [];
|
||||
|
||||
const ip_reputation = repRows.map(r => ({
|
||||
remote_ip : r.ip_address,
|
||||
local_ip : r.local_ip,
|
||||
reputation : r.reputation,
|
||||
score : r.score,
|
||||
country : r.country,
|
||||
app_label : r.app_label,
|
||||
blacklisted : !!r.blacklisted,
|
||||
download : r.download ?? 0,
|
||||
upload : r.upload ?? 0,
|
||||
}));
|
||||
|
||||
// ── 11. VPN detection ─────────────────────────────────────────────────
|
||||
let vpnRows = d.prepare(\`
|
||||
SELECT vpn_type, remote_ip, protocol, country, confidence, download, upload, detected_at
|
||||
FROM intel_vpn_detection
|
||||
WHERE ip_address = ?
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT 20
|
||||
\`).all(ip);
|
||||
|
||||
if (vpnRows.length === 0 && mac) {
|
||||
vpnRows = d.prepare(\`
|
||||
SELECT vpn_type, remote_ip, protocol, country, confidence, download, upload, detected_at
|
||||
FROM intel_vpn_detection
|
||||
WHERE mac_address = ?
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT 20
|
||||
\`).all(mac);
|
||||
}
|
||||
|
||||
const vpn_detections = vpnRows.map(r => ({
|
||||
vpn_type : r.vpn_type,
|
||||
remote_ip : r.remote_ip,
|
||||
protocol : r.protocol,
|
||||
country : r.country,
|
||||
confidence : r.confidence,
|
||||
download : r.download ?? 0,
|
||||
upload : r.upload ?? 0,
|
||||
detected_at : r.detected_at,
|
||||
}));
|
||||
|
||||
// ── 12. Events ────────────────────────────────────────────────────────
|
||||
const evtByIP = d.prepare(\`SELECT event_type, severity, ip_address, mac_address, description, event_at FROM events WHERE ip_address = ? ORDER BY event_at DESC LIMIT 50\`).all(ip);
|
||||
const evtByMAC = mac ? d.prepare(\`SELECT event_type, severity, ip_address, mac_address, description, event_at FROM events WHERE mac_address = ? ORDER BY event_at DESC LIMIT 50\`).all(mac) : [];
|
||||
|
||||
const seenEvt = new Set();
|
||||
const evtMerged = [];
|
||||
for (const r of [...evtByIP, ...evtByMAC]) {
|
||||
const key = \`\${r.event_type}:\${r.event_at}\`;
|
||||
if (!seenEvt.has(key)) {
|
||||
seenEvt.add(key);
|
||||
evtMerged.push({ event_type: r.event_type, severity: r.severity, ip_address: r.ip_address, mac_address: r.mac_address, description: r.description, event_at: r.event_at });
|
||||
}
|
||||
}
|
||||
evtMerged.sort((a, b) => (b.event_at || '').localeCompare(a.event_at || ''));
|
||||
const events = evtMerged.slice(0, 100);
|
||||
|
||||
// ── 13. MAC bandwidth (latest snapshot) ───────────────────────────────
|
||||
const latestMacSnap = d.prepare(\`SELECT MAX(fetched_at) AS t FROM mac_bandwidth\`).get()?.t;
|
||||
const macBwRow = (latestMacSnap && mac)
|
||||
? d.prepare(\`SELECT manufacturer, download, upload, total FROM mac_bandwidth WHERE fetched_at = ? AND mac_address = ? LIMIT 1\`).get(latestMacSnap, mac)
|
||||
: null;
|
||||
|
||||
const mac_bandwidth = macBwRow ? {
|
||||
mac_address : mac,
|
||||
manufacturer : macBwRow.manufacturer,
|
||||
download : macBwRow.download ?? 0,
|
||||
upload : macBwRow.upload ?? 0,
|
||||
total : macBwRow.total ?? 0,
|
||||
} : null;
|
||||
|
||||
return {
|
||||
ip,
|
||||
mac_address : mac,
|
||||
total_download : sumRow?.total_download ?? 0,
|
||||
total_upload : sumRow?.total_upload ?? 0,
|
||||
flow_count : sumRow?.flow_count ?? 0,
|
||||
device_info,
|
||||
top_apps,
|
||||
top_domains : domainRows.map(r => ({ domain: r.domain, download: r.download ?? 0, upload: r.upload ?? 0, flow_count: r.flow_count })),
|
||||
flows,
|
||||
encryption,
|
||||
server_discovery,
|
||||
unencrypted_passwords,
|
||||
ip_reputation,
|
||||
vpn_detections,
|
||||
events,
|
||||
mac_bandwidth,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch data for a specific application`;
|
||||
|
||||
const newContent = content.slice(0, startIdx) + replacement + content.slice(endIdx);
|
||||
fs.writeFileSync(filePath, newContent, 'utf8');
|
||||
console.log('SUCCESS: Patched fetchDeviceDetails, new file length:', newContent.length);
|
||||
@@ -1,18 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const filePath = path.join(__dirname, '..', 'backend', 'netify.js');
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// Replace events block
|
||||
const oldEventsBlockPattern = /\/\/ ── 6\. Events filtered by agent IPs & MACs ─────────────────────────────────[\s\S]*?const events = allEvents\.slice\(0, 100\);/;
|
||||
if (oldEventsBlockPattern.test(content)) {
|
||||
console.log("Found events block! Replacing...");
|
||||
content = content.replace(oldEventsBlockPattern, `// ── 6. Events filtered by agent (aligned with events page) ───────────────
|
||||
const events = db.getLatestEvents(200, null, agentUuid);`);
|
||||
} else {
|
||||
console.error("Could not find events block!");
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
console.log("Successfully patched backend/netify.js events section!");
|
||||
@@ -1,57 +0,0 @@
|
||||
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();
|
||||
@@ -1,26 +0,0 @@
|
||||
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();
|
||||
@@ -1,48 +0,0 @@
|
||||
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();
|
||||
@@ -1,25 +0,0 @@
|
||||
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();
|
||||
@@ -1,27 +0,0 @@
|
||||
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();
|
||||
@@ -1,25 +0,0 @@
|
||||
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();
|
||||
@@ -1,43 +0,0 @@
|
||||
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();
|
||||
@@ -1,36 +0,0 @@
|
||||
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();
|
||||
@@ -1,14 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const files = [
|
||||
'src/app/network-infrastructure/page.tsx',
|
||||
'src/app/dpi-analytics/page.tsx',
|
||||
'src/app/intelligence/page.tsx'
|
||||
];
|
||||
|
||||
files.forEach(f => {
|
||||
let text = fs.readFileSync(f, 'utf8');
|
||||
text = text.replace(/accessorKey:\s*"([^"]+)"/g, 'accessor: (row: any) => row.$1');
|
||||
text = text.replace(/accessorKey:\s*\(/g, 'accessor: (');
|
||||
fs.writeFileSync(f, text);
|
||||
});
|
||||
console.log('Fixed accessorKey');
|
||||
@@ -1,35 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const fetch = global.fetch;
|
||||
|
||||
const env = fs.readFileSync('.env.local', 'utf8').split('\n').reduce((acc, line) => {
|
||||
const match = line.match(/^([^#\s=]+)=(.*)$/);
|
||||
if (match) {
|
||||
acc[match[1]] = match[2];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
async function test() {
|
||||
try {
|
||||
const API_URL = env.NETIFY_BASE_URL ? env.NETIFY_BASE_URL + '/assets/agents' : 'https://manager.netify.ai/api/v1/assets/agents';
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
|
||||
if (env.NETIFY_JWT_TOKEN && env.NETIFY_SITE_UUID) {
|
||||
headers['x-api-key'] = env.NETIFY_JWT_TOKEN;
|
||||
headers['x-net-site'] = env.NETIFY_SITE_UUID;
|
||||
} else if (env.NETIFY_API_KEY) {
|
||||
headers['x-api-key'] = env.NETIFY_API_KEY;
|
||||
}
|
||||
|
||||
console.log('Fetching', API_URL);
|
||||
console.log('Headers:', Object.keys(headers));
|
||||
|
||||
const res = await fetch(API_URL, { headers });
|
||||
console.log('Status:', res.status, res.statusText);
|
||||
const data = await res.text();
|
||||
console.log('Data:', data.substring(0, 500));
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
test();
|
||||
@@ -30,6 +30,8 @@ export default function SummaryPage() {
|
||||
return countries.data.map((row) => ({
|
||||
countryCode: row.country_code,
|
||||
value: row.download + row.upload,
|
||||
download: row.download,
|
||||
upload: row.upload,
|
||||
label: row.country_name,
|
||||
}));
|
||||
}, [countries.data]);
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -451,26 +451,26 @@ body::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Ambient Gradients for Cards & Sidebars */
|
||||
/* Ambient Colors for Cards & Sidebars */
|
||||
:root {
|
||||
--ambient-card-bg: linear-gradient(135deg, rgba(30, 58, 138, 0.1) 0%, rgba(15, 23, 42, 0.5) 100%);
|
||||
--ambient-sidebar-bg: linear-gradient(180deg, rgba(15, 23, 42, 0.85) 0%, rgba(2, 6, 23, 0.95) 100%);
|
||||
--ambient-card-bg: rgba(24, 24, 27, 0.7);
|
||||
--ambient-sidebar-bg: rgba(9, 9, 11, 0.95);
|
||||
}
|
||||
|
||||
.light {
|
||||
--ambient-card-bg: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(241, 245, 249, 0.65) 100%);
|
||||
--ambient-sidebar-bg: linear-gradient(180deg, rgba(255, 255, 255, 1) 0%, rgba(241, 245, 249, 0.9) 100%);
|
||||
--ambient-card-bg: rgba(255, 255, 255, 0.9);
|
||||
--ambient-sidebar-bg: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.bg-card {
|
||||
background-image: var(--ambient-card-bg) !important;
|
||||
background-color: transparent !important;
|
||||
background-color: var(--ambient-card-bg) !important;
|
||||
background-image: none !important;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.bg-sidebar {
|
||||
background-image: var(--ambient-sidebar-bg) !important;
|
||||
background-color: transparent !important;
|
||||
background-color: var(--ambient-sidebar-bg) !important;
|
||||
background-image: none !important;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
@@ -478,14 +478,86 @@ body::after {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, background-image;
|
||||
transition-timing-function: ease-in-out;
|
||||
transition-duration: 150ms;
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, box-shadow, opacity, transform !important;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
transition-duration: 300ms !important;
|
||||
}
|
||||
|
||||
canvas {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* View Transitions API (For perfect whole-screen crossfade including WebGL/Canvas) */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 150ms;
|
||||
animation-duration: 300ms;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
/* ── Custom Scrollbar for Fixed Modals ── */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--muted);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.light .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ── Modal Animations (0.5s) ── */
|
||||
@keyframes modalFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modalFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlayFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes overlayFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-modal-in {
|
||||
animation: modalFadeIn 500ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-modal-out {
|
||||
animation: modalFadeOut 500ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-overlay-in {
|
||||
animation: overlayFadeIn 500ms ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-overlay-out {
|
||||
animation: overlayFadeOut 500ms ease-in forwards;
|
||||
}
|
||||
@@ -10,6 +10,9 @@ const inter = Inter({
|
||||
export const metadata: Metadata = {
|
||||
title: "BackOne - Deep Package Inspection",
|
||||
description: "BackOne by PT. Data Bisnis Solusi - Network Intelligence Dashboard",
|
||||
icons: {
|
||||
icon: "/backone-logo.png",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -26,7 +26,7 @@ function severityStyle(sev: string | null) {
|
||||
case "high": return { badge: "bg-orange-500/15 text-orange-400 ring-orange-500/30", dot: "bg-orange-400" };
|
||||
case "warning": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "info": return { badge: "bg-blue-500/15 text-blue-400 ring-blue-500/30", dot: "bg-blue-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-slate-400 ring-slate-500/30", dot: "bg-slate-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,19 +38,19 @@ function riskStyle(level: string | null) {
|
||||
case "medium": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "aman":
|
||||
case "low": return { badge: "bg-green-500/15 text-green-400 ring-green-500/30", dot: "bg-green-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-slate-400 ring-slate-500/30", dot: "bg-slate-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared table wrapper ────────────────────────────────────────────────────
|
||||
function TableWrap({ children, head }: { children: React.ReactNode; head: string[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-white/5">
|
||||
<div className="overflow-x-auto rounded-xl border border-border/50">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 bg-white/[0.02]">
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{head.map((h) => (
|
||||
<th key={h} className="px-3 py-2 text-left font-medium text-slate-400 whitespace-nowrap">{h}</th>
|
||||
<th key={h} className="px-3 py-2 text-left font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -61,7 +61,7 @@ function TableWrap({ children, head }: { children: React.ReactNode; head: string
|
||||
}
|
||||
|
||||
function EmptyState({ msg }: { msg: string }) {
|
||||
return <p className="text-slate-500 text-sm text-center py-10">{msg}</p>;
|
||||
return <p className="text-muted-foreground text-sm text-center py-10">{msg}</p>;
|
||||
}
|
||||
|
||||
// ── Main component ──────────────────────────────────────────────────────────
|
||||
@@ -71,6 +71,14 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("devices");
|
||||
const [selectedDevice, setSelectedDevice] = useState<{ ip: string; label: string | null; mac: string | null } | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 480);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
@@ -100,49 +108,49 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
|
||||
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="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
||||
<div
|
||||
className="relative bg-[#0d1117] border border-white/10 rounded-2xl w-full max-w-5xl max-h-[92vh] flex flex-col shadow-2xl shadow-black/60 animate-slide-up"
|
||||
className={`relative bg-card border border-border rounded-2xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
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 flex-shrink-0">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-gradient-to-r from-blue-900/30 to-purple-900/20 rounded-t-2xl flex-shrink-0">
|
||||
<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>
|
||||
<h3 className="text-lg font-bold text-card-foreground">{agentLabel}</h3>
|
||||
<p className="text-xs text-muted-foreground 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">
|
||||
<button onClick={handleClose} className="p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
|
||||
{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>
|
||||
<p className="text-muted-foreground text-sm">Loading agent data…</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||
) : data ? (
|
||||
<>
|
||||
{/* ── KPI row ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b border-white/5 flex-shrink-0">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b border-border/50 flex-shrink-0">
|
||||
{[
|
||||
{ label: "Devices", value: data.devices.length, color: "text-white" },
|
||||
{ label: "Flows", value: data.summary?.active_flows ?? data.flows.length, color: "text-white" },
|
||||
{ label: "Devices", value: data.devices.length, color: "text-card-foreground" },
|
||||
{ label: "Flows", value: data.summary?.active_flows ?? data.flows.length, color: "text-card-foreground" },
|
||||
{ label: "Download", value: fmtBytes(data.summary?.bandwidth_down ?? 0), color: "text-cyan-400" },
|
||||
{ label: "Upload", value: fmtBytes(data.summary?.bandwidth_up ?? 0), color: "text-green-400" },
|
||||
].map((kpi) => (
|
||||
<div key={kpi.label} className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<p className="text-xs text-slate-400 mb-1">{kpi.label}</p>
|
||||
<div key={kpi.label} className="bg-secondary rounded-xl p-3 border border-border/50">
|
||||
<p className="text-xs text-muted-foreground mb-1">{kpi.label}</p>
|
||||
<p className={`text-xl font-bold ${kpi.color}`}>{kpi.value}</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -157,7 +165,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
)}
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
<div className="flex gap-1 px-6 py-3 border-b border-white/5 flex-shrink-0 flex-wrap">
|
||||
<div className="flex gap-1 px-6 py-3 border-b border-border/50 flex-shrink-0 flex-wrap">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
@@ -165,7 +173,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs 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"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
@@ -192,15 +200,15 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedDevice({ ip: d.ip_address || "", label: d.device_label, mac: d.mac_address })}
|
||||
className="w-full text-left p-3 bg-white/[0.03] hover:bg-white/[0.07] hover:border-cyan-500/30 border border-white/5 rounded-xl transition-all group cursor-pointer"
|
||||
className="w-full text-left p-3 bg-white/[0.03] hover:bg-white/[0.07] hover:border-cyan-500/30 border border-border/50 rounded-xl transition-all group cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-slate-800/60 group-hover:bg-cyan-500/10 rounded-lg transition-colors">
|
||||
<Monitor className="w-4 h-4 text-slate-400 group-hover:text-cyan-400 transition-colors" />
|
||||
<Monitor className="w-4 h-4 text-muted-foreground group-hover:text-cyan-400 transition-colors" />
|
||||
</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 group-hover:text-cyan-300 transition-colors">{d.device_label || d.ip_address || "Unknown"}</p>
|
||||
<p className="font-medium text-card-foreground text-sm truncate group-hover:text-cyan-300 transition-colors">{d.device_label || d.ip_address || "Unknown"}</p>
|
||||
{d.risk_level && (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ring-1 ${rc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${rc.dot}`} />
|
||||
@@ -214,17 +222,17 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||
<p className="text-xs text-slate-400 font-mono">{d.ip_address}</p>
|
||||
{d.mac_address && <p className="text-xs text-slate-500 font-mono">{d.mac_address}</p>}
|
||||
<p className="text-xs text-muted-foreground font-mono">{d.ip_address}</p>
|
||||
{d.mac_address && <p className="text-xs text-muted-foreground font-mono">{d.mac_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>}
|
||||
{d.manufacturer && <p className="text-xs text-muted-foreground">{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>
|
||||
<p className="text-xs text-green-400">↑ {fmtBytes(d.upload ?? 0)}</p>
|
||||
{d.encrypted_pct !== null && (
|
||||
<p className="text-xs text-slate-500">🔒 {d.encrypted_pct}%</p>
|
||||
<p className="text-xs text-muted-foreground">🔒 {d.encrypted_pct}%</p>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-cyan-400 ml-auto transition-colors" />
|
||||
</div>
|
||||
@@ -246,7 +254,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-slate-300">
|
||||
{f.dst_ip || "—"}{f.dst_port ? <span className="text-slate-500">:{f.dst_port}</span> : ""}
|
||||
{f.dst_ip || "—"}{f.dst_port ? <span className="text-muted-foreground">:{f.dst_port}</span> : ""}
|
||||
</span>
|
||||
{f.dst_ip && <IpDetails ip={f.dst_ip} />}
|
||||
</div>
|
||||
@@ -257,16 +265,16 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
{f.app_label || "—"}
|
||||
</span>
|
||||
{f.domain && (
|
||||
<span className="text-[10px] text-slate-500 italic truncate" title={f.domain}>
|
||||
<span className="text-[10px] text-muted-foreground italic truncate" title={f.domain}>
|
||||
{f.domain}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{f.protocol || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{f.protocol || "—"}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-cyan-400 whitespace-nowrap">{fmtBytes(f.download)}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-green-400 whitespace-nowrap">{fmtBytes(f.upload)}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
@@ -282,21 +290,21 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
const maxTotal = Math.max(...data.top_apps.map(x => (x.download ?? 0) + (x.upload ?? 0)), 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-xl transition-colors">
|
||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-border/50 rounded-xl 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>
|
||||
<span className="font-medium text-card-foreground text-sm flex-1">{a.app_label}</span>
|
||||
{a.category && <span className="text-xs text-muted-foreground bg-secondary px-2 py-0.5 rounded">{a.category}</span>}
|
||||
<span className="text-xs font-mono text-card-foreground">{fmtBytes(total)}</span>
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-slate-400 mb-2">
|
||||
<div className="flex gap-3 text-xs text-muted-foreground mb-2">
|
||||
<span className="text-cyan-400">↓ {fmtBytes(a.download ?? 0)}</span>
|
||||
<span className="text-green-400">↑ {fmtBytes(a.upload ?? 0)}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-1 bg-secondary 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>
|
||||
@@ -311,9 +319,9 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<div className="space-y-6">
|
||||
{/* Encryption Audit */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-white mb-3">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Lock className="w-4 h-4 text-blue-400" /> Encryption Audit
|
||||
<span className="ml-auto text-xs text-slate-500">{data.security?.encryption_audit.length ?? 0} devices</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security?.encryption_audit.length ?? 0} devices</span>
|
||||
</h4>
|
||||
{(data.security?.encryption_audit.length ?? 0) === 0 ? <EmptyState msg="No encryption audit data for this agent." /> : (
|
||||
<TableWrap head={["IP Address", "Device", "Encrypted %", "Risk", "Enc Bytes", "Unenc Bytes"]}>
|
||||
@@ -328,7 +336,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<div className="w-16 h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${r.encrypted_pct ?? 0}%` }} />
|
||||
</div>
|
||||
<span className="text-white">{r.encrypted_pct ?? 0}%</span>
|
||||
<span className="text-card-foreground">{r.encrypted_pct ?? 0}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
@@ -348,9 +356,9 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
|
||||
{/* Insecure Protocols */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-white mb-3">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Unlock className="w-4 h-4 text-orange-400" /> Insecure Protocols
|
||||
<span className="ml-auto text-xs text-slate-500">{data.security?.insecure_protocols.length ?? 0} records</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security?.insecure_protocols.length ?? 0} records</span>
|
||||
</h4>
|
||||
{(data.security?.insecure_protocols.length ?? 0) === 0 ? <EmptyState msg="No insecure protocols detected for this agent." /> : (
|
||||
<TableWrap head={["IP", "Protocol", "Risk", "App", "Dst IP:Port", "↓", "↑"]}>
|
||||
@@ -369,7 +377,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<td className="px-3 py-2 text-xs text-purple-300 truncate max-w-[100px]">{r.app_label || "—"}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-slate-400">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
|
||||
</span>
|
||||
{r.dst_ip && <IpDetails ip={r.dst_ip} />}
|
||||
@@ -389,7 +397,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-red-400 mb-3">
|
||||
<AlertTriangle className="w-4 h-4" /> Unencrypted Passwords Detected!
|
||||
<span className="ml-auto text-xs text-slate-500">{data.security!.unencrypted_passwords.length} records</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security!.unencrypted_passwords.length} records</span>
|
||||
</h4>
|
||||
<TableWrap head={["IP", "Dst IP:Port", "Protocol", "Username", "Severity"]}>
|
||||
{data.security!.unencrypted_passwords.map((r, i) => {
|
||||
@@ -399,7 +407,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-slate-400">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
|
||||
</span>
|
||||
{r.dst_ip && <IpDetails ip={r.dst_ip} />}
|
||||
@@ -423,9 +431,9 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
{/* IP Reputation */}
|
||||
{(data.security?.ip_reputation.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-white mb-3">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Eye className="w-4 h-4 text-purple-400" /> IP Reputation
|
||||
<span className="ml-auto text-xs text-slate-500">{data.security!.ip_reputation.length} records</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security!.ip_reputation.length} records</span>
|
||||
</h4>
|
||||
<TableWrap head={["Remote IP", "Local IP", "Reputation", "Score", "Country", "Blacklisted"]}>
|
||||
{data.security!.ip_reputation.map((r, i) => (
|
||||
@@ -434,7 +442,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.local_ip || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-300">{r.reputation || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-yellow-400">{r.score ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{r.country || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{r.country || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{r.blacklisted
|
||||
? <span className="text-red-400 font-semibold">⛔ Yes</span>
|
||||
@@ -451,9 +459,9 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Tor */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-white mb-3">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Router className="w-4 h-4 text-red-400" /> Tor Detections
|
||||
<span className="ml-auto text-xs text-slate-500">{data.security?.tor_detections.length ?? 0}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security?.tor_detections.length ?? 0}</span>
|
||||
</h4>
|
||||
{(data.security?.tor_detections.length ?? 0) === 0
|
||||
? <EmptyState msg="No Tor usage detected." />
|
||||
@@ -461,9 +469,9 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<div key={i} className="p-2 bg-red-500/5 border border-red-500/20 rounded-lg text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-mono text-blue-400">{r.ip_address || "—"}</span>
|
||||
<span className="text-slate-400">{r.country || "—"}</span>
|
||||
<span className="text-muted-foreground">{r.country || "—"}</span>
|
||||
</div>
|
||||
<div className="text-slate-500 mt-0.5">Exit: {r.exit_node || "—"}</div>
|
||||
<div className="text-muted-foreground mt-0.5">Exit: {r.exit_node || "—"}</div>
|
||||
</div>
|
||||
))}</div>
|
||||
}
|
||||
@@ -471,9 +479,9 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
|
||||
{/* VPN */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-white mb-3">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> VPN Detections
|
||||
<span className="ml-auto text-xs text-slate-500">{data.security?.vpn_detections.length ?? 0}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security?.vpn_detections.length ?? 0}</span>
|
||||
</h4>
|
||||
{(data.security?.vpn_detections.length ?? 0) === 0
|
||||
? <EmptyState msg="No VPN usage detected." />
|
||||
@@ -483,11 +491,11 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<span className="font-mono text-blue-400">{r.ip_address || "—"}</span>
|
||||
<span className="text-purple-400">{r.vpn_type || "—"}</span>
|
||||
</div>
|
||||
<div className="text-slate-500 mt-0.5 flex flex-col gap-0.5">
|
||||
<div className="text-muted-foreground mt-0.5 flex flex-col gap-0.5">
|
||||
<span>Remote: {r.remote_ip || "—"} • {r.country || "—"}</span>
|
||||
{r.remote_ip && <IpDetails ip={r.remote_ip} />}
|
||||
</div>
|
||||
{r.confidence && <div className="text-slate-500">Confidence: {(r.confidence * 100).toFixed(0)}%</div>}
|
||||
{r.confidence && <div className="text-muted-foreground">Confidence: {(r.confidence * 100).toFixed(0)}%</div>}
|
||||
</div>
|
||||
))}</div>
|
||||
}
|
||||
@@ -497,9 +505,9 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
{/* Server Discovery */}
|
||||
{(data.server_discovery?.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-white mb-3">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Server className="w-4 h-4 text-cyan-400" /> Servers Discovered
|
||||
<span className="ml-auto text-xs text-slate-500">{data.server_discovery!.length} records</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.server_discovery!.length} records</span>
|
||||
</h4>
|
||||
<TableWrap head={["IP", "Hostname", "Server Type", "Port", "Protocol", "OS"]}>
|
||||
{data.server_discovery!.map((r, i) => (
|
||||
@@ -512,9 +520,9 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-300 truncate max-w-[120px]">{r.hostname || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-cyan-400">{r.server_type || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{r.port ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{r.port ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-purple-300">{r.protocol || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-500">{r.os_label || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{r.os_label || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
@@ -533,7 +541,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<div key={i} className={`p-3 border rounded-xl text-xs transition-colors ${
|
||||
(ev.severity || "").toLowerCase() === "critical" ? "bg-red-500/5 border-red-500/20 hover:bg-red-500/10" :
|
||||
(ev.severity || "").toLowerCase() === "high" ? "bg-orange-500/5 border-orange-500/20 hover:bg-orange-500/10" :
|
||||
"bg-white/[0.03] border-white/5 hover:bg-white/[0.06]"
|
||||
"bg-white/[0.03] border-border/50 hover:bg-white/[0.06]"
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 flex-shrink-0 ${sc.badge}`}>
|
||||
@@ -542,11 +550,11 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="font-semibold text-white">{ev.event_type || "Unknown"}</span>
|
||||
<span className="font-semibold text-card-foreground">{ev.event_type || "Unknown"}</span>
|
||||
<span className="font-mono text-blue-400">{ev.ip_address || "—"}</span>
|
||||
{ev.mac_address && <span className="font-mono text-slate-500">{ev.mac_address}</span>}
|
||||
{ev.mac_address && <span className="font-mono text-muted-foreground">{ev.mac_address}</span>}
|
||||
</div>
|
||||
{ev.description && <p className="text-slate-400 leading-relaxed">{ev.description}</p>}
|
||||
{ev.description && <p className="text-muted-foreground leading-relaxed">{ev.description}</p>}
|
||||
</div>
|
||||
<span className="text-slate-600 flex-shrink-0 whitespace-nowrap">{ev.event_at?.slice(0, 16) || "—"}</span>
|
||||
</div>
|
||||
@@ -565,23 +573,23 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
const maxTotal = Math.max(...data.mac_bandwidth.map(x => x.total ?? 0), 1);
|
||||
const pct = ((m.total ?? 0) / maxTotal) * 100;
|
||||
return (
|
||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 rounded-xl transition-colors">
|
||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-border/50 rounded-xl transition-colors">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Wifi className="w-4 h-4 text-slate-400 flex-shrink-0" />
|
||||
<Wifi className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono text-xs text-slate-200">{m.mac_address}</p>
|
||||
<p className="text-xs text-slate-500">{m.manufacturer || "Unknown Manufacturer"}</p>
|
||||
<p className="text-xs text-muted-foreground">{m.manufacturer || "Unknown Manufacturer"}</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs text-white font-medium">{fmtBytes(m.total ?? 0)}</p>
|
||||
<p className="text-xs text-slate-500">Total</p>
|
||||
<p className="text-xs text-card-foreground font-medium">{fmtBytes(m.total ?? 0)}</p>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs mb-2">
|
||||
<span className="text-cyan-400">↓ {fmtBytes(m.download ?? 0)}</span>
|
||||
<span className="text-green-400">↑ {fmtBytes(m.upload ?? 0)}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-1 bg-secondary 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>
|
||||
|
||||
@@ -16,6 +16,14 @@ 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);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 480);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
@@ -27,14 +35,14 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
}, [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="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
||||
<div
|
||||
className="relative bg-[#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"
|
||||
className={`relative bg-card border border-border rounded-2xl w-full max-w-2xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
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 justify-between px-6 py-4 border-b border-border 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 ? (
|
||||
@@ -44,24 +52,24 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">{appLabel}</h3>
|
||||
{category && <p className="text-xs text-slate-400">{category}</p>}
|
||||
<h3 className="text-lg font-bold text-card-foreground">{appLabel}</h3>
|
||||
{category && <p className="text-xs text-muted-foreground">{category}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
|
||||
onClick={handleClose}
|
||||
className="p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all flex-shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{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>
|
||||
<p className="text-muted-foreground text-sm">Loading application data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||
@@ -69,13 +77,13 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
<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="grid grid-cols-2 gap-3 p-4 bg-white/[0.03] border border-border/50 rounded-xl">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-500">Total Download</p>
|
||||
<p className="text-xs text-muted-foreground">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-xs text-muted-foreground">Total Upload</p>
|
||||
<p className="text-xl font-bold text-green-400">{fmtBytes(data.total_upload ?? 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,20 +95,20 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
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>
|
||||
<p className="text-muted-foreground 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 key={i} className="p-3 bg-white/[0.03] border border-border/50 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>
|
||||
<span className="text-sm font-medium text-card-foreground">{a.agent_label}</span>
|
||||
{a.agent_uuid && (
|
||||
<span className="text-xs font-mono text-slate-500">{a.agent_uuid}</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{a.agent_uuid}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs font-mono">
|
||||
@@ -108,7 +116,7 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
<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-1.5 bg-secondary 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}%` }}
|
||||
@@ -133,17 +141,17 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
)}
|
||||
</h4>
|
||||
{data.top_ips.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm text-center py-4">No device data available.</p>
|
||||
<p className="text-muted-foreground 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 className="text-left border-b border-border">
|
||||
<th className="pb-2 text-xs font-medium text-muted-foreground">#</th>
|
||||
<th className="pb-2 text-xs font-medium text-muted-foreground">IP Address</th>
|
||||
<th className="pb-2 text-xs font-medium text-muted-foreground text-right">Download</th>
|
||||
<th className="pb-2 text-xs font-medium text-muted-foreground text-right">Upload</th>
|
||||
<th className="pb-2 text-xs font-medium text-muted-foreground">Usage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
@@ -152,7 +160,7 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
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 text-xs text-muted-foreground">{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>
|
||||
@@ -160,14 +168,14 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
<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>
|
||||
<span className="text-xs text-muted-foreground bg-secondary 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-1.5 bg-secondary 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}%` }}
|
||||
|
||||
@@ -75,7 +75,7 @@ function sevStyle(sev: string | null) {
|
||||
case "high": return "bg-orange-500/15 text-orange-400 ring-1 ring-orange-500/30";
|
||||
case "warning": return "bg-yellow-500/15 text-yellow-400 ring-1 ring-yellow-500/30";
|
||||
case "info": return "bg-blue-500/15 text-blue-400 ring-1 ring-blue-500/30";
|
||||
default: return "bg-slate-700/40 text-slate-400";
|
||||
default: return "bg-slate-700/40 text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,17 +87,17 @@ function riskStyle(lvl: string | null) {
|
||||
case "sedang": return { bar: "bg-yellow-500", badge: "bg-yellow-500/15 text-yellow-400 ring-1 ring-yellow-500/30" };
|
||||
case "low":
|
||||
case "aman": return { bar: "bg-green-500", badge: "bg-green-500/15 text-green-400 ring-1 ring-green-500/30" };
|
||||
default: return { bar: "bg-slate-500", badge: "bg-slate-700/40 text-slate-400" };
|
||||
default: return { bar: "bg-slate-500", badge: "bg-slate-700/40 text-muted-foreground" };
|
||||
}
|
||||
}
|
||||
|
||||
function TableWrap({ head, children }: { head: string[]; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-white/5">
|
||||
<div className="overflow-x-auto rounded-xl border border-border/50">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 bg-white/[0.02]">
|
||||
{head.map((h) => <th key={h} className="px-3 py-2 text-left font-medium text-slate-400 whitespace-nowrap">{h}</th>)}
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{head.map((h) => <th key={h} className="px-3 py-2 text-left font-medium text-muted-foreground whitespace-nowrap">{h}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">{children}</tbody>
|
||||
@@ -107,7 +107,7 @@ function TableWrap({ head, children }: { head: string[]; children: React.ReactNo
|
||||
}
|
||||
|
||||
function Empty({ msg }: { msg: string }) {
|
||||
return <p className="text-slate-500 text-sm text-center py-10">{msg}</p>;
|
||||
return <p className="text-muted-foreground text-sm text-center py-10">{msg}</p>;
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
@@ -116,6 +116,14 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("info");
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 480);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
@@ -146,61 +154,61 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/75 backdrop-blur-sm" />
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
||||
<div
|
||||
className="relative bg-[#0b0f16] border border-white/10 rounded-2xl w-full max-w-4xl max-h-[92vh] flex flex-col shadow-2xl shadow-black/70 animate-slide-up"
|
||||
className={`relative bg-card border border-border rounded-2xl w-full max-w-4xl h-[85vh] flex flex-col shadow-2xl shadow-black/70 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
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-teal-900/20 rounded-t-2xl flex-shrink-0">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-gradient-to-r from-cyan-900/30 to-teal-900/20 rounded-t-2xl flex-shrink-0">
|
||||
<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-base font-bold text-white leading-tight">
|
||||
<h3 className="text-base font-bold text-card-foreground leading-tight">
|
||||
{di?.device_label || deviceLabel || ip}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||
<span className="text-xs text-cyan-400 font-mono">{ip}</span>
|
||||
{(di?.mac_address || macAddress) && (
|
||||
<span className="text-xs text-slate-500 font-mono">{di?.mac_address || macAddress}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">{di?.mac_address || macAddress}</span>
|
||||
)}
|
||||
{di?.os_label && (
|
||||
<span className="text-xs text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded">{di.os_label}</span>
|
||||
)}
|
||||
{di?.device_type && (
|
||||
<span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded">{di.device_type}</span>
|
||||
<span className="text-xs text-muted-foreground bg-secondary px-2 py-0.5 rounded">{di.device_type}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all flex-shrink-0">
|
||||
<button onClick={handleClose} className="p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all flex-shrink-0">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
|
||||
{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>
|
||||
<p className="text-muted-foreground text-sm">Loading device data…</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||
) : data ? (
|
||||
<>
|
||||
{/* ── KPI bar ── */}
|
||||
<div className="grid grid-cols-3 gap-3 px-6 py-3 border-b border-white/5 bg-white/[0.01] flex-shrink-0">
|
||||
<div className="grid grid-cols-3 gap-3 px-6 py-3 border-b border-border/50 bg-muted/30 flex-shrink-0">
|
||||
{[
|
||||
{ label: "Download", value: fmtBytes(data.total_download), color: "text-cyan-400" },
|
||||
{ label: "Upload", value: fmtBytes(data.total_upload), color: "text-green-400" },
|
||||
{ label: "Total Flows",value: data.flow_count, color: "text-white" },
|
||||
{ label: "Total Flows",value: data.flow_count, color: "text-card-foreground" },
|
||||
].map(k => (
|
||||
<div key={k.label} className="text-center">
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider mb-0.5">{k.label}</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">{k.label}</p>
|
||||
<p className={`text-xl font-bold ${k.color}`}>{k.value}</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -216,18 +224,18 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
|
||||
{/* ── MAC bandwidth strip ── */}
|
||||
{data.mac_bandwidth && (
|
||||
<div className="mx-6 mt-2 flex items-center gap-3 px-3 py-2 bg-white/[0.02] border border-white/5 rounded-lg text-xs">
|
||||
<Wifi className="w-4 h-4 text-slate-400 flex-shrink-0" />
|
||||
<span className="text-slate-400 font-mono">{data.mac_bandwidth.mac_address}</span>
|
||||
<span className="text-slate-500">{data.mac_bandwidth.manufacturer || "Unknown"}</span>
|
||||
<div className="mx-6 mt-2 flex items-center gap-3 px-3 py-2 bg-muted/50 border border-border/50 rounded-lg text-xs">
|
||||
<Wifi className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-muted-foreground font-mono">{data.mac_bandwidth.mac_address}</span>
|
||||
<span className="text-muted-foreground">{data.mac_bandwidth.manufacturer || "Unknown"}</span>
|
||||
<span className="ml-auto text-cyan-400">↓ {fmtBytes(data.mac_bandwidth.download)}</span>
|
||||
<span className="text-green-400">↑ {fmtBytes(data.mac_bandwidth.upload)}</span>
|
||||
<span className="text-slate-400 font-medium">Total: {fmtBytes(data.mac_bandwidth.total)}</span>
|
||||
<span className="text-muted-foreground font-medium">Total: {fmtBytes(data.mac_bandwidth.total)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
<div className="flex gap-1 px-6 py-3 border-b border-white/5 flex-shrink-0 flex-wrap">
|
||||
<div className="flex gap-1 px-6 py-3 border-b border-border/50 flex-shrink-0 flex-wrap">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
@@ -235,7 +243,7 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
className={`relative flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs 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"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
@@ -259,8 +267,8 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
{activeTab === "info" && (
|
||||
<div className="space-y-4">
|
||||
{/* Device identity card */}
|
||||
<div className="p-4 bg-white/[0.02] border border-white/5 rounded-xl space-y-3">
|
||||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Informasi Device</p>
|
||||
<div className="p-4 bg-muted/50 border border-border/50 rounded-xl space-y-3">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Informasi Device</p>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{[
|
||||
{ label: "Label", value: di?.device_label },
|
||||
@@ -272,8 +280,8 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
{ label: "Is New", value: di?.is_new === 1 ? "Perangkat Baru ✨" : di?.is_new === 0 ? "Dikenal" : null },
|
||||
].filter(f => f.value).map(f => (
|
||||
<div key={f.label} className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-slate-500">{f.label}</span>
|
||||
<span className="text-xs font-medium text-white font-mono">{String(f.value)}</span>
|
||||
<span className="text-xs text-muted-foreground">{f.label}</span>
|
||||
<span className="text-xs font-medium text-card-foreground font-mono">{String(f.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -282,22 +290,22 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
{/* Top domains */}
|
||||
{data.top_domains.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Top Domains Diakses</p>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Top Domains Diakses</p>
|
||||
<div className="space-y-1.5">
|
||||
{data.top_domains.map((d, i) => {
|
||||
const maxDl = Math.max(...data.top_domains.map(x => x.download), 1);
|
||||
const pct = (d.download / maxDl) * 100;
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3 p-2 bg-white/[0.02] rounded-lg">
|
||||
<div key={i} className="flex items-center gap-3 p-2 bg-muted/50 rounded-lg">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-slate-200 truncate font-mono">{d.domain}</p>
|
||||
<div className="h-1 bg-white/5 rounded-full mt-1 overflow-hidden">
|
||||
<div className="h-1 bg-secondary rounded-full mt-1 overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-teal-500 to-cyan-500 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<p className="text-xs text-cyan-400">{fmtBytes(d.download)}</p>
|
||||
<p className="text-[10px] text-slate-500">{d.flow_count} flows</p>
|
||||
<p className="text-[10px] text-muted-foreground">{d.flow_count} flows</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -320,19 +328,19 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
<span className="font-mono text-xs text-blue-400 block">{f.dst_ip || "—"}</span>
|
||||
{f.dst_ip && <IpDetails ip={f.dst_ip} />}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{f.dst_port ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{f.dst_port ?? "—"}</td>
|
||||
<td className="px-3 py-2 max-w-[180px]">
|
||||
<span className="text-xs text-teal-300 font-mono truncate block" title={exp.main}>
|
||||
{exp.main}
|
||||
</span>
|
||||
{exp.sub && (
|
||||
<span className="text-[10px] text-slate-500 block italic">{exp.sub}</span>
|
||||
<span className="text-[10px] text-muted-foreground block italic">{exp.sub}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{f.protocol || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{f.protocol || "—"}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-cyan-400 whitespace-nowrap">{fmtBytes(f.download)}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-green-400 whitespace-nowrap">{fmtBytes(f.upload)}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -347,15 +355,15 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
: (
|
||||
<div className="space-y-2">
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-3 pb-1 mb-1 border-b border-white/5 flex-wrap">
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-slate-400">
|
||||
<div className="flex items-center gap-3 pb-1 mb-1 border-b border-border/50 flex-wrap">
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<Globe className="w-3 h-3 text-teal-400" /> Domain / Website
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-slate-400">
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<Shield className="w-3 h-3 text-blue-400" /> Protokol
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-slate-400">
|
||||
<Link className="w-3 h-3 text-slate-500" /> Port
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<Link className="w-3 h-3 text-muted-foreground" /> Port
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -372,10 +380,10 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
: isProtocol ? 'from-blue-500 to-indigo-500'
|
||||
: 'from-slate-600 to-slate-500';
|
||||
const iconBg = isDomain ? 'bg-teal-500/15' : isProtocol ? 'bg-blue-500/15' : 'bg-slate-700/40';
|
||||
const iconColor = isDomain ? 'text-teal-400' : isProtocol ? 'text-blue-400' : 'text-slate-500';
|
||||
const iconColor = isDomain ? 'text-teal-400' : isProtocol ? 'text-blue-400' : 'text-muted-foreground';
|
||||
|
||||
return (
|
||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 rounded-xl transition-colors">
|
||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-border/50 rounded-xl transition-colors">
|
||||
<div className="flex items-start gap-3 mb-1.5">
|
||||
{/* Icon */}
|
||||
<div className={`w-7 h-7 rounded-lg flex-shrink-0 flex items-center justify-center ${iconBg}`}>
|
||||
@@ -391,7 +399,7 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Main label: domain name or protocol */}
|
||||
<span className={`font-medium text-sm truncate max-w-[200px] ${
|
||||
isDomain ? 'text-teal-200 font-mono' : isProtocol ? 'text-blue-200' : 'text-slate-400'
|
||||
isDomain ? 'text-teal-200 font-mono' : isProtocol ? 'text-blue-200' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{a.label}
|
||||
</span>
|
||||
@@ -412,8 +420,8 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
|
||||
{/* Stats */}
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs font-mono text-white">{fmtBytes(total)}</p>
|
||||
<p className="text-[10px] text-slate-500">{a.flow_count} flows</p>
|
||||
<p className="text-xs font-mono text-card-foreground">{fmtBytes(total)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{a.flow_count} flows</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -424,7 +432,7 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-white/5 rounded-full overflow-hidden ml-10">
|
||||
<div className="h-1 bg-secondary rounded-full overflow-hidden ml-10">
|
||||
<div
|
||||
className={`h-full bg-gradient-to-r ${barColor} rounded-full transition-all`}
|
||||
style={{ width: `${pct}%` }}
|
||||
@@ -445,9 +453,9 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
const { bar, badge } = riskStyle(enc.risk_level);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-5 bg-white/[0.02] border border-white/5 rounded-xl">
|
||||
<div className="p-5 bg-muted/50 border border-border/50 rounded-xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm font-semibold text-white">Status Enkripsi Traffic</p>
|
||||
<p className="text-sm font-semibold text-card-foreground">Status Enkripsi Traffic</p>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge}`}>
|
||||
{enc.risk_level || "Unknown"}
|
||||
</span>
|
||||
@@ -465,32 +473,32 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-white">{pct}%</span>
|
||||
<span className="text-xl font-bold text-card-foreground">{pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-slate-400">Terenkripsi</span>
|
||||
<span className="text-muted-foreground">Terenkripsi</span>
|
||||
<span className="text-green-400">{fmtBytes(enc.encrypted_bytes ?? 0)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-green-500 rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-slate-400">Tidak Terenkripsi</span>
|
||||
<span className="text-muted-foreground">Tidak Terenkripsi</span>
|
||||
<span className="text-red-400">{fmtBytes(enc.unencrypted_bytes ?? 0)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-red-500 rounded-full transition-all" style={{ width: `${100 - pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500 text-right">
|
||||
<div className="text-xs text-muted-foreground text-right">
|
||||
Total: {fmtBytes(enc.total_bytes ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -525,9 +533,9 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-300 font-mono truncate max-w-[140px]">{s.hostname || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{s.port ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{s.port ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-purple-300">{s.protocol || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-500">{s.os_label || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{s.os_label || "—"}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-cyan-400">{fmtBytes(s.download)}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-green-400">{fmtBytes(s.upload)}</td>
|
||||
</tr>
|
||||
@@ -541,7 +549,7 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
data.unencrypted_passwords.length === 0
|
||||
? <div className="text-center py-10">
|
||||
<p className="text-green-400 text-2xl mb-2">✅</p>
|
||||
<p className="text-slate-400 text-sm">Tidak ada password cleartext yang terdeteksi.</p>
|
||||
<p className="text-muted-foreground text-sm">Tidak ada password cleartext yang terdeteksi.</p>
|
||||
</div>
|
||||
: (
|
||||
<div className="space-y-3">
|
||||
@@ -558,7 +566,7 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
{p.dst_ip && <IpDetails ip={p.dst_ip} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{p.dst_port ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{p.dst_port ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-300">{p.protocol || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-yellow-300 font-mono">{p.username || "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
@@ -588,7 +596,7 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-300">{r.reputation || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-yellow-400">{r.score ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-400">{r.country || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{r.country || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-purple-300 truncate max-w-[80px]">{r.app_label || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{r.blacklisted
|
||||
@@ -607,27 +615,27 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
<div className="space-y-6">
|
||||
{/* VPN */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-white mb-3">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> Deteksi VPN
|
||||
<span className="ml-auto text-xs text-slate-500">{data.vpn_detections.length} records</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.vpn_detections.length} records</span>
|
||||
</h4>
|
||||
{data.vpn_detections.length === 0 ? (
|
||||
<p className="text-slate-500 text-xs">Tidak ada penggunaan VPN yang terdeteksi.</p>
|
||||
<p className="text-muted-foreground text-xs">Tidak ada penggunaan VPN yang terdeteksi.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{data.vpn_detections.map((v, i) => (
|
||||
<div key={i} className="p-3 bg-blue-500/5 border border-blue-500/15 rounded-xl text-xs space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-blue-400 font-medium">{v.vpn_type || "Unknown VPN"}</span>
|
||||
<span className="text-slate-500">{v.country || "—"}</span>
|
||||
<span className="text-muted-foreground">{v.country || "—"}</span>
|
||||
</div>
|
||||
{v.remote_ip && (
|
||||
<div className="text-slate-400 font-mono flex flex-col gap-0.5 mt-0.5">
|
||||
<div className="text-muted-foreground font-mono flex flex-col gap-0.5 mt-0.5">
|
||||
<span>→ {v.remote_ip}</span>
|
||||
<IpDetails ip={v.remote_ip} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 text-slate-500">
|
||||
<div className="flex gap-2 text-muted-foreground">
|
||||
<span>{v.protocol || "—"}</span>
|
||||
{v.confidence !== null && v.confidence !== undefined && (
|
||||
<span className="ml-auto text-purple-400">
|
||||
@@ -643,27 +651,27 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
||||
|
||||
{/* Events */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-white mb-3">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Bell className="w-4 h-4 text-yellow-400" /> Security Events
|
||||
<span className="ml-auto text-xs text-slate-500">{data.events.length} records</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.events.length} records</span>
|
||||
</h4>
|
||||
{data.events.length === 0 ? (
|
||||
<p className="text-slate-500 text-xs">Tidak ada security events untuk device ini.</p>
|
||||
<p className="text-muted-foreground text-xs">Tidak ada security events untuk device ini.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.events.map((ev, i) => (
|
||||
<div key={i} className={`p-3 rounded-xl border text-xs ${
|
||||
(ev.severity || "").toLowerCase() === "critical" ? "bg-red-500/5 border-red-500/20" :
|
||||
(ev.severity || "").toLowerCase() === "high" ? "bg-orange-500/5 border-orange-500/15" :
|
||||
"bg-white/[0.02] border-white/5"
|
||||
"bg-muted/50 border-border/50"
|
||||
}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={`flex-shrink-0 px-2 py-0.5 rounded-full text-[10px] font-semibold ${sevStyle(ev.severity)}`}>
|
||||
{ev.severity || "Info"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white">{ev.event_type || "Unknown"}</p>
|
||||
{ev.description && <p className="text-slate-400 mt-0.5 leading-relaxed">{ev.description}</p>}
|
||||
<p className="font-medium text-card-foreground">{ev.event_type || "Unknown"}</p>
|
||||
{ev.description && <p className="text-muted-foreground mt-0.5 leading-relaxed">{ev.description}</p>}
|
||||
</div>
|
||||
<span className="text-slate-600 flex-shrink-0">{ev.event_at?.slice(0, 16) || "—"}</span>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { fmtBytes } from '@/lib/utils';
|
||||
import { useTheme } from "next-themes";
|
||||
import * as THREE from 'three';
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
// Dynamically import Globe to avoid SSR issues
|
||||
const Globe = dynamic(() => import('react-globe.gl'), { ssr: false });
|
||||
@@ -49,6 +50,8 @@ interface GlobeMapProps {
|
||||
data: {
|
||||
countryCode: string;
|
||||
value: number;
|
||||
upload?: number;
|
||||
download?: number;
|
||||
label: string;
|
||||
}[];
|
||||
}
|
||||
@@ -57,48 +60,12 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
const globeRef = useRef<any>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: 500 });
|
||||
const [countries, setCountries] = useState<any>(null);
|
||||
const [userInteracting, setUserInteracting] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { theme } = useTheme();
|
||||
const isLight = theme === 'light';
|
||||
|
||||
// Generate a beautiful, ultra-fresh gradient blue texture for the Light Mode ocean
|
||||
const lightGlobeTexture = useMemo(() => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1024;
|
||||
canvas.height = 512;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 512);
|
||||
gradient.addColorStop(0, '#bae6fd'); // sky-200 (light bright blue at the top)
|
||||
gradient.addColorStop(0.5, '#38bdf8'); // sky-400 (vibrant blue equator)
|
||||
gradient.addColorStop(1, '#0284c7'); // sky-600 (deeper rich blue at the bottom)
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, 1024, 512);
|
||||
}
|
||||
return canvas.toDataURL('image/png');
|
||||
}, []);
|
||||
|
||||
// Generate a sleek, deep gradient dark texture for the Dark Mode ocean
|
||||
const darkGlobeTexture = useMemo(() => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1024;
|
||||
canvas.height = 512;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 512);
|
||||
gradient.addColorStop(0, '#0f172a'); // slate-900
|
||||
gradient.addColorStop(0.5, '#020617'); // slate-950 (deepest dark blue/black)
|
||||
gradient.addColorStop(1, '#000000'); // black
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, 1024, 512);
|
||||
}
|
||||
return canvas.toDataURL('image/png');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle resizing
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
setDimensions({
|
||||
@@ -111,7 +78,6 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
updateDimensions();
|
||||
window.addEventListener('resize', updateDimensions);
|
||||
|
||||
// Fetch high-res vector polygons for both themes' continents
|
||||
fetch('https://unpkg.com/globe.gl/example/datasets/ne_110m_admin_0_countries.geojson')
|
||||
.then(res => res.json())
|
||||
.then(setCountries)
|
||||
@@ -120,27 +86,24 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
return () => window.removeEventListener('resize', updateDimensions);
|
||||
}, []);
|
||||
|
||||
// Setup Initial Controls & Position
|
||||
useEffect(() => {
|
||||
// Auto-rotate and controls configuration
|
||||
// Since Globe is dynamically imported, we must wait until the ref is actually populated.
|
||||
let checkInterval = setInterval(() => {
|
||||
if (globeRef.current && globeRef.current.controls) {
|
||||
clearInterval(checkInterval);
|
||||
|
||||
const controls = globeRef.current.controls();
|
||||
controls.autoRotate = true;
|
||||
controls.autoRotateSpeed = 1.2; // Adjusted auto-rotate speed to 1.5
|
||||
|
||||
// Enable inertia/damping for smooth deceleration after dragging
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.035;
|
||||
|
||||
// Adjust the manual rotation sensitivity
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.rotateSpeed = 0.8;
|
||||
controls.minPolarAngle = Math.PI / 4;
|
||||
controls.maxPolarAngle = Math.PI * 3/4;
|
||||
controls.minDistance = 140; // Zoom in limit
|
||||
controls.maxDistance = 350; // Zoom out limit
|
||||
|
||||
// Remove boundaries to allow 360 degree rotation in all directions (as much as OrbitControls allows)
|
||||
controls.minPolarAngle = 0; // The top pole (North)
|
||||
controls.maxPolarAngle = Math.PI; // The bottom pole (South)
|
||||
// Listen for interactions to pause auto-rotate smoothly
|
||||
controls.addEventListener('start', () => setUserInteracting(true));
|
||||
controls.addEventListener('end', () => setUserInteracting(false));
|
||||
|
||||
// Focus on Southeast Asia initially
|
||||
globeRef.current.pointOfView({ lat: ORIGIN_LAT, lng: ORIGIN_LNG, altitude: 1.8 }, 2000);
|
||||
@@ -150,6 +113,47 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
return () => clearInterval(checkInterval);
|
||||
}, []);
|
||||
|
||||
// Manage Auto Rotate based on Interaction
|
||||
useEffect(() => {
|
||||
if (globeRef.current && globeRef.current.controls) {
|
||||
const controls = globeRef.current.controls();
|
||||
controls.autoRotate = !userInteracting;
|
||||
controls.autoRotateSpeed = 1.0;
|
||||
}
|
||||
}, [userInteracting]);
|
||||
|
||||
// Create a custom material that we can mutate safely
|
||||
const customGlobeMaterial = useMemo(() => {
|
||||
const mat = new THREE.MeshPhongMaterial();
|
||||
mat.color.set('#020617'); // Default to dark mode
|
||||
return mat;
|
||||
}, []);
|
||||
|
||||
// Smooth Material Color Interpolation for Theme Switching
|
||||
useEffect(() => {
|
||||
// Target ocean colors
|
||||
const targetColor = new THREE.Color(isLight ? '#e0f2fe' : '#020617');
|
||||
|
||||
let frameId: number;
|
||||
const lerpColor = () => {
|
||||
customGlobeMaterial.color.lerp(targetColor, 0.08); // Smooth interpolation step
|
||||
|
||||
const rDiff = Math.abs(customGlobeMaterial.color.r - targetColor.r);
|
||||
const gDiff = Math.abs(customGlobeMaterial.color.g - targetColor.g);
|
||||
const bDiff = Math.abs(customGlobeMaterial.color.b - targetColor.b);
|
||||
|
||||
if (rDiff + gDiff + bDiff > 0.01) {
|
||||
frameId = requestAnimationFrame(lerpColor);
|
||||
} else {
|
||||
customGlobeMaterial.color.copy(targetColor);
|
||||
}
|
||||
};
|
||||
lerpColor();
|
||||
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [isLight, customGlobeMaterial]);
|
||||
|
||||
// Prepare Data Memos
|
||||
const arcsData = useMemo(() => {
|
||||
return data
|
||||
.filter(d => d.countryCode !== "ID" && COUNTRY_COORDS[d.countryCode])
|
||||
@@ -160,59 +164,87 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
startLng: ORIGIN_LNG,
|
||||
endLat: lat,
|
||||
endLng: lng,
|
||||
color: ['#0ea5e9', '#38bdf8'],
|
||||
color: isLight ? ['#38bdf8', '#0284c7'] : ['#0ea5e9', '#38bdf8'],
|
||||
name: d.label,
|
||||
value: d.value
|
||||
value: d.value,
|
||||
upload: d.upload || 0,
|
||||
download: d.download || 0
|
||||
};
|
||||
});
|
||||
}, [data]);
|
||||
}, [data, isLight]);
|
||||
|
||||
const pointsData = useMemo(() => {
|
||||
const points = arcsData.map(arc => ({
|
||||
lat: arc.endLat,
|
||||
lng: arc.endLng,
|
||||
size: 0.1,
|
||||
color: '#38bdf8',
|
||||
color: isLight ? '#0284c7' : '#38bdf8',
|
||||
name: arc.name,
|
||||
value: arc.value
|
||||
value: arc.value,
|
||||
upload: arc.upload,
|
||||
download: arc.download,
|
||||
isOrigin: false
|
||||
}));
|
||||
|
||||
// Add origin point
|
||||
points.push({
|
||||
lat: ORIGIN_LAT,
|
||||
lng: ORIGIN_LNG,
|
||||
size: 0.2,
|
||||
color: '#f43f5e',
|
||||
name: 'Indonesia (Origin)',
|
||||
value: 0
|
||||
value: 0,
|
||||
upload: 0,
|
||||
download: 0,
|
||||
isOrigin: true
|
||||
});
|
||||
|
||||
return points;
|
||||
}, [arcsData, isLight]);
|
||||
|
||||
// Extract All Labels + Origin to display statically
|
||||
const labelsData = useMemo(() => {
|
||||
const staticLabels = arcsData.map(arc => ({
|
||||
lat: arc.endLat,
|
||||
lng: arc.endLng,
|
||||
name: arc.name,
|
||||
color: isLight ? '#0f172a' : '#f8fafc'
|
||||
}));
|
||||
|
||||
// Add origin
|
||||
staticLabels.push({
|
||||
lat: ORIGIN_LAT,
|
||||
lng: ORIGIN_LNG,
|
||||
name: 'Indonesia',
|
||||
color: isLight ? '#e11d48' : '#fda4af'
|
||||
});
|
||||
|
||||
return staticLabels;
|
||||
}, [arcsData, isLight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-[500px] rounded-xl overflow-visible bg-transparent cursor-move flex items-center justify-center transition-all duration-500"
|
||||
onMouseEnter={() => setUserInteracting(true)}
|
||||
onMouseLeave={() => setUserInteracting(false)}
|
||||
>
|
||||
|
||||
<Globe
|
||||
ref={globeRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
globeMaterial={customGlobeMaterial}
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
showGraticules={true}
|
||||
globeImageUrl={isLight ? lightGlobeTexture : darkGlobeTexture}
|
||||
showAtmosphere={true}
|
||||
atmosphereColor={isLight ? "#0ea5e9" : "#1e40af"}
|
||||
atmosphereColor={isLight ? "#38bdf8" : "#1e40af"}
|
||||
atmosphereAltitude={isLight ? 0.15 : 0.25}
|
||||
|
||||
// Polygons Configuration for Unified Vector Designs
|
||||
// Polygons Configuration
|
||||
polygonsData={countries ? countries.features : []}
|
||||
polygonAltitude={0.015}
|
||||
polygonCapColor={() => isLight ? '#4ade80' : '#0f172a'} // Fresh green for light, dark slate for dark
|
||||
polygonSideColor={() => isLight ? '#22c55e' : '#020617'} // Depth colors
|
||||
polygonStrokeColor={() => isLight ? '#bbf7d0' : '#0284c7'} // Crisp bright borders for light, neon blue for dark
|
||||
polygonCapColor={() => isLight ? '#4ade80' : '#0f172a'}
|
||||
polygonSideColor={() => isLight ? '#22c55e' : '#020617'}
|
||||
polygonStrokeColor={() => isLight ? '#ffffff' : '#0ea5e9'}
|
||||
|
||||
// Arcs configuration
|
||||
arcsData={arcsData}
|
||||
@@ -225,7 +257,21 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
arcDashGap={0.2}
|
||||
arcDashAnimateTime={2000}
|
||||
arcAltitude={0.3}
|
||||
arcStroke={0.7}
|
||||
arcStroke={isLight ? 0.8 : 0.6}
|
||||
arcLabel={(d: any) => `
|
||||
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: 180px; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px;">Flow to ${d.name}</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
// Points configuration
|
||||
pointsData={pointsData}
|
||||
@@ -235,37 +281,42 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
pointAltitude={0.01}
|
||||
pointRadius="size"
|
||||
pointsMerge={true}
|
||||
pointLabel={(d: any) => `
|
||||
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: ${d.value > 0 ? '180px' : 'auto'}; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||
<div style="font-weight: 600; font-size: 14px; ${d.value > 0 ? 'border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px;' : ''}">${d.name}</div>
|
||||
${d.value > 0 ? `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`}
|
||||
|
||||
// Rings configuration (Ping effect for points)
|
||||
ringsData={pointsData}
|
||||
ringLat="lat"
|
||||
ringLng="lng"
|
||||
ringColor="color"
|
||||
ringMaxRadius={(d: any) => d.name === 'Indonesia (Origin)' ? 3 : 1.5}
|
||||
ringMaxRadius={(d: any) => d.isOrigin ? 3 : 1.5}
|
||||
ringPropagationSpeed={3}
|
||||
ringRepeatPeriod={1000}
|
||||
|
||||
// HTML Labels configuration for better readability with text-shadow
|
||||
htmlElementsData={pointsData}
|
||||
htmlLat="lat"
|
||||
htmlLng="lng"
|
||||
htmlAltitude={0.03}
|
||||
htmlElement={(d: any) => {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = d.name;
|
||||
el.style.color = isLight ? '#0f172a' : '#f8fafc';
|
||||
el.style.fontSize = '12px';
|
||||
el.style.fontWeight = '700';
|
||||
el.style.fontFamily = 'Inter, sans-serif';
|
||||
// Creates a thick outline effect so text doesn't blend into arcs/continents
|
||||
el.style.textShadow = isLight
|
||||
? '0 0 4px #ffffff, 0 0 6px #ffffff, 0 0 8px #ffffff'
|
||||
: '0 0 4px #000000, 0 0 6px #000000, 0 0 8px #000000';
|
||||
el.style.pointerEvents = 'none';
|
||||
el.style.whiteSpace = 'nowrap';
|
||||
el.style.transform = 'translate(-50%, -100%)'; // shift slightly up
|
||||
return el;
|
||||
}}
|
||||
// Labels Configuration (All Destinations + Origin)
|
||||
labelsData={labelsData}
|
||||
labelLat="lat"
|
||||
labelLng="lng"
|
||||
labelText="name"
|
||||
labelSize={1.4}
|
||||
labelDotRadius={0.2}
|
||||
labelColor="color"
|
||||
labelResolution={5}
|
||||
labelAltitude={0.03}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo, useState, useEffect } from "react";
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
Geography,
|
||||
Sphere,
|
||||
Graticule,
|
||||
ZoomableGroup,
|
||||
Line,
|
||||
Marker
|
||||
} from "react-simple-maps";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
const COUNTRY_COORDS: Record<string, [number, number]> = {
|
||||
"ID": [113.9213, -0.7893],
|
||||
"US": [-95.7129, 37.0902],
|
||||
"HK": [114.1095, 22.3964],
|
||||
"SG": [103.8198, 1.3521],
|
||||
"JP": [138.2529, 36.2048],
|
||||
"SC": [55.4920, -4.6796],
|
||||
"GB": [-3.4360, 55.3781],
|
||||
"IT": [12.5674, 41.8719],
|
||||
"CN": [104.1954, 35.8617],
|
||||
"KR": [127.7669, 35.9078],
|
||||
"CA": [-106.3468, 56.1304],
|
||||
"LU": [6.1296, 49.8153],
|
||||
"NL": [5.2913, 52.1326],
|
||||
"TR": [35.2433, 38.9637],
|
||||
"UA": [31.1656, 48.3794]
|
||||
};
|
||||
|
||||
interface LineMapProps {
|
||||
data: {
|
||||
countryCode: string;
|
||||
value: number;
|
||||
label: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const LineMapComponent = ({ data }: LineMapProps) => {
|
||||
const [tooltipContent, setTooltipContent] = useState("");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
// Origin point: Indonesia (Jakarta)
|
||||
const origin: [number, number] = [106.8456, -6.2088];
|
||||
|
||||
return (
|
||||
<div className="relative w-full aspect-[2/1] bg-secondary/20 rounded-xl overflow-hidden border border-border/50 backdrop-blur-sm">
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1 pointer-events-none bg-card/80 p-3 rounded-lg border border-border/50 backdrop-blur-md">
|
||||
<div className="text-sm font-medium text-white flex items-center gap-2">
|
||||
Global Connections
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip Overlay */}
|
||||
{tooltipContent && (
|
||||
<div className="absolute bottom-4 left-4 z-10 bg-card/90 backdrop-blur-md border border-border/50 px-4 py-2.5 rounded-lg shadow-lg pointer-events-none animate-fade-in">
|
||||
<div className="text-sm font-semibold text-white">{tooltipContent.split(':')[0]}</div>
|
||||
<div className="text-xs text-sky-400 mt-1 font-medium">{tooltipContent.split(':')[1]}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
scale: 130,
|
||||
center: [0, 30]
|
||||
}}
|
||||
width={800}
|
||||
height={400}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<ZoomableGroup zoom={1} maxZoom={4} translateExtent={[[-100, -100], [900, 500]]}>
|
||||
<Sphere stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} id="sphere" fill="transparent" />
|
||||
<Graticule stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} />
|
||||
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill="#1e293b"
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: "none" },
|
||||
hover: { outline: "none", fill: "#1e293b" },
|
||||
pressed: { outline: "none" }
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Geographies>
|
||||
|
||||
{data.map((d, i) => {
|
||||
if (d.countryCode === "ID") return null;
|
||||
const dest = COUNTRY_COORDS[d.countryCode];
|
||||
if (!dest) return null;
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
<Line
|
||||
from={origin}
|
||||
to={dest}
|
||||
stroke="#38bdf8"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
style={{ opacity: 0.6 }}
|
||||
/>
|
||||
<Marker coordinates={dest}>
|
||||
<circle
|
||||
r={3}
|
||||
fill="#38bdf8"
|
||||
onMouseEnter={() => setTooltipContent(`${d.label}: ${fmtBytes(d.value)}`)}
|
||||
onMouseLeave={() => setTooltipContent("")}
|
||||
className="cursor-pointer hover:fill-blue-500 transition-colors"
|
||||
/>
|
||||
</Marker>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Origin Marker */}
|
||||
<Marker coordinates={origin}>
|
||||
<circle r={4} fill="#f43f5e" />
|
||||
<circle r={8} fill="#f43f5e" opacity={0.3} className="animate-ping" />
|
||||
</Marker>
|
||||
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LineMap = memo(LineMapComponent);
|
||||
@@ -15,39 +15,58 @@ interface ModalProps {
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setVisible(true);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "unset";
|
||||
if (visible) {
|
||||
setIsClosing(true);
|
||||
const timer = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "unset";
|
||||
}, 480);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [isOpen, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen]);
|
||||
}, []);
|
||||
|
||||
if (!isMounted || !isOpen) return null;
|
||||
if (!isMounted || !visible) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-0">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
|
||||
className={`fixed inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className={cn(
|
||||
"relative z-50 w-full max-w-md transform overflow-hidden rounded-xl border border-border bg-card shadow-2xl transition-all sm:my-8",
|
||||
"relative z-50 w-full max-w-md transform overflow-hidden rounded-xl border border-border bg-card shadow-2xl sm:my-8",
|
||||
isClosing ? 'animate-modal-out' : 'animate-modal-in',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground tracking-tight">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-secondary hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="h-5 w-5" />
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
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();
|
||||