feat: device detail drill-down modal + smart domain/app display
- Add DeviceDetailModal with 8 tabs (Info, Flows, Apps, Encryption, Servers, Password Leaks, IP Reputation, VPN & Events) - Make device cards in AgentDetailModal clickable (nested modal drill-down) - Expand fetchDeviceDetails backend to query all 10 correlated tables (devices, flows, intel_device_discovery, intel_encryption_audit, intel_server_discovery, intel_unencrypted_passwords, intel_ip_reputation, intel_vpn_detection, events, mac_bandwidth) - Smart combined app/domain display: prioritize actual domain names over port-only labels (Port 443 -> scontent.fcgk42-1.fna.fbcdn.net) - Update Flows tab: domain shown in teal monospace, protocols in purple, port-only entries muted - Add 10 new TypeScript interfaces for device detail data types - Add MAC address fallback queries (by MAC when IP yields no results) - Fix fmtBytes for very large values
This commit is contained in:
1 parent
bba75c7dc6
commit
26179a0270
8 files changed
+2134
-363
No files matched your search
+378
-71
@@ -1217,13 +1217,13 @@ async function fetchAgentDetails(agentUuid) {
|
|||||||
const label = AGENT_LABELS[agentUuid] || agentUuid;
|
const label = AGENT_LABELS[agentUuid] || agentUuid;
|
||||||
const macs = AGENT_MAC_MAP[agentUuid];
|
const macs = AGENT_MAC_MAP[agentUuid];
|
||||||
|
|
||||||
if (!macs || macs.length === 0) {
|
const emptyResult = { agent_uuid: agentUuid, agent_label: label, summary: null, devices: [], flows: [], top_apps: [], security: { encryption_audit: [], insecure_protocols: [], unencrypted_passwords: [], ip_reputation: [], tor_detections: [], vpn_detections: [] }, events: [], mac_bandwidth: [], server_discovery: [] };
|
||||||
return { agent_uuid: agentUuid, agent_label: label, summary: null, devices: [], flows: [], top_apps: [] };
|
|
||||||
}
|
if (!macs || macs.length === 0) return emptyResult;
|
||||||
|
|
||||||
const ph = inClause(macs);
|
const ph = inClause(macs);
|
||||||
|
|
||||||
// 1. Top apps by this agent (aggregate from flows)
|
// ── 1. Top apps by this agent (from flows) ─────────────────────────────────
|
||||||
const appRows = d.prepare(`
|
const appRows = d.prepare(`
|
||||||
SELECT app_label,
|
SELECT app_label,
|
||||||
SUM(bytes_download) AS download,
|
SUM(bytes_download) AS download,
|
||||||
@@ -1246,7 +1246,7 @@ async function fetchAgentDetails(agentUuid) {
|
|||||||
upload : r.upload ?? 0,
|
upload : r.upload ?? 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 2. Distinct devices (src_ip + device info + risk badge) for this agent
|
// ── 2. Distinct devices for this agent ─────────────────────────────────────
|
||||||
const devRows = d.prepare(`
|
const devRows = d.prepare(`
|
||||||
SELECT f.src_ip AS ip_address,
|
SELECT f.src_ip AS ip_address,
|
||||||
f.src_mac AS mac_address,
|
f.src_mac AS mac_address,
|
||||||
@@ -1266,26 +1266,23 @@ async function fetchAgentDetails(agentUuid) {
|
|||||||
WHERE f.src_mac IN (${ph})
|
WHERE f.src_mac IN (${ph})
|
||||||
GROUP BY f.src_ip
|
GROUP BY f.src_ip
|
||||||
ORDER BY dl DESC
|
ORDER BY dl DESC
|
||||||
LIMIT 50
|
LIMIT 100
|
||||||
`).all(...macs);
|
`).all(...macs);
|
||||||
|
|
||||||
// Risk badge per device from intel_encryption_audit (latest snapshot)
|
const agentIPs = [...new Set(devRows.map(r => r.ip_address).filter(Boolean))];
|
||||||
|
const phIPs = agentIPs.length > 0 ? agentIPs.map(() => '?').join(',') : null;
|
||||||
|
|
||||||
const latestEncAudit = d.prepare(`SELECT MAX(fetched_at) AS t FROM intel_encryption_audit`).get()?.t;
|
const latestEncAudit = d.prepare(`SELECT MAX(fetched_at) AS t FROM intel_encryption_audit`).get()?.t;
|
||||||
const riskMap = {};
|
const riskMap = {};
|
||||||
if (latestEncAudit) {
|
if (latestEncAudit) {
|
||||||
const riskRows = d.prepare(`
|
const riskRows = d.prepare(`SELECT ip_address, encrypted_pct, risk_level FROM intel_encryption_audit WHERE fetched_at = ?`).all(latestEncAudit);
|
||||||
SELECT ip_address, encrypted_pct, risk_level
|
|
||||||
FROM intel_encryption_audit
|
|
||||||
WHERE fetched_at = ?
|
|
||||||
`).all(latestEncAudit);
|
|
||||||
for (const r of riskRows) {
|
for (const r of riskRows) {
|
||||||
if (r.ip_address) riskMap[r.ip_address] = { encrypted_pct: r.encrypted_pct, risk_level: r.risk_level };
|
if (r.ip_address) riskMap[r.ip_address] = { encrypted_pct: r.encrypted_pct, risk_level: r.risk_level };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insecure protocol flags per IP
|
|
||||||
const insecureIPs = new Set(
|
const insecureIPs = new Set(
|
||||||
d.prepare(`SELECT DISTINCT ip_address FROM intel_insecure_protocols WHERE ip_address IS NOT NULL`).all().map(r => r.ip_address)
|
phIPs ? d.prepare(`SELECT DISTINCT ip_address FROM intel_insecure_protocols WHERE ip_address IN (${phIPs})`).all(...agentIPs).map(r => r.ip_address) : []
|
||||||
);
|
);
|
||||||
|
|
||||||
const devices = devRows.map(r => ({
|
const devices = devRows.map(r => ({
|
||||||
@@ -1298,19 +1295,19 @@ async function fetchAgentDetails(agentUuid) {
|
|||||||
last_seen : r.last_seen || null,
|
last_seen : r.last_seen || null,
|
||||||
download : r.dl ?? 0,
|
download : r.dl ?? 0,
|
||||||
upload : r.ul ?? 0,
|
upload : r.ul ?? 0,
|
||||||
encrypted_pct : riskMap[r.ip_address]?.encrypted_pct ?? null,
|
encrypted_pct: riskMap[r.ip_address]?.encrypted_pct ?? null,
|
||||||
risk_level : riskMap[r.ip_address]?.risk_level ?? null,
|
risk_level : riskMap[r.ip_address]?.risk_level ?? null,
|
||||||
has_insecure : insecureIPs.has(r.ip_address),
|
has_insecure : insecureIPs.has(r.ip_address),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 3. Recent flows for this agent
|
// ── 3. Recent flows for this agent ─────────────────────────────────────────
|
||||||
const flowRows = d.prepare(`
|
const flowRows = d.prepare(`
|
||||||
SELECT src_ip, dst_ip, dst_port, protocol, app_label, domain,
|
SELECT src_ip, dst_ip, dst_port, protocol, app_label, domain,
|
||||||
bytes_download AS download, bytes_upload AS upload, last_seen
|
bytes_download AS download, bytes_upload AS upload, last_seen
|
||||||
FROM flows
|
FROM flows
|
||||||
WHERE src_mac IN (${ph})
|
WHERE src_mac IN (${ph})
|
||||||
ORDER BY last_seen DESC
|
ORDER BY last_seen DESC
|
||||||
LIMIT 50
|
LIMIT 100
|
||||||
`).all(...macs);
|
`).all(...macs);
|
||||||
|
|
||||||
const flows = flowRows.map(r => ({
|
const flows = flowRows.map(r => ({
|
||||||
@@ -1325,7 +1322,7 @@ async function fetchAgentDetails(agentUuid) {
|
|||||||
last_seen : r.last_seen,
|
last_seen : r.last_seen,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 4. Summary stats
|
// ── 4. Summary stats ────────────────────────────────────────────────────────
|
||||||
const sumRow = d.prepare(`
|
const sumRow = d.prepare(`
|
||||||
SELECT COUNT(DISTINCT src_ip) AS device_count,
|
SELECT COUNT(DISTINCT src_ip) AS device_count,
|
||||||
COUNT(*) AS flow_count,
|
COUNT(*) AS flow_count,
|
||||||
@@ -1336,22 +1333,128 @@ async function fetchAgentDetails(agentUuid) {
|
|||||||
`).get(...macs);
|
`).get(...macs);
|
||||||
|
|
||||||
const summary = sumRow ? {
|
const summary = sumRow ? {
|
||||||
total_devices : sumRow.device_count,
|
total_devices : sumRow.device_count ?? 0,
|
||||||
active_flows : sumRow.flow_count,
|
active_flows : sumRow.flow_count ?? 0,
|
||||||
bandwidth_down : sumRow.total_download,
|
bandwidth_down : sumRow.total_download ?? 0,
|
||||||
bandwidth_up : sumRow.total_upload,
|
bandwidth_up : sumRow.total_upload ?? 0,
|
||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
return { agent_uuid: agentUuid, agent_label: label, summary, devices, flows, top_apps };
|
// ── 5. Security Intel filtered by agent IPs & MACs ─────────────────────────
|
||||||
|
const encryptionRows = (latestEncAudit && phIPs)
|
||||||
|
? d.prepare(`SELECT ip_address, mac_address, device_label, encrypted_pct, unencrypted, encrypted, total, risk_level, detected_at FROM intel_encryption_audit WHERE fetched_at = ? AND ip_address IN (${phIPs}) ORDER BY CASE risk_level WHEN 'Rawan' THEN 1 WHEN 'Sedang' THEN 2 ELSE 3 END`).all(latestEncAudit, ...agentIPs)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const insecureProtoRows = phIPs
|
||||||
|
? d.prepare(`SELECT ip_address, mac_address, protocol, risk, app_label, dst_ip, dst_port, download, upload, detected_at FROM intel_insecure_protocols WHERE ip_address IN (${phIPs}) ORDER BY detected_at DESC LIMIT 50`).all(...agentIPs)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let unencPwdRows = d.prepare(`SELECT ip_address, mac_address, dst_ip, dst_port, protocol, username, severity, download, upload, detected_at FROM intel_unencrypted_passwords WHERE mac_address IN (${ph}) ORDER BY detected_at DESC LIMIT 50`).all(...macs);
|
||||||
|
if (unencPwdRows.length === 0 && phIPs) {
|
||||||
|
unencPwdRows = d.prepare(`SELECT ip_address, mac_address, dst_ip, dst_port, protocol, username, severity, download, upload, detected_at FROM intel_unencrypted_passwords WHERE ip_address IN (${phIPs}) ORDER BY detected_at DESC LIMIT 50`).all(...agentIPs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestRepSnap = d.prepare(`SELECT MAX(fetched_at) AS t FROM intel_ip_reputation`).get()?.t;
|
||||||
|
const ipReputRows = (latestRepSnap && phIPs)
|
||||||
|
? d.prepare(`SELECT ip_address, local_ip, mac_address, reputation, score, country, app_label, blacklisted, download, upload, detected_at FROM intel_ip_reputation WHERE fetched_at = ? AND (local_ip IN (${phIPs}) OR ip_address IN (${phIPs})) ORDER BY score DESC LIMIT 50`).all(latestRepSnap, ...agentIPs, ...agentIPs)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let torRows = d.prepare(`SELECT ip_address, mac_address, exit_node, circuit_id, country, download, upload, detected_at FROM intel_tor_detection WHERE mac_address IN (${ph}) ORDER BY detected_at DESC LIMIT 20`).all(...macs);
|
||||||
|
if (torRows.length === 0 && phIPs) {
|
||||||
|
torRows = d.prepare(`SELECT ip_address, mac_address, exit_node, circuit_id, country, download, upload, detected_at FROM intel_tor_detection WHERE ip_address IN (${phIPs}) ORDER BY detected_at DESC LIMIT 20`).all(...agentIPs);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vpnRows = d.prepare(`SELECT ip_address, mac_address, vpn_type, remote_ip, protocol, country, confidence, download, upload, detected_at FROM intel_vpn_detection WHERE mac_address IN (${ph}) ORDER BY detected_at DESC LIMIT 20`).all(...macs);
|
||||||
|
if (vpnRows.length === 0 && phIPs) {
|
||||||
|
vpnRows = d.prepare(`SELECT ip_address, mac_address, vpn_type, remote_ip, protocol, country, confidence, download, upload, detected_at FROM intel_vpn_detection WHERE ip_address IN (${phIPs}) ORDER BY detected_at DESC LIMIT 20`).all(...agentIPs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverDiscRows = phIPs
|
||||||
|
? d.prepare(`SELECT ip_address, mac_address, server_type, hostname, port, protocol, os_label, download, upload, detected_at FROM intel_server_discovery WHERE ip_address IN (${phIPs}) ORDER BY detected_at DESC LIMIT 50`).all(...agentIPs)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const security = {
|
||||||
|
encryption_audit : encryptionRows,
|
||||||
|
insecure_protocols : insecureProtoRows,
|
||||||
|
unencrypted_passwords: unencPwdRows,
|
||||||
|
ip_reputation : ipReputRows,
|
||||||
|
tor_detections : torRows,
|
||||||
|
vpn_detections : vpnRows,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 6. Events filtered by agent IPs & MACs ─────────────────────────────────
|
||||||
|
const eventsByIP = phIPs ? d.prepare(`SELECT event_id, event_type, severity, ip_address, mac_address, description, event_at FROM events WHERE ip_address IN (${phIPs}) ORDER BY event_at DESC LIMIT 100`).all(...agentIPs) : [];
|
||||||
|
const eventsByMAC = d.prepare(`SELECT event_id, event_type, severity, ip_address, mac_address, description, event_at FROM events WHERE mac_address IN (${ph}) ORDER BY event_at DESC LIMIT 100`).all(...macs);
|
||||||
|
|
||||||
|
const seenEvt = new Set();
|
||||||
|
const allEvents = [];
|
||||||
|
for (const r of [...eventsByIP, ...eventsByMAC]) {
|
||||||
|
const key = r.event_id || `${r.ip_address}:${r.event_at}`;
|
||||||
|
if (!seenEvt.has(key)) {
|
||||||
|
seenEvt.add(key);
|
||||||
|
allEvents.push({ event_id: r.event_id, 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allEvents.sort((a, b) => (b.event_at || '').localeCompare(a.event_at || ''));
|
||||||
|
const events = allEvents.slice(0, 100);
|
||||||
|
|
||||||
|
// ── 7. MAC bandwidth for this agent's MACs ──────────────────────────────────
|
||||||
|
const latestMacSnap = d.prepare(`SELECT MAX(fetched_at) AS t FROM mac_bandwidth`).get()?.t;
|
||||||
|
const mac_bandwidth = latestMacSnap
|
||||||
|
? d.prepare(`SELECT mac_address, manufacturer, download, upload, total FROM mac_bandwidth WHERE fetched_at = ? AND mac_address IN (${ph}) ORDER BY download DESC`).all(latestMacSnap, ...macs)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent_uuid : agentUuid,
|
||||||
|
agent_label : label,
|
||||||
|
summary,
|
||||||
|
devices,
|
||||||
|
flows,
|
||||||
|
top_apps,
|
||||||
|
security,
|
||||||
|
events,
|
||||||
|
mac_bandwidth,
|
||||||
|
server_discovery: serverDiscRows,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch data for a specific device IP — from local DB flows + intel tables
|
// Fetch data for a specific device IP — from local DB (all 10 correlated tables)
|
||||||
async function fetchDeviceDetails(ip) {
|
async function fetchDeviceDetails(ip) {
|
||||||
const db = require('./database');
|
const db = require('./database');
|
||||||
const d = db.getDB();
|
const d = db.getDB();
|
||||||
|
|
||||||
// Top apps used by this IP — aggregated from flows
|
// ── 0. Resolve MAC from flows (most recent) ──────────────────────────────
|
||||||
const appRows = d.prepare(`
|
const macRow = d.prepare(`SELECT src_mac FROM flows WHERE src_ip = ? AND src_mac IS NOT NULL ORDER BY last_seen DESC LIMIT 1`).get(ip);
|
||||||
|
const mac = macRow?.src_mac || null;
|
||||||
|
|
||||||
|
// ── 1. Device info from devices table ────────────────────────────────────
|
||||||
|
const deviceRow = d.prepare(`
|
||||||
|
SELECT device_label, device_type, os_label, manufacturer, download, upload, last_seen
|
||||||
|
FROM devices
|
||||||
|
WHERE ip_address = ?
|
||||||
|
ORDER BY fetched_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(ip);
|
||||||
|
|
||||||
|
// ── 2. Discovery info (may differ from devices table) ────────────────────
|
||||||
|
const discRow = d.prepare(`
|
||||||
|
SELECT device_type, os_label, manufacturer, device_label, is_new
|
||||||
|
FROM intel_device_discovery
|
||||||
|
WHERE ip_address = ?
|
||||||
|
ORDER BY fetched_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(ip);
|
||||||
|
|
||||||
|
const device_info = {
|
||||||
|
device_label : deviceRow?.device_label || discRow?.device_label || null,
|
||||||
|
device_type : deviceRow?.device_type || discRow?.device_type || null,
|
||||||
|
os_label : deviceRow?.os_label || discRow?.os_label || null,
|
||||||
|
manufacturer : deviceRow?.manufacturer || discRow?.manufacturer || null,
|
||||||
|
mac_address : mac,
|
||||||
|
is_new : discRow?.is_new ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 3. Named apps (exclude "Port XXX" port-only entries) ─────────────────
|
||||||
|
const namedAppRows = d.prepare(`
|
||||||
SELECT app_label,
|
SELECT app_label,
|
||||||
SUM(bytes_download) AS download,
|
SUM(bytes_download) AS download,
|
||||||
SUM(bytes_upload) AS upload,
|
SUM(bytes_upload) AS upload,
|
||||||
@@ -1359,28 +1462,94 @@ async function fetchDeviceDetails(ip) {
|
|||||||
FROM flows
|
FROM flows
|
||||||
WHERE src_ip = ?
|
WHERE src_ip = ?
|
||||||
AND app_label IS NOT NULL
|
AND app_label IS NOT NULL
|
||||||
|
AND app_label NOT LIKE 'Port %'
|
||||||
GROUP BY app_label
|
GROUP BY app_label
|
||||||
ORDER BY download DESC
|
ORDER BY download DESC
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
`).all(ip);
|
`).all(ip);
|
||||||
|
|
||||||
const top_apps = appRows.map(r => ({
|
// ── 4. Top domains accessed by this device ─────────────────────────────
|
||||||
app_id : null,
|
const domainRows = d.prepare(`
|
||||||
app_label : r.app_label,
|
SELECT domain,
|
||||||
category : null,
|
-- use app_label that appeared most with this domain
|
||||||
favicon : null,
|
(SELECT app_label FROM flows
|
||||||
|
WHERE src_ip = f.src_ip AND domain = f.domain
|
||||||
|
AND app_label IS NOT NULL
|
||||||
|
ORDER BY bytes_download DESC LIMIT 1) AS app_label,
|
||||||
|
-- extract root domain for display
|
||||||
|
domain AS display_name,
|
||||||
|
SUM(bytes_download) AS download,
|
||||||
|
SUM(bytes_upload) AS upload,
|
||||||
|
COUNT(*) AS flow_count
|
||||||
|
FROM flows f
|
||||||
|
WHERE src_ip = ?
|
||||||
|
AND domain IS NOT NULL
|
||||||
|
GROUP BY domain
|
||||||
|
ORDER BY download DESC
|
||||||
|
LIMIT 30
|
||||||
|
`).all(ip);
|
||||||
|
|
||||||
|
// ── 5. Smart combined: flows with BOTH domain and app_label, or just one ─
|
||||||
|
// Build combined display list:
|
||||||
|
// Priority 1 = rows with actual domain (show domain as label)
|
||||||
|
// Priority 2 = rows with named app (not port-only)
|
||||||
|
// Merge & de-duplicate by display name
|
||||||
|
const combinedMap = new Map();
|
||||||
|
|
||||||
|
// Add domains first (higher priority)
|
||||||
|
for (const r of domainRows) {
|
||||||
|
combinedMap.set('domain:' + r.domain, {
|
||||||
|
label : r.domain, // the actual website/domain
|
||||||
|
sub_label : r.app_label || null, // protocol (HTTPS/TLS etc)
|
||||||
|
type : 'domain',
|
||||||
download : r.download ?? 0,
|
download : r.download ?? 0,
|
||||||
upload : r.upload ?? 0,
|
upload : r.upload ?? 0,
|
||||||
|
flow_count : r.flow_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add named apps that don't duplicate a domain entry
|
||||||
|
for (const r of namedAppRows) {
|
||||||
|
const key = 'app:' + r.app_label;
|
||||||
|
if (!combinedMap.has(key)) {
|
||||||
|
combinedMap.set(key, {
|
||||||
|
label : r.app_label, // protocol name (DNS, HTTPS/TLS, etc)
|
||||||
|
sub_label : null,
|
||||||
|
type : 'protocol',
|
||||||
|
download : r.download ?? 0,
|
||||||
|
upload : r.upload ?? 0,
|
||||||
|
flow_count : r.flow_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither domain nor named app found, fall back to ALL app_labels incl Port XXX
|
||||||
|
const top_apps = combinedMap.size > 0
|
||||||
|
? [...combinedMap.values()].sort((a, b) => b.download - a.download).slice(0, 25)
|
||||||
|
: d.prepare(`
|
||||||
|
SELECT app_label AS label, NULL AS sub_label, 'port' AS type,
|
||||||
|
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 25
|
||||||
|
`).all(ip).map(r => ({ label: r.label, sub_label: null, type: 'port', download: r.download ?? 0, upload: r.upload ?? 0, flow_count: r.flow_count }));
|
||||||
|
|
||||||
|
// top_domains: keep simple list for Info tab
|
||||||
|
const top_domains = domainRows.map(r => ({
|
||||||
|
domain : r.domain,
|
||||||
|
app_label : r.app_label,
|
||||||
|
download : r.download ?? 0,
|
||||||
|
upload : r.upload ?? 0,
|
||||||
|
flow_count : r.flow_count,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Recent flows from this IP
|
// ── 5. Recent flows ────────────────────────────────────────────────────
|
||||||
const flowRows = d.prepare(`
|
const flowRows = d.prepare(`
|
||||||
SELECT dst_ip, dst_port, protocol, app_label, domain,
|
SELECT dst_ip, dst_port, protocol, app_label, domain,
|
||||||
bytes_download AS download, bytes_upload AS upload, last_seen
|
bytes_download AS download, bytes_upload AS upload, last_seen
|
||||||
FROM flows
|
FROM flows
|
||||||
WHERE src_ip = ?
|
WHERE src_ip = ?
|
||||||
ORDER BY last_seen DESC
|
ORDER BY last_seen DESC
|
||||||
LIMIT 50
|
LIMIT 100
|
||||||
`).all(ip);
|
`).all(ip);
|
||||||
|
|
||||||
const flows = flowRows.map(r => ({
|
const flows = flowRows.map(r => ({
|
||||||
@@ -1394,7 +1563,7 @@ async function fetchDeviceDetails(ip) {
|
|||||||
last_seen : r.last_seen,
|
last_seen : r.last_seen,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Totals for this IP
|
// ── 6. Totals ─────────────────────────────────────────────────────────
|
||||||
const sumRow = d.prepare(`
|
const sumRow = d.prepare(`
|
||||||
SELECT SUM(bytes_download) AS total_download,
|
SELECT SUM(bytes_download) AS total_download,
|
||||||
SUM(bytes_upload) AS total_upload,
|
SUM(bytes_upload) AS total_upload,
|
||||||
@@ -1403,70 +1572,208 @@ async function fetchDeviceDetails(ip) {
|
|||||||
WHERE src_ip = ?
|
WHERE src_ip = ?
|
||||||
`).get(ip);
|
`).get(ip);
|
||||||
|
|
||||||
// ── Security Info ──────────────────────────────────────────────
|
// ── 7. Encryption audit (latest snapshot) ─────────────────────────────
|
||||||
// Encryption audit (latest snapshot for this IP)
|
|
||||||
const latestAudit = d.prepare(`SELECT MAX(fetched_at) AS t FROM intel_encryption_audit`).get()?.t;
|
const latestAudit = d.prepare(`SELECT MAX(fetched_at) AS t FROM intel_encryption_audit`).get()?.t;
|
||||||
const encRow = latestAudit
|
const encRow = latestAudit
|
||||||
? d.prepare(`
|
? d.prepare(`
|
||||||
SELECT encrypted_pct, encrypted, unencrypted, total, risk_level
|
SELECT encrypted_pct, encrypted, unencrypted, total, risk_level, mac_address
|
||||||
FROM intel_encryption_audit
|
FROM intel_encryption_audit
|
||||||
WHERE fetched_at = ? AND ip_address = ?
|
WHERE fetched_at = ? AND ip_address = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`).get(latestAudit, ip)
|
`).get(latestAudit, ip)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Insecure protocols used by this IP
|
// Also try fallback by MAC if not found by IP
|
||||||
const insecureRows = d.prepare(`
|
const encRowMac = (!encRow && mac && latestAudit)
|
||||||
SELECT DISTINCT protocol, risk
|
? d.prepare(`
|
||||||
FROM intel_insecure_protocols
|
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 = ?
|
WHERE ip_address = ?
|
||||||
|
GROUP BY server_type, port, protocol
|
||||||
|
ORDER BY download DESC
|
||||||
|
LIMIT 50
|
||||||
`).all(ip);
|
`).all(ip);
|
||||||
|
|
||||||
// Device discovery info (OS, type, manufacturer)
|
// fallback by MAC if no rows by IP
|
||||||
const discRow = d.prepare(`
|
const serverRowsMac = (serverRows.length === 0 && mac)
|
||||||
SELECT device_type, os_label, manufacturer, device_label
|
? d.prepare(`
|
||||||
FROM intel_device_discovery
|
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 = ?
|
WHERE ip_address = ?
|
||||||
ORDER BY fetched_at DESC
|
ORDER BY detected_at DESC
|
||||||
LIMIT 1
|
LIMIT 50
|
||||||
`).get(ip);
|
`).all(ip);
|
||||||
|
|
||||||
// IP reputation (is this device flagged?)
|
if (pwdRows.length === 0 && mac) {
|
||||||
const reputRow = d.prepare(`
|
pwdRows = d.prepare(`
|
||||||
SELECT reputation, score, blacklisted
|
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
|
FROM intel_ip_reputation
|
||||||
WHERE local_ip = ? OR ip_address = ?
|
WHERE fetched_at = ? AND (local_ip = ? OR ip_address = ?)
|
||||||
ORDER BY fetched_at DESC
|
ORDER BY score DESC NULLS LAST
|
||||||
LIMIT 1
|
LIMIT 30
|
||||||
`).get(ip, ip);
|
`).all(latestRepSnap, ip, ip)
|
||||||
|
: [];
|
||||||
|
|
||||||
const security_info = {
|
const ip_reputation = repRows.map(r => ({
|
||||||
encrypted_pct : encRow?.encrypted_pct ?? null,
|
remote_ip : r.ip_address,
|
||||||
encrypted_bytes : encRow?.encrypted ?? null,
|
local_ip : r.local_ip,
|
||||||
unencrypted_bytes: encRow?.unencrypted ?? null,
|
reputation : r.reputation,
|
||||||
risk_level : encRow?.risk_level ?? null,
|
score : r.score,
|
||||||
insecure_protocols: insecureRows.map(r => ({ protocol: r.protocol, risk: r.risk })),
|
country : r.country,
|
||||||
device_type : discRow?.device_type ?? null,
|
app_label : r.app_label,
|
||||||
os_label : discRow?.os_label ?? null,
|
blacklisted : !!r.blacklisted,
|
||||||
manufacturer : discRow?.manufacturer ?? null,
|
download : r.download ?? 0,
|
||||||
device_label : discRow?.device_label ?? null,
|
upload : r.upload ?? 0,
|
||||||
reputation : reputRow?.reputation ?? null,
|
}));
|
||||||
rep_score : reputRow?.score ?? null,
|
|
||||||
blacklisted : reputRow?.blacklisted ?? false,
|
// ── 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 {
|
return {
|
||||||
ip,
|
ip,
|
||||||
|
mac_address : mac,
|
||||||
total_download : sumRow?.total_download ?? 0,
|
total_download : sumRow?.total_download ?? 0,
|
||||||
total_upload : sumRow?.total_upload ?? 0,
|
total_upload : sumRow?.total_upload ?? 0,
|
||||||
flow_count : sumRow?.flow_count ?? 0,
|
flow_count : sumRow?.flow_count ?? 0,
|
||||||
|
device_info,
|
||||||
top_apps,
|
top_apps,
|
||||||
|
top_domains : domainRows.map(r => ({ domain: r.domain, download: r.download ?? 0, upload: r.upload ?? 0, flow_count: r.flow_count })),
|
||||||
flows,
|
flows,
|
||||||
security_info,
|
encryption,
|
||||||
|
server_discovery,
|
||||||
|
unencrypted_passwords,
|
||||||
|
ip_reputation,
|
||||||
|
vpn_detections,
|
||||||
|
events,
|
||||||
|
mac_bandwidth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch data for a specific application — from local DB
|
// Fetch data for a specific application// Fetch data for a specific application — from local DB
|
||||||
async function fetchAppDetails(appLabel) {
|
async function fetchAppDetails(appLabel) {
|
||||||
const db = require('./database');
|
const db = require('./database');
|
||||||
const d = db.getDB();
|
const d = db.getDB();
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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(', '));
|
||||||
|
});
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
// 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);
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// patch_netify.js — patches the fetchAgentDetails function 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');
|
||||||
|
|
||||||
|
// Find the start marker (after the top_apps mapping block)
|
||||||
|
const startMarker = ' // ── 2. Distinct devices for this agent ─────────────────────────────────────\n const devRows = d.prepare(`\n WHERE src_mac IN (${ph})\n ORDER BY last_seen DESC\n LIMIT 50\n `).all(...macs);';
|
||||||
|
|
||||||
|
const endMarker = 'return { agent_uuid: agentUuid, agent_label: label, summary, devices, flows, top_apps };\r\n}';
|
||||||
|
|
||||||
|
const startIdx = content.indexOf(' // ── 2. Distinct devices for this agent ─────────────────────────────────────');
|
||||||
|
const endIdx = content.indexOf('return { agent_uuid: agentUuid, agent_label: label, summary, devices, flows, top_apps };\r\n}');
|
||||||
|
|
||||||
|
if (startIdx === -1) { console.error('START MARKER NOT FOUND'); process.exit(1); }
|
||||||
|
if (endIdx === -1) { console.error('END MARKER NOT FOUND'); process.exit(1); }
|
||||||
|
|
||||||
|
console.log(`Found start at char ${startIdx}, end at char ${endIdx}`);
|
||||||
|
|
||||||
|
const endOffset = endIdx + endMarker.length;
|
||||||
|
|
||||||
|
const replacement = ` // ── 2. Distinct devices for this agent ─────────────────────────────────────
|
||||||
|
const devRows = d.prepare(\`
|
||||||
|
SELECT f.src_ip AS ip_address,
|
||||||
|
f.src_mac AS mac_address,
|
||||||
|
d.device_label,
|
||||||
|
d.device_type,
|
||||||
|
d.os_label,
|
||||||
|
d.manufacturer,
|
||||||
|
SUM(f.bytes_download) AS dl,
|
||||||
|
SUM(f.bytes_upload) AS ul,
|
||||||
|
MAX(f.last_seen) AS last_seen
|
||||||
|
FROM flows f
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT ip_address, device_label, device_type, os_label, manufacturer
|
||||||
|
FROM devices
|
||||||
|
GROUP BY ip_address
|
||||||
|
) d ON d.ip_address = f.src_ip
|
||||||
|
WHERE f.src_mac IN (\${ph})
|
||||||
|
GROUP BY f.src_ip
|
||||||
|
ORDER BY dl DESC
|
||||||
|
LIMIT 100
|
||||||
|
\`).all(...macs);
|
||||||
|
|
||||||
|
const agentIPs = [...new Set(devRows.map(r => r.ip_address).filter(Boolean))];
|
||||||
|
const phIPs = agentIPs.length > 0 ? agentIPs.map(() => '?').join(',') : null;
|
||||||
|
|
||||||
|
const latestEncAudit = d.prepare(\`SELECT MAX(fetched_at) AS t FROM intel_encryption_audit\`).get()?.t;
|
||||||
|
const riskMap = {};
|
||||||
|
if (latestEncAudit) {
|
||||||
|
const riskRows = d.prepare(\`SELECT ip_address, encrypted_pct, risk_level FROM intel_encryption_audit WHERE fetched_at = ?\`).all(latestEncAudit);
|
||||||
|
for (const r of riskRows) {
|
||||||
|
if (r.ip_address) riskMap[r.ip_address] = { encrypted_pct: r.encrypted_pct, risk_level: r.risk_level };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insecureIPs = new Set(
|
||||||
|
phIPs ? d.prepare(\`SELECT DISTINCT ip_address FROM intel_insecure_protocols WHERE ip_address IN (\${phIPs})\`).all(...agentIPs).map(r => r.ip_address) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
const devices = devRows.map(r => ({
|
||||||
|
ip_address : r.ip_address,
|
||||||
|
mac_address : r.mac_address,
|
||||||
|
device_label : r.device_label || r.ip_address || 'Unknown',
|
||||||
|
device_type : r.device_type || null,
|
||||||
|
os_label : r.os_label || null,
|
||||||
|
manufacturer : r.manufacturer || null,
|
||||||
|
last_seen : r.last_seen || null,
|
||||||
|
download : r.dl ?? 0,
|
||||||
|
upload : r.ul ?? 0,
|
||||||
|
encrypted_pct: riskMap[r.ip_address]?.encrypted_pct ?? null,
|
||||||
|
risk_level : riskMap[r.ip_address]?.risk_level ?? null,
|
||||||
|
has_insecure : insecureIPs.has(r.ip_address),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── 3. Recent flows for this agent ─────────────────────────────────────────
|
||||||
|
const flowRows = d.prepare(\`
|
||||||
|
SELECT src_ip, dst_ip, dst_port, protocol, app_label, domain,
|
||||||
|
bytes_download AS download, bytes_upload AS upload, last_seen
|
||||||
|
FROM flows
|
||||||
|
WHERE src_mac IN (\${ph})
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
LIMIT 100
|
||||||
|
\`).all(...macs);
|
||||||
|
|
||||||
|
const flows = flowRows.map(r => ({
|
||||||
|
src_ip : r.src_ip,
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── 4. Summary stats ────────────────────────────────────────────────────────
|
||||||
|
const sumRow = d.prepare(\`
|
||||||
|
SELECT COUNT(DISTINCT src_ip) AS device_count,
|
||||||
|
COUNT(*) AS flow_count,
|
||||||
|
SUM(bytes_download) AS total_download,
|
||||||
|
SUM(bytes_upload) AS total_upload
|
||||||
|
FROM flows
|
||||||
|
WHERE src_mac IN (\${ph})
|
||||||
|
\`).get(...macs);
|
||||||
|
|
||||||
|
const summary = sumRow ? {
|
||||||
|
total_devices : sumRow.device_count ?? 0,
|
||||||
|
active_flows : sumRow.flow_count ?? 0,
|
||||||
|
bandwidth_down : sumRow.total_download ?? 0,
|
||||||
|
bandwidth_up : sumRow.total_upload ?? 0,
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
// ── 5. Security Intel filtered by agent IPs & MACs ─────────────────────────
|
||||||
|
const encryptionRows = (latestEncAudit && phIPs)
|
||||||
|
? d.prepare(\`SELECT ip_address, mac_address, device_label, encrypted_pct, unencrypted, encrypted, total, risk_level, detected_at FROM intel_encryption_audit WHERE fetched_at = ? AND ip_address IN (\${phIPs}) ORDER BY CASE risk_level WHEN 'Rawan' THEN 1 WHEN 'Sedang' THEN 2 ELSE 3 END\`).all(latestEncAudit, ...agentIPs)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const insecureProtoRows = phIPs
|
||||||
|
? d.prepare(\`SELECT ip_address, mac_address, protocol, risk, app_label, dst_ip, dst_port, download, upload, detected_at FROM intel_insecure_protocols WHERE ip_address IN (\${phIPs}) ORDER BY detected_at DESC LIMIT 50\`).all(...agentIPs)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let unencPwdRows = d.prepare(\`SELECT ip_address, mac_address, dst_ip, dst_port, protocol, username, severity, download, upload, detected_at FROM intel_unencrypted_passwords WHERE mac_address IN (\${ph}) ORDER BY detected_at DESC LIMIT 50\`).all(...macs);
|
||||||
|
if (unencPwdRows.length === 0 && phIPs) {
|
||||||
|
unencPwdRows = d.prepare(\`SELECT ip_address, mac_address, dst_ip, dst_port, protocol, username, severity, download, upload, detected_at FROM intel_unencrypted_passwords WHERE ip_address IN (\${phIPs}) ORDER BY detected_at DESC LIMIT 50\`).all(...agentIPs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestRepSnap = d.prepare(\`SELECT MAX(fetched_at) AS t FROM intel_ip_reputation\`).get()?.t;
|
||||||
|
const ipReputRows = (latestRepSnap && phIPs)
|
||||||
|
? d.prepare(\`SELECT ip_address, local_ip, mac_address, reputation, score, country, app_label, blacklisted, download, upload, detected_at FROM intel_ip_reputation WHERE fetched_at = ? AND (local_ip IN (\${phIPs}) OR ip_address IN (\${phIPs})) ORDER BY score DESC LIMIT 50\`).all(latestRepSnap, ...agentIPs, ...agentIPs)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let torRows = d.prepare(\`SELECT ip_address, mac_address, exit_node, circuit_id, country, download, upload, detected_at FROM intel_tor_detection WHERE mac_address IN (\${ph}) ORDER BY detected_at DESC LIMIT 20\`).all(...macs);
|
||||||
|
if (torRows.length === 0 && phIPs) {
|
||||||
|
torRows = d.prepare(\`SELECT ip_address, mac_address, exit_node, circuit_id, country, download, upload, detected_at FROM intel_tor_detection WHERE ip_address IN (\${phIPs}) ORDER BY detected_at DESC LIMIT 20\`).all(...agentIPs);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vpnRows = d.prepare(\`SELECT ip_address, mac_address, vpn_type, remote_ip, protocol, country, confidence, download, upload, detected_at FROM intel_vpn_detection WHERE mac_address IN (\${ph}) ORDER BY detected_at DESC LIMIT 20\`).all(...macs);
|
||||||
|
if (vpnRows.length === 0 && phIPs) {
|
||||||
|
vpnRows = d.prepare(\`SELECT ip_address, mac_address, vpn_type, remote_ip, protocol, country, confidence, download, upload, detected_at FROM intel_vpn_detection WHERE ip_address IN (\${phIPs}) ORDER BY detected_at DESC LIMIT 20\`).all(...agentIPs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverDiscRows = phIPs
|
||||||
|
? d.prepare(\`SELECT ip_address, mac_address, server_type, hostname, port, protocol, os_label, download, upload, detected_at FROM intel_server_discovery WHERE ip_address IN (\${phIPs}) ORDER BY detected_at DESC LIMIT 50\`).all(...agentIPs)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const security = {
|
||||||
|
encryption_audit : encryptionRows,
|
||||||
|
insecure_protocols : insecureProtoRows,
|
||||||
|
unencrypted_passwords: unencPwdRows,
|
||||||
|
ip_reputation : ipReputRows,
|
||||||
|
tor_detections : torRows,
|
||||||
|
vpn_detections : vpnRows,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 6. Events filtered by agent IPs & MACs ─────────────────────────────────
|
||||||
|
const eventsByIP = phIPs ? d.prepare(\`SELECT event_id, event_type, severity, ip_address, mac_address, description, event_at FROM events WHERE ip_address IN (\${phIPs}) ORDER BY event_at DESC LIMIT 100\`).all(...agentIPs) : [];
|
||||||
|
const eventsByMAC = d.prepare(\`SELECT event_id, event_type, severity, ip_address, mac_address, description, event_at FROM events WHERE mac_address IN (\${ph}) ORDER BY event_at DESC LIMIT 100\`).all(...macs);
|
||||||
|
|
||||||
|
const seenEvt = new Set();
|
||||||
|
const allEvents = [];
|
||||||
|
for (const r of [...eventsByIP, ...eventsByMAC]) {
|
||||||
|
const key = r.event_id || \`\${r.ip_address}:\${r.event_at}\`;
|
||||||
|
if (!seenEvt.has(key)) {
|
||||||
|
seenEvt.add(key);
|
||||||
|
allEvents.push({ event_id: r.event_id, 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allEvents.sort((a, b) => (b.event_at || '').localeCompare(a.event_at || ''));
|
||||||
|
const events = allEvents.slice(0, 100);
|
||||||
|
|
||||||
|
// ── 7. MAC bandwidth for this agent's MACs ──────────────────────────────────
|
||||||
|
const latestMacSnap = d.prepare(\`SELECT MAX(fetched_at) AS t FROM mac_bandwidth\`).get()?.t;
|
||||||
|
const mac_bandwidth = latestMacSnap
|
||||||
|
? d.prepare(\`SELECT mac_address, manufacturer, download, upload, total FROM mac_bandwidth WHERE fetched_at = ? AND mac_address IN (\${ph}) ORDER BY download DESC\`).all(latestMacSnap, ...macs)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent_uuid : agentUuid,
|
||||||
|
agent_label : label,
|
||||||
|
summary,
|
||||||
|
devices,
|
||||||
|
flows,
|
||||||
|
top_apps,
|
||||||
|
security,
|
||||||
|
events,
|
||||||
|
mac_bandwidth,
|
||||||
|
server_discovery: serverDiscRows,
|
||||||
|
};
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const newContent = content.slice(0, startIdx) + replacement + content.slice(endOffset);
|
||||||
|
fs.writeFileSync(filePath, newContent, 'utf8');
|
||||||
|
console.log('SUCCESS: Patched netify.js, new length:', newContent.length);
|
||||||
@@ -3,7 +3,12 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { fetchAgentDetails, AgentDetails } from "@/lib/api";
|
import { fetchAgentDetails, AgentDetails } from "@/lib/api";
|
||||||
import { fmtBytes } from "@/lib/utils";
|
import { fmtBytes } from "@/lib/utils";
|
||||||
import { Loader2, X, Server, Monitor, Activity, Globe, ChevronRight } from "lucide-react";
|
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||||
|
import {
|
||||||
|
Loader2, X, Server, Monitor, Activity, Globe, Shield,
|
||||||
|
Bell, Wifi, AlertTriangle, Lock, Unlock, ChevronRight,
|
||||||
|
Eye, Router
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
agentUuid: string;
|
agentUuid: string;
|
||||||
@@ -11,13 +16,60 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = "devices" | "flows" | "apps";
|
type Tab = "devices" | "flows" | "apps" | "security" | "events" | "mac";
|
||||||
|
|
||||||
|
// ── Severity colour helper ─────────────────────────────────────────────────
|
||||||
|
function severityStyle(sev: string | null) {
|
||||||
|
switch ((sev || "").toLowerCase()) {
|
||||||
|
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskStyle(level: string | null) {
|
||||||
|
switch ((level || "").toLowerCase()) {
|
||||||
|
case "rawan":
|
||||||
|
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||||
|
case "sedang":
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared table wrapper ────────────────────────────────────────────────────
|
||||||
|
function TableWrap({ children, head }: { children: React.ReactNode; head: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-white/5">
|
||||||
|
<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>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">{children}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ msg }: { msg: string }) {
|
||||||
|
return <p className="text-slate-500 text-sm text-center py-10">{msg}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ──────────────────────────────────────────────────────────
|
||||||
export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||||
const [data, setData] = useState<AgentDetails | null>(null);
|
const [data, setData] = useState<AgentDetails | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("devices");
|
const [activeTab, setActiveTab] = useState<Tab>("devices");
|
||||||
|
const [selectedDevice, setSelectedDevice] = useState<{ ip: string; label: string | null; mac: string | null } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -28,21 +80,33 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}, [agentUuid]);
|
}, [agentUuid]);
|
||||||
|
|
||||||
|
// Count security alerts
|
||||||
|
const secCount = data
|
||||||
|
? (data.security?.insecure_protocols?.length ?? 0) +
|
||||||
|
(data.security?.unencrypted_passwords?.length ?? 0) +
|
||||||
|
(data.security?.tor_detections?.length ?? 0) +
|
||||||
|
(data.security?.vpn_detections?.length ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const tabs: { key: Tab; label: string; icon: React.ReactNode; count?: number }[] = [
|
const tabs: { key: Tab; label: string; icon: React.ReactNode; count?: number }[] = [
|
||||||
{ key: "devices", label: "Devices", icon: <Monitor className="w-4 h-4" />, count: data?.devices.length },
|
{ key: "devices", label: "Devices", icon: <Monitor className="w-3.5 h-3.5" />, count: data?.devices.length },
|
||||||
{ key: "flows", label: "Active Flows", icon: <Activity className="w-4 h-4" />, count: data?.flows.length },
|
{ key: "flows", label: "Flows", icon: <Activity className="w-3.5 h-3.5" />, count: data?.flows.length },
|
||||||
{ key: "apps", label: "Top Apps", icon: <Globe className="w-4 h-4" />, count: data?.top_apps.length },
|
{ key: "apps", label: "Top Apps", icon: <Globe className="w-3.5 h-3.5" />, count: data?.top_apps.length },
|
||||||
|
{ key: "security", label: "Security", icon: <Shield className="w-3.5 h-3.5" />, count: secCount || undefined },
|
||||||
|
{ key: "events", label: "Events", icon: <Bell className="w-3.5 h-3.5" />, count: data?.events.length },
|
||||||
|
{ key: "mac", label: "MAC Bandwidth", icon: <Wifi className="w-3.5 h-3.5" />, count: data?.mac_bandwidth.length },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
<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="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
<div
|
<div
|
||||||
className="relative bg-[#0d1117] border border-white/10 rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl shadow-black/60 animate-slide-up"
|
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"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-gradient-to-r from-blue-900/30 to-purple-900/20 rounded-t-2xl">
|
<div className="flex items-center 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 gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-blue-500/20 rounded-lg ring-1 ring-blue-500/30">
|
<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" />
|
<Server className="w-5 h-5 text-blue-400" />
|
||||||
@@ -52,52 +116,52 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
|||||||
<p className="text-xs text-slate-400 font-mono">{agentUuid}</p>
|
<p className="text-xs text-slate-400 font-mono">{agentUuid}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={onClose} className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all">
|
||||||
onClick={onClose}
|
|
||||||
className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* ── Body ── */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
|
<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-slate-400 text-sm">Loading agent data…</p>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<>
|
<>
|
||||||
{/* KPI Summary row */}
|
{/* ── KPI row ── */}
|
||||||
<div className="grid grid-cols-4 gap-4 px-6 py-4 border-b border-white/5">
|
<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="bg-white/5 rounded-xl p-4 border border-white/5">
|
{[
|
||||||
<p className="text-xs text-slate-400 mb-1">Devices</p>
|
{ label: "Devices", value: data.devices.length, color: "text-white" },
|
||||||
<p className="text-2xl font-bold text-white">{data.devices.length}</p>
|
{ label: "Flows", value: data.summary?.active_flows ?? data.flows.length, color: "text-white" },
|
||||||
</div>
|
{ label: "Download", value: fmtBytes(data.summary?.bandwidth_down ?? 0), color: "text-cyan-400" },
|
||||||
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
|
{ label: "Upload", value: fmtBytes(data.summary?.bandwidth_up ?? 0), color: "text-green-400" },
|
||||||
<p className="text-xs text-slate-400 mb-1">Active Flows</p>
|
].map((kpi) => (
|
||||||
<p className="text-2xl font-bold text-white">{data.summary?.active_flows ?? data.flows.length}</p>
|
<div key={kpi.label} className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||||
</div>
|
<p className="text-xs text-slate-400 mb-1">{kpi.label}</p>
|
||||||
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
|
<p className={`text-xl font-bold ${kpi.color}`}>{kpi.value}</p>
|
||||||
<p className="text-xs text-slate-400 mb-1">Total Download</p>
|
|
||||||
<p className="text-2xl font-bold text-cyan-400">{fmtBytes(data.summary?.bandwidth_down ?? 0)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
|
|
||||||
<p className="text-xs text-slate-400 mb-1">Total Upload</p>
|
|
||||||
<p className="text-2xl font-bold text-green-400">{fmtBytes(data.summary?.bandwidth_up ?? 0)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* ── Security alert strip ── */}
|
||||||
<div className="flex gap-1 px-6 py-3 border-b border-white/5">
|
{secCount > 0 && (
|
||||||
|
<div className="mx-6 mt-3 flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-xs text-red-400">
|
||||||
|
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span><strong>{secCount}</strong> security alerts detected on this agent — check the Security tab</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Tabs ── */}
|
||||||
|
<div className="flex gap-1 px-6 py-3 border-b border-white/5 flex-shrink-0 flex-wrap">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
? "bg-blue-500/20 text-blue-300 ring-1 ring-blue-500/40"
|
? "bg-blue-500/20 text-blue-300 ring-1 ring-blue-500/40"
|
||||||
: "text-slate-400 hover:text-white hover:bg-white/5"
|
: "text-slate-400 hover:text-white hover:bg-white/5"
|
||||||
@@ -105,8 +169,8 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
|||||||
>
|
>
|
||||||
{tab.icon}
|
{tab.icon}
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{tab.count !== undefined && (
|
{tab.count !== undefined && tab.count > 0 && (
|
||||||
<span className="ml-1 px-1.5 py-0.5 bg-white/10 rounded text-xs text-slate-300">
|
<span className={`ml-0.5 px-1.5 py-0.5 rounded text-xs ${activeTab === tab.key ? "bg-blue-500/30 text-blue-200" : "bg-white/10 text-slate-300"}`}>
|
||||||
{tab.count}
|
{tab.count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -114,34 +178,32 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* ── Tab content ── */}
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
|
||||||
|
{/* DEVICES */}
|
||||||
{activeTab === "devices" && (
|
{activeTab === "devices" && (
|
||||||
|
data.devices.length === 0 ? <EmptyState msg="No devices found for this agent." /> : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{data.devices.length === 0 ? (
|
{data.devices.map((d, i) => {
|
||||||
<p className="text-slate-500 text-sm text-center py-8">No devices found for this agent.</p>
|
const rc = riskStyle(d.risk_level);
|
||||||
) : (
|
|
||||||
data.devices.map((d, i) => {
|
|
||||||
const riskCfg = d.risk_level === "Rawan" || d.risk_level === "Critical"
|
|
||||||
? { color: "text-red-400", bg: "bg-red-500/10 ring-red-500/30", dot: "bg-red-400", label: d.risk_level }
|
|
||||||
: d.risk_level === "Sedang" || d.risk_level === "Medium"
|
|
||||||
? { color: "text-yellow-400", bg: "bg-yellow-500/10 ring-yellow-500/30", dot: "bg-yellow-400", label: d.risk_level }
|
|
||||||
: d.risk_level === "Aman" || d.risk_level === "Low"
|
|
||||||
? { color: "text-green-400", bg: "bg-green-500/10 ring-green-500/30", dot: "bg-green-400", label: d.risk_level }
|
|
||||||
: null;
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 rounded-lg transition-colors">
|
<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"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-shrink-0 p-2 bg-slate-800/60 rounded-lg">
|
<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" />
|
<Monitor className="w-4 h-4 text-slate-400 group-hover:text-cyan-400 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<p className="font-medium text-white text-sm truncate">{d.device_label || d.ip_address || "Unknown"}</p>
|
<p className="font-medium text-white text-sm truncate group-hover:text-cyan-300 transition-colors">{d.device_label || d.ip_address || "Unknown"}</p>
|
||||||
{riskCfg && (
|
{d.risk_level && (
|
||||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ring-1 ${riskCfg.bg} ${riskCfg.color}`}>
|
<span className={`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 ${riskCfg.dot}`} />
|
<span className={`w-1.5 h-1.5 rounded-full ${rc.dot}`} />
|
||||||
{riskCfg.label}
|
{d.risk_level}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{d.has_insecure && (
|
{d.has_insecure && (
|
||||||
@@ -150,106 +212,360 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-0.5">
|
<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>
|
<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>}
|
||||||
{d.os_label && <p className="text-xs text-blue-400">{d.os_label}</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-slate-500">{d.manufacturer}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right flex-shrink-0 space-y-0.5">
|
<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-cyan-400">↓ {fmtBytes(d.download ?? 0)}</p>
|
||||||
|
<p className="text-xs text-green-400">↑ {fmtBytes(d.upload ?? 0)}</p>
|
||||||
{d.encrypted_pct !== null && (
|
{d.encrypted_pct !== null && (
|
||||||
<p className="text-xs text-slate-500">🔒 {d.encrypted_pct}%</p>
|
<p className="text-xs text-slate-500">🔒 {d.encrypted_pct}%</p>
|
||||||
)}
|
)}
|
||||||
|
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-cyan-400 ml-auto transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* FLOWS */}
|
||||||
{activeTab === "flows" && (
|
{activeTab === "flows" && (
|
||||||
<div className="overflow-x-auto">
|
data.flows.length === 0 ? <EmptyState msg="No flows found for this agent." /> : (
|
||||||
{data.flows.length === 0 ? (
|
<TableWrap head={["Source IP", "Destination", "App / Domain", "Proto", "↓", "↑", "Last Seen"]}>
|
||||||
<p className="text-slate-500 text-sm text-center py-8">No flows found for this agent.</p>
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left border-b border-white/10">
|
|
||||||
<th className="pb-2 text-xs font-medium text-slate-400">Source IP</th>
|
|
||||||
<th className="pb-2 text-xs font-medium text-slate-400">Destination</th>
|
|
||||||
<th className="pb-2 text-xs font-medium text-slate-400">App / Domain</th>
|
|
||||||
<th className="pb-2 text-xs font-medium text-slate-400">Protocol</th>
|
|
||||||
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Download</th>
|
|
||||||
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Upload</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-white/5">
|
|
||||||
{data.flows.map((f, i) => (
|
{data.flows.map((f, i) => (
|
||||||
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
||||||
<td className="py-2 pr-4 font-mono text-xs text-blue-400">{f.src_ip || "-"}</td>
|
<td className="px-3 py-2 font-mono text-xs text-blue-400 whitespace-nowrap">{f.src_ip || "—"}</td>
|
||||||
<td className="py-2 pr-4 font-mono text-xs text-slate-300">
|
<td className="px-3 py-2 font-mono text-xs text-slate-300 whitespace-nowrap">
|
||||||
{f.dst_ip || "-"}
|
{f.dst_ip || "—"}{f.dst_port ? <span className="text-slate-500">:{f.dst_port}</span> : ""}
|
||||||
{f.dst_port ? <span className="text-slate-500">:{f.dst_port}</span> : ""}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-4 text-xs text-purple-300 max-w-[140px] truncate">
|
<td className="px-3 py-2 text-xs text-purple-300 max-w-[140px] truncate">{f.app_label || f.domain || "—"}</td>
|
||||||
{f.app_label || f.domain || "-"}
|
<td className="px-3 py-2 text-xs text-slate-400">{f.protocol || "—"}</td>
|
||||||
</td>
|
<td className="px-3 py-2 text-right text-xs text-cyan-400 whitespace-nowrap">{fmtBytes(f.download)}</td>
|
||||||
<td className="py-2 pr-4 text-xs text-slate-400">{f.protocol || "-"}</td>
|
<td className="px-3 py-2 text-right text-xs text-green-400 whitespace-nowrap">{fmtBytes(f.upload)}</td>
|
||||||
<td className="py-2 pr-4 text-right text-xs text-cyan-400">{fmtBytes(f.download)}</td>
|
<td className="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
|
||||||
<td className="py-2 text-right text-xs text-green-400">{fmtBytes(f.upload)}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableWrap>
|
||||||
</table>
|
)
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* TOP APPS */}
|
||||||
{activeTab === "apps" && (
|
{activeTab === "apps" && (
|
||||||
|
data.top_apps.length === 0 ? <EmptyState msg="No application data for this agent." /> : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{data.top_apps.length === 0 ? (
|
{data.top_apps.map((a, i) => {
|
||||||
<p className="text-slate-500 text-sm text-center py-8">No application data for this agent.</p>
|
const total = (a.download ?? 0) + (a.upload ?? 0);
|
||||||
) : (
|
const maxTotal = Math.max(...data.top_apps.map(x => (x.download ?? 0) + (x.upload ?? 0)), 1);
|
||||||
data.top_apps.map((a, i) => {
|
|
||||||
const total = a.download + a.upload;
|
|
||||||
const maxTotal = Math.max(...data.top_apps.map(x => x.download + x.upload), 1);
|
|
||||||
const pct = (total / maxTotal) * 100;
|
const pct = (total / maxTotal) * 100;
|
||||||
return (
|
return (
|
||||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 rounded-lg transition-colors">
|
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 rounded-xl transition-colors">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
{a.favicon ? (
|
{a.favicon
|
||||||
<img src={a.favicon} alt={a.app_label} className="w-5 h-5 rounded bg-white p-0.5 flex-shrink-0" />
|
? <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" />
|
||||||
<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>
|
<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>}
|
{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="text-xs font-mono text-white">{fmtBytes(total)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 text-xs text-slate-400">
|
<div className="flex gap-3 text-xs text-slate-400 mb-2">
|
||||||
<span className="text-cyan-400">↓ {fmtBytes(a.download)}</span>
|
<span className="text-cyan-400">↓ {fmtBytes(a.download ?? 0)}</span>
|
||||||
<span className="text-green-400">↑ {fmtBytes(a.upload)}</span>
|
<span className="text-green-400">↑ {fmtBytes(a.upload ?? 0)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-1 bg-white/5 rounded-full overflow-hidden">
|
<div className="h-1 bg-white/5 rounded-full overflow-hidden">
|
||||||
<div
|
<div className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||||
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all"
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SECURITY */}
|
||||||
|
{activeTab === "security" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Encryption Audit */}
|
||||||
|
<section>
|
||||||
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-white 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>
|
||||||
|
</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"]}>
|
||||||
|
{data.security!.encryption_audit.map((r, i) => {
|
||||||
|
const rc = riskStyle(r.risk_level);
|
||||||
|
return (
|
||||||
|
<tr key={i} className="hover:bg-white/[0.03]">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-xs text-slate-300 truncate max-w-[120px]">{r.device_label || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 ${rc.badge}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${rc.dot}`} />
|
||||||
|
{r.risk_level || "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs text-cyan-400">{fmtBytes(r.encrypted ?? 0)}</td>
|
||||||
|
<td className="px-3 py-2 text-xs text-orange-400">{fmtBytes(r.unencrypted ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableWrap>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Insecure Protocols */}
|
||||||
|
<section>
|
||||||
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-white 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>
|
||||||
|
</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", "↓", "↑"]}>
|
||||||
|
{data.security!.insecure_protocols.map((r, i) => {
|
||||||
|
const sc = severityStyle(r.risk);
|
||||||
|
return (
|
||||||
|
<tr key={i} className="hover:bg-white/[0.03]">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-xs text-slate-200">{r.protocol || "—"}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 ${sc.badge}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||||
|
{r.risk || "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<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 font-mono text-xs text-slate-400">{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-xs text-cyan-400">{fmtBytes(r.download)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-xs text-green-400">{fmtBytes(r.upload)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableWrap>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Unencrypted Passwords */}
|
||||||
|
{(data.security?.unencrypted_passwords.length ?? 0) > 0 && (
|
||||||
|
<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>
|
||||||
|
</h4>
|
||||||
|
<TableWrap head={["IP", "Dst IP:Port", "Protocol", "Username", "Severity"]}>
|
||||||
|
{data.security!.unencrypted_passwords.map((r, i) => {
|
||||||
|
const sc = severityStyle(r.severity);
|
||||||
|
return (
|
||||||
|
<tr key={i} className="hover:bg-white/[0.03]">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-slate-400">{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-xs text-slate-300">{r.protocol || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-xs text-yellow-300 font-mono">{r.username || "—"}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 ${sc.badge}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||||
|
{r.severity || "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableWrap>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
<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>
|
||||||
|
</h4>
|
||||||
|
<TableWrap head={["Remote IP", "Local IP", "Reputation", "Score", "Country", "Blacklisted"]}>
|
||||||
|
{data.security!.ip_reputation.map((r, i) => (
|
||||||
|
<tr key={i} className="hover:bg-white/[0.03]">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-red-400">{r.ip_address || "—"}</td>
|
||||||
|
<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">
|
||||||
|
{r.blacklisted
|
||||||
|
? <span className="text-red-400 font-semibold">⛔ Yes</span>
|
||||||
|
: <span className="text-green-400">✓ No</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TableWrap>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tor & VPN side-by-side */}
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</h4>
|
||||||
|
{(data.security?.tor_detections.length ?? 0) === 0
|
||||||
|
? <EmptyState msg="No Tor usage detected." />
|
||||||
|
: <div className="space-y-2">{data.security!.tor_detections.map((r, i) => (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-500 mt-0.5">Exit: {r.exit_node || "—"}</div>
|
||||||
|
</div>
|
||||||
|
))}</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* VPN */}
|
||||||
|
<section>
|
||||||
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-white 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>
|
||||||
|
</h4>
|
||||||
|
{(data.security?.vpn_detections.length ?? 0) === 0
|
||||||
|
? <EmptyState msg="No VPN usage detected." />
|
||||||
|
: <div className="space-y-2">{data.security!.vpn_detections.map((r, i) => (
|
||||||
|
<div key={i} className="p-2 bg-blue-500/5 border border-blue-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-purple-400">{r.vpn_type || "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-500 mt-0.5">Remote: {r.remote_ip || "—"} • {r.country || "—"}</div>
|
||||||
|
{r.confidence && <div className="text-slate-500">Confidence: {(r.confidence * 100).toFixed(0)}%</div>}
|
||||||
|
</div>
|
||||||
|
))}</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Discovery */}
|
||||||
|
{(data.server_discovery?.length ?? 0) > 0 && (
|
||||||
|
<section>
|
||||||
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-white 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>
|
||||||
|
</h4>
|
||||||
|
<TableWrap head={["IP", "Hostname", "Server Type", "Port", "Protocol", "OS"]}>
|
||||||
|
{data.server_discovery!.map((r, i) => (
|
||||||
|
<tr key={i} className="hover:bg-white/[0.03]">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</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-purple-300">{r.protocol || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-xs text-slate-500">{r.os_label || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TableWrap>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* EVENTS */}
|
||||||
|
{activeTab === "events" && (
|
||||||
|
data.events.length === 0 ? <EmptyState msg="No events found for this agent." /> : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.events.map((ev, i) => {
|
||||||
|
const sc = severityStyle(ev.severity);
|
||||||
|
return (
|
||||||
|
<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]"
|
||||||
|
}`}>
|
||||||
|
<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}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||||
|
{ev.severity || "—"}
|
||||||
|
</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-mono text-blue-400">{ev.ip_address || "—"}</span>
|
||||||
|
{ev.mac_address && <span className="font-mono text-slate-500">{ev.mac_address}</span>}
|
||||||
|
</div>
|
||||||
|
{ev.description && <p className="text-slate-400 leading-relaxed">{ev.description}</p>}
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-600 flex-shrink-0 whitespace-nowrap">{ev.event_at?.slice(0, 16) || "—"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MAC BANDWIDTH */}
|
||||||
|
{activeTab === "mac" && (
|
||||||
|
data.mac_bandwidth.length === 0 ? <EmptyState msg="No MAC bandwidth data for this agent." /> : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.mac_bandwidth.map((m, i) => {
|
||||||
|
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 className="flex items-center gap-3 mb-2">
|
||||||
|
<Wifi className="w-4 h-4 text-slate-400 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>
|
||||||
|
</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>
|
||||||
|
</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-full bg-gradient-to-r from-cyan-500 to-blue-500 rounded-full" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedDevice && selectedDevice.ip && (
|
||||||
|
<DeviceDetailModal
|
||||||
|
ip={selectedDevice.ip}
|
||||||
|
deviceLabel={selectedDevice.label}
|
||||||
|
macAddress={selectedDevice.mac}
|
||||||
|
onClose={() => setSelectedDevice(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,73 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { fetchDeviceDetails, DeviceDetails } from "@/lib/api";
|
import {
|
||||||
|
fetchDeviceDetails, DeviceDetails,
|
||||||
|
} from "@/lib/api";
|
||||||
import { fmtBytes } from "@/lib/utils";
|
import { fmtBytes } from "@/lib/utils";
|
||||||
import { Loader2, X, Monitor, Globe, Activity } from "lucide-react";
|
import {
|
||||||
|
Loader2, X, Monitor, Globe, Activity, Lock, Server,
|
||||||
|
AlertTriangle, Eye, Shield, Bell, Wifi, Unlock, Router,
|
||||||
|
Link
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ip: string;
|
ip : string;
|
||||||
deviceLabel?: string | null;
|
deviceLabel?: string | null;
|
||||||
macAddress?: string | null;
|
macAddress? : string | null;
|
||||||
onClose: () => void;
|
onClose : () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = "apps" | "flows";
|
type Tab = "info" | "flows" | "apps" | "encryption" | "servers" | "passwords" | "reputation" | "vpn_events";
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
function sevStyle(sev: string | null) {
|
||||||
|
switch ((sev || "").toLowerCase()) {
|
||||||
|
case "critical": return "bg-red-500/15 text-red-400 ring-1 ring-red-500/30";
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskStyle(lvl: string | null) {
|
||||||
|
switch ((lvl || "").toLowerCase()) {
|
||||||
|
case "critical":
|
||||||
|
case "rawan": return { bar: "bg-red-500", badge: "bg-red-500/15 text-red-400 ring-1 ring-red-500/30" };
|
||||||
|
case "medium":
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableWrap({ head, children }: { head: string[]; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-white/5">
|
||||||
|
<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>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">{children}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty({ msg }: { msg: string }) {
|
||||||
|
return <p className="text-slate-500 text-sm text-center py-10">{msg}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Props) {
|
export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Props) {
|
||||||
const [data, setData] = useState<DeviceDetails | null>(null);
|
const [data, setData] = useState<DeviceDetails | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("apps");
|
const [activeTab, setActiveTab] = useState<Tab>("info");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -29,141 +78,113 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
|||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
}, [ip]);
|
}, [ip]);
|
||||||
|
|
||||||
const tabs: { key: Tab; label: string; icon: React.ReactNode; count?: number }[] = [
|
const di = data?.device_info;
|
||||||
{ key: "apps", label: "Applications", icon: <Globe className="w-4 h-4" />, count: data?.top_apps.length },
|
|
||||||
{ key: "flows", label: "Active Flows", icon: <Activity className="w-4 h-4" />, count: data?.flows.length },
|
// Alert counts
|
||||||
|
const alertCount =
|
||||||
|
(data?.unencrypted_passwords.length ?? 0) +
|
||||||
|
(data?.vpn_detections.length ?? 0) +
|
||||||
|
(data?.events.filter(e => ["critical","high"].includes((e.severity||"").toLowerCase())).length ?? 0);
|
||||||
|
|
||||||
|
const tabs: { key: Tab; label: string; icon: React.ReactNode; count?: number; alert?: boolean }[] = [
|
||||||
|
{ key: "info", label: "Info", icon: <Monitor className="w-3.5 h-3.5" /> },
|
||||||
|
{ key: "flows", label: "Flows", icon: <Activity className="w-3.5 h-3.5" />, count: data?.flows.length },
|
||||||
|
{ key: "apps", label: "Apps", icon: <Globe className="w-3.5 h-3.5" />, count: data?.top_apps.length },
|
||||||
|
{ key: "encryption", label: "Enkripsi", icon: <Lock className="w-3.5 h-3.5" /> },
|
||||||
|
{ key: "servers", label: "Servers", icon: <Server className="w-3.5 h-3.5" />, count: data?.server_discovery.length },
|
||||||
|
{ key: "passwords", label: "Pwd Leaks", icon: <Unlock className="w-3.5 h-3.5" />, count: data?.unencrypted_passwords.length, alert: (data?.unencrypted_passwords.length ?? 0) > 0 },
|
||||||
|
{ key: "reputation", label: "Reputasi IP", icon: <Eye className="w-3.5 h-3.5" />, count: data?.ip_reputation.length },
|
||||||
|
{ key: "vpn_events", label: "VPN & Events", icon: <Bell className="w-3.5 h-3.5" />, count: (data?.vpn_detections.length ?? 0) + (data?.events.length ?? 0), alert: alertCount > 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4" onClick={onClose}>
|
||||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
<div className="absolute inset-0 bg-black/75 backdrop-blur-sm" />
|
||||||
<div
|
<div
|
||||||
className="relative bg-[#0d1117] border border-white/10 rounded-2xl w-full max-w-3xl max-h-[85vh] flex flex-col shadow-2xl shadow-black/60 animate-slide-up"
|
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"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-gradient-to-r from-cyan-900/30 to-blue-900/20 rounded-t-2xl">
|
<div className="flex items-center 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 gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-cyan-500/20 rounded-lg ring-1 ring-cyan-500/30">
|
<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" />
|
<Monitor className="w-5 h-5 text-cyan-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold text-white">{deviceLabel || ip}</h3>
|
<h3 className="text-base font-bold text-white leading-tight">
|
||||||
<div className="flex items-center gap-3 mt-0.5">
|
{di?.device_label || deviceLabel || ip}
|
||||||
<p className="text-xs text-blue-400 font-mono">{ip}</p>
|
</h3>
|
||||||
{macAddress && <p className="text-xs text-slate-400 font-mono">{macAddress}</p>}
|
<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>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={onClose} className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all flex-shrink-0">
|
||||||
onClick={onClose}
|
|
||||||
className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* ── Body ── */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-cyan-400" />
|
<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-slate-400 text-sm">Loading device data…</p>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<>
|
<>
|
||||||
{/* KPI Summary */}
|
{/* ── KPI bar ── */}
|
||||||
<div className="grid grid-cols-3 gap-3 px-6 py-3 border-b border-white/5 bg-white/[0.02]">
|
<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="text-center">
|
{[
|
||||||
<p className="text-xs text-slate-500">Total Download</p>
|
{ label: "Download", value: fmtBytes(data.total_download), color: "text-cyan-400" },
|
||||||
<p className="text-lg font-bold text-cyan-400">{fmtBytes(data.total_download ?? 0)}</p>
|
{ label: "Upload", value: fmtBytes(data.total_upload), color: "text-green-400" },
|
||||||
|
{ label: "Total Flows",value: data.flow_count, color: "text-white" },
|
||||||
|
].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-xl font-bold ${k.color}`}>{k.value}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-xs text-slate-500">Total Upload</p>
|
|
||||||
<p className="text-lg font-bold text-green-400">{fmtBytes(data.total_upload ?? 0)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-xs text-slate-500">Total Flows</p>
|
|
||||||
<p className="text-lg font-bold text-white">{data.flow_count ?? data.flows.length}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Security Info Panel */}
|
|
||||||
{data.security_info && (data.security_info.risk_level || data.security_info.os_label || data.security_info.device_type || data.security_info.insecure_protocols.length > 0 || data.security_info.blacklisted) && (() => {
|
|
||||||
const si = data.security_info!;
|
|
||||||
const riskColor = si.risk_level === "Rawan" || si.risk_level === "Critical" ? "text-red-400 bg-red-500/10 ring-red-500/30"
|
|
||||||
: si.risk_level === "Sedang" || si.risk_level === "Medium" ? "text-yellow-400 bg-yellow-500/10 ring-yellow-500/30"
|
|
||||||
: "text-green-400 bg-green-500/10 ring-green-500/30";
|
|
||||||
const encPct = si.encrypted_pct ?? 0;
|
|
||||||
return (
|
|
||||||
<div className="mx-6 my-3 p-4 bg-white/[0.02] border border-white/8 rounded-xl space-y-3">
|
|
||||||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Security Info</p>
|
|
||||||
|
|
||||||
{/* Row 1: Device Identity */}
|
|
||||||
{(si.device_type || si.os_label || si.manufacturer) && (
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
{si.device_type && <span className="text-xs text-slate-300 bg-white/5 px-2 py-0.5 rounded">{si.device_type}</span>}
|
|
||||||
{si.os_label && <span className="text-xs text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded">{si.os_label}</span>}
|
|
||||||
{si.manufacturer && <span className="text-xs text-slate-400">{si.manufacturer}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Row 2: Encryption bar */}
|
|
||||||
{si.encrypted_pct !== null && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className="text-xs text-slate-400">Enkripsi Traffic</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ring-1 ${riskColor}`}>
|
|
||||||
{si.risk_level}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-white font-mono">{encPct}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 bg-white/5 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full transition-all ${encPct >= 80 ? "bg-green-500" : encPct >= 50 ? "bg-yellow-500" : "bg-red-500"}`}
|
|
||||||
style={{ width: `${encPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between mt-1 text-xs text-slate-500">
|
|
||||||
<span>Terenkripsi: {fmtBytes(si.encrypted_bytes ?? 0)}</span>
|
|
||||||
<span>Tidak: {fmtBytes(si.unencrypted_bytes ?? 0)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Row 3: Warnings */}
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{si.insecure_protocols.length > 0 && si.insecure_protocols.map((p, i) => (
|
|
||||||
<span key={i} className="inline-flex items-center gap-1 text-xs text-orange-300 bg-orange-500/10 px-2 py-0.5 rounded-full ring-1 ring-orange-500/30">
|
|
||||||
⚠ {p.protocol} ({p.risk})
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
{si.blacklisted && (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-red-300 bg-red-500/10 px-2 py-0.5 rounded-full ring-1 ring-red-500/30">
|
|
||||||
🚫 Blacklisted
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{si.reputation && si.reputation !== "Unknown" && !si.blacklisted && (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-slate-300 bg-white/5 px-2 py-0.5 rounded-full">
|
|
||||||
Reputasi: {si.reputation}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* ── Alert strip ── */}
|
||||||
<div className="flex gap-1 px-6 py-3 border-b border-white/5">
|
{alertCount > 0 && (
|
||||||
|
<div className="mx-6 mt-3 flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-xs text-red-400">
|
||||||
|
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span><strong>{alertCount}</strong> security alerts — cek tab <strong>Pwd Leaks</strong> atau <strong>VPN & Events</strong></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Tabs ── */}
|
||||||
|
<div className="flex gap-1 px-6 py-3 border-b border-white/5 flex-shrink-0 flex-wrap">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
className={`relative flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
? "bg-cyan-500/20 text-cyan-300 ring-1 ring-cyan-500/40"
|
? "bg-cyan-500/20 text-cyan-300 ring-1 ring-cyan-500/40"
|
||||||
: "text-slate-400 hover:text-white hover:bg-white/5"
|
: "text-slate-400 hover:text-white hover:bg-white/5"
|
||||||
@@ -171,90 +192,432 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
|
|||||||
>
|
>
|
||||||
{tab.icon}
|
{tab.icon}
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{tab.count !== undefined && (
|
{tab.count !== undefined && tab.count > 0 && (
|
||||||
<span className="ml-1 px-1.5 py-0.5 bg-white/10 rounded text-xs text-slate-300">
|
<span className={`px-1.5 py-0.5 rounded text-[10px] ${tab.alert ? "bg-red-500/20 text-red-300" : activeTab === tab.key ? "bg-cyan-500/30 text-cyan-200" : "bg-white/10 text-slate-300"}`}>
|
||||||
{tab.count}
|
{tab.count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{tab.alert && tab.count === 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4">
|
{/* ── Tab content ── */}
|
||||||
{activeTab === "apps" && (
|
<div className="px-6 py-4 space-y-4">
|
||||||
<div className="space-y-2">
|
|
||||||
{data.top_apps.length === 0 ? (
|
{/* ══ INFO ══ */}
|
||||||
<p className="text-slate-500 text-sm text-center py-8">No application data for this device. It may not have active flows yet.</p>
|
{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="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
{[
|
||||||
|
{ label: "Label", value: di?.device_label },
|
||||||
|
{ label: "Tipe", value: di?.device_type },
|
||||||
|
{ label: "OS", value: di?.os_label },
|
||||||
|
{ label: "Manufacturer", value: di?.manufacturer },
|
||||||
|
{ label: "IP Address", value: ip },
|
||||||
|
{ label: "MAC Address", value: di?.mac_address || macAddress },
|
||||||
|
{ 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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<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 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-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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══ FLOWS ══ */}
|
||||||
|
{activeTab === "flows" && (
|
||||||
|
data.flows.length === 0 ? <Empty msg="Tidak ada flows untuk device ini." /> : (
|
||||||
|
<TableWrap head={["Destination IP", "Port", "Domain / App", "Proto", "↓", "↑", "Last Seen"]}>
|
||||||
|
{data.flows.map((f, i) => (
|
||||||
|
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-blue-400 whitespace-nowrap">{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 max-w-[180px]">
|
||||||
|
{f.domain ? (
|
||||||
|
/* Domain name takes priority — shown in teal monospace */
|
||||||
|
<span className="text-xs text-teal-300 font-mono truncate block" title={f.domain}>
|
||||||
|
{f.domain}
|
||||||
|
</span>
|
||||||
|
) : f.app_label && !f.app_label.startsWith('Port ') ? (
|
||||||
|
/* Named protocol — purple */
|
||||||
|
<span className="text-xs text-purple-300 truncate block">{f.app_label}</span>
|
||||||
|
) : f.app_label ? (
|
||||||
|
/* Port-only entry — muted */
|
||||||
|
<span className="text-xs text-slate-500 truncate block">{f.app_label}</span>
|
||||||
) : (
|
) : (
|
||||||
data.top_apps.map((a, i) => {
|
<span className="text-xs text-slate-600">—</span>
|
||||||
|
)}
|
||||||
|
{/* Show app_label as secondary if domain is set */}
|
||||||
|
{f.domain && f.app_label && (
|
||||||
|
<span className="text-[10px] text-slate-500 block">{f.app_label}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs text-slate-400">{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>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TableWrap>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══ APPS ══ */}
|
||||||
|
{activeTab === "apps" && (
|
||||||
|
data.top_apps.length === 0
|
||||||
|
? <Empty msg="Tidak ada data aplikasi / domain yang terdeteksi untuk device ini." />
|
||||||
|
: (
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.top_apps.map((a, i) => {
|
||||||
const total = a.download + a.upload;
|
const total = a.download + a.upload;
|
||||||
const maxTotal = Math.max(...data.top_apps.map(x => x.download + x.upload), 1);
|
const maxTotal = Math.max(...data.top_apps.map(x => x.download + x.upload), 1);
|
||||||
const pct = (total / maxTotal) * 100;
|
const pct = (total / maxTotal) * 100;
|
||||||
|
|
||||||
|
const isDomain = a.type === 'domain';
|
||||||
|
const isProtocol = a.type === 'protocol';
|
||||||
|
|
||||||
|
// Color scheme by type
|
||||||
|
const barColor = isDomain ? 'from-teal-500 to-cyan-500'
|
||||||
|
: 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';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 rounded-lg transition-colors">
|
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 rounded-xl transition-colors">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-start gap-3 mb-1.5">
|
||||||
{a.favicon ? (
|
{/* Icon */}
|
||||||
<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-7 h-7 rounded-lg flex-shrink-0 flex items-center justify-center ${iconBg}`}>
|
||||||
) : (
|
{isDomain
|
||||||
<div className="w-5 h-5 rounded bg-slate-700 flex-shrink-0" />
|
? <Globe className={`w-3.5 h-3.5 ${iconColor}`} />
|
||||||
)}
|
: isProtocol
|
||||||
<span className="font-medium text-white text-sm flex-1">{a.app_label}</span>
|
? <Shield className={`w-3.5 h-3.5 ${iconColor}`} />
|
||||||
{a.category && (
|
: <Link className={`w-3.5 h-3.5 ${iconColor}`} />}
|
||||||
<span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded">{a.category}</span>
|
|
||||||
)}
|
|
||||||
<span className="text-xs font-mono text-white">{fmtBytes(total)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 text-xs mb-2">
|
|
||||||
|
{/* Label block */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<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'
|
||||||
|
}`}>
|
||||||
|
{a.label}
|
||||||
|
</span>
|
||||||
|
{/* Sub-label: protocol badge if domain */}
|
||||||
|
{a.sub_label && (
|
||||||
|
<span className="inline-flex text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 ring-1 ring-blue-500/20 flex-shrink-0">
|
||||||
|
{a.sub_label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Type badge */}
|
||||||
|
{isDomain && (
|
||||||
|
<span className="inline-flex text-[10px] px-1.5 py-0.5 rounded bg-teal-500/10 text-teal-400 flex-shrink-0">
|
||||||
|
website
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bandwidth breakdown */}
|
||||||
|
<div className="flex gap-3 text-xs mb-1.5 pl-10">
|
||||||
<span className="text-cyan-400">↓ {fmtBytes(a.download)}</span>
|
<span className="text-cyan-400">↓ {fmtBytes(a.download)}</span>
|
||||||
<span className="text-green-400">↑ {fmtBytes(a.upload)}</span>
|
<span className="text-green-400">↑ {fmtBytes(a.upload)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1 bg-white/5 rounded-full overflow-hidden">
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="h-1 bg-white/5 rounded-full overflow-hidden ml-10">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 rounded-full"
|
className={`h-full bg-gradient-to-r ${barColor} rounded-full transition-all`}
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ══ ENCRYPTION ══ */}
|
||||||
|
{activeTab === "encryption" && (() => {
|
||||||
|
const enc = data.encryption;
|
||||||
|
if (!enc) return <Empty msg="Tidak ada data enkripsi untuk device ini." />;
|
||||||
|
const pct = enc.encrypted_pct ?? 0;
|
||||||
|
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="flex items-center justify-between mb-4">
|
||||||
|
<p className="text-sm font-semibold text-white">Status Enkripsi Traffic</p>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${badge}`}>
|
||||||
|
{enc.risk_level || "Unknown"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Big donut-style display */}
|
||||||
|
<div className="flex items-center gap-6 mb-4">
|
||||||
|
<div className="relative w-24 h-24 flex-shrink-0">
|
||||||
|
<svg className="w-24 h-24 -rotate-90" viewBox="0 0 36 36">
|
||||||
|
<circle cx="18" cy="18" r="15.9" fill="none" stroke="#ffffff10" strokeWidth="3" />
|
||||||
|
<circle
|
||||||
|
cx="18" cy="18" r="15.9" fill="none"
|
||||||
|
stroke={pct >= 80 ? "#22c55e" : pct >= 50 ? "#eab308" : "#ef4444"}
|
||||||
|
strokeWidth="3" strokeDasharray={`${pct} 100`} strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-xl font-bold text-white">{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-green-400">{fmtBytes(enc.encrypted_bytes ?? 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-white/5 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-red-400">{fmtBytes(enc.unencrypted_bytes ?? 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-white/5 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">
|
||||||
|
Total: {fmtBytes(enc.total_bytes ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Risk interpretation */}
|
||||||
|
<div className={`p-3 rounded-xl border ${
|
||||||
|
(enc.risk_level || "").toLowerCase().includes("critical") || (enc.risk_level || "").toLowerCase().includes("rawan")
|
||||||
|
? "border-red-500/20 bg-red-500/5 text-red-300"
|
||||||
|
: (enc.risk_level || "").toLowerCase().includes("medium") || (enc.risk_level || "").toLowerCase().includes("sedang")
|
||||||
|
? "border-yellow-500/20 bg-yellow-500/5 text-yellow-300"
|
||||||
|
: "border-green-500/20 bg-green-500/5 text-green-300"
|
||||||
|
} text-xs`}>
|
||||||
|
{(enc.risk_level || "").toLowerCase().includes("critical") || (enc.risk_level || "").toLowerCase().includes("rawan")
|
||||||
|
? "🚨 Device ini memiliki risiko tinggi — sebagian besar traffic tidak terenkripsi!"
|
||||||
|
: (enc.risk_level || "").toLowerCase().includes("medium") || (enc.risk_level || "").toLowerCase().includes("sedang")
|
||||||
|
? "⚠️ Traffic device ini sebagian tidak terenkripsi. Pertimbangkan upgrade ke HTTPS/TLS."
|
||||||
|
: "✅ Device ini memiliki enkripsi yang baik."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* ══ SERVERS ══ */}
|
||||||
|
{activeTab === "servers" && (
|
||||||
|
data.server_discovery.length === 0 ? <Empty msg="Tidak ada server yang terdeteksi untuk device ini." /> : (
|
||||||
|
<TableWrap head={["Server Type", "Hostname", "Port", "Protocol", "OS", "↓", "↑"]}>
|
||||||
|
{data.server_discovery.map((s, i) => (
|
||||||
|
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-teal-500/10 text-teal-400 ring-1 ring-teal-500/20">
|
||||||
|
{s.server_type || "—"}
|
||||||
|
</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-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-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>
|
||||||
|
))}
|
||||||
|
</TableWrap>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══ PASSWORDS ══ */}
|
||||||
|
{activeTab === "passwords" && (
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
: (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-xs text-red-400">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
<span><strong>{data.unencrypted_passwords.length}</strong> koneksi dengan password tidak terenkripsi terdeteksi!</span>
|
||||||
|
</div>
|
||||||
|
<TableWrap head={["Destination IP", "Port", "Protocol", "Username", "Severity", "↓"]}>
|
||||||
|
{data.unencrypted_passwords.map((p, i) => (
|
||||||
|
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-red-400">{p.dst_ip || "—"}</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-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">
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold ${sevStyle(p.severity)}`}>
|
||||||
|
{p.severity || "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-xs text-cyan-400">{fmtBytes(p.download)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TableWrap>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══ REPUTATION ══ */}
|
||||||
|
{activeTab === "reputation" && (
|
||||||
|
data.ip_reputation.length === 0 ? <Empty msg="Tidak ada data reputasi IP untuk device ini." /> : (
|
||||||
|
<TableWrap head={["Remote IP", "Reputation", "Score", "Country", "App", "Blacklisted", "↓"]}>
|
||||||
|
{data.ip_reputation.map((r, i) => (
|
||||||
|
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-red-400">{r.remote_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-purple-300 truncate max-w-[80px]">{r.app_label || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
{r.blacklisted
|
||||||
|
? <span className="text-red-400 font-semibold">⛔ Yes</span>
|
||||||
|
: <span className="text-green-400">✓ No</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-xs text-cyan-400">{fmtBytes(r.download)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TableWrap>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══ VPN & EVENTS ══ */}
|
||||||
|
{activeTab === "vpn_events" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* VPN */}
|
||||||
|
<section>
|
||||||
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-white 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>
|
||||||
|
</h4>
|
||||||
|
{data.vpn_detections.length === 0 ? (
|
||||||
|
<p className="text-slate-500 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>
|
||||||
|
</div>
|
||||||
|
{v.remote_ip && <p className="text-slate-400 font-mono">→ {v.remote_ip}</p>}
|
||||||
|
<div className="flex gap-2 text-slate-500">
|
||||||
|
<span>{v.protocol || "—"}</span>
|
||||||
|
{v.confidence !== null && v.confidence !== undefined && (
|
||||||
|
<span className="ml-auto text-purple-400">
|
||||||
|
Conf: {((v.confidence) * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Events */}
|
||||||
|
<section>
|
||||||
|
<h4 className="flex items-center gap-2 text-sm font-semibold text-white 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>
|
||||||
|
</h4>
|
||||||
|
{data.events.length === 0 ? (
|
||||||
|
<p className="text-slate-500 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"
|
||||||
|
}`}>
|
||||||
|
<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>}
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-600 flex-shrink-0">{ev.event_at?.slice(0, 16) || "—"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "flows" && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
{data.flows.length === 0 ? (
|
|
||||||
<p className="text-slate-500 text-sm text-center py-8">No active flows for this device.</p>
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left border-b border-white/10">
|
|
||||||
<th className="pb-2 text-xs font-medium text-slate-400">Destination IP</th>
|
|
||||||
<th className="pb-2 text-xs font-medium text-slate-400">Port</th>
|
|
||||||
<th className="pb-2 text-xs font-medium text-slate-400">App / Domain</th>
|
|
||||||
<th className="pb-2 text-xs font-medium text-slate-400">Protocol</th>
|
|
||||||
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Download</th>
|
|
||||||
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Upload</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-white/5">
|
|
||||||
{data.flows.map((f, i) => (
|
|
||||||
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
|
||||||
<td className="py-2 pr-4 font-mono text-xs text-blue-400">{f.dst_ip || "-"}</td>
|
|
||||||
<td className="py-2 pr-4 text-xs text-slate-400">{f.dst_port ?? "-"}</td>
|
|
||||||
<td className="py-2 pr-4 text-xs text-purple-300 max-w-[140px] truncate">
|
|
||||||
{(f as any).app_label || (f as any).domain || "-"}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4 text-xs text-slate-300">{f.protocol || "-"}</td>
|
|
||||||
<td className="py-2 pr-4 text-right text-xs text-cyan-400">{fmtBytes(f.download)}</td>
|
|
||||||
<td className="py-2 text-right text-xs text-green-400">{fmtBytes(f.upload)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+233
-8
@@ -505,6 +505,122 @@ export interface AgentSummary {
|
|||||||
bandwidth_up: number;
|
bandwidth_up: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentEncryptionItem {
|
||||||
|
ip_address: string | null;
|
||||||
|
mac_address: string | null;
|
||||||
|
device_label: string | null;
|
||||||
|
encrypted_pct: number | null;
|
||||||
|
unencrypted: number | null;
|
||||||
|
encrypted: number | null;
|
||||||
|
total: number | null;
|
||||||
|
risk_level: string | null;
|
||||||
|
detected_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentInsecureProtocolItem {
|
||||||
|
ip_address: string | null;
|
||||||
|
mac_address: string | null;
|
||||||
|
protocol: string | null;
|
||||||
|
risk: string | null;
|
||||||
|
app_label: string | null;
|
||||||
|
dst_ip: string | null;
|
||||||
|
dst_port: number | null;
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
detected_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentUnencryptedPwdItem {
|
||||||
|
ip_address: string | null;
|
||||||
|
mac_address: string | null;
|
||||||
|
dst_ip: string | null;
|
||||||
|
dst_port: number | null;
|
||||||
|
protocol: string | null;
|
||||||
|
username: string | null;
|
||||||
|
severity: string | null;
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
detected_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentIpReputationItem {
|
||||||
|
ip_address: string | null;
|
||||||
|
local_ip: string | null;
|
||||||
|
mac_address: string | null;
|
||||||
|
reputation: string | null;
|
||||||
|
score: number | null;
|
||||||
|
country: string | null;
|
||||||
|
app_label: string | null;
|
||||||
|
blacklisted: number | boolean;
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
detected_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentTorItem {
|
||||||
|
ip_address: string | null;
|
||||||
|
mac_address: string | null;
|
||||||
|
exit_node: string | null;
|
||||||
|
circuit_id: string | null;
|
||||||
|
country: string | null;
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
detected_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentVpnItem {
|
||||||
|
ip_address: string | null;
|
||||||
|
mac_address: string | null;
|
||||||
|
vpn_type: string | null;
|
||||||
|
remote_ip: string | null;
|
||||||
|
protocol: string | null;
|
||||||
|
country: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
detected_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSecurity {
|
||||||
|
encryption_audit: AgentEncryptionItem[];
|
||||||
|
insecure_protocols: AgentInsecureProtocolItem[];
|
||||||
|
unencrypted_passwords: AgentUnencryptedPwdItem[];
|
||||||
|
ip_reputation: AgentIpReputationItem[];
|
||||||
|
tor_detections: AgentTorItem[];
|
||||||
|
vpn_detections: AgentVpnItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentEventItem {
|
||||||
|
event_id: string | null;
|
||||||
|
event_type: string | null;
|
||||||
|
severity: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
mac_address: string | null;
|
||||||
|
description: string | null;
|
||||||
|
event_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentMacBandwidthItem {
|
||||||
|
mac_address: string;
|
||||||
|
manufacturer: string | null;
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentServerDiscoveryItem {
|
||||||
|
ip_address: string | null;
|
||||||
|
mac_address: string | null;
|
||||||
|
server_type: string | null;
|
||||||
|
hostname: string | null;
|
||||||
|
port: number | null;
|
||||||
|
protocol: string | null;
|
||||||
|
os_label: string | null;
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
detected_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentDetails {
|
export interface AgentDetails {
|
||||||
agent_uuid: string;
|
agent_uuid: string;
|
||||||
agent_label: string;
|
agent_label: string;
|
||||||
@@ -512,6 +628,10 @@ export interface AgentDetails {
|
|||||||
devices: AgentDeviceItem[];
|
devices: AgentDeviceItem[];
|
||||||
flows: AgentFlowItem[];
|
flows: AgentFlowItem[];
|
||||||
top_apps: AgentAppItem[];
|
top_apps: AgentAppItem[];
|
||||||
|
security: AgentSecurity | null;
|
||||||
|
events: AgentEventItem[];
|
||||||
|
mac_bandwidth: AgentMacBandwidthItem[];
|
||||||
|
server_discovery: AgentServerDiscoveryItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceFlowItem {
|
export interface DeviceFlowItem {
|
||||||
@@ -540,16 +660,121 @@ export interface SecurityInfo {
|
|||||||
blacklisted: boolean;
|
blacklisted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceDetails {
|
export interface DeviceInfo {
|
||||||
ip: string;
|
device_label : string | null;
|
||||||
total_download: number;
|
device_type : string | null;
|
||||||
total_upload: number;
|
os_label : string | null;
|
||||||
flow_count: number;
|
manufacturer : string | null;
|
||||||
top_apps: AgentAppItem[];
|
mac_address : string | null;
|
||||||
flows: DeviceFlowItem[];
|
is_new : number | null;
|
||||||
security_info: SecurityInfo | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceAppItem {
|
||||||
|
label : string; // domain name OR protocol name
|
||||||
|
sub_label : string | null; // protocol when label is domain; null otherwise
|
||||||
|
type : 'domain' | 'protocol' | 'port';
|
||||||
|
download : number;
|
||||||
|
upload : number;
|
||||||
|
flow_count : number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceDomainItem {
|
||||||
|
domain : string;
|
||||||
|
download : number;
|
||||||
|
upload : number;
|
||||||
|
flow_count : number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceEncryption {
|
||||||
|
encrypted_pct : number | null;
|
||||||
|
encrypted_bytes : number | null;
|
||||||
|
unencrypted_bytes: number | null;
|
||||||
|
total_bytes : number | null;
|
||||||
|
risk_level : string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceServerItem {
|
||||||
|
server_type : string | null;
|
||||||
|
hostname : string | null;
|
||||||
|
port : number | null;
|
||||||
|
protocol : string | null;
|
||||||
|
os_label : string | null;
|
||||||
|
download : number;
|
||||||
|
upload : number;
|
||||||
|
detected_at : string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevicePwdItem {
|
||||||
|
dst_ip : string | null;
|
||||||
|
dst_port : number | null;
|
||||||
|
protocol : string | null;
|
||||||
|
username : string | null;
|
||||||
|
severity : string | null;
|
||||||
|
download : number;
|
||||||
|
upload : number;
|
||||||
|
detected_at : string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceReputationItem {
|
||||||
|
remote_ip : string | null;
|
||||||
|
local_ip : string | null;
|
||||||
|
reputation : string | null;
|
||||||
|
score : number | null;
|
||||||
|
country : string | null;
|
||||||
|
app_label : string | null;
|
||||||
|
blacklisted : boolean;
|
||||||
|
download : number;
|
||||||
|
upload : number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceVpnItem {
|
||||||
|
vpn_type : string | null;
|
||||||
|
remote_ip : string | null;
|
||||||
|
protocol : string | null;
|
||||||
|
country : string | null;
|
||||||
|
confidence : number | null;
|
||||||
|
download : number;
|
||||||
|
upload : number;
|
||||||
|
detected_at : string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceEventItem {
|
||||||
|
event_type : string | null;
|
||||||
|
severity : string | null;
|
||||||
|
ip_address : string | null;
|
||||||
|
mac_address : string | null;
|
||||||
|
description : string | null;
|
||||||
|
event_at : string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceMacBandwidth {
|
||||||
|
mac_address : string;
|
||||||
|
manufacturer : string | null;
|
||||||
|
download : number;
|
||||||
|
upload : number;
|
||||||
|
total : number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceDetails {
|
||||||
|
ip : string;
|
||||||
|
mac_address : string | null;
|
||||||
|
total_download : number;
|
||||||
|
total_upload : number;
|
||||||
|
flow_count : number;
|
||||||
|
device_info : DeviceInfo;
|
||||||
|
top_apps : DeviceAppItem[];
|
||||||
|
top_domains : DeviceDomainItem[];
|
||||||
|
flows : DeviceFlowItem[];
|
||||||
|
encryption : DeviceEncryption | null;
|
||||||
|
server_discovery : DeviceServerItem[];
|
||||||
|
unencrypted_passwords: DevicePwdItem[];
|
||||||
|
ip_reputation : DeviceReputationItem[];
|
||||||
|
vpn_detections : DeviceVpnItem[];
|
||||||
|
events : DeviceEventItem[];
|
||||||
|
mac_bandwidth : DeviceMacBandwidth | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface AppAgentItem {
|
export interface AppAgentItem {
|
||||||
agent_uuid: string | null;
|
agent_uuid: string | null;
|
||||||
agent_label: string;
|
agent_label: string;
|
||||||
|
|||||||
+5
-4
@@ -5,9 +5,10 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fmtBytes(bytes?: number) {
|
export function fmtBytes(bytes?: number | null) {
|
||||||
if (!bytes || bytes === 0) return '0 B';
|
if (bytes == null || bytes === 0 || isNaN(bytes)) return '0 B';
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
const i = Math.min(Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024)), units.length - 1);
|
||||||
|
if (i < 0) return '0 B';
|
||||||
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
|
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user