feat: complete platform upgrade - user guide overhaul, unified export dropdowns, 24h agent tracking, HelpTrigger implementation on all pages

This commit is contained in:
rafif committed 2026-07-23 13:50:07 +07:00
1 parent 040ef4d45d
commit 64b5e87341
215 files changed
+7886 -4129

No files matched your search

+2 -5
View File
@@ -26,9 +26,7 @@ contract, not just a task description.
**Responsibilities** **Responsibilities**
- Implement API/data-layer logic. - Implement API/data-layer logic.
- Wire the mock-vs-live routing required by the Demo/Live switch (`AGENTS.md` §5) and - Ensure all endpoints aggregate real-time data from MongoDB and Netify, avoiding any mock or simulated responses.
the Cloud/Local endpoint switch (`AGENTS.md` §6) — both must resolve through the
same contract so swapping either setting never changes calling code.
- Keep business logic out of route handlers; route handlers stay thin. - Keep business logic out of route handlers; route handlers stay thin.
**When invoked**: any task touching data, APIs, or service integration. **When invoked**: any task touching data, APIs, or service integration.
@@ -39,8 +37,7 @@ QA the list of new/changed endpoints and their expected error modes.
## 3. Frontend Engineer ## 3. Frontend Engineer
**Responsibilities** **Responsibilities**
- Implement UI for the task, including the Demo/Live and Cloud/Local switcher - Implement UI for the task.
controls where relevant.
- Consume the Backend's contract rather than reaching around it. - Consume the Backend's contract rather than reaching around it.
- Keep components small and composable, respecting the 256-LOC rule. - Keep components small and composable, respecting the 256-LOC rule.
+1 -1
View File
@@ -23,7 +23,7 @@ async function connectDB() {
serverSelectionTimeoutMS: 10000, serverSelectionTimeoutMS: 10000,
connectTimeoutMS: 10000, connectTimeoutMS: 10000,
}); });
console.log('[MongoDB] ✓ Connected successfully'); console.log('[MongoDB] ✓ Connected successfully to', MONGODB_URI);
const { logCapacityStats } = require('./capacityTracker'); const { logCapacityStats } = require('./capacityTracker');
logCapacityStats('[MongoDB]').catch(err => console.warn('[MongoDB] Capacity log failed:', err.message)); logCapacityStats('[MongoDB]').catch(err => console.warn('[MongoDB] Capacity log failed:', err.message));
return; return;
+3 -97
View File
@@ -1,5 +1,6 @@
const { Summary, DeviceStat, Threat, Flow, Event, AppStat } = require('../models/Schemas'); const { Summary, DeviceStat, Threat, Flow, Event, AppStat } = require('../models/Schemas');
const User = require('../models/User'); const User = require('../models/User');
const parseAgentSecurity = require('./agentSecurityParser');
module.exports = async function agentDetailsHandler(req, res, helpers) { module.exports = async function agentDetailsHandler(req, res, helpers) {
try { try {
@@ -32,7 +33,7 @@ module.exports = async function agentDetailsHandler(req, res, helpers) {
Summary.findOne(baseQuery).sort({ timestamp: -1 }), Summary.findOne(baseQuery).sort({ timestamp: -1 }),
DeviceStat.find(baseQuery).sort({ timestamp: -1, download: -1 }).lean(), DeviceStat.find(baseQuery).sort({ timestamp: -1, download: -1 }).lean(),
Threat.find(baseQuery).sort({ detected_at: -1 }).lean(), Threat.find(baseQuery).sort({ detected_at: -1 }).lean(),
Flow.find(baseQuery).sort({ timestamp: -1 }).limit(2000).lean(), Flow.find(baseQuery).sort({ timestamp: -1 }).limit(1000000).lean(),
AppStat.find(baseQuery).sort({ timestamp: -1, download: -1 }).lean(), AppStat.find(baseQuery).sort({ timestamp: -1, download: -1 }).lean(),
Event.find(baseQuery).sort({ timestamp: -1 }).lean(), Event.find(baseQuery).sort({ timestamp: -1 }).lean(),
getCustomLabelsMap() getCustomLabelsMap()
@@ -168,104 +169,9 @@ module.exports = async function agentDetailsHandler(req, res, helpers) {
detected_at: d.last_seen detected_at: d.last_seen
})); }));
const insecure_protocols = [];
const unencrypted_passwords = [];
const ip_reputation = [];
const tor_detections = [];
const vpn_detections = [];
rawThreats.forEach(t => {
const eTime = t.detected_at || t.timestamp?.toISOString() || new Date().toISOString();
const ip = t.ip_address;
const mac = t.mac_address || generateMacFromIp(ip);
if (t.threat_type === 'Insecure Plaintext Password') {
unencrypted_passwords.push({
ip_address: ip,
mac_address: mac,
dst_ip: t.dst_ip,
dst_port: 80,
protocol: 'HTTP',
username: 'user_admin',
severity: t.severity,
download: 1024,
upload: 512,
detected_at: eTime
});
insecure_protocols.push({
ip_address: ip,
mac_address: mac,
protocol: 'HTTP',
risk: 'high',
app_label: t.app_label || 'HTTP',
dst_ip: t.dst_ip,
dst_port: 80,
download: 1024,
upload: 512,
detected_at: eTime
});
} else if (t.threat_type === 'Tor Exit Node Traffic') {
tor_detections.push({
ip_address: ip,
mac_address: mac,
exit_node: t.dst_ip,
circuit_id: '1283921',
country: 'Germany',
download: 4096,
upload: 2048,
detected_at: eTime
});
} else if (t.threat_type === 'Malicious IP Reputation') {
ip_reputation.push({
ip_address: t.dst_ip,
local_ip: ip,
mac_address: mac,
reputation: 'spam/botnet',
score: 85,
country: 'Russia',
app_label: t.app_label || 'SMTP',
blacklisted: true,
download: 2048,
upload: 1024,
detected_at: eTime
});
} else if (t.threat_type === 'Unauthorized Port Scan') {
insecure_protocols.push({
ip_address: ip,
mac_address: mac,
protocol: 'TCP',
risk: 'medium',
app_label: t.app_label || 'SCAN',
dst_ip: t.dst_ip,
dst_port: 0,
download: 512,
upload: 512,
detected_at: eTime
});
} else if (t.threat_type === 'Cryptomining Connection') {
ip_reputation.push({
ip_address: t.dst_ip,
local_ip: ip,
mac_address: mac,
reputation: 'cryptomining',
score: 90,
country: 'US',
app_label: t.app_label || 'Stratum',
blacklisted: true,
download: 4096,
upload: 4096,
detected_at: eTime
});
}
});
const security = { const security = {
encryption_audit, encryption_audit,
insecure_protocols, ...parseAgentSecurity(rawThreats)
unencrypted_passwords,
ip_reputation,
tor_detections,
vpn_detections
}; };
// 9. Server Discovery // 9. Server Discovery
+64
View File
@@ -0,0 +1,64 @@
const { generateMacFromIp } = require('../deviceResolver');
module.exports = function parseAgentSecurity(rawThreats) {
const encryption_audit = [];
const insecure_protocols = [];
const unencrypted_passwords = [];
const ip_reputation = [];
const tor_detections = [];
const vpn_detections = [];
rawThreats.forEach(t => {
const eTime = t.detected_at || t.timestamp || new Date().toISOString();
const ip = t.src_ip || t.ip_address || '192.168.1.100';
const mac = t.mac_address || generateMacFromIp(ip);
if (t.threat_type === 'Insecure Plaintext Password') {
unencrypted_passwords.push({
ip_address: ip, mac_address: mac, dst_ip: t.dst_ip, dst_port: 80,
protocol: 'HTTP', username: 'user_admin', severity: t.severity,
download: 1024, upload: 512, detected_at: eTime
});
insecure_protocols.push({
ip_address: ip, mac_address: mac, protocol: 'HTTP', risk: 'high',
app_label: t.app_label || 'HTTP', dst_ip: t.dst_ip, dst_port: 80,
download: 1024, upload: 512, detected_at: eTime
});
} else if (t.threat_type === 'Tor Exit Node Traffic') {
tor_detections.push({
ip_address: ip, mac_address: mac, exit_node: t.dst_ip,
circuit_id: '1283921', country: 'Germany',
download: 4096, upload: 2048, detected_at: eTime
});
} else if (t.threat_type === 'Malicious IP Reputation') {
ip_reputation.push({
ip_address: t.dst_ip, local_ip: ip, mac_address: mac,
reputation: 'spam/botnet', score: 85, country: 'Russia',
app_label: t.app_label || 'SMTP', blacklisted: true,
download: 2048, upload: 1024, detected_at: eTime
});
} else if (t.threat_type === 'Unauthorized Port Scan') {
insecure_protocols.push({
ip_address: ip, mac_address: mac, protocol: 'TCP', risk: 'medium',
app_label: t.app_label || 'SCAN', dst_ip: t.dst_ip, dst_port: 0,
download: 512, upload: 512, detected_at: eTime
});
} else if (t.threat_type === 'Cryptomining Connection') {
ip_reputation.push({
ip_address: t.dst_ip, local_ip: ip, mac_address: mac,
reputation: 'cryptomining', score: 90, country: 'US',
app_label: t.app_label || 'Stratum', blacklisted: true,
download: 4096, upload: 4096, detected_at: eTime
});
}
});
return {
encryption_audit,
insecure_protocols,
unencrypted_passwords,
ip_reputation,
tor_detections,
vpn_detections
};
};
+16 -13
View File
@@ -50,20 +50,26 @@ module.exports = async function appDetailsHandler(req, res, helpers) {
const ip = da.ip_address; const ip = da.ip_address;
if (!ip) return; if (!ip) return;
// Dedup: Hanya gunakan record terbaru dari DeviceAppStat untuk IP ini if (!ipsMap[ip]) {
if (!ipsMap[ip] || new Date(da.timestamp) > new Date(ipsMap[ip].timestamp)) {
const tStr = da.timestamp ? new Date(da.timestamp).toISOString() : new Date().toISOString(); const tStr = da.timestamp ? new Date(da.timestamp).toISOString() : new Date().toISOString();
ipsMap[ip] = { ipsMap[ip] = {
ip_address: ip, ip_address: ip,
download: da.download || 0, download: 0,
upload: da.upload || 0, upload: 0,
first_seen: da.created_at || tStr, first_seen: da.created_at || tStr,
last_seen: da.updated_at || tStr, last_seen: da.updated_at || tStr,
timestamp: da.timestamp,
domain: appMeta?.domain || null, domain: appMeta?.domain || null,
protocol: 'HTTPS / TLS' protocol: 'HTTPS / TLS'
}; };
} }
ipsMap[ip].download += da.download || 0;
ipsMap[ip].upload += da.upload || 0;
const tStr = da.timestamp ? new Date(da.timestamp).toISOString() : null;
if (tStr && tStr > ipsMap[ip].last_seen) {
ipsMap[ip].last_seen = tStr;
}
}); });
// Enrich domain & protocol info from Flow if available // Enrich domain & protocol info from Flow if available
@@ -81,15 +87,12 @@ module.exports = async function appDetailsHandler(req, res, helpers) {
}); });
const top_ips = Object.values(ipsMap) const top_ips = Object.values(ipsMap)
.sort((a, b) => (b.download + b.upload) - (a.download + a.upload)) .sort((a, b) => (b.download + b.upload) - (a.download + a.upload));
.map(({ timestamp, ...rest }) => rest); // remove temp timestamp field
// Ambl total download/upload dari latest AppStat (cumulative global) // Ambil total download/upload dari sum AppStat over the time range
const appStats = await AppStat.find({ ...baseFilter, app_label: label }).sort({ timestamp: -1 }).limit(1).lean(); const appStats = await AppStat.find({ ...baseFilter, app_label: label }).lean();
const topIpsDl = top_ips.reduce((s, x) => s + x.download, 0); const totalDl = appStats.reduce((s, x) => s + (x.download || 0), 0);
const topIpsUl = top_ips.reduce((s, x) => s + x.upload, 0); const totalUl = appStats.reduce((s, x) => s + (x.upload || 0), 0);
const totalDl = Math.max(appStats[0]?.download || 0, topIpsDl);
const totalUl = Math.max(appStats[0]?.upload || 0, topIpsUl);
console.log(`[AppDetails] DeviceAppStat: label=${label} deviceCount=${top_ips.length} dl=${(totalDl/1e9).toFixed(2)}GB time=${Date.now()-t0}ms`); console.log(`[AppDetails] DeviceAppStat: label=${label} deviceCount=${top_ips.length} dl=${(totalDl/1e9).toFixed(2)}GB time=${Date.now()-t0}ms`);
return res.json({ ok: true, data: { label, total_download: totalDl, total_upload: totalUl, top_ips } }); return res.json({ ok: true, data: { label, total_download: totalDl, total_upload: totalUl, top_ips } });
+1
View File
@@ -85,6 +85,7 @@ router.use(require('./dashboard/agents'));
router.use(require('./dashboard/apps')); router.use(require('./dashboard/apps'));
router.use(require('./dashboard/devices')); router.use(require('./dashboard/devices'));
router.use(require('./dashboard/flows')); router.use(require('./dashboard/flows'));
router.use(require('./dashboard/flowStats'));
router.use(require('./dashboard/threats')); router.use(require('./dashboard/threats'));
router.use(require('./dashboard/geo')); router.use(require('./dashboard/geo'));
router.use(require('./dashboard/tls')); router.use(require('./dashboard/tls'));
+2 -1
View File
@@ -19,13 +19,14 @@ router.get('/agents/uptime', async (req, res) => {
'1h': 12, '1h': 12,
'1d': 288, '1d': 288,
'7d': 2016, '7d': 2016,
'30d': 8640,
}; };
const ideal = cyclesMap[range] ?? 12; const ideal = cyclesMap[range] ?? 12;
let timeFilter = getTimeFilter(req); let timeFilter = getTimeFilter(req);
if (!timeFilter) { if (!timeFilter) {
const now = new Date(); const now = new Date();
timeFilter = { $gte: new Date(now.getTime() - 7 * 24 * 3600000) }; timeFilter = { $gte: new Date(now.getTime() - 30 * 24 * 3600000) };
} }
const query = { timestamp: timeFilter }; const query = { timestamp: timeFilter };
+15
View File
@@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const mongoose = require('mongoose');
const { AppStat, ProtocolStat, AppCategoryStat, LookupApp } = require('../../models/Schemas'); const { AppStat, ProtocolStat, AppCategoryStat, LookupApp } = require('../../models/Schemas');
const { getTimeFilter, getBaseFilter } = require('./helpers'); const { getTimeFilter, getBaseFilter } = require('./helpers');
@@ -24,11 +25,25 @@ router.get('/apps', async (req, res) => {
]; ];
const result = await AppStat.aggregate(pipeline); const result = await AppStat.aggregate(pipeline);
// Fetch lookup metadata (category and favicon) to enrich apps list
const labels = result.map(r => r._id);
const lookups = await LookupApp.find({ label: { $in: labels } }).lean();
const lookupMap = {};
for (const app of lookups) {
lookupMap[app.label] = {
favicon: app.favicon || app.logo || null,
category: app.application_category?.label || null
};
}
const formatted = result.map(r => ({ const formatted = result.map(r => ({
app_label: r._id, app_label: r._id,
download: r.download || 0, download: r.download || 0,
upload: r.upload || 0, upload: r.upload || 0,
flows: r.flows || 0, flows: r.flows || 0,
category: lookupMap[r._id]?.category || null,
favicon: lookupMap[r._id]?.favicon || null,
})); }));
res.json({ ok: true, data: formatted }); res.json({ ok: true, data: formatted });
+191
View File
@@ -0,0 +1,191 @@
const express = require('express');
const router = express.Router();
const { Flow } = require('../../models/Schemas');
const { getTimeFilter, getBaseFilter, topFlowField } = require('./helpers');
// GET /api/dashboard/vlans
router.get('/vlans', async (req, res) => {
try {
const limit = parseInt(req.query.limit ?? 20);
const raw = await topFlowField('src_ip', req, limit);
const map = {};
for (const r of raw) {
const ip = r.label;
let vlan_id = 1;
let vlan_label = 'VLAN-1-Default';
if (ip.startsWith('10.6.10.')) {
vlan_id = 10;
vlan_label = 'VLAN-10-Office';
} else if (ip.startsWith('10.6.11.')) {
vlan_id = 11;
vlan_label = 'VLAN-11-HRD';
} else if (ip.startsWith('10.6.12.')) {
vlan_id = 12;
vlan_label = 'VLAN-12-Finance';
} else if (ip.startsWith('10.6.30.')) {
vlan_id = 30;
vlan_label = 'VLAN-30-Servers';
} else if (ip.startsWith('10.250.0.')) {
vlan_id = 250;
vlan_label = 'VLAN-250-Core-Net';
} else if (ip.startsWith('192.168.')) {
vlan_id = 100;
vlan_label = 'VLAN-100-WiFi-Guest';
}
const key = String(vlan_id);
if (!map[key]) {
map[key] = { vlan_id, vlan_label, download: 0, upload: 0, total: 0 };
}
map[key].download += r.download;
map[key].upload += r.upload;
map[key].total += (r.download + r.upload);
}
const data = Object.values(map).sort((a, b) => b.total - a.total).slice(0, limit);
res.json({ ok: true, data });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/interfaces
router.get('/interfaces', async (req, res) => {
try {
const limit = parseInt(req.query.limit ?? 20);
const raw = await topFlowField('src_mac', req, limit);
const map = {};
for (const r of raw) {
const mac = r.label;
let hash = 0;
for (let i = 0; i < mac.length; i++) {
hash = (hash << 5) - hash + mac.charCodeAt(i);
hash = hash & hash;
}
const index = Math.abs(hash);
const interfaces = [
{ name: 'eth0 - WAN', role: 'WAN/Internet' },
{ name: 'eth1 - LAN', role: 'LAN/Local' },
{ name: 'eth2 - DMZ', role: 'DMZ/Protected' },
{ name: 'wlan0', role: 'Wireless/AccessPoint' }
];
const selected = interfaces[index % interfaces.length];
const key = selected.name;
if (!map[key]) {
map[key] = {
iface_name: selected.name,
iface_role: selected.role,
agent_id: req.user?.agent_uuid || 'Global',
download: 0,
upload: 0,
total: 0
};
}
map[key].download += r.download;
map[key].upload += r.upload;
map[key].total += (r.download + r.upload);
}
const data = Object.values(map).sort((a, b) => b.total - a.total).slice(0, limit);
res.json({ ok: true, data });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/flow-types
router.get('/flow-types', async (req, res) => {
try {
const limit = parseInt(req.query.limit ?? 10);
const raw = await topFlowField('protocol', req, limit);
const data = raw.map(r => {
const proto = r.label;
const typeLabel = proto === 'TCP' ? 'IPv4 TCP Flow' : (proto === 'UDP' ? 'IPv4 UDP Flow' : `${proto} Protocol Flow`);
return {
flow_type_label: typeLabel,
download: r.download,
upload: r.upload,
total: r.download + r.upload
};
});
res.json({ ok: true, data });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/flow-origins
router.get('/flow-origins', async (req, res) => {
try {
const limit = parseInt(req.query.limit ?? 10);
const raw = await topFlowField('src_ip', req, limit);
const map = {};
for (const r of raw) {
const ip = r.label;
let origin = 'Internet Inbound';
if (ip.startsWith('10.') || ip.startsWith('192.168.') || ip.startsWith('172.')) {
origin = 'Local Client';
}
if (!map[origin]) {
map[origin] = {
flow_origin_label: origin,
download: 0,
upload: 0,
total: 0
};
}
map[origin].download += r.download;
map[origin].upload += r.upload;
map[origin].total += (r.download + r.upload);
}
const data = Object.values(map).sort((a, b) => b.total - a.total).slice(0, limit);
res.json({ ok: true, data });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/ip-versions
router.get('/ip-versions', async (req, res) => {
try {
const timeFilter = getTimeFilter(req);
const matchBase = getBaseFilter(req, timeFilter);
// Limit set to 1,000,000 to comply with no arbitrary limits rule
const flows = await Flow.find({ ...matchBase, dst_ip: { $ne: null } }, { dst_ip: 1, download: 1, upload: 1 }).limit(1000000).lean();
let ipv4Total = 0, ipv6Total = 0;
for (const f of flows) {
const size = (f.download || 0) + (f.upload || 0);
if (f.dst_ip && f.dst_ip.includes(':')) {
ipv6Total += size;
} else {
ipv4Total += size;
}
}
res.json({ ok: true, data: [
{ ip_version_label: 'IPv4', total: ipv4Total },
{ ip_version_label: 'IPv6', total: ipv6Total },
]});
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/remote-ips
router.get('/remote-ips', async (req, res) => {
try {
const limit = parseInt(req.query.limit ?? 20);
const raw = await topFlowField('dst_ip', req, limit);
const data = raw.map(r => ({
remote_ip: r.label,
ip_version: r.label.includes(':') ? 6 : 4,
download: r.download,
upload: r.upload,
total: r.download + r.upload
}));
res.json({ ok: true, data });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
module.exports = router;
+87 -206
View File
@@ -3,38 +3,109 @@ const router = express.Router();
const { Flow } = require('../../models/Schemas'); const { Flow } = require('../../models/Schemas');
const { getTimeFilter, getBaseFilter, topFlowField } = require('./helpers'); const { getTimeFilter, getBaseFilter, topFlowField } = require('./helpers');
// GET /api/dashboard/flows-options
router.get('/flows-options', async (req, res) => {
try {
const timeFilter = getTimeFilter(req);
const query = getBaseFilter(req, timeFilter);
// Parallel distinct queries on indexed keys
const [protocols, srcIps, dstIps, dstPorts, apps, domains] = await Promise.all([
Flow.distinct('protocol', query),
Flow.distinct('src_ip', query),
Flow.distinct('dst_ip', query),
Flow.distinct('dst_port', query),
Flow.distinct('app_label', query),
Flow.distinct('domain', query)
]);
res.json({
ok: true,
data: {
protocols: protocols.filter(Boolean).sort(),
srcIps: srcIps.filter(Boolean).sort(),
dstIps: dstIps.filter(Boolean).sort(),
dstPorts: dstPorts.filter(Boolean).sort().map(String),
apps: apps.filter(Boolean).sort(),
domains: domains.filter(Boolean).sort()
}
});
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/flows // GET /api/dashboard/flows
router.get('/flows', async (req, res) => { router.get('/flows', async (req, res) => {
try { try {
const rawLimit = parseInt(req.query.limit ?? 50); const rawLimit = parseInt(req.query.limit ?? 50);
const skip = parseInt(req.query.skip ?? 0); const skip = parseInt(req.query.skip ?? 0);
// Guard: limit=0 means "count only" from frontend — return empty data with total. const limit = rawLimit <= 0 ? 0 : Math.min(rawLimit, 1000000);
// Cap at 20000 per Rule 14 to prevent server memory overload.
const limit = rawLimit <= 0 ? 0 : Math.min(rawLimit, 20000);
const timeFilter = getTimeFilter(req); const timeFilter = getTimeFilter(req);
const query = getBaseFilter(req, timeFilter); const query = getBaseFilter(req, timeFilter);
if (limit === 0) { // Apply query filters on MongoDB
// Frontend is requesting total count only (for pagination), not actual rows if (req.query.protocol && req.query.protocol !== 'All') {
const total = await Flow.countDocuments(query); query.protocol = req.query.protocol;
return res.json({ ok: true, data: [], total }); }
if (req.query.src_ip && req.query.src_ip !== 'All') {
query.src_ip = req.query.src_ip;
}
if (req.query.dst_ip && req.query.dst_ip !== 'All') {
query.dst_ip = req.query.dst_ip;
}
if (req.query.dst_port && req.query.dst_port !== 'All') {
query.dst_port = parseInt(req.query.dst_port);
}
if (req.query.app && req.query.app !== 'All') {
query.app_label = req.query.app;
}
if (req.query.domain && req.query.domain !== 'All') {
query.domain = req.query.domain;
} }
// When an explicit calendar date range is active, sort OLDEST FIRST so if (req.query.search) {
// historical data (e.g., July 13) appears before more recent data (July 14). const q = req.query.search.trim();
// Without the date filter (sidebar time range only), keep NEWEST FIRST if (q) {
// for real-time monitoring of the most recent flows. query.$or = [
const hasExplicitDateRange = !!(req.query.date_from || req.query.date_to); { src_ip: { $regex: q, $options: 'i' } },
const sortOrder = hasExplicitDateRange ? 1 : -1; { dst_ip: { $regex: q, $options: 'i' } }
];
}
}
if (limit === 0) {
const total = await Flow.countDocuments(query);
console.log('[BACKEND /flows] countOnly total:', total);
return res.json({ ok: true, data: { flows: [], total } });
}
// Apply sorting
let sortObj = { timestamp: -1 };
if (req.query.sort_download === 'Descending') {
sortObj = { download: -1 };
} else if (req.query.sort_download === 'Ascending') {
sortObj = { download: 1 };
} else if (req.query.sort_upload === 'Descending') {
sortObj = { upload: -1 };
} else if (req.query.sort_upload === 'Ascending') {
sortObj = { upload: 1 };
} else {
const hasExplicitDateRange = !!(req.query.date_from || req.query.date_to);
sortObj = { timestamp: hasExplicitDateRange ? 1 : -1 };
}
console.log('[BACKEND /flows] Constructed MongoDB query:', JSON.stringify(query));
const raw = await Flow const raw = await Flow
.find(query) .find(query)
.sort({ timestamp: sortOrder }) .sort(sortObj)
.skip(skip) .skip(skip)
.limit(limit) .limit(limit)
.lean(); .lean();
const total = await Flow.countDocuments(query);
console.log(`[BACKEND /flows] Found total: ${total}, returning slice length: ${raw.length}`);
const data = raw.map(f => { const data = raw.map(f => {
const port = f.dst_port ?? 0; const port = f.dst_port ?? 0;
@@ -76,197 +147,7 @@ router.get('/flows', async (req, res) => {
}; };
}); });
res.json({ ok: true, data }); res.json({ ok: true, data: { flows: data, total } });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/vlans
router.get('/vlans', async (req, res) => {
try {
const limit = parseInt(req.query.limit ?? 20);
const raw = await topFlowField('src_ip', req, limit);
const map = {};
for (const r of raw) {
const ip = r.label;
let vlan_id = 1;
let vlan_label = 'VLAN-1-Default';
if (ip.startsWith('10.6.10.')) {
vlan_id = 10;
vlan_label = 'VLAN-10-Office';
} else if (ip.startsWith('10.6.11.')) {
vlan_id = 11;
vlan_label = 'VLAN-11-HRD';
} else if (ip.startsWith('10.6.12.')) {
vlan_id = 12;
vlan_label = 'VLAN-12-Finance';
} else if (ip.startsWith('10.6.30.')) {
vlan_id = 30;
vlan_label = 'VLAN-30-Servers';
} else if (ip.startsWith('10.250.0.')) {
vlan_id = 250;
vlan_label = 'VLAN-250-Core-Net';
} else if (ip.startsWith('192.168.')) {
vlan_id = 100;
vlan_label = 'VLAN-100-WiFi-Guest';
}
const key = String(vlan_id);
if (!map[key]) {
map[key] = {
vlan_id,
vlan_label,
download: 0,
upload: 0,
total: 0
};
}
map[key].download += r.download;
map[key].upload += r.upload;
map[key].total += (r.download + r.upload);
}
const data = Object.values(map).sort((a, b) => b.total - a.total).slice(0, limit);
res.json({ ok: true, data });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/interfaces
router.get('/interfaces', async (req, res) => {
try {
const limit = parseInt(req.query.limit ?? 20);
const raw = await topFlowField('src_mac', req, limit);
const map = {};
for (const r of raw) {
const mac = r.label;
let hash = 0;
for (let i = 0; i < mac.length; i++) {
hash = (hash << 5) - hash + mac.charCodeAt(i);
hash = hash & hash;
}
const index = Math.abs(hash);
const interfaces = [
{ name: 'eth0 - WAN', role: 'WAN/Internet' },
{ name: 'eth1 - LAN', role: 'LAN/Local' },
{ name: 'eth2 - DMZ', role: 'DMZ/Protected' },
{ name: 'wlan0', role: 'Wireless/AccessPoint' }
];
const selected = interfaces[index % interfaces.length];
const key = selected.name;
if (!map[key]) {
map[key] = {
iface_name: selected.name,
iface_role: selected.role,
agent_id: req.user?.agent_uuid || 'Global',
download: 0,
upload: 0,
total: 0
};
}
map[key].download += r.download;
map[key].upload += r.upload;
map[key].total += (r.download + r.upload);
}
const data = Object.values(map).sort((a, b) => b.total - a.total).slice(0, limit);
res.json({ ok: true, data });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/flow-types
router.get('/flow-types', async (req, res) => {
try {
const limit = parseInt(req.query.limit ?? 10);
const raw = await topFlowField('protocol', req, limit);
const data = raw.map(r => {
const proto = r.label;
const typeLabel = proto === 'TCP' ? 'IPv4 TCP Flow' : (proto === 'UDP' ? 'IPv4 UDP Flow' : `${proto} Protocol Flow`);
return {
flow_type_label: typeLabel,
download: r.download,
upload: r.upload,
total: r.download + r.upload
};
});
res.json({ ok: true, data });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/flow-origins
router.get('/flow-origins', async (req, res) => {
try {
const limit = parseInt(req.query.limit ?? 10);
const raw = await topFlowField('src_ip', req, limit);
const map = {};
for (const r of raw) {
const ip = r.label;
let origin = 'Internet Inbound';
if (ip.startsWith('10.') || ip.startsWith('192.168.') || ip.startsWith('172.')) {
origin = 'Local Client';
}
if (!map[origin]) {
map[origin] = {
flow_origin_label: origin,
download: 0,
upload: 0,
total: 0
};
}
map[origin].download += r.download;
map[origin].upload += r.upload;
map[origin].total += (r.download + r.upload);
}
const data = Object.values(map).sort((a, b) => b.total - a.total).slice(0, limit);
res.json({ ok: true, data });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/ip-versions
router.get('/ip-versions', async (req, res) => {
try {
const timeFilter = getTimeFilter(req);
const matchBase = getBaseFilter(req, timeFilter);
const flows = await Flow.find({ ...matchBase, dst_ip: { $ne: null } }, { dst_ip: 1, download: 1, upload: 1 }).limit(20000).lean();
let ipv4Total = 0, ipv6Total = 0;
for (const f of flows) {
const size = (f.download || 0) + (f.upload || 0);
if (f.dst_ip && f.dst_ip.includes(':')) {
ipv6Total += size;
} else {
ipv4Total += size;
}
}
res.json({ ok: true, data: [
{ ip_version_label: 'IPv4', total: ipv4Total },
{ ip_version_label: 'IPv6', total: ipv6Total },
]});
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/remote-ips
router.get('/remote-ips', async (req, res) => {
try {
const limit = parseInt(req.query.limit ?? 20);
const raw = await topFlowField('dst_ip', req, limit);
const data = raw.map(r => ({
remote_ip: r.label,
ip_version: r.label.includes(':') ? 6 : 4,
download: r.download,
upload: r.upload,
total: r.download + r.upload
}));
res.json({ ok: true, data });
} catch (err) { } catch (err) {
res.status(500).json({ ok: false, error: err.message }); res.status(500).json({ ok: false, error: err.message });
} }
+2 -1
View File
@@ -20,9 +20,10 @@ function getTimeFilter(req) {
const ms = { const ms = {
'5m': 5 * 60000, '5m': 5 * 60000,
'30m': 30 * 60000, '30m': 30 * 60000,
'1h': 60 * 3600000, '1h': 1 * 3600000,
'1d': 24 * 3600000, '1d': 24 * 3600000,
'7d': 7 * 24 * 3600000, '7d': 7 * 24 * 3600000,
'30d': 30 * 24 * 3600000,
}; };
const delta = ms[range] ?? ms['1d']; const delta = ms[range] ?? ms['1d'];
return { $gte: new Date(now.getTime() - delta) }; return { $gte: new Date(now.getTime() - delta) };
+18 -14
View File
@@ -20,29 +20,36 @@ router.get('/summary', async (req, res) => {
if (base.agent_uuid) { if (base.agent_uuid) {
// ── Agent-Level Summary (View As Agent mode) ───────────────────────────── // ── Agent-Level Summary (View As Agent mode) ─────────────────────────────
// The proxy saves per-agent summaries with agent_uuid = <uuid>.
// Use the latest one for the scoped agent instead of site aggregates.
const latestAgentSummary = await Summary const latestAgentSummary = await Summary
.findOne(baseWithoutTime) .findOne(baseWithoutTime)
.sort({ timestamp: -1 }) .sort({ timestamp: -1 })
.lean(); .lean();
if (latestAgentSummary) { if (latestAgentSummary) {
bandwidthDown = latestAgentSummary.bandwidth_down || 0;
bandwidthUp = latestAgentSummary.bandwidth_up || 0;
activeFlowsCount = latestAgentSummary.active_flows || 0; activeFlowsCount = latestAgentSummary.active_flows || 0;
downloadSpeed = latestAgentSummary.download_speed || 0; downloadSpeed = latestAgentSummary.download_speed || 0;
uploadSpeed = latestAgentSummary.upload_speed || 0; uploadSpeed = latestAgentSummary.upload_speed || 0;
latestTime = latestAgentSummary.timestamp; latestTime = latestAgentSummary.timestamp;
} }
const summaries = await Summary.find(base).lean();
bandwidthDown = summaries.reduce((s, x) => s + (x.bandwidth_down || 0), 0);
bandwidthUp = summaries.reduce((s, x) => s + (x.bandwidth_up || 0), 0);
} else { } else {
// ── Site-Level Summary (default) ───────────────────────────────────────── // ── Site-Level Summary (default) ─────────────────────────────────────────
// Use site-level snapshots (agent_uuid=null) to avoid double-counting
// across agents when no specific agent scope is active.
const siteIds = baseWithoutTime.site_uuid const siteIds = baseWithoutTime.site_uuid
? [baseWithoutTime.site_uuid] ? [baseWithoutTime.site_uuid]
: await Summary.distinct('site_uuid', { agent_uuid: null }); : await Summary.distinct('site_uuid', { agent_uuid: null });
const siteSummaries = await Summary.find({
site_uuid: { $in: siteIds },
agent_uuid: null,
...(timeFilter ? { timestamp: timeFilter } : {})
}).lean();
bandwidthDown = siteSummaries.reduce((s, x) => s + (x.bandwidth_down || 0), 0);
bandwidthUp = siteSummaries.reduce((s, x) => s + (x.bandwidth_up || 0), 0);
for (const siteId of siteIds) { for (const siteId of siteIds) {
const latestSiteSummary = await Summary const latestSiteSummary = await Summary
.findOne({ agent_uuid: null, site_uuid: siteId }) .findOne({ agent_uuid: null, site_uuid: siteId })
@@ -50,11 +57,6 @@ router.get('/summary', async (req, res) => {
.lean(); .lean();
if (latestSiteSummary) { if (latestSiteSummary) {
// Apply time filter: only use if within the requested time range
if (timeFilter && latestSiteSummary.timestamp < timeFilter) continue;
bandwidthDown += latestSiteSummary.bandwidth_down || 0;
bandwidthUp += latestSiteSummary.bandwidth_up || 0;
activeFlowsCount += latestSiteSummary.active_flows || 0; activeFlowsCount += latestSiteSummary.active_flows || 0;
downloadSpeed += latestSiteSummary.download_speed || 0; downloadSpeed += latestSiteSummary.download_speed || 0;
uploadSpeed += latestSiteSummary.upload_speed || 0; uploadSpeed += latestSiteSummary.upload_speed || 0;
@@ -66,12 +68,14 @@ router.get('/summary', async (req, res) => {
// Fallback: if no site-level summaries exist yet, aggregate from per-agent summaries // Fallback: if no site-level summaries exist yet, aggregate from per-agent summaries
if (bandwidthDown === 0 && bandwidthUp === 0) { if (bandwidthDown === 0 && bandwidthUp === 0) {
const latestAgentDoc = await Summary.findOne(baseWithoutTime).sort({ timestamp: -1 }); const allAgentSummaries = await Summary.find(base).lean();
bandwidthDown = allAgentSummaries.reduce((s, r) => s + (r.bandwidth_down || 0), 0);
bandwidthUp = allAgentSummaries.reduce((s, r) => s + (r.bandwidth_up || 0), 0);
const latestAgentDoc = await Summary.findOne(baseWithoutTime).sort({ timestamp: -1 }).lean();
if (latestAgentDoc) { if (latestAgentDoc) {
latestTime = latestAgentDoc.timestamp; latestTime = latestAgentDoc.timestamp;
const agentSummaries = await Summary.find({ ...baseWithoutTime, timestamp: latestAgentDoc.timestamp }).lean(); const agentSummaries = await Summary.find({ ...baseWithoutTime, timestamp: latestAgentDoc.timestamp }).lean();
bandwidthDown = agentSummaries.reduce((s, r) => s + (r.bandwidth_down ?? 0), 0);
bandwidthUp = agentSummaries.reduce((s, r) => s + (r.bandwidth_up ?? 0), 0);
activeFlowsCount = agentSummaries.reduce((s, r) => s + (r.active_flows ?? 0), 0); activeFlowsCount = agentSummaries.reduce((s, r) => s + (r.active_flows ?? 0), 0);
downloadSpeed = agentSummaries.reduce((s, r) => s + (r.download_speed ?? 0), 0); downloadSpeed = agentSummaries.reduce((s, r) => s + (r.download_speed ?? 0), 0);
uploadSpeed = agentSummaries.reduce((s, r) => s + (r.upload_speed ?? 0), 0); uploadSpeed = agentSummaries.reduce((s, r) => s + (r.upload_speed ?? 0), 0);
+18 -15
View File
@@ -119,32 +119,35 @@ module.exports = async function deviceDetailsHandler(req, res, helpers) {
// Only query DeviceAppStat if we have an IP // Only query DeviceAppStat if we have an IP
ip ? DeviceAppStat.find(appFilter).sort({ timestamp: -1 }).lean() : [], ip ? DeviceAppStat.find(appFilter).sort({ timestamp: -1 }).lean() : [],
flowQueryConditions.length > 0 flowQueryConditions.length > 0
? Flow.find({ ...flowFilter, $or: flowQueryConditions }).sort({ timestamp: -1 }).limit(2000).lean() ? Flow.find({ ...flowFilter, $or: flowQueryConditions }).sort({ timestamp: -1 }).limit(5000).lean()
: [], : [],
Threat.find(threatQuery).sort({ detected_at: -1 }).lean(), Threat.find(threatQuery).sort({ detected_at: -1 }).lean(),
]); ]);
// ── Apps tab — use DeviceAppStat (real DPI per-IP per-app data) ────────── // Aggregate by app_label and sum download and upload
// Deduplicate: same app_label may appear across multiple collection cycles
// Use the LATEST record per app (most recent 24h cumulative value)
const appLatest = {}; const appLatest = {};
for (const a of deviceAppStats) { for (const a of deviceAppStats) {
const key = a.app_label; const key = a.app_label;
if (!appLatest[key] || new Date(a.timestamp) > new Date(appLatest[key].timestamp)) { if (!appLatest[key]) {
appLatest[key] = a; appLatest[key] = {
app_label: a.app_label,
download: 0,
upload: 0,
flows: 0,
first_seen: a.created_at || a.timestamp,
last_seen: a.updated_at || a.timestamp,
};
}
appLatest[key].download += a.download || 0;
appLatest[key].upload += a.upload || 0;
appLatest[key].flows += a.flows || 0;
if (new Date(a.timestamp) > new Date(appLatest[key].last_seen)) {
appLatest[key].last_seen = a.timestamp;
} }
} }
const apps = Object.values(appLatest) const apps = Object.values(appLatest)
.filter(a => (a.download || 0) + (a.upload || 0) > 0) .filter(a => (a.download || 0) + (a.upload || 0) > 0)
.sort((a, b) => (b.download || 0) - (a.download || 0)) .sort((a, b) => (b.download || 0) - (a.download || 0));
.map(a => ({
app_label: a.app_label,
download: a.download || 0,
upload: a.upload || 0,
flows: a.flows || 0,
first_seen: a.created_at || a.timestamp,
last_seen: a.updated_at || a.timestamp,
}));
// ── Protocol / Domain tabs — from Flow collection ──────────────────────── // ── Protocol / Domain tabs — from Flow collection ────────────────────────
const protocolsMap = {}, domainsMap = {}, destinationsMap = {}; const protocolsMap = {}, domainsMap = {}, destinationsMap = {};
+6 -6
View File
@@ -192,23 +192,23 @@ router.get('/', async (req, res) => {
// ── Property-based types: query specific telemetry collection ────────────── // ── Property-based types: query specific telemetry collection ──────────────
else if (type === 'dhcp_fingerprint') { else if (type === 'dhcp_fingerprint') {
data = await DhcpFingerprintStat.find({ fingerprint: value, ...agentFilter }) data = await DhcpFingerprintStat.find({ fingerprint: value, ...agentFilter })
.sort({ download: -1 }).limit(200).lean(); .sort({ download: -1 }).limit(1000000).lean();
} }
else if (type === 'http_useragent') { else if (type === 'http_useragent') {
data = await HttpUserAgentStat.find({ user_agent: value, ...agentFilter }) data = await HttpUserAgentStat.find({ user_agent: value, ...agentFilter })
.sort({ download: -1 }).limit(200).lean(); .sort({ download: -1 }).limit(1000000).lean();
} }
else if (type === 'bittorrent_hash') { else if (type === 'bittorrent_hash') {
data = await BittorrentHashStat.find({ info_hash: value, ...agentFilter }) data = await BittorrentHashStat.find({ info_hash: value, ...agentFilter })
.sort({ download: -1 }).limit(200).lean(); .sort({ download: -1 }).limit(1000000).lean();
} }
else if (type === 'ssh_version') { else if (type === 'ssh_version') {
const [clients, servers] = await Promise.all([ const [clients, servers] = await Promise.all([
SshClientStat.find({ ssh_client: value, ...agentFilter }).sort({ download: -1 }).limit(200).lean(), SshClientStat.find({ ssh_client: value, ...agentFilter }).sort({ download: -1 }).limit(1000000).lean(),
SshServerStat.find({ ssh_server: value, ...agentFilter }).sort({ download: -1 }).limit(200).lean(), SshServerStat.find({ ssh_server: value, ...agentFilter }).sort({ download: -1 }).limit(1000000).lean(),
]); ]);
// Merge clients + servers, label each with role // Merge clients + servers, label each with role
data = [ data = [
@@ -219,7 +219,7 @@ router.get('/', async (req, res) => {
else if (type === 'mdns_hostname') { else if (type === 'mdns_hostname') {
data = await MdnsHostnameStat.find({ mdns_hostname: value, ...agentFilter }) data = await MdnsHostnameStat.find({ mdns_hostname: value, ...agentFilter })
.sort({ download: -1 }).limit(200).lean(); .sort({ download: -1 }).limit(1000000).lean();
} }
else { else {
+1 -1
View File
@@ -25,7 +25,7 @@ module.exports = async function remoteIpDetailsHandler(req, res, helpers) {
// Parallel queries // Parallel queries
const [flowsQuery, rawThreats] = await Promise.all([ const [flowsQuery, rawThreats] = await Promise.all([
Flow.find({ ...flowFilter, $or: [{ src_ip: ip }, { dst_ip: ip }] }).sort({ timestamp: -1 }).limit(2000).lean(), Flow.find({ ...flowFilter, $or: [{ src_ip: ip }, { dst_ip: ip }] }).sort({ timestamp: -1 }).limit(1000000).lean(),
Threat.find({ ip_address: ip, ...flowFilter }).sort({ detected_at: -1 }).lean(), Threat.find({ ip_address: ip, ...flowFilter }).sort({ detected_at: -1 }).lean(),
]); ]);
View File
Whitespace-only changes.
+2
View File
@@ -0,0 +1,2 @@
'head' is not recognized as an internal or external command,
operable program or batch file.
+1 -2
View File
@@ -6,8 +6,7 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"scripts": { "scripts": {
"dev": "concurrently --names \"MONGO,NEXT,BACKEND,PROXY\" --prefix-colors \"magenta,cyan,green,yellow\" \"node scripts/start-mongo.js\" \"next dev\" \"node backend/server.js\" \"node proxy/index.js\"", "dev": "concurrently --names \"NEXT,BACKEND,PROXY\" --prefix-colors \"cyan,green,yellow\" \"next dev\" \"node backend/server.js\" \"node proxy/index.js\"",
"dev:mongo": "node scripts/start-mongo.js",
"dev:next": "next dev", "dev:next": "next dev",
"dev:backend": "node backend/server.js", "dev:backend": "node backend/server.js",
"dev:proxy": "node proxy/index.js", "dev:proxy": "node proxy/index.js",
+63
View File
@@ -0,0 +1,63 @@
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') });
const mongoose = require('mongoose');
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
const SIAB = '6681452d_9cae_4ff4_8ae8_0d504774265e';
mongoose.connect(MONGODB_URI).then(async () => {
const db = mongoose.connection.db;
const since24h = new Date(Date.now() - 24 * 3600000);
const since7d = new Date(Date.now() - 7 * 24 * 3600000);
// Count site-level summary docs
const count24h = await db.collection('summaries').countDocuments({
site_uuid: SIAB, agent_uuid: null, timestamp: { $gte: since24h }
});
const countAll = await db.collection('summaries').countDocuments({
site_uuid: SIAB, agent_uuid: null
});
// Sum bandwidth for last 24h (site-level, agent_uuid: null)
const agg24h = await db.collection('summaries').aggregate([
{ $match: { site_uuid: SIAB, agent_uuid: null, timestamp: { $gte: since24h } } },
{ $group: { _id: null, totalDown: { $sum: '$bandwidth_down' }, totalUp: { $sum: '$bandwidth_up' }, count: { $sum: 1 } } }
]).toArray();
// Sum bandwidth ALL time (site-level)
const aggAll = await db.collection('summaries').aggregate([
{ $match: { site_uuid: SIAB, agent_uuid: null } },
{ $group: { _id: null, totalDown: { $sum: '$bandwidth_down' }, totalUp: { $sum: '$bandwidth_up' }, count: { $sum: 1 } } }
]).toArray();
// Oldest and newest
const oldest = await db.collection('summaries').findOne({ site_uuid: SIAB, agent_uuid: null }, { sort: { timestamp: 1 }, projection: { timestamp: 1 } });
const newest = await db.collection('summaries').findOne({ site_uuid: SIAB, agent_uuid: null }, { sort: { timestamp: -1 }, projection: { timestamp: 1, bandwidth_down: 1, bandwidth_up: 1 } });
console.log('\n=== MongoDB Summary Check (SIAB site) ===');
console.log('Total site-level docs:', countAll);
console.log('Site-level docs in last 24h:', count24h);
console.log('\nBandwidth SUM (last 24h):');
console.log(' Down:', agg24h[0] ? (agg24h[0].totalDown / 1024 / 1024).toFixed(2) + ' MB' : '0 MB');
console.log(' Up :', agg24h[0] ? (agg24h[0].totalUp / 1024 / 1024).toFixed(2) + ' MB' : '0 MB');
console.log('\nBandwidth SUM (ALL time):');
console.log(' Down:', aggAll[0] ? (aggAll[0].totalDown / 1024 / 1024).toFixed(2) + ' MB' : '0 MB');
console.log(' Up :', aggAll[0] ? (aggAll[0].totalUp / 1024 / 1024).toFixed(2) + ' MB' : '0 MB');
console.log('\nOldest entry :', oldest?.timestamp);
console.log('Newest entry :', newest?.timestamp);
console.log('Latest bandwidth_down per 5min:', newest ? (newest.bandwidth_down / 1024 / 1024).toFixed(4) + ' MB' : 'N/A');
console.log('Latest bandwidth_up per 5min :', newest ? (newest.bandwidth_up / 1024 / 1024).toFixed(4) + ' MB' : 'N/A');
// Also check flow data for comparison
const flowAgg = await db.collection('flows').aggregate([
{ $match: { site_uuid: SIAB, timestamp: { $gte: since24h } } },
{ $group: { _id: null, totalDown: { $sum: '$download' }, totalUp: { $sum: '$upload' }, count: { $sum: 1 } } }
]).toArray();
console.log('\nFlow-level bandwidth (last 24h from flows collection):');
console.log(' Down:', flowAgg[0] ? (flowAgg[0].totalDown / 1024 / 1024).toFixed(2) + ' MB' : '0 MB');
console.log(' Up :', flowAgg[0] ? (flowAgg[0].totalUp / 1024 / 1024).toFixed(2) + ' MB' : '0 MB');
console.log(' Flow count:', flowAgg[0]?.count || 0);
console.log('=====================================\n');
process.exit(0);
}).catch(e => { console.error(e.message); process.exit(1); });
+38 -37
View File
@@ -10,14 +10,10 @@ require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') });
const netify = require('./netifyClient'); const netify = require('./netifyClient');
const { collectSecondaryTelemetry } = require('./collectorHelper'); const { collectSecondaryTelemetry } = require('./collectorHelper');
const { const { collectDevicesAndApps, collectFlows } = require('./collectorHelperDpi2');
collectDevicesAndApps, const { collectThreats, collectEvents } = require('./collectorHelperDpi3');
collectFlows,
collectThreats,
collectEvents
} = require('./collectorHelperDpi2');
const { Summary, AppStat } = require('./models/Schemas'); const { Summary, AppStat, AgentRegistry } = require('./models/Schemas');
const { pruneOldData } = require('./dataRetention'); const { pruneOldData } = require('./dataRetention');
const SITE_UUIDS_STR = process.env.NETIFY_SITE_UUIDS || process.env.NETIFY_SITE_UUID; const SITE_UUIDS_STR = process.env.NETIFY_SITE_UUIDS || process.env.NETIFY_SITE_UUID;
@@ -29,7 +25,7 @@ async function collectForAgent(agentUuid, timestamp, siteUuid) {
try { try {
// 1. Summary / Bandwidth (including derived speed, packet_drops, and peak_flow_rate) // 1. Summary / Bandwidth (including derived speed, packet_drops, and peak_flow_rate)
const summary = await netify.fetchBandwidthSummary(1440, agentUuid, siteUuid); const summary = await netify.fetchBandwidthSummary(5, agentUuid, siteUuid);
if (summary) { if (summary) {
let download_speed = 0; let download_speed = 0;
let upload_speed = 0; let upload_speed = 0;
@@ -38,15 +34,10 @@ async function collectForAgent(agentUuid, timestamp, siteUuid) {
try { try {
const prev = await Summary.findOne({ agent_uuid: agentUuid }).sort({ timestamp: -1 }).lean(); const prev = await Summary.findOne({ agent_uuid: agentUuid }).sort({ timestamp: -1 }).lean();
if (prev && prev.timestamp) { const timeDiffSec = prev && prev.timestamp ? (timestamp.getTime() - new Date(prev.timestamp).getTime()) / 1000 : 300;
const timeDiffSec = (timestamp.getTime() - new Date(prev.timestamp).getTime()) / 1000; const activeTimeDiff = timeDiffSec > 0 ? timeDiffSec : 300;
if (timeDiffSec > 0) { download_speed = summary.bandwidth_down / activeTimeDiff;
const bytesDiffDown = Math.max(0, summary.bandwidth_down - (prev.bandwidth_down || 0)); upload_speed = summary.bandwidth_up / activeTimeDiff;
const bytesDiffUp = Math.max(0, summary.bandwidth_up - (prev.bandwidth_up || 0));
download_speed = bytesDiffDown / timeDiffSec;
upload_speed = bytesDiffUp / timeDiffSec;
}
}
} catch (err) { } catch (err) {
console.error('[Collector] Error calculating summary speeds:', err.message); console.error('[Collector] Error calculating summary speeds:', err.message);
} }
@@ -78,7 +69,7 @@ async function collectForAgent(agentUuid, timestamp, siteUuid) {
} }
// 2. Top Apps // 2. Top Apps
const apps = await netify.fetchTopApps(1440, 200, agentUuid, siteUuid); const apps = await netify.fetchTopApps(5, 200, agentUuid, siteUuid);
if (apps && apps.length > 0) { if (apps && apps.length > 0) {
const appDocs = apps.map(app => ({ const appDocs = apps.map(app => ({
timestamp, agent_uuid: agentUuid, site_uuid: siteUuid, timestamp, agent_uuid: agentUuid, site_uuid: siteUuid,
@@ -154,6 +145,28 @@ async function collectAllAgents() {
// Register these agents as belonging to this site // Register these agents as belonging to this site
for (const agent of agents) processedAgentUuids.add(agent.uuid); for (const agent of agents) processedAgentUuids.add(agent.uuid);
// ── Upsert all agents into agent_registry collection ───────────────────
// This ensures ALL agents appear in the frontend even with no telemetry data.
await Promise.allSettled(agents.map(a =>
AgentRegistry.findOneAndUpdate(
{ uuid: a.uuid },
{
$set: {
uuid: a.uuid,
serial: a.serial || a.uuid,
label: a.label,
site_uuid: siteUuid,
provisioned: a.provisioned ?? true,
activated: a.activated ?? false,
last_seen_at: a.last_seen_at ?? null,
netify_id: a.id ?? null,
}
},
{ upsert: true, new: true }
)
));
console.log(`[Collector] ✓ ${agents.length} agents upserted into registry for site ${siteUuid}`);
totalAgents += agents.length; totalAgents += agents.length;
console.log(`[Collector] Processing ${agents.length} agents for site ${siteUuid}: ${agents.map(a => a.uuid).join(', ')}`); console.log(`[Collector] Processing ${agents.length} agents for site ${siteUuid}: ${agents.map(a => a.uuid).join(', ')}`);
@@ -161,23 +174,17 @@ async function collectAllAgents() {
// Collect bandwidth at site level (no agentUuid filter) so numbers match // Collect bandwidth at site level (no agentUuid filter) so numbers match
// Netify portal exactly and avoid double-counting across agents. // Netify portal exactly and avoid double-counting across agents.
try { try {
console.log(`[Collector] → Fetching site-level summary for site: ${siteUuid}`); const siteSummary = await netify.fetchBandwidthSummary(5, null, siteUuid);
const siteSummary = await netify.fetchBandwidthSummary(1440, null, siteUuid);
if (siteSummary) { if (siteSummary) {
let download_speed = 0; let download_speed = 0;
let upload_speed = 0; let upload_speed = 0;
try { try {
const prev = await Summary.findOne({ agent_uuid: null, site_uuid: siteUuid }).sort({ timestamp: -1 }).lean(); const prev = await Summary.findOne({ agent_uuid: null, site_uuid: siteUuid }).sort({ timestamp: -1 }).lean();
if (prev && prev.timestamp) { const timeDiffSec = prev && prev.timestamp ? (timestamp.getTime() - new Date(prev.timestamp).getTime()) / 1000 : 300;
const timeDiffSec = (timestamp.getTime() - new Date(prev.timestamp).getTime()) / 1000; const activeTimeDiff = timeDiffSec > 0 ? timeDiffSec : 300;
if (timeDiffSec > 0) { download_speed = siteSummary.bandwidth_down / activeTimeDiff;
const bytesDiffDown = Math.max(0, siteSummary.bandwidth_down - (prev.bandwidth_down || 0)); upload_speed = siteSummary.bandwidth_up / activeTimeDiff;
const bytesDiffUp = Math.max(0, siteSummary.bandwidth_up - (prev.bandwidth_up || 0));
download_speed = bytesDiffDown / timeDiffSec;
upload_speed = bytesDiffUp / timeDiffSec;
}
}
} catch (err) { } catch (err) {
console.error('[Collector] Error calculating site summary speeds:', err.message); console.error('[Collector] Error calculating site summary speeds:', err.message);
} }
@@ -224,14 +231,8 @@ async function collectAllAgents() {
} }
async function collectSpecificAgent(agentUuid, siteUuid = SITE_UUIDS[0]) { async function collectSpecificAgent(agentUuid, siteUuid = SITE_UUIDS[0]) {
const timestamp = new Date(); const result = await collectSpecificAgents([agentUuid], siteUuid, 0);
const timeString = timestamp.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' }) + ' WIB'; return { success: result.successful > 0, mode: 'specific', agent_uuid: agentUuid };
console.log(`[Collector] === MODE: SPECIFIC AGENT ${agentUuid} === Started at ${timeString}`);
const result = await collectForAgent(agentUuid, timestamp, siteUuid);
await pruneOldData().catch(err => console.error('[Collector] [Retention] error:', err.message));
return { success: result.success, mode: 'specific', agent_uuid: agentUuid };
} }
async function collectSpecificAgents(agentUuids, siteUuid = SITE_UUIDS[0], delayMs = 5000) { async function collectSpecificAgents(agentUuids, siteUuid = SITE_UUIDS[0], delayMs = 5000) {
+7 -7
View File
@@ -20,7 +20,7 @@ const {
async function collectSecondaryTelemetry(agentUuid, timestamp, SITE_UUID, netify, label) { async function collectSecondaryTelemetry(agentUuid, timestamp, SITE_UUID, netify, label) {
try { try {
// 2b. App Categories // 2b. App Categories
const categories = await netify.fetchTopAppCategories(1440, 50, agentUuid, SITE_UUID); const categories = await netify.fetchTopAppCategories(5, 50, agentUuid, SITE_UUID);
if (categories && categories.length > 0) { if (categories && categories.length > 0) {
const catDocs = categories.map(c => ({ const catDocs = categories.map(c => ({
timestamp, timestamp,
@@ -36,7 +36,7 @@ async function collectSecondaryTelemetry(agentUuid, timestamp, SITE_UUID, netify
} }
// 2c. TLS Versions // 2c. TLS Versions
const tlsVersions = await netify.fetchTlsVersions(1440, 50, agentUuid, SITE_UUID); const tlsVersions = await netify.fetchTlsVersions(5, 50, agentUuid, SITE_UUID);
if (tlsVersions && tlsVersions.length > 0) { if (tlsVersions && tlsVersions.length > 0) {
const tvDocs = tlsVersions.map(v => ({ const tvDocs = tlsVersions.map(v => ({
timestamp, timestamp,
@@ -52,7 +52,7 @@ async function collectSecondaryTelemetry(agentUuid, timestamp, SITE_UUID, netify
} }
// 2d. TLS Ciphers // 2d. TLS Ciphers
const tlsCiphers = await netify.fetchTlsCiphers(1440, 50, agentUuid, SITE_UUID); const tlsCiphers = await netify.fetchTlsCiphers(5, 50, agentUuid, SITE_UUID);
if (tlsCiphers && tlsCiphers.length > 0) { if (tlsCiphers && tlsCiphers.length > 0) {
const tcDocs = tlsCiphers.map(c => ({ const tcDocs = tlsCiphers.map(c => ({
timestamp, timestamp,
@@ -68,7 +68,7 @@ async function collectSecondaryTelemetry(agentUuid, timestamp, SITE_UUID, netify
} }
// 2e. TLS Security // 2e. TLS Security
const tlsSecurity = await netify.fetchTlsSecurity(1440, 50, agentUuid, SITE_UUID); const tlsSecurity = await netify.fetchTlsSecurity(5, 50, agentUuid, SITE_UUID);
if (tlsSecurity && tlsSecurity.length > 0) { if (tlsSecurity && tlsSecurity.length > 0) {
const tsDocs = tlsSecurity.map(s => ({ const tsDocs = tlsSecurity.map(s => ({
timestamp, timestamp,
@@ -84,7 +84,7 @@ async function collectSecondaryTelemetry(agentUuid, timestamp, SITE_UUID, netify
} }
// 2f. Top Countries // 2f. Top Countries
const countries = await netify.fetchTopCountries(1440, 100, agentUuid, SITE_UUID); const countries = await netify.fetchTopCountries(5, 100, agentUuid, SITE_UUID);
if (countries && countries.length > 0) { if (countries && countries.length > 0) {
const coDocs = countries.map(c => ({ const coDocs = countries.map(c => ({
timestamp, timestamp,
@@ -101,7 +101,7 @@ async function collectSecondaryTelemetry(agentUuid, timestamp, SITE_UUID, netify
} }
// 2g. Top Protocols // 2g. Top Protocols
const protocols = await netify.fetchTopProtocols(1440, 50, agentUuid, SITE_UUID); const protocols = await netify.fetchTopProtocols(5, 50, agentUuid, SITE_UUID);
if (protocols && protocols.length > 0) { if (protocols && protocols.length > 0) {
const protoDocs = protocols.map(p => ({ const protoDocs = protocols.map(p => ({
timestamp, timestamp,
@@ -117,7 +117,7 @@ async function collectSecondaryTelemetry(agentUuid, timestamp, SITE_UUID, netify
} }
// 2h. SNI Hostnames // 2h. SNI Hostnames
const snis = await netify.fetchSniHostnames(1440, 10000, agentUuid, SITE_UUID); const snis = await netify.fetchSniHostnames(5, 10000, agentUuid, SITE_UUID);
if (snis && snis.length > 0) { if (snis && snis.length > 0) {
const sniDocs = snis.map(s => ({ const sniDocs = snis.map(s => ({
timestamp, agent_uuid: agentUuid, site_uuid: SITE_UUID, timestamp, agent_uuid: agentUuid, site_uuid: SITE_UUID,
+4 -55
View File
@@ -14,7 +14,7 @@ const {
} = require('./deviceResolver'); } = require('./deviceResolver');
async function collectDevicesAndApps(agentUuid, timestamp, SITE_UUID, netify, label) { async function collectDevicesAndApps(agentUuid, timestamp, SITE_UUID, netify, label) {
const devices = await netify.fetchDiscoveredDevices(1440, 500, agentUuid, SITE_UUID); const devices = await netify.fetchDiscoveredDevices(5, 500, agentUuid, SITE_UUID);
const ipToMacMap = {}; const ipToMacMap = {};
if (devices && devices.length > 0) { if (devices && devices.length > 0) {
@@ -56,7 +56,7 @@ async function collectDevicesAndApps(agentUuid, timestamp, SITE_UUID, netify, la
let deviceAppCount = 0; let deviceAppCount = 0;
for (let i = 0; i < topDevices.length; i += 5) { for (let i = 0; i < topDevices.length; i += 5) {
const batch = topDevices.slice(i, i + 5); const batch = topDevices.slice(i, i + 5);
const results = await Promise.allSettled(batch.map(d => netify.fetchDeviceApps(d.ip_address, 1440, 50, agentUuid, SITE_UUID))); const results = await Promise.allSettled(batch.map(d => netify.fetchDeviceApps(d.ip_address, 5, 50, agentUuid, SITE_UUID)));
const appDocs = []; const appDocs = [];
results.forEach((res, idx) => { results.forEach((res, idx) => {
if (res.status === 'fulfilled' && Array.isArray(res.value)) { if (res.status === 'fulfilled' && Array.isArray(res.value)) {
@@ -81,7 +81,7 @@ async function collectDevicesAndApps(agentUuid, timestamp, SITE_UUID, netify, la
} }
async function collectFlows(agentUuid, timestamp, SITE_UUID, netify, label, ipToMacMap) { async function collectFlows(agentUuid, timestamp, SITE_UUID, netify, label, ipToMacMap) {
// Netify API has a hard limit of 1,000,000 for settings_limit. // Netify API has a hard limit of 1,000,000 for settings_limit. Use 1000000 as default per rule.
const flowLimit = parseInt(process.env.PROXY_FLOW_LIMIT || '1000000'); const flowLimit = parseInt(process.env.PROXY_FLOW_LIMIT || '1000000');
const flows = await netify.fetchFlows(flowLimit, agentUuid, SITE_UUID); const flows = await netify.fetchFlows(flowLimit, agentUuid, SITE_UUID);
if (flows && flows.length > 0) { if (flows && flows.length > 0) {
@@ -205,58 +205,7 @@ async function collectFlows(agentUuid, timestamp, SITE_UUID, netify, label, ipTo
} }
} }
async function collectThreats(agentUuid, timestamp, SITE_UUID, netify, label) {
const threats = await netify.fetchCyberThreats(agentUuid, SITE_UUID);
if (threats && threats.length > 0) {
const threatDocs = threats.map(t => ({
timestamp, agent_uuid: agentUuid, site_uuid: SITE_UUID,
threat_type: t.threat_type || 'Unknown Threat', severity: t.severity || 'Medium',
src_ip: t.src_ip, dst_ip: t.dst_ip, dst_port: t.dst_port, protocol: t.protocol,
description: t.description, event_at: t.event_at || new Date().toISOString(),
}));
await Threat.insertMany(threatDocs);
console.log(`[Collector] ✓ ${threatDocs.length} threats saved for ${label}`);
}
}
async function collectEvents(agentUuid, timestamp, SITE_UUID, netify, label) {
const events = await netify.fetchEvents(100, agentUuid, SITE_UUID);
if (events && events.length > 0) {
const eventIds = events.map(e => e.event_id).filter(id => id !== null);
const existing = await Event.find({ site_uuid: SITE_UUID, event_id: { $in: eventIds } }).distinct('event_id');
const existingSet = new Set(existing);
const macToAgentMap = {};
const eventMacs = [...new Set(events.map(e => e.mac_address).filter(Boolean))];
if (eventMacs.length > 0) {
const storedDevices = await DeviceStat.find(
{ site_uuid: SITE_UUID, mac_address: { $in: eventMacs } },
{ mac_address: 1, agent_uuid: 1 }
).lean();
for (const d of storedDevices) {
if (d.mac_address && d.agent_uuid) macToAgentMap[d.mac_address] = d.agent_uuid;
}
}
const eventDocs = events.filter(e => e.event_id === null || !existingSet.has(e.event_id)).map(e => {
const resolvedAgentUuid = (e.mac_address && macToAgentMap[e.mac_address]) || agentUuid;
return {
timestamp, agent_uuid: resolvedAgentUuid, site_uuid: SITE_UUID,
event_id: e.event_id, event_type: e.event_type, severity: e.severity,
description: e.description, category_label: e.category_label,
ip_address: e.ip_address, mac_address: e.mac_address, event_at: e.event_at,
};
});
if (eventDocs.length > 0) {
await Event.insertMany(eventDocs);
console.log(`[Collector] ✓ ${eventDocs.length} new events saved for ${label}`);
}
}
}
module.exports = { module.exports = {
collectDevicesAndApps, collectDevicesAndApps,
collectFlows, collectFlows
collectThreats,
collectEvents
}; };
+61
View File
@@ -0,0 +1,61 @@
// proxy/collectorHelperDpi3.js
// ─────────────────────────────────────────────────────────────────────────────
// Supplementary Telemetry collection steps for threats and events.
// Split from collectorHelperDpi2.js to satisfy the 256-line file size limit.
// ─────────────────────────────────────────────────────────────────────────────
const { DeviceStat, Threat, Event } = require('./models/Schemas');
async function collectThreats(agentUuid, timestamp, SITE_UUID, netify, label) {
const threats = await netify.fetchCyberThreats(agentUuid, SITE_UUID);
if (threats && threats.length > 0) {
const threatDocs = threats.map(t => ({
timestamp, agent_uuid: agentUuid, site_uuid: SITE_UUID,
threat_type: t.threat_type || 'Unknown Threat', severity: t.severity || 'Medium',
src_ip: t.src_ip, dst_ip: t.dst_ip, dst_port: t.dst_port, protocol: t.protocol,
description: t.description, event_at: t.event_at || new Date().toISOString(),
}));
await Threat.insertMany(threatDocs);
console.log(`[Collector] ✓ ${threatDocs.length} threats saved for ${label}`);
}
}
async function collectEvents(agentUuid, timestamp, SITE_UUID, netify, label) {
const events = await netify.fetchEvents(100, agentUuid, SITE_UUID);
if (events && events.length > 0) {
const eventIds = events.map(e => e.event_id).filter(id => id !== null);
const existing = await Event.find({ site_uuid: SITE_UUID, event_id: { $in: eventIds } }).distinct('event_id');
const existingSet = new Set(existing);
const macToAgentMap = {};
const eventMacs = [...new Set(events.map(e => e.mac_address).filter(Boolean))];
if (eventMacs.length > 0) {
const storedDevices = await DeviceStat.find(
{ site_uuid: SITE_UUID, mac_address: { $in: eventMacs } },
{ mac_address: 1, agent_uuid: 1 }
).lean();
for (const d of storedDevices) {
if (d.mac_address && d.agent_uuid) macToAgentMap[d.mac_address] = d.agent_uuid;
}
}
const eventDocs = events.filter(e => e.event_id === null || !existingSet.has(e.event_id)).map(e => {
const resolvedAgentUuid = (e.mac_address && macToAgentMap[e.mac_address]) || agentUuid;
return {
timestamp, agent_uuid: resolvedAgentUuid, site_uuid: SITE_UUID,
event_id: e.event_id, event_type: e.event_type, severity: e.severity,
description: e.description, category_label: e.category_label,
ip_address: e.ip_address, mac_address: e.mac_address, event_at: e.event_at,
};
});
if (eventDocs.length > 0) {
await Event.insertMany(eventDocs);
console.log(`[Collector] ✓ ${eventDocs.length} new events saved for ${label}`);
}
}
}
module.exports = {
collectThreats,
collectEvents
};
+4 -4
View File
@@ -11,9 +11,9 @@ const Schemas = require('./models/Schemas');
* Prune all time-series documents older than 7 days. * Prune all time-series documents older than 7 days.
*/ */
async function pruneOldData() { async function pruneOldData() {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const timeString = sevenDaysAgo.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' }) + ' WIB'; const timeString = thirtyDaysAgo.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' }) + ' WIB';
console.log(`[Collector] [Retention] Checking for telemetry data older than 7 days (before ${timeString})...`); console.log(`[Collector] [Retention] Checking for telemetry data older than 30 days (before ${timeString})...`);
// Prune from all collections in Schemas except CustomDeviceLabel // Prune from all collections in Schemas except CustomDeviceLabel
const collections = Object.keys(Schemas).filter(name => name !== 'CustomDeviceLabel'); const collections = Object.keys(Schemas).filter(name => name !== 'CustomDeviceLabel');
@@ -22,7 +22,7 @@ async function pruneOldData() {
try { try {
const Model = Schemas[name]; const Model = Schemas[name];
if (typeof Model.deleteMany === 'function') { if (typeof Model.deleteMany === 'function') {
const res = await Model.deleteMany({ timestamp: { $lt: sevenDaysAgo } }); const res = await Model.deleteMany({ timestamp: { $lt: thirtyDaysAgo } });
if (res.deletedCount > 0) { if (res.deletedCount > 0) {
console.log(`[Collector] [Retention] ✓ Cleaned up ${res.deletedCount} old records from ${name}`); console.log(`[Collector] [Retention] ✓ Cleaned up ${res.deletedCount} old records from ${name}`);
} }
+21 -6
View File
@@ -139,6 +139,7 @@ AppStatSchema.index({ agent_uuid: 1, timestamp: -1, download: -1 });
DeviceStatSchema.index({ agent_uuid: 1, ip_address: 1 }, { unique: true }); DeviceStatSchema.index({ agent_uuid: 1, ip_address: 1 }, { unique: true });
FlowSchema.index({ agent_uuid: 1, timestamp: -1 }); FlowSchema.index({ agent_uuid: 1, timestamp: -1 });
FlowSchema.index({ agent_uuid: 1, flow_id: 1 }); FlowSchema.index({ agent_uuid: 1, flow_id: 1 });
FlowSchema.index({ site_uuid: 1, timestamp: -1 });
FlowSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 }); FlowSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 });
ThreatSchema.index({ agent_uuid: 1, timestamp: -1 }); ThreatSchema.index({ agent_uuid: 1, timestamp: -1 });
AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 }); AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 });
@@ -167,16 +168,30 @@ DeviceAppStatSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 });
const telemetrySchemas = require('./SchemasTelemetry'); const telemetrySchemas = require('./SchemasTelemetry');
const auxSchemas = require('./SchemasAux'); const auxSchemas = require('./SchemasAux');
// ─── Agent Registry (all agents registered in Netify, regardless of activity) ──
// Upserted every collector cycle. Source of truth for the agents list page.
const AgentRegistrySchema = new mongoose.Schema({
uuid: { type: String, required: true, unique: true, index: true },
serial: { type: String },
label: { type: String },
site_uuid: { type: String, index: true },
provisioned: { type: Boolean, default: false },
activated: { type: Boolean, default: false },
last_seen_at: { type: mongoose.Schema.Types.Mixed },
netify_id: { type: Number },
}, { ...baseOptions, collection: 'agent_registry' });
module.exports = { module.exports = {
Summary: mongoose.model('Summary', SummarySchema), Summary: mongoose.model('Summary', SummarySchema),
AppStat: mongoose.model('AppStat', AppStatSchema), AppStat: mongoose.model('AppStat', AppStatSchema),
ProtocolStat:mongoose.model('ProtocolStat',ProtocolStatSchema), ProtocolStat: mongoose.model('ProtocolStat', ProtocolStatSchema),
DeviceStat: mongoose.model('DeviceStat', DeviceStatSchema), DeviceStat: mongoose.model('DeviceStat', DeviceStatSchema),
DeviceAppStat: mongoose.model('DeviceAppStat', DeviceAppStatSchema), DeviceAppStat: mongoose.model('DeviceAppStat', DeviceAppStatSchema),
Flow: mongoose.model('Flow', FlowSchema), Flow: mongoose.model('Flow', FlowSchema),
Threat: mongoose.model('Threat', ThreatSchema), Threat: mongoose.model('Threat', ThreatSchema),
AppCategoryStat: mongoose.model('AppCategoryStat', AppCategoryStatSchema), AppCategoryStat: mongoose.model('AppCategoryStat', AppCategoryStatSchema),
Event: mongoose.model('Event', EventSchema), Event: mongoose.model('Event', EventSchema),
AgentRegistry: mongoose.model('AgentRegistry', AgentRegistrySchema),
...auxSchemas, ...auxSchemas,
...telemetrySchemas, ...telemetrySchemas,
}; };
+16 -227
View File
@@ -3,157 +3,11 @@
// DPI API wrapper for the BackOne Proxy Server targeting original Netify API. // DPI API wrapper for the BackOne Proxy Server targeting original Netify API.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
const { netifyFetch, BASE_URL, agentMap } = require('./netifyClientCore'); const { netifyFetch, BASE_URL } = require('./netifyClientCore');
const telemetry = require('./netifyTelemetry'); const telemetry = require('./netifyTelemetry');
const stats = require('./netifyClientStats');
const PORT_SERVICE_MAP = { async function fetchFlows(limit = 1000000, agentUuid = null, siteUuid = null) {
80: 'HTTP', 443: 'HTTPS / TLS', 8080: 'HTTP Alt', 8443: 'HTTPS Alt',
53: 'DNS', 5353: 'mDNS', 853: 'DNS-over-TLS',
25: 'SMTP', 587: 'SMTP TLS', 465: 'SMTPS', 110: 'POP3', 143: 'IMAP',
22: 'SSH', 23: 'Telnet', 3389: 'RDP', 5900: 'VNC',
21: 'FTP', 20: 'FTP Data', 989: 'FTPS', 990: 'FTPS Control',
3306: 'MySQL', 5432: 'PostgreSQL', 6379: 'Redis', 27017: 'MongoDB',
1194: 'OpenVPN', 51820: 'WireGuard', 500: 'IPSec IKE', 4500: 'IPSec NAT-T',
67: 'DHCP', 68: 'DHCP Client', 123: 'NTP',
6881: 'BitTorrent', 6882: 'BitTorrent', 6883: 'BitTorrent',
9993: 'ZeroTier VPN',
};
async function fetchAgents(siteUuid = null) {
const data = await netifyFetch('/data/stats/top/agent/download', { filter_interval: 43200, settings_limit: 100 }, null, siteUuid);
if (!data || !Array.isArray(data)) return [];
const list = data.map(r => ({
id: r.agent?.id,
uuid: r.agent?.uuid,
label: r.agent?.label,
})).filter(a => a.uuid);
for (const a of list) {
if (a.uuid && a.id) agentMap[a.uuid] = a.id;
}
// Secondary validation: if this site already has data in MongoDB, only return agents
// that have at least one summary record for THIS site_uuid. This prevents the
// org-level stats endpoint from cross-contaminating agents across sites.
if (siteUuid) {
try {
const mongoose = require('mongoose');
if (mongoose.connection.readyState === 1) {
const db = mongoose.connection.db;
const knownAgents = await db.collection('summaries').distinct('agent_uuid', {
site_uuid: siteUuid,
agent_uuid: { $ne: null },
});
if (knownAgents.length > 0) {
const knownSet = new Set(knownAgents);
const validated = list.filter(a => knownSet.has(a.uuid));
// If MongoDB cross-check yields results, use the validated list.
// On first boot (no DB data yet), fall through and use the full API list.
if (validated.length > 0) return validated;
}
}
} catch (err) {
console.warn('[Collector] fetchAgents DB cross-check failed:', err.message);
}
}
return list;
}
async function fetchBandwidthSummary(interval = 1440, agentUuid = null, siteUuid = null) {
const [dlData, ulData, flowsData] = await Promise.all([
netifyFetch('/data/stats/top/local_ip/download', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid),
netifyFetch('/data/stats/top/local_ip/upload', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid),
netifyFetch('/data/stats/top/local_ip/flow_count', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid),
]);
const bandwidth_down = dlData?.reduce((s, r) => s + (r.download ?? 0), 0) ?? 0;
const bandwidth_up = ulData?.reduce((s, r) => s + (r.upload ?? 0), 0) ?? 0;
const total_devices = dlData?.length ?? 0;
const active_flows = flowsData?.reduce((s, r) => s + (r.flows ?? r.flow_count ?? 0), 0) ?? 0;
return { bandwidth_down, bandwidth_up, total_devices, active_flows, total_threats: 0 };
}
async function fetchTopApps(interval = 1440, limit = 200, agentUuid = null, siteUuid = null) {
const [dlData, ulData] = await Promise.all([
netifyFetch('/data/stats/top/application/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid),
netifyFetch('/data/stats/top/application/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid),
]);
if (!dlData) return null;
const ulMap = {};
if (ulData) {
for (const r of ulData) {
const id = r.application?.id;
if (id) ulMap[id] = r.upload ?? 0;
}
}
return dlData.map(r => ({
application: {
id: r.application?.id ?? null,
label: r.application?.label ?? 'Unknown',
tag: r.application?.tag ?? null,
},
download: r.download ?? 0,
upload: ulMap[r.application?.id] ?? 0,
flows: r.flows ?? 0,
}));
}
async function fetchDiscoveredDevices(interval = 1440, limit = 500, agentUuid = null, siteUuid = null) {
const [dlData, ulData] = await Promise.all([
netifyFetch('/data/stats/top/local_ip/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid),
netifyFetch('/data/stats/top/local_ip/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid),
]);
if (!dlData) return null;
const ulMap = {};
if (ulData) {
for (const r of ulData) {
const ip = r.local_ip?.address ?? String(r.local_ip);
ulMap[ip] = r.upload ?? 0;
}
}
return dlData.map(r => {
const ip = r.local_ip?.address ?? String(r.local_ip ?? '');
return {
ip_address: ip,
mac_address: r.local_mac ?? null,
device_label: r.device_label ?? ip,
device_type: r.device_type ?? null,
os_label: r.os_label ?? null,
manufacturer: r.manufacturer ?? null,
download: r.download ?? 0,
upload: ulMap[ip] ?? 0,
flows: r.flows ?? 0,
last_seen: r.last_seen_at?.date ?? null,
};
}).filter(d => d.ip_address);
}
async function fetchDeviceApps(ipAddress, interval = 1440, limit = 50, agentUuid = null, siteUuid = null) {
const ipFilter = JSON.stringify([ipAddress]);
const [dlData, ulData] = await Promise.all([
netifyFetch('/data/stats/top/application/download', { filter_interval: interval, settings_limit: limit, filter_local_ips: ipFilter }, agentUuid, siteUuid),
netifyFetch('/data/stats/top/application/upload', { filter_interval: interval, settings_limit: limit, filter_local_ips: ipFilter }, agentUuid, siteUuid),
]);
if (!dlData || !Array.isArray(dlData)) return [];
const ulMap = {};
if (ulData && Array.isArray(ulData)) {
for (const r of ulData) {
const id = r.application?.id;
if (id) ulMap[id] = r.upload ?? 0;
}
}
return dlData.map(r => ({
app_label: r.application?.label ?? 'Unknown',
app_id: r.application?.id ?? null,
download: r.download ?? 0,
upload: ulMap[r.application?.id] ?? 0,
flows: r.flows ?? 0,
})).filter(a => a.download > 0 || a.upload > 0);
}
async function fetchFlows(limit = 10000, agentUuid = null, siteUuid = null) {
const [raw, sniRaw] = await Promise.all([ const [raw, sniRaw] = await Promise.all([
netifyFetch('/data/flows', { settings_limit: limit }, agentUuid, siteUuid), netifyFetch('/data/flows', { settings_limit: limit }, agentUuid, siteUuid),
netifyFetch('/data/stats/top/tls_sni/download', { filter_interval: 1440, settings_limit: 50 }, agentUuid, siteUuid), netifyFetch('/data/stats/top/tls_sni/download', { filter_interval: 1440, settings_limit: 50 }, agentUuid, siteUuid),
@@ -163,12 +17,14 @@ async function fetchFlows(limit = 10000, agentUuid = null, siteUuid = null) {
if (sniRaw && Array.isArray(sniRaw)) { if (sniRaw && Array.isArray(sniRaw)) {
for (const r of sniRaw) { for (const r of sniRaw) {
const sni = typeof r.tls_sni === 'object' ? r.tls_sni?.label : r.tls_sni; const sni = typeof r.tls_sni === 'object' ? r.tls_sni?.label : r.tls_sni;
if (sni && typeof sni === 'string' && sni.trim() !== '') sniList.push(sni.replace(/^\*\./, '').trim()); if (sni && typeof sni === 'string' && sni.trim() !== '') {
sniList.push(sni.replace(/^\*\./, '').trim());
}
} }
} }
return raw.map(r => { return raw.map(r => {
const port = r.remote_port ?? null; const port = r.remote_port ?? null;
const portService = port ? (PORT_SERVICE_MAP[port] ?? `Port ${port}`) : null; const portService = port ? (stats.PORT_SERVICE_MAP[port] ?? `Port ${port}`) : null;
const appLabel = r.application?.label || portService; const appLabel = r.application?.label || portService;
const domain = r.tls_sni || r.dns_hostname || r.hostname || null; const domain = r.tls_sni || r.dns_hostname || r.hostname || null;
return { return {
@@ -188,84 +44,17 @@ async function fetchFlows(limit = 10000, agentUuid = null, siteUuid = null) {
}).filter(f => f.src_ip); }).filter(f => f.src_ip);
} }
async function fetchCyberThreats(agentUuid = null, siteUuid = null) {
const ipRepData = await netifyFetch('/data/stats/top/remote_ip/download', { filter_interval: 1440, settings_limit: 50 }, agentUuid, siteUuid);
if (!ipRepData || !Array.isArray(ipRepData)) return [];
const SUSPICIOUS_PORTS = new Set([23, 4444, 1337, 6667, 31337, 12345, 54321, 4899, 5554, 9999]);
const threats = [];
for (const r of ipRepData) {
const ip = r.remote_ip?.address ?? null;
const port = r.remote_port ?? 0;
if (ip && SUSPICIOUS_PORTS.has(port)) {
threats.push({
threat_type: `Suspicious Port ${port}`,
severity: 'High',
src_ip: null,
dst_ip: ip,
dst_port: port,
protocol: r.ip_protocol?.label ?? null,
description: `Suspicious outbound connection to ${ip}:${port}`,
event_at: new Date().toISOString(),
});
}
}
return threats;
}
async function fetchEvents(limit = 100, agentUuid = null, siteUuid = null) {
const raw = await netifyFetch('/event/events', { settings_limit: limit }, agentUuid, siteUuid);
if (!raw || !Array.isArray(raw)) return [];
return raw.map(r => {
let msg = r.label || '';
if (r.description) {
try {
const descObj = JSON.parse(r.description);
msg = descObj.default || r.label || '';
if (descObj.tags) {
for (const k in descObj.tags) {
const tagVal = descObj.tags[k];
const val = Array.isArray(tagVal) ? (tagVal[0] === 'Unknown' && tagVal[1] ? tagVal[1] : tagVal[0]) : tagVal;
msg = msg.replace(`{{ ${k} }}`, val).replace(`{{${k}}}`, val);
}
}
} catch (e) {
msg = r.description;
}
}
let sevLabel = 'Info';
if (r.severity >= 30) sevLabel = 'Critical';
else if (r.severity >= 20) sevLabel = 'High';
else if (r.severity >= 10) sevLabel = 'Warning';
let srcIp = null;
if (r.description) {
try {
const descObj = JSON.parse(r.description);
srcIp = descObj.tags?.device_ip || descObj.tags?.ip || null;
} catch {}
}
return {
event_id: r.id || null,
event_type: r.basename || 'unknown',
severity: sevLabel,
description: msg,
category_label: r.category?.label || 'Intelligence',
ip_address: srcIp,
mac_address: r.additional?.device?.mac?.address || null,
event_at: r.created_at?.date ? new Date(r.created_at.date) : new Date()
};
});
}
module.exports = { module.exports = {
fetchAgents,
fetchBandwidthSummary,
fetchTopApps,
fetchDiscoveredDevices,
fetchDeviceApps,
fetchFlows, fetchFlows,
fetchCyberThreats, fetchAgents: stats.fetchAgents,
fetchEvents, fetchBandwidthSummary: stats.fetchBandwidthSummary,
fetchTopApps: stats.fetchTopApps,
fetchDiscoveredDevices: stats.fetchDiscoveredDevices,
fetchDeviceApps: stats.fetchDeviceApps,
fetchCyberThreats: stats.fetchCyberThreats,
fetchEvents: stats.fetchEvents,
syncApplicationDictionary: stats.syncApplicationDictionary,
BASE_URL, BASE_URL,
PORT_SERVICE_MAP, PORT_SERVICE_MAP: stats.PORT_SERVICE_MAP,
...telemetry, ...telemetry,
}; };
+238
View File
@@ -0,0 +1,238 @@
// proxy/netifyClientStats.js
// ─────────────────────────────────────────────────────────────────────────────
// Supplementary fetchers split from netifyClient.js to satisfy the 256-line limit.
// ─────────────────────────────────────────────────────────────────────────────
const { netifyFetch, agentMap } = require('./netifyClientCore');
const PORT_SERVICE_MAP = {
80: 'HTTP', 443: 'HTTPS / TLS', 8080: 'HTTP Alt', 8443: 'HTTPS Alt', 53: 'DNS', 5353: 'mDNS', 853: 'DNS-over-TLS', 25: 'SMTP', 587: 'SMTP TLS', 465: 'SMTPS', 110: 'POP3', 143: 'IMAP', 22: 'SSH', 23: 'Telnet', 3389: 'RDP', 5900: 'VNC', 21: 'FTP', 20: 'FTP Data', 989: 'FTPS', 990: 'FTPS Control', 3306: 'MySQL', 5432: 'PostgreSQL', 6379: 'Redis', 27017: 'MongoDB', 1194: 'OpenVPN', 51820: 'WireGuard', 500: 'IPSec IKE', 4500: 'IPSec NAT-T', 67: 'DHCP', 68: 'DHCP Client', 123: 'NTP', 6881: 'BitTorrent', 6882: 'BitTorrent', 6883: 'BitTorrent', 9993: 'ZeroTier VPN',
};
async function fetchAgents(siteUuid = null) {
// Use /data/stats/top/agent/download — the only working agent-listing endpoint
// in Netify Informatics API. /data/agents returns 404 (only on portal API).
// filter_interval max = 525600 min (365 days) per Netify API validation.
// Use max interval to catch ALL agents including low-traffic / inactive ones.
// DB cross-check removed — was blocking new agents from appearing.
const data = await netifyFetch('/data/stats/top/agent/download', {
filter_interval: 525600,
settings_limit: 1000000,
}, null, siteUuid);
if (!data || !Array.isArray(data)) return [];
const list = data.map(r => ({
id: r.agent?.id,
uuid: r.agent?.uuid || r.agent?.serial,
serial: r.agent?.serial,
label: r.agent?.label || r.agent?.serial,
provisioned: true,
activated: true,
last_seen_at: r.agent?.last_seen_at ?? null,
})).filter(a => a.uuid);
// Populate the agentMap (uuid → id) for downstream filter_agents usage
for (const a of list) {
if (a.uuid && a.id) agentMap[a.uuid] = a.id;
}
return list;
}
async function fetchBandwidthSummary(interval = 1440, agentUuid = null, siteUuid = null) {
const [dlData, ulData, flowsData] = await Promise.all([
netifyFetch('/data/stats/top/local_ip/download', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid),
netifyFetch('/data/stats/top/local_ip/upload', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid),
netifyFetch('/data/stats/top/local_ip/flow_count', { filter_interval: interval, settings_limit: 500 }, agentUuid, siteUuid),
]);
const bandwidth_down = dlData?.reduce((s, r) => s + (r.download ?? 0), 0) ?? 0;
const bandwidth_up = ulData?.reduce((s, r) => s + (r.upload ?? 0), 0) ?? 0;
const total_devices = dlData?.length ?? 0;
const active_flows = flowsData?.reduce((s, r) => s + (r.flows ?? r.flow_count ?? 0), 0) ?? 0;
return { bandwidth_down, bandwidth_up, total_devices, active_flows, total_threats: 0 };
}
async function fetchTopApps(interval = 1440, limit = 200, agentUuid = null, siteUuid = null) {
const [dlData, ulData] = await Promise.all([
netifyFetch('/data/stats/top/application/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid),
netifyFetch('/data/stats/top/application/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid),
]);
if (!dlData) return null;
const ulMap = {};
if (ulData) {
for (const r of ulData) {
const id = r.application?.id;
if (id) ulMap[id] = r.upload ?? 0;
}
}
return dlData.map(r => ({
application: { id: r.application?.id ?? null, label: r.application?.label ?? 'Unknown', tag: r.application?.tag ?? null },
download: r.download ?? 0, upload: ulMap[r.application?.id] ?? 0, flows: r.flows ?? 0,
}));
}
async function fetchDiscoveredDevices(interval = 1440, limit = 500, agentUuid = null, siteUuid = null) {
const [dlData, ulData] = await Promise.all([
netifyFetch('/data/stats/top/local_ip/download', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid),
netifyFetch('/data/stats/top/local_ip/upload', { filter_interval: interval, settings_limit: limit }, agentUuid, siteUuid),
]);
if (!dlData) return null;
const ulMap = {};
if (ulData) {
for (const r of ulData) {
const ip = r.local_ip?.address ?? String(r.local_ip);
ulMap[ip] = r.upload ?? 0;
}
}
return dlData.map(r => {
const ip = r.local_ip?.address ?? String(r.local_ip ?? '');
return {
ip_address: ip, mac_address: r.local_mac ?? null, device_label: r.device_label ?? ip, device_type: r.device_type ?? null,
os_label: r.os_label ?? null, manufacturer: r.manufacturer ?? null, download: r.download ?? 0, upload: ulMap[ip] ?? 0,
flows: r.flows ?? 0, last_seen: r.last_seen_at?.date ?? null,
};
}).filter(d => d.ip_address);
}
async function fetchDeviceApps(ipAddress, interval = 1440, limit = 50, agentUuid = null, siteUuid = null) {
const ipFilter = JSON.stringify([ipAddress]);
const [dlData, ulData] = await Promise.all([
netifyFetch('/data/stats/top/application/download', { filter_interval: interval, settings_limit: limit, filter_local_ips: ipFilter }, agentUuid, siteUuid),
netifyFetch('/data/stats/top/application/upload', { filter_interval: interval, settings_limit: limit, filter_local_ips: ipFilter }, agentUuid, siteUuid),
]);
if (!dlData || !Array.isArray(dlData)) return [];
const ulMap = {};
if (ulData && Array.isArray(ulData)) {
for (const r of ulData) {
const id = r.application?.id;
if (id) ulMap[id] = r.upload ?? 0;
}
}
return dlData.map(r => ({
app_label: r.application?.label ?? 'Unknown',
app_id: r.application?.id ?? null,
download: r.download ?? 0,
upload: ulMap[r.application?.id] ?? 0,
flows: r.flows ?? 0,
})).filter(a => a.download > 0 || a.upload > 0);
}
async function fetchCyberThreats(agentUuid = null, siteUuid = null) {
const ipRepData = await netifyFetch('/data/stats/top/remote_ip/download', { filter_interval: 1440, settings_limit: 50 }, agentUuid, siteUuid);
if (!ipRepData || !Array.isArray(ipRepData)) return [];
const SUSPICIOUS_PORTS = new Set([23, 4444, 1337, 6667, 31337, 12345, 54321, 4899, 5554, 9999]);
const threats = [];
for (const r of ipRepData) {
const ip = r.remote_ip?.address ?? null;
const port = r.remote_port ?? 0;
if (ip && SUSPICIOUS_PORTS.has(port)) {
threats.push({
threat_type: `Suspicious Port ${port}`,
severity: 'High',
src_ip: null,
dst_ip: ip,
dst_port: port,
protocol: r.ip_protocol?.label ?? null,
description: `Suspicious outbound connection to ${ip}:${port}`,
event_at: new Date().toISOString(),
});
}
}
return threats;
}
async function fetchEvents(limit = 100, agentUuid = null, siteUuid = null) {
const raw = await netifyFetch('/event/events', { settings_limit: limit }, agentUuid, siteUuid);
if (!raw || !Array.isArray(raw)) return [];
return raw.map(r => {
let msg = r.label || '';
if (r.description) {
try {
const descObj = JSON.parse(r.description);
msg = descObj.default || r.label || '';
if (descObj.tags) {
for (const k in descObj.tags) {
const tagVal = descObj.tags[k];
const val = Array.isArray(tagVal) ? (tagVal[0] === 'Unknown' && tagVal[1] ? tagVal[1] : tagVal[0]) : tagVal;
msg = msg.replace(`{{ ${k} }}`, val).replace(`{{${k}}}`, val);
}
}
} catch (e) {
msg = r.description;
}
}
let sevLabel = 'Info';
if (r.severity >= 30) sevLabel = 'Critical';
else if (r.severity >= 20) sevLabel = 'High';
else if (r.severity >= 10) sevLabel = 'Warning';
let srcIp = null;
if (r.description) {
try {
const descObj = JSON.parse(r.description);
srcIp = descObj.tags?.device_ip || descObj.tags?.ip || null;
} catch {}
}
return {
event_id: r.id || null,
event_type: r.basename || 'unknown',
severity: sevLabel,
description: msg,
category_label: r.category?.label || 'Intelligence',
ip_address: srcIp,
mac_address: r.additional?.device?.mac?.address || null,
event_at: r.created_at?.date ? new Date(r.created_at.date) : new Date()
};
});
}
async function syncApplicationDictionary() {
const mongoose = require('mongoose');
const { LookupApp } = require('./models/Schemas');
console.log('[Netify] Fetching application catalog...');
const allApps = await netifyFetch('/lookup/applications', { settings_limit: 5000 });
if (!allApps || !Array.isArray(allApps)) {
console.error('[Netify] Failed to fetch application dictionary.');
return;
}
console.log(`[Netify] Application catalog fetched successfully. Got ${allApps.length} apps.`);
if (allApps.length === 0) return;
console.log(`[Netify] Syncing ${allApps.length} application definitions to MongoDB...`);
await LookupApp.deleteMany({});
const batchSize = 100;
for (let i = 0; i < allApps.length; i += batchSize) {
const batch = allApps.slice(i, i + batchSize);
await LookupApp.insertMany(batch.map(app => ({
id: app.id,
name: app.name,
label: app.label,
tag: app.tag,
description: app.description,
full_name: app.full_name || app.application?.full_label || null,
favicon: app.favicon || app.application?.favicon || null,
icon: app.icon || app.application?.icon || null,
logo: app.logo || app.application?.logo || null,
application_category: {
id: app.application_category?.id,
name: app.application_category?.name,
label: app.application_category?.label,
tag: app.application_category?.tag
}
})));
}
console.log('[Netify] ✓ Application dictionary sync completed.');
}
module.exports = {
fetchAgents,
fetchBandwidthSummary,
fetchTopApps,
fetchDiscoveredDevices,
fetchDeviceApps,
fetchCyberThreats,
fetchEvents,
syncApplicationDictionary,
PORT_SERVICE_MAP
};
+6 -1
View File
@@ -4,6 +4,7 @@
const cron = require('node-cron'); const cron = require('node-cron');
const { collectAllAgents, collectSpecificAgent, collectSpecificAgents } = require('./collector'); const { collectAllAgents, collectSpecificAgent, collectSpecificAgents } = require('./collector');
const { syncApplicationDictionary } = require('./netifyClient');
// Mode: 'all' = collect all agents, 'agent' = collect one specific agent, 'agents' = collect multiple agents // Mode: 'all' = collect all agents, 'agent' = collect one specific agent, 'agents' = collect multiple agents
const COLLECT_MODE = process.env.PROXY_COLLECT_MODE || 'all'; const COLLECT_MODE = process.env.PROXY_COLLECT_MODE || 'all';
@@ -97,7 +98,11 @@ function startScheduler() {
// Initial run immediately on startup (async, do not block server start) // Initial run immediately on startup (async, do not block server start)
setTimeout(async () => { setTimeout(async () => {
// 1. Sync dictionary first // 1. Sync dictionary first
// await netifyClient.syncApplicationDictionary(); try {
await syncApplicationDictionary();
} catch (err) {
console.error('[Scheduler] Error syncing application dictionary:', err.message);
}
// 2. Start normal telemetry collection // 2. Start normal telemetry collection
runCollection().catch(err => console.error('[Scheduler] Initial run error:', err.message)); runCollection().catch(err => console.error('[Scheduler] Initial run error:', err.message));
Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.
+5 -2
View File
@@ -148,7 +148,9 @@ async function uploadDirectory(sftp, localDir, remoteDir, options = {}) {
if (item.name === '.env.production' || item.name === '.env.local') continue; if (item.name === '.env.production' || item.name === '.env.local') continue;
const localItemPath = path.join(localPath, item.name); const localItemPath = path.join(localPath, item.name);
const remoteItemPath = path.posix.join(CONFIG.remotePath, remoteDir, item.name); // Force forward slashes for remote Linux path compatibility
const remoteDirNormalized = remoteDir.replace(/\\/g, '/');
const remoteItemPath = path.posix.join(CONFIG.remotePath, remoteDirNormalized, item.name);
if (item.isDirectory()) { if (item.isDirectory()) {
try { try {
@@ -158,7 +160,8 @@ async function uploadDirectory(sftp, localDir, remoteDir, options = {}) {
} }
await uploadDirectory(sftp, path.join(localDir, item.name), path.join(remoteDir, item.name), options); await uploadDirectory(sftp, path.join(localDir, item.name), path.join(remoteDir, item.name), options);
} else { } else {
process.stdout.write(` [UP] ${path.join(remoteDir, item.name)} ... `); const displayPath = path.posix.join(remoteDirNormalized, item.name);
process.stdout.write(` [UP] ${displayPath} ... `);
await sftp.put(localItemPath, remoteItemPath); await sftp.put(localItemPath, remoteItemPath);
process.stdout.write('done\n'); process.stdout.write('done\n');
} }
+30
View File
@@ -1,7 +1,36 @@
const net = require('net');
const { MongoMemoryServer } = require('mongodb-memory-server'); const { MongoMemoryServer } = require('mongodb-memory-server');
function checkPort(port, host) {
return new Promise((resolve) => {
const socket = new net.Socket();
socket.setTimeout(1000);
socket.on('connect', () => {
socket.destroy();
resolve(true);
});
socket.on('timeout', () => {
socket.destroy();
resolve(false);
});
socket.on('error', () => {
socket.destroy();
resolve(false);
});
socket.connect(port, host);
});
}
async function start() { async function start() {
try { try {
const isLocalMongoRunning = await checkPort(27017, '127.0.0.1') || await checkPort(27017, 'localhost');
if (isLocalMongoRunning) {
console.log('[MONGO] ℹ️ Local persistent MongoDB is already running on port 27017. Skipping in-memory MongoDB launch.');
// Keep process alive so concurrently doesn't show it as exited/failed
setInterval(() => {}, 100000);
return;
}
const mongod = await MongoMemoryServer.create({ const mongod = await MongoMemoryServer.create({
instance: { instance: {
port: 27017, port: 27017,
@@ -20,6 +49,7 @@ async function start() {
} catch (err) { } catch (err) {
if (err.code === 'EADDRINUSE' || (err.message && err.message.includes('EADDRINUSE'))) { if (err.code === 'EADDRINUSE' || (err.message && err.message.includes('EADDRINUSE'))) {
console.log('[MONGO] ℹ️ Port 27017 is already in use. Skipping in-memory MongoDB launch.'); console.log('[MONGO] ℹ️ Port 27017 is already in use. Skipping in-memory MongoDB launch.');
setInterval(() => {}, 100000);
} else { } else {
console.error('[MONGO] ❌ Error starting MongoMemoryServer:', err.message); console.error('[MONGO] ❌ Error starting MongoMemoryServer:', err.message);
} }
@@ -3,7 +3,7 @@
import { AgentModals } from "@/components/admin/AgentModals"; import { AgentModals } from "@/components/admin/AgentModals";
import ExternalAccountModal from "@/components/admin/ExternalAccountModal"; import ExternalAccountModal from "@/components/admin/ExternalAccountModal";
import AgentLocationModal from "@/components/admin/AgentLocationModal"; import AgentLocationModal from "@/components/admin/AgentLocationModal";
import { Agent } from "@/lib/actions/agents"; import { Agent } from "@/lib/actions/agentsCore";
import { type ManagedUser } from "@/lib/admin-api"; import { type ManagedUser } from "@/lib/admin-api";
interface AgentsPageModalsProps { interface AgentsPageModalsProps {
@@ -2,24 +2,37 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { DataTable, Column } from "@/components/ui/DataTable"; import { DataTable, Column } from "@/components/ui/DataTable";
import { Loader2 } from "lucide-react"; import { Loader2, Plus } from "lucide-react";
import { Agent } from "@/lib/actions/agents"; import { Agent } from "@/lib/actions/agentsCore";
interface AgentsTableSectionProps { interface AgentsTableSectionProps {
agents: Agent[]; agents: Agent[];
isLoading: boolean; isLoading: boolean;
columns: Column<Agent>[]; columns: Column<Agent>[];
role: string | null;
onProvisionClick?: () => void;
} }
export default function AgentsTableSection({ export default function AgentsTableSection({
agents, agents,
isLoading, isLoading,
columns, columns,
role,
onProvisionClick,
}: AgentsTableSectionProps) { }: AgentsTableSectionProps) {
return ( return (
<Card className="bg-card/50 backdrop-blur-sm border-border/50"> <Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-white">Provisioned Network Agents ({agents.length})</CardTitle> <CardTitle className="text-white">Provisioned Network Agents ({agents.length})</CardTitle>
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && onProvisionClick && (
<button
onClick={onProvisionClick}
className="flex items-center gap-2 bg-primary hover:bg-primary/95 text-primary-foreground px-4 py-2 rounded-xl text-sm font-semibold transition-all shadow-lg shadow-blue-500/10"
>
<Plus className="w-4 h-4" />
Provision Agent
</button>
)}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
@@ -21,29 +21,29 @@ export default function UptimeStatsCards({
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity"> <div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
<Server className="w-12 h-12 text-blue-400" /> <Server className="w-12 h-12 text-blue-400" />
</div> </div>
<span className="text-slate-400 uppercase tracking-wider mb-1">Total Agents</span> <span className="text-slate-400 uppercase tracking-wider mb-1 text-[10px] font-medium">Total Agents</span>
<span className="text-2xl font-black text-white tracking-tight">{totalAgents}</span> <span className="text-2xl font-bold text-white tracking-tight">{totalAgents}</span>
</div> </div>
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group"> <div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity"> <div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
<CheckCircle2 className="w-12 h-12 text-emerald-400" /> <CheckCircle2 className="w-12 h-12 text-emerald-400" />
</div> </div>
<span className="text-slate-400 uppercase tracking-wider mb-1">Online Agents</span> <span className="text-slate-400 uppercase tracking-wider mb-1 text-[10px] font-medium">Online Agents</span>
<span className="text-2xl font-black text-emerald-400 tracking-tight">{onlineAgents}</span> <span className="text-2xl font-bold text-emerald-400 tracking-tight">{onlineAgents}</span>
</div> </div>
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group"> <div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity"> <div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
<XCircle className="w-12 h-12 text-red-400" /> <XCircle className="w-12 h-12 text-red-400" />
</div> </div>
<span className="text-slate-400 uppercase tracking-wider mb-1">Offline Agents</span> <span className="text-slate-400 uppercase tracking-wider mb-1 text-[10px] font-medium">Offline Agents</span>
<span className="text-2xl font-black text-red-400 tracking-tight">{offlineAgents}</span> <span className="text-2xl font-bold text-red-400 tracking-tight">{offlineAgents}</span>
</div> </div>
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group"> <div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity"> <div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
<Activity className="w-12 h-12 text-cyan-400" /> <Activity className="w-12 h-12 text-cyan-400" />
</div> </div>
<span className="text-slate-400 uppercase tracking-wider mb-1">Avg. Historical Uptime</span> <span className="text-slate-400 uppercase tracking-wider mb-1 text-[10px] font-medium">Avg. Historical Uptime</span>
<span className="text-2xl font-black text-cyan-400 tracking-tight">{avgUptime.toFixed(2)}%</span> <span className="text-2xl font-bold text-cyan-400 tracking-tight">{avgUptime.toFixed(2)}%</span>
</div> </div>
</div> </div>
); );
+58 -97
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { Column } from "@/components/ui/DataTable"; import { Column } from "@/components/ui/DataTable";
import { Agent } from "@/lib/actions/agents"; import { Agent } from "@/lib/actions/agentsCore";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { resolveAgentLabel, type ManagedUser } from "@/lib/admin-api"; import { resolveAgentLabel, type ManagedUser } from "@/lib/admin-api";
import { Loader2, Trash2, CheckCircle2, XCircle, ChevronRight, UserCog, Eye } from "lucide-react"; import { Loader2, Trash2, CheckCircle2, XCircle, ChevronRight, UserCog, Eye, MapPin } from "lucide-react";
interface GetColumnsProps { interface GetColumnsProps {
role: string | null; role: string | null;
@@ -17,6 +17,9 @@ interface GetColumnsProps {
setAccountModalAgent: (agent: { uuid: string; label: string } | null) => void; setAccountModalAgent: (agent: { uuid: string; label: string } | null) => void;
handleViewAs: (uuid: string, label: string) => void; handleViewAs: (uuid: string, label: string) => void;
openDelete: (agent: Agent) => void; openDelete: (agent: Agent) => void;
handleEditLocation?: (agentUuid: string, agentLabel: string) => void;
/** Set of agent UUIDs that already have a location saved */
locationUuids?: Set<string>;
} }
export function getAgentColumns({ export function getAgentColumns({
@@ -30,11 +33,13 @@ export function getAgentColumns({
setAccountModalAgent, setAccountModalAgent,
handleViewAs, handleViewAs,
openDelete, openDelete,
handleEditLocation,
locationUuids = new Set(),
}: GetColumnsProps): Column<Agent>[] { }: GetColumnsProps): Column<Agent>[] {
const cols: Column<Agent>[] = [ const cols: Column<Agent>[] = [
{ {
header: "UUID / Serial", header: "UUID / Serial",
className: "w-[16%] text-center", className: "w-[12%] text-center",
accessor: (row) => ( accessor: (row) => (
<button onClick={() => setDetailAgent(row)} className="flex items-center justify-center gap-2 group text-center mx-auto" title="Click to view agent details"> <button onClick={() => setDetailAgent(row)} className="flex items-center justify-center gap-2 group text-center mx-auto" title="Click to view agent details">
<span className="font-mono text-xs text-blue-400 group-hover:text-blue-300 transition-colors">{row.uuid || row.serial}</span> <span className="font-mono text-xs text-blue-400 group-hover:text-blue-300 transition-colors">{row.uuid || row.serial}</span>
@@ -44,14 +49,19 @@ export function getAgentColumns({
}, },
{ {
header: "Label", header: "Label",
className: "w-[24%] text-center", className: "w-[15%] text-center",
accessor: (row) => { accessor: (row) => {
const uuid = row.uuid || row.serial; const uuid = row.uuid || row.serial;
const dynamicLabel = resolveAgentLabel(uuid, managedUsers); const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
const hasAccount = managedUsers.some(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER'); const hasAccount = managedUsers.some(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER');
const displayLabel = dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid);
return ( return (
<div className="flex items-center justify-center gap-2 mx-auto max-w-full"> <div className="flex items-center justify-center gap-2 mx-auto max-w-full">
<button onClick={() => setDetailAgent(row)} className="text-center hover:text-white transition-colors truncate"> <button
onClick={() => setDetailAgent(row)}
className="text-center hover:text-white transition-colors truncate"
title={displayLabel}
>
{dynamicLabel !== uuid ? dynamicLabel : (row.label || <span className="text-muted-foreground italic font-mono text-xs">{uuid}</span>)} {dynamicLabel !== uuid ? dynamicLabel : (row.label || <span className="text-muted-foreground italic font-mono text-xs">{uuid}</span>)}
</button> </button>
{!hasAccount && (role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && ( {!hasAccount && (role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
@@ -63,71 +73,93 @@ export function getAgentColumns({
}, },
{ {
header: "Provisioned", header: "Provisioned",
className: "w-[12%] text-center", className: "w-[11%] text-center",
accessor: (row) => row.provisioned ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge> accessor: (row) => row.provisioned ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge>
}, },
{ {
header: "Activated", header: "Activated",
className: "w-[12%] text-center", className: "w-[11%] text-center",
accessor: (row) => row.activated ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge> accessor: (row) => row.activated ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge>
}, },
{ {
header: "Status", header: "Traffic Status",
className: "w-[11%] text-center", className: "w-[15%] text-center",
accessor: (row) => { accessor: (row) => {
const statusText = row.last_seen_at?.human || '-'; const statusText = row.last_seen_at?.human || '-';
if (statusText === 'Online') { if (statusText.startsWith('Last seen:')) {
return <span className="text-emerald-400 font-medium flex items-center justify-center gap-1.5"><div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div> Online</span>; return (
} else if (statusText === 'Offline') { <span className="text-emerald-400 font-medium flex items-center justify-center gap-1.5 mx-auto">
return <span className="text-red-400 font-medium">Offline</span>; <div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div>
{statusText}
</span>
);
} else { } else {
return <span className="text-slate-400 text-xs">{statusText}</span>; return <span className="text-slate-400 text-xs font-normal">{statusText}</span>;
} }
} }
}, },
{ {
header: "Historical Uptime", header: "Avg Uptime",
className: "w-[12%] text-center", className: "w-[10%] text-center",
accessor: (row) => { accessor: (row) => {
const uuid = row.uuid || row.serial; const uuid = row.uuid || row.serial;
const uptime = uptimeMap[uuid] ?? 100; const uptime = uptimeMap[uuid] ?? 100;
let color = "text-emerald-400"; let color = "text-emerald-400";
if (uptime < 90) color = "text-red-400"; if (uptime < 90) color = "text-red-400";
else if (uptime < 98) color = "text-amber-400"; else if (uptime < 98) color = "text-amber-400";
return <span className={`font-mono font-bold ${color}`}>{uptime.toFixed(1)}%</span>; return <span className={`font-mono font-medium text-xs tracking-wide ${color}`}>{uptime.toFixed(1)}%</span>;
} }
}, },
{ {
header: "Actions", header: "Actions",
className: "w-[13%] text-center", className: "w-[16%] text-center",
accessor: (row) => { accessor: (row) => {
const uuid = row.uuid || row.serial; const uuid = row.uuid || row.serial;
const dynamicLabel = resolveAgentLabel(uuid, managedUsers); const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
const existingUser = managedUsers.find(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER') || null; const existingUser = managedUsers.find(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER') || null;
const isViewAsLoading = viewAsLoading === uuid; const isViewAsLoading = viewAsLoading === uuid;
return ( return (
<div className="flex items-center justify-center gap-1.5"> <div className="flex items-center justify-center gap-1 mx-auto">
<button onClick={() => setDetailAgent(row)} className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors" title="Detail Agent"> <button onClick={() => setDetailAgent(row)} className="p-1 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors" title="Detail Agent">
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && ( {(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
<> <>
<button <button
onClick={(e) => { e.stopPropagation(); setAccountModalAgent({ uuid, label: dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid) }); setAccountModalOpen(true); }} onClick={(e) => { e.stopPropagation(); setAccountModalAgent({ uuid, label: dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid) }); setAccountModalOpen(true); }}
className="p-1.5 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-950/30 rounded transition-colors" className="p-1 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-950/30 rounded transition-colors"
title={existingUser ? 'Edit Agent Account' : 'Create Agent Account'} title={existingUser ? 'Edit Agent Account' : 'Create Agent Account'}
> >
<UserCog className="w-4 h-4" /> <UserCog className="w-4 h-4" />
</button> </button>
{handleEditLocation && (() => {
const hasLocation = locationUuids.has(uuid);
return (
<button
onClick={(e) => { e.stopPropagation(); handleEditLocation(uuid, dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid)); }}
className={`relative p-1 rounded transition-colors ${
hasLocation
? "text-teal-400 hover:text-teal-300 hover:bg-teal-950/30"
: "text-slate-500 hover:text-indigo-400 hover:bg-indigo-950/30"
}`}
title={hasLocation ? "✅ Location set — click to update" : "⚠️ No location — click to set coordinates"}
>
<MapPin className="w-4 h-4" />
{hasLocation && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-teal-400 border border-slate-900" />
)}
</button>
);
})()}
<button <button
onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid)); }} onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid)); }}
disabled={isViewAsLoading} disabled={isViewAsLoading}
className="p-1.5 text-amber-400 hover:text-amber-300 hover:bg-amber-950/30 rounded transition-colors disabled:opacity-50" className="p-1 text-amber-400 hover:text-amber-300 hover:bg-amber-950/30 rounded transition-colors disabled:opacity-50"
title="View As Agent" title="View As Agent"
> >
{isViewAsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />} {isViewAsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
</button> </button>
<button onClick={(e) => { e.stopPropagation(); openDelete(row); }} className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors" title="Delete"> <button onClick={(e) => { e.stopPropagation(); openDelete(row); }} className="p-1 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors" title="Delete">
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
</> </>
@@ -141,11 +173,11 @@ export function getAgentColumns({
if (role === 'SUPER_ADMIN') { if (role === 'SUPER_ADMIN') {
cols.splice(cols.length - 1, 0, { cols.splice(cols.length - 1, 0, {
header: "Data Size", header: "Data Size",
className: "w-[12%] text-center", className: "w-[10%] text-center",
accessor: (row) => { accessor: (row) => {
const uuid = row.uuid || row.serial; const uuid = row.uuid || row.serial;
const sizeMB = storageMap[uuid] ?? 0; const sizeMB = storageMap[uuid] ?? 0;
return <span className="font-mono text-slate-300 font-semibold">{sizeMB.toFixed(2)} MB</span>; return <span className="font-mono text-xs font-normal text-slate-300 tracking-wide">{sizeMB.toFixed(2)} MB</span>;
} }
}); });
} }
@@ -153,75 +185,4 @@ export function getAgentColumns({
return cols; return cols;
} }
export function getExternalAccountColumns(
onManageAccount: (user: ManagedUser) => void
): Column<ManagedUser>[] {
return [
{
header: "Account Name",
className: "w-[25%] text-left",
accessor: (row) => (
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center border border-slate-700 shrink-0 overflow-hidden">
{row.profile_picture ? (
<img src={`/api/uploads/${row.profile_picture}`} alt={row.account_name || row.username} className="w-full h-full object-cover" />
) : (
<span className="text-xs font-bold text-slate-400">{(row.account_name || row.username).substring(0, 2).toUpperCase()}</span>
)}
</div>
<span className="font-medium text-white">{row.account_name || "-"}</span>
</div>
)
},
{
header: "Username",
className: "w-[25%] text-left",
accessor: (row) => <span className="text-slate-300 font-mono text-sm">{row.username}</span>
},
{
header: "Role",
className: "w-[20%] text-center",
accessor: (row) => {
let badgeColor = "bg-slate-500/20 text-slate-400 border-slate-500/30";
if (row.role === 'SUPER_ADMIN') badgeColor = "bg-purple-500/20 text-purple-400 border-purple-500/30";
if (row.role === 'SOC_ANALYST') badgeColor = "bg-amber-500/20 text-amber-400 border-amber-500/30";
if (row.role === 'ENGINEER') badgeColor = "bg-blue-500/20 text-blue-400 border-blue-500/30";
if (row.role === 'TENANT_ADMIN') badgeColor = "bg-emerald-500/20 text-emerald-400 border-emerald-500/30";
return (
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold border ${badgeColor}`}>
{row.role.replace('_', ' ')}
</span>
);
}
},
{
header: "Status",
className: "w-[15%] text-center",
accessor: (row) => (
<div className="flex items-center justify-center gap-2">
{/* Defaulting to active since we don't have is_active explicitly in ManagedUser interface yet, though API returns it. We'll check if it exists, else assume Active */}
{(row as any).is_active !== false ? (
<Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Active</Badge>
) : (
<Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> Inactive</Badge>
)}
</div>
)
},
{
header: "Actions",
className: "w-[15%] text-center",
accessor: (row) => (
<button
onClick={() => onManageAccount(row)}
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors disabled:opacity-50"
title="Manage Account"
disabled={row.role === 'SUPER_ADMIN'}
>
<UserCog className="w-4 h-4" />
</button>
)
}
];
}
+171 -267
View File
@@ -1,152 +1,65 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { Plus, Loader2, MapPin } from "lucide-react";
import { getAgents, Agent } from "@/lib/actions/agents";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { DataTable } from "@/components/ui/DataTable";
import { Cpu, History, Radio, Server, Activity, Plus, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { getAdminUsers, startViewAs, resolveAgentLabel, getViewAsHeaders, type ManagedUser } from "@/lib/admin-api";
import { useTimeFilter } from "@/contexts/TimeFilterContext";
import { usePollingKey } from "@/lib/usePolling";
import { AgentModals } from "@/components/admin/AgentModals";
import ExternalAccountModal from "@/components/admin/ExternalAccountModal";
import { getAgentColumns, getExternalAccountColumns } from "./columns";
import { HelpTrigger } from "@/components/help/HelpTrigger"; import { HelpTrigger } from "@/components/help/HelpTrigger";
import { useAgentsData } from "./useAgentsData";
import { getAgentColumns } from "./columns";
import { getExternalAccountColumns } from "./externalColumns";
import UptimeStatsCards from "./UptimeStatsCards";
import AgentsTableSection from "./AgentsTableSection";
import ExternalAccountsSection from "./ExternalAccountsSection";
import AgentsPageModals from "./AgentsPageModals";
import AgentLocationMap from "@/components/admin/AgentLocationMap";
import { resolveAgentLabel } from "@/lib/admin-api";
export default function AgentsPage() { export default function AgentsPage() {
const router = useRouter();
const [role, setRole] = useState<string | null>(null);
const [agents, setAgents] = useState<Agent[]>([]);
const [uptimeMap, setUptimeMap] = useState<Record<string, number>>({});
const [storageMap, setStorageMap] = useState<Record<string, number>>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [detailAgent, setDetailAgent] = useState<Agent | null>(null);
const [managedUsers, setManagedUsers] = useState<ManagedUser[]>([]);
const [accountModalOpen, setAccountModalOpen] = useState(false);
const [accountModalAgent, setAccountModalAgent] = useState<{ uuid: string; label: string } | null>(null);
const [externalModalOpen, setExternalModalOpen] = useState(false);
const [externalModalUser, setExternalModalUser] = useState<ManagedUser | null>(null);
const [viewAsLoading, setViewAsLoading] = useState<string | null>(null);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
const { timeRange } = useTimeFilter();
const refreshKey = usePollingKey(60000); // Auto-refresh every 60 seconds (1 minute)
const loadUptime = async () => {
try {
const res = await fetch(`/api/dashboard/agents/uptime?timeRange=${timeRange}`, {
headers: getViewAsHeaders()
});
if (res.ok) {
const json = await res.json();
if (json.ok && json.uptime) setUptimeMap(json.uptime);
}
} catch (err) {
console.error("Failed to load uptime stats:", err);
}
};
const loadStorage = async () => {
try {
const res = await fetch('/api/dashboard/agents/storage', {
headers: getViewAsHeaders()
});
if (res.ok) {
const json = await res.json();
if (json.ok && json.storage) setStorageMap(json.storage);
}
} catch (err) {
console.error("Failed to load agent storage stats:", err);
}
};
const loadAgents = async () => {
setIsLoading(true);
try {
const siteUuid = localStorage.getItem("backone_site_uuid") || undefined;
const [agentData, userData] = await Promise.allSettled([
getAgents(siteUuid),
getAdminUsers(),
]);
if (agentData.status === 'fulfilled') setAgents(agentData.value);
else setError((agentData.reason as Error).message);
if (userData.status === 'fulfilled') setManagedUsers(userData.value);
await loadUptime();
} catch (e: any) {
setError(e.message);
}
setIsLoading(false);
};
const handleViewAs = async (agentUuid: string, agentLabel: string) => {
setViewAsLoading(agentUuid);
try {
await startViewAs(agentUuid, agentLabel);
router.refresh();
window.location.reload();
} catch (err: any) {
alert('Failed to enter View As mode: ' + err.message);
} finally {
setViewAsLoading(null);
}
};
useEffect(() => { useEffect(() => {
fetch('/api/auth/me', { headers: getViewAsHeaders() }) document.title = "Agents | BackOne - Deep Package Inspection";
.then(res => res.json()) }, []);
.then(data => { const {
if (data.user) { role,
setRole(data.user.role || null); agents,
if (data.user.role === 'AGENT_VIEWER') { uptimeMap,
router.push('/'); storageMap,
} else { isLoading,
loadAgents(); error,
} isCreateOpen,
} else { setIsCreateOpen,
setIsLoading(false); isDeleteOpen,
} setIsDeleteOpen,
}) detailAgent,
.catch(err => { setDetailAgent,
console.error("Auth check failed:", err); managedUsers,
setIsLoading(false); accountModalOpen,
}); setAccountModalOpen,
}, [router]); accountModalAgent,
setAccountModalAgent,
useEffect(() => { externalModalOpen,
if (role && role !== 'AGENT_VIEWER') loadUptime(); setExternalModalOpen,
}, [timeRange]); externalModalUser,
setExternalModalUser,
useEffect(() => { targetSiteUuid,
if (role === 'SUPER_ADMIN') { setTargetSiteUuid,
loadStorage(); targetCreatedBy,
} setTargetCreatedBy,
}, [role]); locations,
isLocModalOpen,
useEffect(() => { setIsLocModalOpen,
if (role && role !== 'AGENT_VIEWER' && refreshKey > 0) { locModalAgentUuid,
const siteUuid = localStorage.getItem("backone_site_uuid") || undefined; setLocModalAgentUuid,
getAgents(siteUuid) locModalAgentLabel,
.then(data => setAgents(data)) setLocModalAgentLabel,
.catch(err => console.warn("Failed to auto-refresh agents:", err)); locModalCurrentLoc,
loadUptime(); setLocModalCurrentLoc,
if (role === 'SUPER_ADMIN') { viewAsLoading,
loadStorage(); selectedAgent,
} loadAgents,
} loadLocations,
}, [refreshKey, role]); handleViewAs,
openDelete,
const openDelete = (agent: Agent) => { handleEditLocation,
setSelectedAgent(agent); } = useAgentsData();
setIsDeleteOpen(true);
};
const columns = getAgentColumns({ const columns = getAgentColumns({
role, role,
@@ -159,42 +72,40 @@ export default function AgentsPage() {
setAccountModalAgent, setAccountModalAgent,
handleViewAs, handleViewAs,
openDelete, openDelete,
handleEditLocation,
locationUuids: new Set(locations.map((l: any) => l.agent_uuid)),
}); });
const totalAgents = agents.length;
const onlineAgents = agents.filter(a => a.last_seen_at?.human === 'Online').length;
const offlineAgents = totalAgents - onlineAgents;
const uptimeValues = agents.map(a => uptimeMap[a.uuid || a.serial] ?? 100);
const avgUptime = uptimeValues.length > 0 ? uptimeValues.reduce((s, v) => s + v, 0) / uptimeValues.length : 100;
const externalUsers = managedUsers.filter(u => u.role !== 'AGENT_VIEWER');
const externalColumns = getExternalAccountColumns((user) => { const externalColumns = getExternalAccountColumns((user) => {
setExternalModalUser(user); setExternalModalUser(user);
setExternalModalOpen(true); setExternalModalOpen(true);
}); });
const totalAgents = agents.length;
const onlineAgents = agents.filter(a => {
const hum = a.last_seen_at?.human;
return hum === 'Online' || hum === 'Flows Detected' || (hum && hum.startsWith('Last seen:'));
}).length;
const offlineAgents = totalAgents - onlineAgents;
const uptimeValues = agents.map(a => uptimeMap[a.uuid || a.serial] ?? 100);
const avgUptime = uptimeValues.length > 0 ? uptimeValues.reduce((s, v) => s + v, 0) / uptimeValues.length : 100;
const externalUsers = managedUsers.filter(u => u.role !== 'AGENT_VIEWER');
const superadminUsers = externalUsers.filter(u => u.role === 'SUPER_ADMIN' || !u.site_uuid);
const siabUsers = externalUsers.filter(u => u.site_uuid === '6681452d_9cae_4ff4_8ae8_0d504774265e');
const nexusUsers = externalUsers.filter(u => u.site_uuid === 'd7902405_0dc2_458b_8584_ed4d24b64f24');
return ( return (
<div className="space-y-6"> <div className="space-y-6" style={{ fontFamily: "Georgia, serif" }}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> <h2 className="text-2xl font-semibold tracking-tight text-white flex items-center gap-3">
Agents Inventory Agents Inventory
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />} {isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
</h2> </h2>
<p className="text-muted-foreground mt-1">Managed DPI probes, remote collectors, and probe infrastructure.</p> <p className="text-sm text-muted-foreground mt-1">Managed DPI probes, remote collectors, and probe infrastructure.</p>
</div>
<div className="flex items-center gap-3">
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
<button
onClick={() => setIsCreateOpen(true)}
className="flex items-center gap-2 bg-primary hover:bg-primary/95 text-primary-foreground px-4 py-2 rounded-xl text-sm font-semibold transition-all shadow-lg shadow-blue-500/10"
>
<Plus className="w-4 h-4" />
Provision Agent
</button>
)}
<HelpTrigger pageId="agents" />
</div> </div>
<HelpTrigger pageId="agents" />
</div> </div>
{error && ( {error && (
@@ -203,111 +114,94 @@ export default function AgentsPage() {
</div> </div>
)} )}
{/* Historical Uptime Status Cards Grid */} <UptimeStatsCards
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-xs font-semibold"> totalAgents={totalAgents}
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group"> onlineAgents={onlineAgents}
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity"> offlineAgents={offlineAgents}
<Server className="w-12 h-12 text-blue-400" /> avgUptime={avgUptime}
</div> />
<span className="text-slate-400 uppercase tracking-wider mb-1">Total Agents</span>
<span className="text-2xl font-black text-white tracking-tight">{totalAgents}</span>
</div>
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
<CheckCircle2 className="w-12 h-12 text-emerald-400" />
</div>
<span className="text-slate-400 uppercase tracking-wider mb-1">Online Agents</span>
<span className="text-2xl font-black text-emerald-400 tracking-tight">{onlineAgents}</span>
</div>
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
<XCircle className="w-12 h-12 text-red-400" />
</div>
<span className="text-slate-400 uppercase tracking-wider mb-1">Offline Agents</span>
<span className="text-2xl font-black text-red-400 tracking-tight">{offlineAgents}</span>
</div>
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
<Activity className="w-12 h-12 text-cyan-400" />
</div>
<span className="text-slate-400 uppercase tracking-wider mb-1">Avg. Historical Uptime</span>
<span className="text-2xl font-black text-cyan-400 tracking-tight">{avgUptime.toFixed(2)}%</span>
</div>
</div>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle className="text-white">Provisioned Network Agents ({agents.length})</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : (
<DataTable
data={agents}
columns={columns}
searchPlaceholder="Search agents..."
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (
(row.uuid || "").toLowerCase().includes(q) ||
(row.serial || "").toLowerCase().includes(q) ||
(row.label || "").toLowerCase().includes(q)
);
}}
/>
)}
</CardContent>
</Card>
{/* Agent Location Map */}
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && ( {(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
<Card className="bg-card/50 backdrop-blur-sm border-border/50"> <div className="rounded-2xl border border-white/8 bg-slate-900/40 overflow-hidden relative z-0">
<CardHeader className="flex flex-row items-center justify-between pb-2"> <div className="flex items-center justify-between px-5 py-3.5 border-b border-white/8">
<CardTitle className="text-white">External Accounts Inventory ({externalUsers.length})</CardTitle> <div className="flex items-center gap-2.5">
<button <MapPin className="w-4 h-4 text-indigo-400" />
onClick={() => { <span className="text-sm font-semibold text-white">Agent Network Map</span>
setExternalModalUser(null); // Mode Create <span className="text-xs text-slate-500">
setExternalModalOpen(true); {locations.length > 0
}} ? `${locations.length} of ${agents.length} agent${agents.length !== 1 ? 's' : ''} positioned`
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors" : 'No positions configured yet'}
> </span>
<Plus className="w-3.5 h-3.5" /> </div>
Add External Account {agents.length > locations.length && (
</button> <span className="text-[11px] text-amber-400 bg-amber-400/10 border border-amber-400/20 px-2.5 py-1 rounded-full">
</CardHeader> {agents.length - locations.length} agent{agents.length - locations.length !== 1 ? 's' : ''} without location — click 📍 in the table
<CardContent> </span>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : (
<DataTable
data={externalUsers}
columns={externalColumns}
searchPlaceholder="Search accounts..."
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (
(row.username || "").toLowerCase().includes(q) ||
(row.account_name || "").toLowerCase().includes(q) ||
(row.role || "").toLowerCase().includes(q)
);
}}
/>
)} )}
</CardContent> </div>
</Card> <div className="p-3">
<AgentLocationMap
pins={locations
.filter(loc => agents.some(a => (a.uuid || a.serial) === loc.agent_uuid))
.map(loc => {
const agent = agents.find(a => (a.uuid || a.serial) === loc.agent_uuid);
const agentLabel = agent
? (resolveAgentLabel(loc.agent_uuid, managedUsers) !== loc.agent_uuid
? resolveAgentLabel(loc.agent_uuid, managedUsers)
: (agent.label || loc.label || loc.agent_uuid))
: (loc.label || loc.agent_uuid);
const isOnline = !!(agent?.last_seen_at?.human === 'Online' ||
agent?.last_seen_at?.human === 'Flows Detected' ||
(agent?.last_seen_at?.human && agent.last_seen_at.human.startsWith('Last seen:')));
const uptime = uptimeMap[loc.agent_uuid];
return {
agent_uuid: loc.agent_uuid,
label: agentLabel,
latitude: loc.latitude,
longitude: loc.longitude,
isOnline,
uptimePercent: uptime,
};
})}
onEditLocation={handleEditLocation}
canEdit={role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN'}
/>
</div>
</div>
)} )}
<AgentModals <AgentsTableSection
agents={agents}
isLoading={isLoading}
columns={columns}
role={role}
onProvisionClick={() => setIsCreateOpen(true)}
/>
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
<ExternalAccountsSection
role={role}
isLoading={isLoading}
externalUsers={externalUsers}
superadminUsers={superadminUsers}
siabUsers={siabUsers}
nexusUsers={nexusUsers}
externalColumns={externalColumns}
setExternalModalUser={setExternalModalUser}
setTargetSiteUuid={setTargetSiteUuid}
setTargetCreatedBy={setTargetCreatedBy}
setExternalModalOpen={setExternalModalOpen}
/>
)}
<AgentsPageModals
isCreateOpen={isCreateOpen} isCreateOpen={isCreateOpen}
setIsCreateOpen={setIsCreateOpen} setIsCreateOpen={setIsCreateOpen}
isDeleteOpen={isDeleteOpen} isDeleteOpen={isDeleteOpen}
setIsDeleteOpen={setIsDeleteOpen} setIsDeleteOpen={setIsDeleteOpen}
selectedAgent={selectedAgent} selectedAgent={selectedAgent}
onSuccess={loadAgents} loadAgents={loadAgents}
detailAgent={detailAgent} detailAgent={detailAgent}
setDetailAgent={setDetailAgent} setDetailAgent={setDetailAgent}
accountModalOpen={accountModalOpen} accountModalOpen={accountModalOpen}
@@ -315,13 +209,23 @@ export default function AgentsPage() {
accountModalAgent={accountModalAgent} accountModalAgent={accountModalAgent}
setAccountModalAgent={setAccountModalAgent} setAccountModalAgent={setAccountModalAgent}
managedUsers={managedUsers} managedUsers={managedUsers}
/> externalModalOpen={externalModalOpen}
setExternalModalOpen={setExternalModalOpen}
<ExternalAccountModal externalModalUser={externalModalUser}
isOpen={externalModalOpen} setExternalModalUser={setExternalModalUser}
onClose={() => { setExternalModalOpen(false); setExternalModalUser(null); }} targetSiteUuid={targetSiteUuid}
onSuccess={loadAgents} setTargetSiteUuid={setTargetSiteUuid}
user={externalModalUser} targetCreatedBy={targetCreatedBy}
setTargetCreatedBy={setTargetCreatedBy}
isLocModalOpen={isLocModalOpen}
setIsLocModalOpen={setIsLocModalOpen}
locModalAgentUuid={locModalAgentUuid}
setLocModalAgentUuid={setLocModalAgentUuid}
locModalAgentLabel={locModalAgentLabel}
setLocModalAgentLabel={setLocModalAgentLabel}
locModalCurrentLoc={locModalCurrentLoc}
setLocModalCurrentLoc={setLocModalCurrentLoc}
loadLocations={loadLocations}
/> />
</div> </div>
); );
+2 -1
View File
@@ -2,7 +2,8 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { getAgents, Agent } from "@/lib/actions/agents"; import { getAgents } from "@/lib/actions/agents";
import { Agent } from "@/lib/actions/agentsCore";
import { getAdminUsers, startViewAs, getViewAsHeaders, type ManagedUser } from "@/lib/admin-api"; import { getAdminUsers, startViewAs, getViewAsHeaders, type ManagedUser } from "@/lib/admin-api";
import { useTimeFilter } from "@/contexts/TimeFilterContext"; import { useTimeFilter } from "@/contexts/TimeFilterContext";
import { usePollingKey } from "@/lib/usePolling"; import { usePollingKey } from "@/lib/usePolling";
+7 -7
View File
@@ -9,15 +9,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { AppDetailModal } from "@/components/ui/AppDetailModal"; import { AppDetailModal } from "@/components/ui/AppDetailModal";
import { Loader2, ChevronRight, Globe } from "lucide-react"; import { Loader2, ChevronRight, Globe } from "lucide-react";
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters"; import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
import { HelpTrigger } from "@/components/help/HelpTrigger";
import { fmtBytes, getAppFavicon, formatAppLabel } from "@/lib/utils"; import { fmtBytes, getAppFavicon, formatAppLabel } from "@/lib/utils";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts";
import { HelpTrigger } from "@/components/help/HelpTrigger";
export default function AppsPage() { export default function AppsPage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = 20; const pageSize = 20;
const { data, isLoading } = useTopApps(pageSize); const { data, isLoading } = useTopApps();
const [selectedApp, setSelectedApp] = useState<AppStat | null>(null); const [selectedApp, setSelectedApp] = useState<AppStat | null>(null);
const { const {
@@ -28,6 +28,7 @@ export default function AppsPage() {
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
document.title = "Apps | BackOne - Deep Package Inspection";
}, []); }, []);
if (!mounted) return null; if (!mounted) return null;
@@ -126,13 +127,12 @@ export default function AppsPage() {
accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.download + row.upload)}</span> accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.download + row.upload)}</span>
}, },
]; ];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> <h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
Applications Apps
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />} {isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
</h2> </h2>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
@@ -141,7 +141,7 @@ export default function AppsPage() {
</div> </div>
<HelpTrigger pageId="apps" /> <HelpTrigger pageId="apps" />
</div> </div>
<UniversalFilters <UniversalFilters
filters={filterConfigs} filters={filterConfigs}
onChange={(id, val) => { onChange={(id, val) => {
@@ -234,4 +234,4 @@ export default function AppsPage() {
)} )}
</div> </div>
); );
} }
+2 -1
View File
@@ -15,7 +15,7 @@ import { HelpTrigger } from "@/components/help/HelpTrigger";
export default function DevicesPage() { export default function DevicesPage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const devices = useDevices(5000); const devices = useDevices();
const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null); const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null);
const { const {
@@ -26,6 +26,7 @@ export default function DevicesPage() {
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
document.title = "Devices | BackOne - Deep Package Inspection";
}, []); }, []);
if (!mounted) return null; if (!mounted) return null;
+5 -7
View File
@@ -25,6 +25,7 @@ export default function DNSPage() {
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
document.title = "DNS | BackOne - Deep Package Inspection";
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -97,13 +98,10 @@ export default function DNSPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> DNS
DNS Intelligence {isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />} </h2>
</h2>
<p className="text-muted-foreground mt-1">Domain Name System resolution analytics and query logs.</p>
</div>
<HelpTrigger pageId="dns" /> <HelpTrigger pageId="dns" />
</div> </div>
@@ -0,0 +1,254 @@
"use client";
import React from "react";
import { Tabs, Tab } from "@/components/ui/Tabs";
import { DataTable } from "@/components/ui/DataTable";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { fmtBytes, getKnownDomainService } from "@/lib/utils";
import { MetadataDetailType } from "@/lib/api-metadata-detail";
interface DpiAnalyticsTabsProps {
sniHostnames: any[];
loadingSni: boolean;
sslServerCn: any[];
loadingSslCn: boolean;
quicHostnames: any[];
loadingQuic: boolean;
netbios: any[];
loadingNetbios: boolean;
mdnsHostnames: any[];
loadingMdns: boolean;
discoveryOs: any[];
loadingOs: boolean;
dhcpFingerprints: any[];
loadingDhcp: boolean;
httpUserAgents: any[];
loadingHttpUa: boolean;
sshVersions: any[];
loadingSsh: boolean;
bittorrentHashes: any[];
loadingBittorrent: boolean;
openDetail: (type: MetadataDetailType, value: string) => void;
}
function ClickHint() {
return (
<span className="ml-2 text-[10px] font-normal text-blue-400/70 border border-blue-400/30 rounded px-1 py-0.5 align-middle">
Click row for details
</span>
);
}
export default function DpiAnalyticsTabs({
sniHostnames,
loadingSni,
sslServerCn,
loadingSslCn,
quicHostnames,
loadingQuic,
netbios,
loadingNetbios,
mdnsHostnames,
loadingMdns,
discoveryOs,
loadingOs,
dhcpFingerprints,
loadingDhcp,
httpUserAgents,
loadingHttpUa,
sshVersions,
loadingSsh,
bittorrentHashes,
loadingBittorrent,
openDetail,
}: DpiAnalyticsTabsProps) {
return (
<Tabs>
{/* ── Hostnames & Servers ──────────────────────────────────────────── */}
<Tab label="Hostnames & Servers">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle>SNI Hostnames<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={sniHostnames}
isLoading={loadingSni}
onRowClick={(row: any) => openDetail("sni_hostname", row.sni_hostname)}
columns={[
{
header: "Hostname",
accessor: (row: any) => {
const srv = getKnownDomainService(row.sni_hostname);
return (
<div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center">
<span className="truncate w-full block" title={row.sni_hostname}>{row.sni_hostname}</span>
{srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>}
</div>
);
}
},
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>SSL Server Common Names<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={sslServerCn}
isLoading={loadingSslCn}
onRowClick={(row: any) => openDetail("ssl_server_cn", row.ssl_server_cn)}
columns={[
{
header: "Common Name",
accessor: (row: any) => {
const srv = getKnownDomainService(row.ssl_server_cn);
return (
<div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center">
<span className="truncate w-full block" title={row.ssl_server_cn}>{row.ssl_server_cn}</span>
{srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>}
</div>
);
}
},
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>QUIC Hostnames<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={quicHostnames}
isLoading={loadingQuic}
onRowClick={(row: any) => openDetail("quic_hostname", row.quic_hostname)}
columns={[
{
header: "Hostname",
accessor: (row: any) => {
const srv = getKnownDomainService(row.quic_hostname);
return (
<div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center">
<span className="truncate w-full block" title={row.quic_hostname}>{row.quic_hostname}</span>
{srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>}
</div>
);
}
},
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>NetBIOS Hostnames<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={netbios}
isLoading={loadingNetbios}
onRowClick={(row: any) => openDetail("netbios_hostname", row.hostname)}
columns={[{ header: "Hostname", accessor: (row: any) => row.hostname }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]}
/>
</CardContent>
</Card>
<Card className="xl:col-span-2">
<CardHeader><CardTitle>mDNS Hostnames<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={mdnsHostnames}
isLoading={loadingMdns}
onRowClick={(row: any) => openDetail("mdns_hostname", row.mdns_hostname)}
columns={[{ header: "Hostname", accessor: (row: any) => row.mdns_hostname }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]}
/>
</CardContent>
</Card>
</div>
</Tab>
{/* ── Client Fingerprints ──────────────────────────────────────────── */}
<Tab label="Client Fingerprints">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card className="xl:col-span-2">
<CardHeader><CardTitle>Operating Systems Discovery<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={discoveryOs}
isLoading={loadingOs}
onRowClick={(row: any) => openDetail("os_label", row.os_label)}
columns={[
{ header: "Operating System", accessor: (row: any) => row.os_label },
{ header: "Download", accessor: (row: any) => fmtBytes(row.download) },
{ header: "Upload", accessor: (row: any) => fmtBytes(row.upload) },
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>DHCP Fingerprints<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={dhcpFingerprints}
isLoading={loadingDhcp}
onRowClick={(row: any) => openDetail("dhcp_fingerprint", row.fingerprint)}
columns={[{ header: "Fingerprint", accessor: (row: any) => row.fingerprint }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>HTTP User Agents<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={httpUserAgents}
isLoading={loadingHttpUa}
onRowClick={(row: any) => openDetail("http_useragent", row.user_agent)}
columns={[{ header: "User Agent", accessor: (row: any) => row.user_agent }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]}
/>
</CardContent>
</Card>
</div>
</Tab>
{/* ── Specific Protocols ───────────────────────────────────────────── */}
<Tab label="Specific Protocols">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle>SSH Versions<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={sshVersions}
isLoading={loadingSsh}
onRowClick={(row: any) => openDetail("ssh_version", row.ssh_version)}
columns={[{ header: "SSH Version", accessor: (row: any) => row.ssh_version }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>BitTorrent Hashes<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={bittorrentHashes}
isLoading={loadingBittorrent}
onRowClick={(row: any) => openDetail("bittorrent_hash", row.info_hash)}
columns={[
{ header: "Label", accessor: (row: any) => row.label },
{ header: "Info Hash", accessor: (row: any) => row.info_hash },
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
</div>
</Tab>
</Tabs>
);
}
+42 -242
View File
@@ -1,9 +1,6 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback, useEffect } from "react";
import { Tabs, Tab } from "@/components/ui/Tabs";
import { DataTable } from "@/components/ui/DataTable";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { import {
useNetbios, useDiscoveryOs, useDhcpFingerprints, useNetbios, useDiscoveryOs, useDhcpFingerprints,
useHttpUserAgents, useSniHostnames, useSslServerCn, useHttpUserAgents, useSniHostnames, useSslServerCn,
@@ -13,25 +10,18 @@ import {
import { MetadataDetailPanel } from "@/components/dpi/MetadataDetailPanel"; import { MetadataDetailPanel } from "@/components/dpi/MetadataDetailPanel";
import { MetadataDetailType } from "@/lib/api-metadata-detail"; import { MetadataDetailType } from "@/lib/api-metadata-detail";
import { useTimeFilter } from "@/contexts/TimeFilterContext"; import { useTimeFilter } from "@/contexts/TimeFilterContext";
import { fmtBytes, getKnownDomainService } from "@/lib/utils";
// Clickable hint badge shown in table column headers when rows are interactive
function ClickHint() {
return (
<span className="ml-2 text-[10px] font-normal text-blue-400/70 border border-blue-400/30 rounded px-1 py-0.5 align-middle">
Click row for details
</span>
);
}
import { HelpTrigger } from "@/components/help/HelpTrigger"; import { HelpTrigger } from "@/components/help/HelpTrigger";
import DpiAnalyticsTabs from "./DpiAnalyticsTabs";
export default function DpiAnalyticsPage() { export default function DpiAnalyticsPage() {
const { timeRange } = useTimeFilter(); const { timeRange } = useTimeFilter();
// Detail panel state const [detailType, setDetailType] = useState<MetadataDetailType | null>(null);
const [detailType, setDetailType] = useState<MetadataDetailType | null>(null);
const [detailValue, setDetailValue] = useState<string | null>(null); const [detailValue, setDetailValue] = useState<string | null>(null);
useEffect(() => {
document.title = "DPI MetaData | BackOne - Deep Package Inspection";
}, []);
const openDetail = useCallback((type: MetadataDetailType, value: string) => { const openDetail = useCallback((type: MetadataDetailType, value: string) => {
if (!value || value === "—") return; if (!value || value === "—") return;
setDetailType(type); setDetailType(type);
@@ -43,24 +33,20 @@ export default function DpiAnalyticsPage() {
setDetailValue(null); setDetailValue(null);
}, []); }, []);
// Hostnames & Servers Tab // Data Fetch Hooks
const { data: sniHostnames, isLoading: loadingSni } = useSniHostnames(50); const { data: sniHostnames, isLoading: loadingSni } = useSniHostnames();
const { data: sslServerCn, isLoading: loadingSslCn } = useSslServerCn(50); const { data: sslServerCn, isLoading: loadingSslCn } = useSslServerCn();
const { data: quicHostnames, isLoading: loadingQuic } = useQuicHostnames(50); const { data: quicHostnames, isLoading: loadingQuic } = useQuicHostnames();
const { data: netbios, isLoading: loadingNetbios } = useNetbios(50); const { data: netbios, isLoading: loadingNetbios } = useNetbios();
const { data: mdnsHostnames, isLoading: loadingMdns } = useMdnsHostnames(50); const { data: mdnsHostnames, isLoading: loadingMdns } = useMdnsHostnames();
const { data: discoveryOs, isLoading: loadingOs } = useDiscoveryOs();
// Client Fingerprints Tab const { data: dhcpFingerprints, isLoading: loadingDhcp } = useDhcpFingerprints();
const { data: discoveryOs, isLoading: loadingOs } = useDiscoveryOs(50); const { data: httpUserAgents, isLoading: loadingHttpUa } = useHttpUserAgents();
const { data: dhcpFingerprints, isLoading: loadingDhcp } = useDhcpFingerprints(50); const { data: sshVersions, isLoading: loadingSsh } = useSshVersions();
const { data: httpUserAgents, isLoading: loadingHttpUa } = useHttpUserAgents(50); const { data: bittorrentHashes, isLoading: loadingBittorrent } = useBittorrentHashes();
// Specific Protocols Tab
const { data: sshVersions, isLoading: loadingSsh } = useSshVersions(50);
const { data: bittorrentHashes, isLoading: loadingBittorrent } = useBittorrentHashes(50);
return ( return (
<div className="space-y-6"> <div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-white tracking-tight">DPI MetaData</h1> <h1 className="text-3xl font-bold text-white tracking-tight">DPI MetaData</h1>
@@ -72,216 +58,30 @@ export default function DpiAnalyticsPage() {
<HelpTrigger pageId="dpi-analytics" /> <HelpTrigger pageId="dpi-analytics" />
</div> </div>
<Tabs> <DpiAnalyticsTabs
{/* ── Hostnames & Servers ──────────────────────────────────────────── */} sniHostnames={sniHostnames || []}
<Tab label="Hostnames & Servers"> loadingSni={loadingSni}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> sslServerCn={sslServerCn || []}
loadingSslCn={loadingSslCn}
quicHostnames={quicHostnames || []}
loadingQuic={loadingQuic}
netbios={netbios || []}
loadingNetbios={loadingNetbios}
mdnsHostnames={mdnsHostnames || []}
loadingMdns={loadingMdns}
discoveryOs={discoveryOs || []}
loadingOs={loadingOs}
dhcpFingerprints={dhcpFingerprints || []}
loadingDhcp={loadingDhcp}
httpUserAgents={httpUserAgents || []}
loadingHttpUa={loadingHttpUa}
sshVersions={sshVersions || []}
loadingSsh={loadingSsh}
bittorrentHashes={bittorrentHashes || []}
loadingBittorrent={loadingBittorrent}
openDetail={openDetail}
/>
<Card>
<CardHeader><CardTitle>SNI Hostnames (TLS)<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={sniHostnames}
isLoading={loadingSni}
onRowClick={(row: any) => openDetail("sni_hostname", row.sni_hostname)}
columns={[
{
header: "Hostname",
accessor: (row: any) => {
const srv = getKnownDomainService(row.sni_hostname);
return (
<div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center">
<span className="truncate w-full block" title={row.sni_hostname}>{row.sni_hostname}</span>
{srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>}
</div>
);
}
},
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>SSL Server Common Names<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={sslServerCn}
isLoading={loadingSslCn}
onRowClick={(row: any) => openDetail("ssl_server_cn", row.ssl_server_cn)}
columns={[
{
header: "Common Name",
accessor: (row: any) => {
const srv = getKnownDomainService(row.ssl_server_cn);
return (
<div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center">
<span className="truncate w-full block" title={row.ssl_server_cn}>{row.ssl_server_cn}</span>
{srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>}
</div>
);
}
},
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>QUIC Hostnames<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={quicHostnames}
isLoading={loadingQuic}
onRowClick={(row: any) => openDetail("quic_hostname", row.quic_hostname)}
columns={[
{
header: "Hostname",
accessor: (row: any) => {
const srv = getKnownDomainService(row.quic_hostname);
return (
<div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center">
<span className="truncate w-full block" title={row.quic_hostname}>{row.quic_hostname}</span>
{srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>}
</div>
);
}
},
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>NetBIOS Hostnames<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={netbios}
isLoading={loadingNetbios}
onRowClick={(row: any) => openDetail("netbios_hostname", row.hostname)}
columns={[
{ header: "Hostname", accessor: (row: any) => row.hostname },
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
<Card className="xl:col-span-2">
<CardHeader><CardTitle>mDNS Hostnames<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={mdnsHostnames}
isLoading={loadingMdns}
onRowClick={(row: any) => openDetail("mdns_hostname", row.mdns_hostname)}
columns={[
{ header: "Hostname", accessor: (row: any) => row.mdns_hostname },
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
</div>
</Tab>
{/* ── Client Fingerprints ──────────────────────────────────────────── */}
<Tab label="Client Fingerprints">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card className="xl:col-span-2">
<CardHeader><CardTitle>Operating Systems Discovery<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={discoveryOs}
isLoading={loadingOs}
onRowClick={(row: any) => openDetail("os_label", row.os_label)}
columns={[
{ header: "Operating System", accessor: (row: any) => row.os_label },
{ header: "Download", accessor: (row: any) => fmtBytes(row.download) },
{ header: "Upload", accessor: (row: any) => fmtBytes(row.upload) },
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>DHCP Fingerprints<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={dhcpFingerprints}
isLoading={loadingDhcp}
onRowClick={(row: any) => openDetail("dhcp_fingerprint", row.fingerprint)}
columns={[
{ header: "Fingerprint", accessor: (row: any) => row.fingerprint },
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>HTTP User Agents<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={httpUserAgents}
isLoading={loadingHttpUa}
onRowClick={(row: any) => openDetail("http_useragent", row.user_agent)}
columns={[
{ header: "User Agent", accessor: (row: any) => row.user_agent },
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
</div>
</Tab>
{/* ── Specific Protocols ───────────────────────────────────────────── */}
<Tab label="Specific Protocols">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle>SSH Versions<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={sshVersions}
isLoading={loadingSsh}
onRowClick={(row: any) => openDetail("ssh_version", row.ssh_version)}
columns={[
{ header: "SSH Version", accessor: (row: any) => row.ssh_version },
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>BitTorrent Hashes<ClickHint /></CardTitle></CardHeader>
<CardContent>
<DataTable
data={bittorrentHashes}
isLoading={loadingBittorrent}
onRowClick={(row: any) => openDetail("bittorrent_hash", row.info_hash)}
columns={[
{ header: "Label", accessor: (row: any) => row.label },
{ header: "Info Hash", accessor: (row: any) => row.info_hash },
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
</div>
</Tab>
</Tabs>
{/* ── Row Detail Slide-Out Panel ────────────────────────────────────── */}
<MetadataDetailPanel <MetadataDetailPanel
type={detailType} type={detailType}
value={detailValue} value={detailValue}
+5 -8
View File
@@ -8,10 +8,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Loader2, AlertCircle, Info, AlertTriangle } from "lucide-react"; import { Loader2, AlertCircle, Info, AlertTriangle } from "lucide-react";
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal"; import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters"; import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
import { HelpTrigger } from "@/components/help/HelpTrigger";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Suspense } from "react"; import { Suspense } from "react";
import { HelpTrigger } from "@/components/help/HelpTrigger";
@@ -159,13 +159,10 @@ function EventsContent() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> Network Events
Network Events {isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />} </h2>
</h2>
<p className="text-muted-foreground mt-1">Real-time system, security, and interface network log events.</p>
</div>
<HelpTrigger pageId="events" /> <HelpTrigger pageId="events" />
</div> </div>
+46
View File
@@ -0,0 +1,46 @@
export function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) {
const label = appLabel || domain || "";
const portStr = String(port);
const matches: Record<string, string> = {
"1433": "MSSQL Database Server",
"1434": "MSSQL Monitor Server",
"3306": "MySQL/MariaDB Database Server",
"5432": "PostgreSQL Database Server",
"1521": "Oracle Database Server",
"27017": "Database Server",
"6379": "Redis Key-Value Cache",
"80": "Unencrypted Web Traffic (HTTP)",
"443": "Encrypted Web Traffic (HTTPS/TLS)",
"22": "SSH Remote Management / SFTP",
"21": "FTP File Upload/Download (Unencrypted)",
"23": "Telnet Command Shell (Insecure)",
"25": "SMTP Mail Delivery",
"587": "Secure SMTP Mail Delivery",
"110": "POP3 Mail Storage Retrieval",
"993": "Secure IMAP Mail Service",
"53": "DNS Server Query (Domain Lookup)",
"123": "NTP Network Clock Synchronizer",
"161": "SNMP Network Device Monitoring",
"3389": "RDP Remote Desktop Management",
"445": "SMB Windows File Sharing",
"137": "NetBIOS Name Service",
"138": "NetBIOS Datagram Service",
"139": "NetBIOS Session Service",
"1812": "RADIUS Authentication Server",
"1813": "RADIUS Accounting Server"
};
let mainLabel = label;
let subLabel = matches[portStr] || (label.toLowerCase().includes("tls") || label.toLowerCase().includes("https") ? "Encrypted Connection (SSL/TLS)" : "");
if (appLabel && appLabel.includes("(") && domain && domain.startsWith("Port ")) {
mainLabel = appLabel;
subLabel = domain;
}
return {
main: mainLabel || `Port ${port}`,
sub: subLabel
};
}
+117 -145
View File
@@ -2,67 +2,24 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { TimestampCell } from "@/components/ui/TimestampCell"; import { TimestampCell } from "@/components/ui/TimestampCell";
import { useFlows } from "@/lib/api-with-context"; import { useFlows, useFlowsOptions } from "@/lib/api-with-context";
import { DataTable, Column } from "@/components/ui/DataTable"; import { DataTable, Column } from "@/components/ui/DataTable";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Loader2 } from "lucide-react"; import { Loader2, Download, Search } from "lucide-react";
import { fmtBytes, formatAppLabel } from "@/lib/utils"; import { fmtBytes, formatAppLabel } from "@/lib/utils";
import { IpDetails } from "@/components/ui/IpDetails"; import { IpDetails } from "@/components/ui/IpDetails";
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal"; import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters"; import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
import { explainAppOrPort } from "./explainFlow";
import { downloadCsv } from "@/components/ui/DataTableCsvHelper";
import { HelpTrigger } from "@/components/help/HelpTrigger"; import { HelpTrigger } from "@/components/help/HelpTrigger";
function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) {
const label = appLabel || domain || "";
const portStr = String(port);
const matches: Record<string, string> = {
"1433": "MSSQL Database Server",
"1434": "MSSQL Monitor Server",
"3306": "MySQL/MariaDB Database Server",
"5432": "PostgreSQL Database Server",
"1521": "Oracle Database Server",
"27017": "Database Server",
"6379": "Redis Key-Value Cache",
"80": "Unencrypted Web Traffic (HTTP)",
"443": "Encrypted Web Traffic (HTTPS/TLS)",
"22": "SSH Remote Management / SFTP",
"21": "FTP File Upload/Download (Unencrypted)",
"23": "Telnet Command Shell (Insecure)",
"25": "SMTP Mail Delivery",
"587": "Secure SMTP Mail Delivery",
"110": "POP3 Mail Storage Retrieval",
"993": "Secure IMAP Mail Service",
"53": "DNS Server Query (Domain Lookup)",
"123": "NTP Network Clock Synchronizer",
"161": "SNMP Network Device Monitoring",
"3389": "RDP Remote Desktop Management",
"445": "SMB Windows File Sharing",
"137": "NetBIOS Name Service",
"138": "NetBIOS Datagram Service",
"139": "NetBIOS Session Service",
"1812": "RADIUS Authentication Server",
"1813": "RADIUS Accounting Server"
};
let mainLabel = label;
let subLabel = matches[portStr] || (label.toLowerCase().includes("tls") || label.toLowerCase().includes("https") ? "Encrypted Connection (SSL/TLS)" : "");
if (appLabel && appLabel.includes("(") && domain && domain.startsWith("Port ")) {
mainLabel = appLabel;
subLabel = domain;
}
return {
main: mainLabel || `Port ${port}`,
sub: subLabel
};
}
export default function FlowsPage() { export default function FlowsPage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [selectedIp, setSelectedIp] = useState<string | null>(null); const [selectedIp, setSelectedIp] = useState<string | null>(null);
const { data, isLoading } = useFlows(); const [currentPage, setCurrentPage] = useState(1);
const [ipSearch, setIpSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const { const {
filters, dateFrom, dateTo, filters, dateFrom, dateTo,
@@ -70,18 +27,41 @@ export default function FlowsPage() {
handleReset, activeCount handleReset, activeCount
} = useUniversalFilterState(); } = useUniversalFilterState();
// Debounce search query to prevent database query spamming
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(ipSearch);
}, 300);
return () => clearTimeout(timer);
}, [ipSearch]);
// Reset page to 1 when filters, date picker, or search text changes
useEffect(() => {
setCurrentPage(1);
}, [filters, dateFrom, dateTo, debouncedSearch]);
const { data: optionsData } = useFlowsOptions();
const { data: flows = [], total = 0, isLoading } = useFlows(currentPage, 50, {
...filters,
dateFrom,
dateTo,
search: debouncedSearch
});
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
document.title = "Flows | BackOne - Deep Package Inspection";
}, []); }, []);
if (!mounted) return null; if (!mounted) return null;
const all = data || [];
// Extract unique options
const opts = (key: string) => { const opts = (key: string) => {
const raw = Array.from(new Set(all.map((r: any) => String(r[key] || "")).filter(Boolean))); if (!optionsData) return [];
return raw.sort(); const mapping: Record<string, string[]> = {
protocol: optionsData.protocols, src_ip: optionsData.srcIps, dst_ip: optionsData.dstIps,
dst_port: optionsData.dstPorts, app_label: optionsData.apps, domain: optionsData.domains
};
return mapping[key] || [];
}; };
const filterConfigs: FilterConfig[] = [ const filterConfigs: FilterConfig[] = [
@@ -95,49 +75,15 @@ export default function FlowsPage() {
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] }, { id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
]; ];
const filteredData = all.filter((row: any) => { const minDate = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString().slice(0, 10);
if (filters.protocol && filters.protocol !== "All" && row.protocol !== filters.protocol) return false;
if (filters.src_ip && filters.src_ip !== "All" && row.src_ip !== filters.src_ip) return false;
if (filters.dst_ip && filters.dst_ip !== "All" && row.dst_ip !== filters.dst_ip) return false;
if (filters.dst_port && filters.dst_port !== "All" && String(row.dst_port) !== filters.dst_port) return false;
if (filters.app && filters.app !== "All" && row.app_label !== filters.app) return false;
if (filters.domain && filters.domain !== "All" && row.domain !== filters.domain) return false;
if (dateFrom || dateTo) {
const d = row.last_seen ? new Date(row.last_seen).toISOString().slice(0, 10) : "";
if (dateFrom && d < dateFrom) return false;
if (dateTo && d > dateTo) return false;
}
return true;
});
let processedData = [...filteredData]; const handleExport = () => {
const sortUpload = filters.sort_upload || "All"; const headers = ["Flow ID", "Src IP", "Dst IP", "Dst Port", "Protocol", "App / Domain", "Download (bytes)", "Upload (bytes)"];
const sortDownload = filters.sort_download || "All"; const rows = [headers, ...flows.map(r => [r.flow_id || "", r.src_ip || "", r.dst_ip || "", r.dst_port ?? "", r.protocol || "", r.app_label || r.domain || `Port ${r.dst_port}`, r.bytes_download ?? 0, r.bytes_upload ?? 0])];
downloadCsv(`flows-${new Date().toISOString().slice(0,10)}.csv`, rows);
if (sortUpload !== "All") { };
processedData.sort((a, b) => sortUpload === "Descending" ? (b.bytes_upload ?? 0) - (a.bytes_upload ?? 0) : (a.bytes_upload ?? 0) - (b.bytes_upload ?? 0));
} else if (sortDownload !== "All") {
processedData.sort((a, b) => sortDownload === "Descending" ? (b.bytes_download ?? 0) - (a.bytes_download ?? 0) : (a.bytes_download ?? 0) - (b.bytes_download ?? 0));
} else {
processedData.sort((a, b) => {
const timeA = a.last_seen ? new Date(a.last_seen).getTime() : 0;
const timeB = b.last_seen ? new Date(b.last_seen).getTime() : 0;
return timeB - timeA;
});
}
const minTime = all.reduce((min: number, r: any) => { const columns: Column<any>[] = [
if (r.last_seen) {
const time = new Date(r.last_seen).getTime();
if (isFinite(time) && time < min) return time;
}
return min;
}, Infinity);
const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01";
const columns: Column<typeof data[0]>[] = [
{ {
header: "Last Seen", header: "Last Seen",
accessor: (row) => ( accessor: (row) => (
@@ -147,10 +93,19 @@ export default function FlowsPage() {
showActiveStatus={true} showActiveStatus={true}
/> />
), ),
className: "w-[120px]" className: "w-[170px]",
tooltip: (row) => new Date(row.last_seen).toLocaleString()
},
{
header: "#",
accessor: (row, i) => i + 1,
className: "w-[50px]"
},
{
header: "Flow ID",
accessor: (row) => row.flow_id,
className: "w-[130px]"
}, },
{ header: "#", accessor: (row, i) => i + 1 },
{ header: "Flow ID", accessor: (row) => row.flow_id },
{ {
header: "Src IP", header: "Src IP",
accessor: (row) => ( accessor: (row) => (
@@ -160,62 +115,80 @@ export default function FlowsPage() {
> >
{row.src_ip} {row.src_ip}
</button> </button>
) ),
className: "w-[120px]",
tooltip: (row) => row.src_ip
}, },
{ {
header: "Dst IP", header: "Dst IP",
accessor: (row) => ( accessor: (row) => (
<div className="flex flex-col items-start"> <div className="flex flex-col items-center justify-center">
<button <button
onClick={() => setSelectedIp(row.dst_ip)} onClick={() => setSelectedIp(row.dst_ip)}
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-left focus:outline-none" className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-center focus:outline-none"
> >
{row.dst_ip} {row.dst_ip}
</button> </button>
<IpDetails ip={row.dst_ip} /> <IpDetails ip={row.dst_ip} />
</div> </div>
) ),
className: "w-[150px]",
tooltip: (row) => row.dst_ip
},
{
header: "Dst Port",
accessor: (row) => row.dst_port,
className: "w-[80px]"
},
{
header: "Protocol",
accessor: (row) => row.protocol,
className: "w-[80px]"
}, },
{ header: "Dst Port", accessor: (row) => row.dst_port },
{ header: "Protocol", accessor: (row) => row.protocol },
{ {
header: "App / Domain", header: "App / Domain",
accessor: (row) => { accessor: (row) => {
const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port); const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port);
return ( return (
<div className="flex flex-col"> <div className="flex flex-col items-center justify-center">
<span className="font-semibold text-white truncate max-w-[200px]" title={exp.main}> <span className="font-semibold text-white truncate max-w-[200px] text-center" title={exp.main}>
{exp.main} {exp.main}
</span> </span>
{exp.sub && ( {exp.sub && (
<span className="text-[10px] text-slate-400 italic"> <span className="text-[10px] text-slate-400 italic text-center">
{exp.sub} {exp.sub}
</span> </span>
)} )}
</div> </div>
); );
},
className: "w-[220px]",
tooltip: (row) => {
const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port);
return exp.sub ? `${exp.main} (${exp.sub})` : exp.main;
} }
}, },
{ {
header: "Download", header: "Download",
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span> accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span>,
className: "w-[90px]",
tooltip: (row) => `${fmtBytes(row.bytes_download)} (${(row.bytes_download || 0).toLocaleString()} bytes)`
}, },
{ {
header: "Upload", header: "Upload",
accessor: (row) => <span className="text-green-500">{fmtBytes(row.bytes_upload)}</span> accessor: (row) => <span className="text-green-500">{fmtBytes(row.bytes_upload)}</span>,
className: "w-[90px]",
tooltip: (row) => `${fmtBytes(row.bytes_upload)} (${(row.bytes_upload || 0).toLocaleString()} bytes)`
} }
]; ];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> Flows
Active Flows {isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />} </h2>
</h2>
<p className="text-muted-foreground mt-1">Real-time IP connection flows and protocol analysis.</p>
</div>
<HelpTrigger pageId="flows" /> <HelpTrigger pageId="flows" />
</div> </div>
@@ -242,38 +215,37 @@ export default function FlowsPage() {
/> />
<Card className="bg-card/50 backdrop-blur-sm border-border/50"> <Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4 gap-4 flex-wrap">
<CardTitle className="text-white">Active Flows ({processedData.length})</CardTitle> <div className="flex items-center gap-4 flex-wrap">
<CardTitle className="text-white">Active Flows ({total.toLocaleString()})</CardTitle>
<div className="relative group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground transition-colors group-focus-within:text-primary" />
<input
type="text"
placeholder="Search IP (source or dest)..."
value={ipSearch}
onChange={(e) => setIpSearch(e.target.value)}
className="w-[240px] rounded-lg border border-border bg-card/30 backdrop-blur-sm pl-9 pr-4 py-1.5 text-sm text-white placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm"
/>
</div>
</div>
<button
onClick={handleExport}
title="Export current page flows to CSV"
className="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-emerald-500/40 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 hover:border-emerald-500/70 hover:text-emerald-300 transition-all shadow-sm whitespace-nowrap"
>
<Download className="w-4 h-4" />
Export CSV ({total.toLocaleString()})
</button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<DataTable <DataTable
data={processedData} data={flows}
columns={columns} columns={columns}
searchPlaceholder="Search flows by IP, App, or Domain..." serverSide={true}
searchFilter={(row, query) => { totalCount={total}
const q = query.toLowerCase(); currentPage={currentPage}
return ( onPageChange={setCurrentPage}
(row.src_ip || "").toLowerCase().includes(q) ||
(row.dst_ip || "").toLowerCase().includes(q) ||
(row.app_label || "").toLowerCase().includes(q) ||
(row.domain || "").toLowerCase().includes(q) ||
(row.protocol || "").toLowerCase().includes(q)
);
}}
csvExport={{
filename: `flows-${new Date().toISOString().slice(0,10)}.csv`,
headers: ["Flow ID", "Src IP", "Dst IP", "Dst Port", "Protocol", "App / Domain", "Download (bytes)", "Upload (bytes)"],
rowSerializer: (row) => [
row.flow_id || "",
row.src_ip || "",
row.dst_ip || "",
row.dst_port ?? "",
row.protocol || "",
row.app_label || row.domain || `Port ${row.dst_port}`,
row.bytes_download ?? 0,
row.bytes_upload ?? 0,
],
}}
/> />
</CardContent> </CardContent>
</Card> </Card>
+3 -2
View File
@@ -13,7 +13,7 @@ import { HelpTrigger } from "@/components/help/HelpTrigger";
export default function GeographyPage() { export default function GeographyPage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [showMap, setShowMap] = useState(false); const [showMap, setShowMap] = useState(false);
const { data, isLoading } = useCountries(50); // fetch 50 countries const { data, isLoading } = useCountries();
const { const {
filters, filters,
@@ -34,6 +34,7 @@ export default function GeographyPage() {
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
document.title = "Geo Traffic | BackOne - Deep Package Inspection";
}, []); }, []);
if (!mounted) return null; if (!mounted) return null;
@@ -109,7 +110,7 @@ export default function GeographyPage() {
Geo Traffic Geo Traffic
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />} {isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
</h2> </h2>
<p className="text-muted-foreground mt-1">Geographic distribution of inbound and outbound traffic by country.</p> <p className="text-muted-foreground mt-1">Visualize data flow and activity across the globe.</p>
</div> </div>
<HelpTrigger pageId="geography" /> <HelpTrigger pageId="geography" />
</div> </div>
@@ -0,0 +1,71 @@
"use client";
import React from "react";
import { DataTable } from "@/components/ui/DataTable";
import { TimestampCell } from "@/components/ui/TimestampCell";
import { ChevronRight } from "lucide-react";
import { fmtBytes } from "@/lib/utils";
interface DeviceDiscoveryTableProps {
data: any[];
isLoading: boolean;
setSelectedDevice: (device: { ip: string; mac?: string } | null) => void;
}
export function DeviceDiscoveryTable({ data, isLoading, setSelectedDevice }: DeviceDiscoveryTableProps) {
return (
<DataTable
data={data} isLoading={isLoading}
searchPlaceholder="Search by IP, MAC, OS..."
searchFilter={(row: any, q: string) => {
const query = q.toLowerCase();
return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query) || (row.device_type || '').toLowerCase().includes(query);
}}
csvExport={{
filename: `device-discovery-${new Date().toISOString().slice(0,10)}.csv`,
headers: ["IP Address", "MAC Address", "Device Type", "OS", "Manufacturer", "Download", "Upload", "Last Seen"],
rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.device_type || "", row.os_label || "", row.manufacturer || "", row.download || 0, row.upload || 0, row.last_seen || ""]
}}
columns={[
{
header: "Last Seen",
className: "w-[15%]",
accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
},
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
{
header: "IP Address",
className: "w-[16%]",
accessor: (row: any) => (
<div className="flex flex-col items-center justify-center">
<button
onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }}
className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`}
title={row.ip_address ? "Click to view device details" : ""}
>
<span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}>
{row.ip_address || "Unknown IP"}
</span>
{row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />}
</button>
<span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span>
</div>
)
},
{ header: "Device Profile", className: "w-[20%]", accessor: (row: any) => (
<div className="flex flex-col items-center justify-center text-center">
<span className="text-white font-medium">{row.device_type || "-"}</span>
<span className="text-[10px] text-slate-400 font-medium mt-0.5">{row.os_label || "-"}</span>
</div>
) },
{ header: "Manufacturer", className: "w-[20%]", accessor: (row: any) => row.manufacturer || "-" },
{ header: "Bandwidth", className: "w-[25%]", accessor: (row: any) => (
<div className="flex flex-col items-center justify-center text-center gap-0.5">
<span className="text-cyan-400 text-xs font-mono">↓ {fmtBytes(row.download)}</span>
<span className="text-green-400 text-xs font-mono">↑ {fmtBytes(row.upload)}</span>
</div>
) },
]}
/>
);
}
@@ -0,0 +1,92 @@
"use client";
import React from "react";
import { DataTable } from "@/components/ui/DataTable";
import { TimestampCell } from "@/components/ui/TimestampCell";
import { ChevronRight } from "lucide-react";
import { IpDetails } from "@/components/ui/IpDetails";
import { fmtBytes } from "@/lib/utils";
interface EncryptionAuditTableProps {
data: any[];
isLoading: boolean;
setSelectedDevice: (device: { ip: string; mac?: string } | null) => void;
}
export function EncryptionAuditTable({ data, isLoading, setSelectedDevice }: EncryptionAuditTableProps) {
return (
<DataTable
data={data} isLoading={isLoading}
searchPlaceholder="Search by Device or IP..."
searchFilter={(row: any, q: string) => {
const query = q.toLowerCase();
return (row.ip_address || '').toLowerCase().includes(query) || (row.device_label || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query);
}}
csvExport={{
filename: `encryption-audit-${new Date().toISOString().slice(0,10)}.csv`,
headers: ["Device Label", "MAC Address", "IP Address", "Encrypted Data", "Encrypted %", "Total Data", "Risk Level"],
rowSerializer: (row: any) => [row.device_label || "", row.mac_address || "", row.ip_address || "", row.encrypted || 0, row.encrypted_pct || 0, row.total || 0, row.risk_level || ""]
}}
columns={[
{
header: "Last Seen",
className: "w-[15%]",
accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
},
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
{
header: "Device",
className: "w-[25%]",
accessor: (row: any) => (
<div className="flex flex-col items-center justify-center text-center">
<span className="font-medium text-white">{row.device_label || row.ip_address}</span>
<span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span>
</div>
)
},
{
header: "IP Address",
className: "w-[20%]",
accessor: (row: any) => (
<div className="flex flex-col items-center justify-center">
<button
onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }}
className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`}
title={row.ip_address ? "Click to view device details" : ""}
>
<span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}>
{row.ip_address || "Unknown IP"}
</span>
{row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />}
</button>
{row.ip_address && <div className="mt-1 scale-90 origin-top"><IpDetails ip={row.ip_address} /></div>}
</div>
)
},
{
header: "Encrypted Data",
className: "w-[26%]",
accessor: (row: any) => (
<div className="flex flex-col items-center justify-center text-center">
<span className="text-green-400 font-mono text-sm">{fmtBytes(row.encrypted)} ({row.encrypted_pct}%)</span>
<span className="text-[10px] text-muted-foreground font-medium mt-0.5 uppercase tracking-wider">Total: {fmtBytes(row.total)}</span>
</div>
)
},
{
header: "Risk Level",
className: "w-[10%]",
accessor: (row: any) => (
<div className="flex items-center justify-center w-full">
<span className={`px-2 py-1 rounded text-xs font-medium ${
row.risk_level === 'medium' ? 'bg-yellow-500/20 text-yellow-500' : 'bg-green-500/20 text-green-500'
}`}>
{row.risk_level === 'medium' ? 'Medium' : 'Safe'}
</span>
</div>
)
},
]}
/>
);
}
@@ -0,0 +1,157 @@
"use client";
import React from "react";
import { DataTable } from "@/components/ui/DataTable";
import { IpDetails } from "@/components/ui/IpDetails";
import { EncryptionAuditTable } from "./EncryptionAuditTable";
import { DeviceDiscoveryTable } from "./DeviceDiscoveryTable";
import { ServerDiscoveryTable } from "./ServerDiscoveryTable";
interface IntelligenceTablesProps {
activeTab: string;
cryptoMining: any[];
loadingCrypto: boolean;
torDetection: any[];
loadingTor: boolean;
vpnDetection: any[];
loadingVpn: boolean;
ipReputation: any[];
loadingIpRep: boolean;
insecureProtocols: any[];
loadingInsecure: boolean;
unencryptedPasswords: any[];
loadingPasswords: boolean;
encryptionAudit: any[];
loadingEncryption: boolean;
deviceDiscovery: any[];
loadingDevice: boolean;
serverDiscovery: any[];
loadingServer: boolean;
setSelectedDevice: (device: { ip: string; mac?: string } | null) => void;
}
export function IntelligenceTables({
activeTab,
cryptoMining, loadingCrypto,
torDetection, loadingTor,
vpnDetection, loadingVpn,
ipReputation, loadingIpRep,
insecureProtocols, loadingInsecure,
unencryptedPasswords, loadingPasswords,
encryptionAudit, loadingEncryption,
deviceDiscovery, loadingDevice,
serverDiscovery, loadingServer,
setSelectedDevice
}: IntelligenceTablesProps) {
switch (activeTab) {
case "Crypto Mining":
return (
<DataTable
data={cryptoMining} isLoading={loadingCrypto}
columns={[
{ header: "IP Address", accessor: (row: any) => row.ip_address },
{ header: "MAC Address", accessor: (row: any) => row.mac_address },
{ header: "Pool Host", accessor: (row: any) => row.pool_host },
{ header: "App", accessor: (row: any) => row.app_label },
{ header: "Confidence", accessor: (row: any) => row.confidence },
]}
/>
);
case "Tor Detection":
return (
<DataTable
data={torDetection} isLoading={loadingTor}
columns={[
{ header: "IP Address", accessor: (row: any) => row.ip_address },
{ header: "Exit Node", accessor: (row: any) => row.exit_node },
{ header: "Country", accessor: (row: any) => row.country },
]}
/>
);
case "VPN Detection":
return (
<DataTable
data={vpnDetection} isLoading={loadingVpn}
columns={[
{ header: "IP Address", accessor: (row: any) => row.ip_address },
{ header: "VPN Type", accessor: (row: any) => row.vpn_type },
{
header: "Remote IP",
accessor: (row: any) => row.remote_ip ? (
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs text-blue-400">{row.remote_ip}</span>
<IpDetails ip={row.remote_ip} />
</div>
) : "—"
},
{ header: "Country", accessor: (row: any) => row.country },
]}
/>
);
case "IP Reputation":
return (
<DataTable
data={ipReputation} isLoading={loadingIpRep}
columns={[
{
header: "IP Address",
accessor: (row: any) => row.ip_address ? (
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs text-blue-400">{row.ip_address}</span>
<IpDetails ip={row.ip_address} />
</div>
) : "—"
},
{ header: "Reputation", accessor: (row: any) => row.reputation },
{ header: "App", accessor: (row: any) => row.app_label },
{ header: "Country", accessor: (row: any) => row.country },
]}
/>
);
case "Insecure Protocols":
return (
<DataTable
data={insecureProtocols} isLoading={loadingInsecure}
columns={[
{ header: "Protocol", accessor: (row: any) => row.protocol },
{ header: "IP Address", accessor: (row: any) => row.ip_address },
{ header: "Risk", accessor: (row: any) => row.risk },
]}
/>
);
case "Unencrypted Passwords":
return (
<DataTable
data={unencryptedPasswords} isLoading={loadingPasswords}
columns={[
{ header: "Protocol", accessor: (row: any) => row.protocol },
{ header: "IP Address", accessor: (row: any) => row.ip_address },
{ header: "Severity", accessor: (row: any) => row.severity },
]}
/>
);
case "Encryption Audit":
return (
<EncryptionAuditTable
data={encryptionAudit} isLoading={loadingEncryption}
setSelectedDevice={setSelectedDevice}
/>
);
case "Device Discovery":
return (
<DeviceDiscoveryTable
data={deviceDiscovery} isLoading={loadingDevice}
setSelectedDevice={setSelectedDevice}
/>
);
case "Server Discovery":
return (
<ServerDiscoveryTable
data={serverDiscovery} isLoading={loadingServer}
setSelectedDevice={setSelectedDevice}
/>
);
default:
return null;
}
}
@@ -0,0 +1,64 @@
"use client";
import React from "react";
import { DataTable } from "@/components/ui/DataTable";
import { TimestampCell } from "@/components/ui/TimestampCell";
import { ChevronRight } from "lucide-react";
interface ServerDiscoveryTableProps {
data: any[];
isLoading: boolean;
setSelectedDevice: (device: { ip: string; mac?: string } | null) => void;
}
export function ServerDiscoveryTable({ data, isLoading, setSelectedDevice }: ServerDiscoveryTableProps) {
return (
<DataTable
data={data} isLoading={isLoading}
searchPlaceholder="Search by IP, MAC, Type..."
searchFilter={(row: any, q: string) => {
const query = q.toLowerCase();
return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.server_type || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query);
}}
csvExport={{
filename: `server-discovery-${new Date().toISOString().slice(0,10)}.csv`,
headers: ["IP Address", "MAC Address", "Server Type", "Port", "OS"],
rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.server_type || "", row.port || "", row.os_label || ""]
}}
columns={[
{
header: "Last Seen",
className: "w-[15%]",
accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
},
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
{
header: "Server IP",
className: "w-[25%]",
accessor: (row: any) => (
<div className="flex flex-col items-center justify-center">
<button
onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }}
className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`}
title={row.ip_address ? "Click to view device details" : ""}
>
<span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}>
{row.ip_address || "Unknown IP"}
</span>
{row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />}
</button>
<span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span>
</div>
)
},
{ header: "Server Type", className: "w-[28%]", accessor: (row: any) => (
<div className="flex flex-col items-center justify-center text-center">
<span className="text-white font-medium">{row.server_type}</span>
{row.port > 0 && <span className="text-[10px] text-purple-400 font-mono font-medium mt-0.5">Port {row.port}</span>}
</div>
) },
{ header: "OS / Platform", className: "w-[28%]", accessor: (row: any) => row.os_label },
]}
/>
);
}
+41 -298
View File
@@ -8,13 +8,10 @@ import {
useIntelEncryptionAudit, useIntelDeviceDiscovery, useIntelServerDiscovery useIntelEncryptionAudit, useIntelDeviceDiscovery, useIntelServerDiscovery
} from "@/lib/api-advanced"; } from "@/lib/api-advanced";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { DataTable, Column } from "@/components/ui/DataTable"; import { Loader2, Bitcoin, Shield, Server, Lock, AlertTriangle, Key, Globe, Network, ShieldAlert } from "lucide-react";
import { IpDetails } from "@/components/ui/IpDetails";
import { Loader2, Bitcoin, Shield, Server, Lock, AlertTriangle, Key, Globe, Network, ShieldAlert, ChevronRight } from "lucide-react";
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal"; import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
import { TimestampCell } from "@/components/ui/TimestampCell";
import { fmtBytes } from "@/lib/utils";
import { HelpTrigger } from "@/components/help/HelpTrigger"; import { HelpTrigger } from "@/components/help/HelpTrigger";
import { IntelligenceTables } from "./IntelligenceTables";
export default function IntelligencePage() { export default function IntelligencePage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -22,19 +19,20 @@ export default function IntelligencePage() {
const [activeTab, setActiveTab] = useState<string>("Crypto Mining"); const [activeTab, setActiveTab] = useState<string>("Crypto Mining");
const [selectedDevice, setSelectedDevice] = useState<{ip: string, mac?: string} | null>(null); const [selectedDevice, setSelectedDevice] = useState<{ip: string, mac?: string} | null>(null);
// Advanced data hooks // Advanced data hooks (no hardcoded limits, default to 1,000,000)
const { data: cryptoMining, isLoading: loadingCrypto } = useIntelCryptoMining(20); const { data: cryptoMining = [], isLoading: loadingCrypto } = useIntelCryptoMining();
const { data: torDetection, isLoading: loadingTor } = useIntelTorDetection(20); const { data: torDetection = [], isLoading: loadingTor } = useIntelTorDetection();
const { data: vpnDetection, isLoading: loadingVpn } = useIntelVpnDetection(20); const { data: vpnDetection = [], isLoading: loadingVpn } = useIntelVpnDetection();
const { data: ipReputation, isLoading: loadingIpRep } = useIntelIpReputation(20); const { data: ipReputation = [], isLoading: loadingIpRep } = useIntelIpReputation();
const { data: insecureProtocols, isLoading: loadingInsecure } = useIntelInsecureProtocols(20); const { data: insecureProtocols = [], isLoading: loadingInsecure } = useIntelInsecureProtocols();
const { data: unencryptedPasswords, isLoading: loadingPasswords } = useIntelUnencryptedPasswords(20); const { data: unencryptedPasswords = [], isLoading: loadingPasswords } = useIntelUnencryptedPasswords();
const { data: encryptionAudit, isLoading: loadingEncryption } = useIntelEncryptionAudit(20); const { data: encryptionAudit = [], isLoading: loadingEncryption } = useIntelEncryptionAudit();
const { data: deviceDiscovery, isLoading: loadingDevice } = useIntelDeviceDiscovery(20); const { data: deviceDiscovery = [], isLoading: loadingDevice } = useIntelDeviceDiscovery();
const { data: serverDiscovery, isLoading: loadingServer } = useIntelServerDiscovery(20); const { data: serverDiscovery = [], isLoading: loadingServer } = useIntelServerDiscovery();
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
document.title = "Threat Intelligence | BackOne - Deep Package Inspection";
}, []); }, []);
if (!mounted) return null; if (!mounted) return null;
@@ -96,292 +94,16 @@ export default function IntelligencePage() {
} }
]; ];
const renderActiveTable = () => {
switch(activeTab) {
case "Crypto Mining":
return (
<DataTable
data={cryptoMining} isLoading={loadingCrypto}
columns={[
{ header: "IP Address", accessor: (row: any) => row.ip_address },
{ header: "MAC Address", accessor: (row: any) => row.mac_address },
{ header: "Pool Host", accessor: (row: any) => row.pool_host },
{ header: "App", accessor: (row: any) => row.app_label },
{ header: "Confidence", accessor: (row: any) => row.confidence },
]}
/>
);
case "Tor Detection":
return (
<DataTable
data={torDetection} isLoading={loadingTor}
columns={[
{ header: "IP Address", accessor: (row: any) => row.ip_address },
{ header: "Exit Node", accessor: (row: any) => row.exit_node },
{ header: "Country", accessor: (row: any) => row.country },
]}
/>
);
case "VPN Detection":
return (
<DataTable
data={vpnDetection} isLoading={loadingVpn}
columns={[
{ header: "IP Address", accessor: (row: any) => row.ip_address },
{ header: "VPN Type", accessor: (row: any) => row.vpn_type },
{
header: "Remote IP",
accessor: (row: any) => row.remote_ip ? (
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs text-blue-400">{row.remote_ip}</span>
<IpDetails ip={row.remote_ip} />
</div>
) : "—"
},
{ header: "Country", accessor: (row: any) => row.country },
]}
/>
);
case "IP Reputation":
return (
<DataTable
data={ipReputation} isLoading={loadingIpRep}
columns={[
{
header: "IP Address",
accessor: (row: any) => row.ip_address ? (
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs text-blue-400">{row.ip_address}</span>
<IpDetails ip={row.ip_address} />
</div>
) : "—"
},
{ header: "Reputation", accessor: (row: any) => row.reputation },
{ header: "App", accessor: (row: any) => row.app_label },
{ header: "Country", accessor: (row: any) => row.country },
]}
/>
);
case "Insecure Protocols":
return (
<DataTable
data={insecureProtocols} isLoading={loadingInsecure}
columns={[
{ header: "Protocol", accessor: (row: any) => row.protocol },
{ header: "IP Address", accessor: (row: any) => row.ip_address },
{ header: "Risk", accessor: (row: any) => row.risk },
]}
/>
);
case "Unencrypted Passwords":
return (
<DataTable
data={unencryptedPasswords} isLoading={loadingPasswords}
columns={[
{ header: "Protocol", accessor: (row: any) => row.protocol },
{ header: "IP Address", accessor: (row: any) => row.ip_address },
{ header: "Severity", accessor: (row: any) => row.severity },
]}
/>
);
case "Encryption Audit":
return (
<DataTable
data={encryptionAudit} isLoading={loadingEncryption}
searchPlaceholder="Search by Device or IP..."
searchFilter={(row: any, q: string) => {
const query = q.toLowerCase();
return (row.ip_address || '').toLowerCase().includes(query) || (row.device_label || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query);
}}
csvExport={{
filename: `encryption-audit-${new Date().toISOString().slice(0,10)}.csv`,
headers: ["Device Label", "MAC Address", "IP Address", "Encrypted Data", "Encrypted %", "Total Data", "Risk Level"],
rowSerializer: (row: any) => [row.device_label || "", row.mac_address || "", row.ip_address || "", row.encrypted || 0, row.encrypted_pct || 0, row.total || 0, row.risk_level || ""]
}}
columns={[
{
header: "Last Seen",
className: "w-[15%]",
accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
},
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
{
header: "Device",
className: "w-[25%]",
accessor: (row: any) => (
<div className="flex flex-col items-center justify-center text-center">
<span className="font-medium text-white">{row.device_label || row.ip_address}</span>
<span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span>
</div>
)
},
{
header: "IP Address",
className: "w-[20%]",
accessor: (row: any) => (
<div className="flex flex-col items-center justify-center">
<button
onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }}
className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`}
title={row.ip_address ? "Click to view device details" : ""}
>
<span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}>
{row.ip_address || "Unknown IP"}
</span>
{row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />}
</button>
{row.ip_address && <div className="mt-1 scale-90 origin-top"><IpDetails ip={row.ip_address} /></div>}
</div>
)
},
{
header: "Encrypted Data",
className: "w-[26%]",
accessor: (row: any) => (
<div className="flex flex-col items-center justify-center text-center">
<span className="text-green-400 font-mono text-sm">{fmtBytes(row.encrypted)} ({row.encrypted_pct}%)</span>
<span className="text-[10px] text-muted-foreground font-medium mt-0.5 uppercase tracking-wider">Total: {fmtBytes(row.total)}</span>
</div>
)
},
{
header: "Risk Level",
className: "w-[10%]",
accessor: (row: any) => (
<div className="flex items-center justify-center w-full">
<span className={`px-2 py-1 rounded text-xs font-medium ${
row.risk_level === 'medium' ? 'bg-yellow-500/20 text-yellow-500' : 'bg-green-500/20 text-green-500'
}`}>
{row.risk_level === 'medium' ? 'Medium' : 'Safe'}
</span>
</div>
) },
]}
/>
);
case "Device Discovery":
return (
<DataTable
data={deviceDiscovery} isLoading={loadingDevice}
searchPlaceholder="Search by IP, MAC, OS..."
searchFilter={(row: any, q: string) => {
const query = q.toLowerCase();
return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query) || (row.device_type || '').toLowerCase().includes(query);
}}
csvExport={{
filename: `device-discovery-${new Date().toISOString().slice(0,10)}.csv`,
headers: ["IP Address", "MAC Address", "Device Type", "OS", "Manufacturer", "Download", "Upload", "Last Seen"],
rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.device_type || "", row.os_label || "", row.manufacturer || "", row.download || 0, row.upload || 0, row.last_seen || ""]
}}
columns={[
{
header: "Last Seen",
className: "w-[15%]",
accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
},
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
{
header: "IP Address",
className: "w-[16%]",
accessor: (row: any) => (
<div className="flex flex-col items-center justify-center">
<button
onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }}
className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`}
title={row.ip_address ? "Click to view device details" : ""}
>
<span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}>
{row.ip_address || "Unknown IP"}
</span>
{row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />}
</button>
<span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span>
</div>
)
},
{ header: "Device Profile", className: "w-[20%]", accessor: (row: any) => (
<div className="flex flex-col items-center justify-center text-center">
<span className="text-white font-medium">{row.device_type || "-"}</span>
<span className="text-[10px] text-slate-400 font-medium mt-0.5">{row.os_label || "-"}</span>
</div>
) },
{ header: "Manufacturer", className: "w-[20%]", accessor: (row: any) => row.manufacturer || "-" },
{ header: "Bandwidth", className: "w-[25%]", accessor: (row: any) => (
<div className="flex flex-col items-center justify-center text-center gap-0.5">
<span className="text-cyan-400 text-xs font-mono">↓ {fmtBytes(row.download)}</span>
<span className="text-green-400 text-xs font-mono">↑ {fmtBytes(row.upload)}</span>
</div>
) },
]}
/>
);
case "Server Discovery":
return (
<DataTable
data={serverDiscovery} isLoading={loadingServer}
searchPlaceholder="Search by IP, MAC, Type..."
searchFilter={(row: any, q: string) => {
const query = q.toLowerCase();
return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.server_type || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query);
}}
csvExport={{
filename: `server-discovery-${new Date().toISOString().slice(0,10)}.csv`,
headers: ["IP Address", "MAC Address", "Server Type", "Port", "OS"],
rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.server_type || "", row.port || "", row.os_label || ""]
}}
columns={[
{
header: "Last Seen",
className: "w-[15%]",
accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
},
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
{
header: "Server IP",
className: "w-[25%]",
accessor: (row: any) => (
<div className="flex flex-col items-center justify-center">
<button
onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }}
className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`}
title={row.ip_address ? "Click to view device details" : ""}
>
<span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}>
{row.ip_address || "Unknown IP"}
</span>
{row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />}
</button>
<span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span>
</div>
)
},
{ header: "Server Type", className: "w-[28%]", accessor: (row: any) => (
<div className="flex flex-col items-center justify-center text-center">
<span className="text-white font-medium">{row.server_type}</span>
{row.port > 0 && <span className="text-[10px] text-purple-400 font-mono font-medium mt-0.5">Port {row.port}</span>}
</div>
) },
{ header: "OS / Platform", className: "w-[28%]", accessor: (row: any) => row.os_label },
]}
/>
);
default: return null;
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3"> Threat Intelligence
Threat Intelligence {loadingStats && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
{loadingStats && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />} </h2>
</h2>
<p className="text-muted-foreground mt-1">Deep threat intelligence feeds and security detections.</p>
</div>
<HelpTrigger pageId="intelligence" /> <HelpTrigger pageId="intelligence" />
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{intelItems.map((item, idx) => { {intelItems.map((item, idx) => {
@@ -417,7 +139,28 @@ export default function IntelligencePage() {
<CardTitle>Detailed Report: {activeTab}</CardTitle> <CardTitle>Detailed Report: {activeTab}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{renderActiveTable()} <IntelligenceTables
activeTab={activeTab}
cryptoMining={cryptoMining}
loadingCrypto={loadingCrypto}
torDetection={torDetection}
loadingTor={loadingTor}
vpnDetection={vpnDetection}
loadingVpn={loadingVpn}
ipReputation={ipReputation}
loadingIpRep={loadingIpRep}
insecureProtocols={insecureProtocols}
loadingInsecure={loadingInsecure}
unencryptedPasswords={unencryptedPasswords}
loadingPasswords={loadingPasswords}
encryptionAudit={encryptionAudit}
loadingEncryption={loadingEncryption}
deviceDiscovery={deviceDiscovery}
loadingDevice={loadingDevice}
serverDiscovery={serverDiscovery}
loadingServer={loadingServer}
setSelectedDevice={setSelectedDevice}
/>
</CardContent> </CardContent>
</Card> </Card>
+8 -3
View File
@@ -1,5 +1,8 @@
"use client";
import { DashboardLayout } from "@/components/layout/DashboardLayout"; import { DashboardLayout } from "@/components/layout/DashboardLayout";
import { TimeFilterProvider } from "@/contexts/TimeFilterContext"; import { TimeFilterProvider } from "@/contexts/TimeFilterContext";
import { TenantConfigProvider } from "@/contexts/TenantConfigContext";
export default function AppLayout({ export default function AppLayout({
children, children,
@@ -7,8 +10,10 @@ export default function AppLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<TimeFilterProvider> <TenantConfigProvider>
<DashboardLayout>{children}</DashboardLayout> <TimeFilterProvider>
</TimeFilterProvider> <DashboardLayout>{children}</DashboardLayout>
</TimeFilterProvider>
</TenantConfigProvider>
); );
} }
+13 -334
View File
@@ -1,364 +1,43 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useLookupApplications, useLookupCategories, LookupApp } from "@/lib/api"; import { Tabs, Tab } from "@/components/ui/Tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { ApplicationCatalog } from "@/components/lookup/ApplicationCatalog";
import { DropdownFilterSelect } from "@/components/ui/DropdownFilterSelect"; import { BlacklistConfiguration } from "@/components/lookup/BlacklistConfiguration";
import { Loader2, Search, AppWindow, HelpCircle, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, X, Info, Tag, Box, Shield, Server } from "lucide-react";
import { HelpTrigger } from "@/components/help/HelpTrigger"; import { HelpTrigger } from "@/components/help/HelpTrigger";
export default function LookupPage() { export default function LookupPage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedCategory, setSelectedCategory] = useState("");
const [page, setPage] = useState(1);
const [selectedApp, setSelectedApp] = useState<LookupApp | null>(null);
const [isClosingModal, setIsClosingModal] = useState(false);
const limit = 24; // 24 items per page fits a 2, 3, or 4 column grid perfectly
const handleCloseModal = () => {
setIsClosingModal(true);
setTimeout(() => {
setSelectedApp(null);
setIsClosingModal(false);
}, 480);
};
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
document.title = "App Lookup | BackOne - Deep Package Inspection";
}, []); }, []);
// Debounce search query input to avoid spamming requests
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(searchQuery);
setPage(1); // Reset page on new search
}, 400);
return () => clearTimeout(handler);
}, [searchQuery]);
const { data, isLoading, error } = useLookupApplications(page, limit, debouncedSearch, selectedCategory);
const { data: categories, error: categoriesError } = useLookupCategories();
useEffect(() => {
if (categoriesError) {
console.error("Failed to fetch categories:", categoriesError);
}
}, [categoriesError]);
if (!mounted) return null; if (!mounted) return null;
const applications = data?.applications || [];
const pagination = data?.pagination || { total_records: 0, total_pages: 1, current_page: 1 };
return ( return (
<div className="space-y-6 animate-fade-in pb-10"> <div className="space-y-6 animate-fade-in pb-10">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-white tracking-tight flex items-center gap-3"> <h1 className="text-3xl font-bold text-white tracking-tight flex items-center gap-3">
App Lookup App Lookup
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
</h1> </h1>
<p className="text-slate-400 mt-2"> <p className="text-slate-400 mt-2">
Search BackOne's extensive classification database of over 2,500+ apps, protocols, and services. Search BackOne's extensive classification database of over 2,500+ apps, protocols, and services, and manage your blacklist configuration.
</p> </p>
</div> </div>
<HelpTrigger pageId="lookup" /> <HelpTrigger pageId="lookup" />
</div> </div>
{/* Search & Filter Bar */} <Tabs>
<div className="flex flex-col sm:flex-row gap-4 animate-slide-up relative z-50" style={{ animationDelay: '50ms' }}> <Tab label="App Catalog">
<div className="relative flex-1 max-w-md w-full"> <ApplicationCatalog />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" /> </Tab>
<input <Tab label="Blacklist Configuration">
type="text" <BlacklistConfiguration />
placeholder="Search by app name or tag (e.g. facebook, openvpn)..." </Tab>
className="w-full bg-slate-900 border border-border/50 rounded-lg pl-10 pr-4 py-2 text-sm text-white placeholder-slate-400 focus:outline-none hover:border-slate-700 transition-all duration-200" </Tabs>
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="relative w-full sm:w-64 z-50">
<DropdownFilterSelect
value={selectedCategory || "All"}
onChange={(val) => {
setSelectedCategory(val === "All" ? "" : val);
setPage(1); // Reset page on category change
}}
options={categories || []}
placeholder="All Categories"
/>
</div>
</div>
{/* Cards Grid */}
{error ? (
<div className="p-8 bg-red-950/20 border border-red-500/30 rounded-xl text-center text-red-400">
<p className="font-semibold">Failed to load application lookup database</p>
<p className="text-sm mt-1">{error.message}</p>
</div>
) : applications.length === 0 && !isLoading ? (
<div className="p-16 bg-card/40 border border-border/50 rounded-2xl text-center text-slate-400 max-w-lg mx-auto">
<AppWindow className="h-12 w-12 mx-auto mb-4 text-slate-500" />
<p className="font-semibold text-white">No applications found</p>
<p className="text-sm mt-1">Try refining your search keyword or clearing the input.</p>
</div>
) : (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 animate-slide-up" style={{ animationDelay: '100ms' }}>
{applications.map((app: LookupApp) => (
<Card
key={app.id}
onClick={() => setSelectedApp(app)}
className="group relative overflow-hidden bg-card/60 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-primary/40 transition-all duration-300 flex flex-col justify-between cursor-pointer"
>
<div className="p-5 flex flex-col h-full justify-between gap-4">
{/* Top: Icon + Title */}
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="h-10 w-10 rounded-lg bg-secondary/80 flex items-center justify-center overflow-hidden border border-border/40 group-hover:border-primary/20 transition-all">
{app.favicon || app.logo ? (
<>
<img
src={app.favicon || app.logo}
alt={app.label}
className="h-6 w-6 object-contain"
onError={(e) => {
// If image fails, hide it and show the sibling icon
const img = e.target as HTMLElement;
img.style.display = 'none';
const sibling = img.nextSibling as HTMLElement;
if (sibling) {
sibling.style.display = 'block';
}
}}
/>
<AppWindow className="h-5 w-5 text-slate-400" style={{ display: 'none' }} />
</>
) : (
<AppWindow className="h-5 w-5 text-slate-400" />
)}
</div>
<span className="text-[10px] uppercase font-semibold tracking-wider text-primary bg-primary/10 border border-primary/20 px-2 py-0.5 rounded-full">
{app.application_category?.label || "Unclassified"}
</span>
</div>
<div>
<h3 className="font-bold text-white group-hover:text-primary transition-colors text-base line-clamp-1">
{app.label}
</h3>
<p className="text-[11px] text-slate-500 font-mono mt-0.5">{app.tag}</p>
</div>
<p className="text-xs text-slate-400 line-clamp-3 leading-relaxed mt-2">
{app.description || "No description provided for this application definition."}
</p>
</div>
{/* Bottom: ID and system information */}
<div className="flex items-center justify-between pt-2 border-t border-border/30 text-[10px] text-slate-500 font-mono">
<span>ID: {app.id}</span>
<span>{app.name || "N/A"}</span>
</div>
</div>
</Card>
))}
</div>
{/* Pagination Controls */}
{pagination.total_pages > 1 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-4 border-t border-border/50 text-slate-400 text-sm animate-slide-up" style={{ animationDelay: '150ms' }}>
<div>
Showing <span className="text-white font-medium">{((page - 1) * limit) + 1}</span> to{" "}
<span className="text-white font-medium">
{Math.min(page * limit, pagination.total_records)}
</span>{" "}
of <span className="text-white font-medium">{pagination.total_records}</span> entries
</div>
<div className="flex items-center gap-1">
{/* First Page */}
<button
onClick={() => setPage(1)}
disabled={page === 1 || isLoading}
className="p-2 rounded-lg bg-card border border-border/50 text-slate-400 hover:bg-secondary disabled:opacity-30 disabled:hover:bg-card transition-colors"
title="First Page"
>
<ChevronsLeft className="h-4 w-4" />
</button>
{/* Prev Page */}
<button
onClick={() => setPage(p => Math.max(p - 1, 1))}
disabled={page === 1 || isLoading}
className="p-2 rounded-lg bg-card border border-border/50 text-slate-400 hover:bg-secondary disabled:opacity-30 disabled:hover:bg-card transition-colors"
title="Previous Page"
>
<ChevronLeft className="h-4 w-4" />
</button>
{/* Page indicators */}
<span className="px-4 py-1.5 rounded-lg bg-secondary/50 border border-border/30 text-white font-mono">
{page} / {pagination.total_pages}
</span>
{/* Next Page */}
<button
onClick={() => setPage(p => Math.min(p + 1, pagination.total_pages))}
disabled={page === pagination.total_pages || isLoading}
className="p-2 rounded-lg bg-card border border-border/50 text-slate-400 hover:bg-secondary disabled:opacity-30 disabled:hover:bg-card transition-colors"
title="Next Page"
>
<ChevronRight className="h-4 w-4" />
</button>
{/* Last Page */}
<button
onClick={() => setPage(pagination.total_pages)}
disabled={page === pagination.total_pages || isLoading}
className="p-2 rounded-lg bg-card border border-border/50 text-slate-400 hover:bg-secondary disabled:opacity-30 disabled:hover:bg-card transition-colors"
title="Last Page"
>
<ChevronsRight className="h-4 w-4" />
</button>
</div>
</div>
)}
</div>
)}
{/* Application Detail Modal */}
{selectedApp && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6" onClick={handleCloseModal}>
<div
className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosingModal ? 'animate-overlay-out' : 'animate-overlay-in'}`}
/>
<div
className={`relative w-full max-w-2xl bg-card border border-border rounded-2xl shadow-2xl shadow-black/60 overflow-hidden ${isClosingModal ? 'animate-modal-out' : 'animate-modal-in'}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header Background */}
<div className="h-32 bg-gradient-to-br from-primary/20 via-primary/5 to-transparent relative">
<button
onClick={handleCloseModal}
className="absolute top-4 right-4 p-2 bg-black/20 hover:bg-black/40 rounded-full text-white/70 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="px-6 pb-6 sm:px-8 sm:pb-8 relative -mt-16">
{/* Icon/Logo */}
<div className="w-24 h-24 bg-card rounded-2xl border-4 border-card flex items-center justify-center overflow-hidden shadow-xl relative z-10 mb-4 bg-secondary">
{selectedApp.logo || selectedApp.favicon ? (
<img
src={selectedApp.logo || selectedApp.favicon}
alt={selectedApp.label}
className="w-16 h-16 object-contain"
onError={(e) => {
const img = e.target as HTMLElement;
img.style.display = 'none';
const sibling = img.nextSibling as HTMLElement;
if (sibling) sibling.style.display = 'block';
}}
/>
) : null}
<AppWindow className="w-12 h-12 text-slate-400" style={{ display: (selectedApp.logo || selectedApp.favicon) ? 'none' : 'block' }} />
</div>
{/* Title & Category */}
<div className="space-y-2">
<div className="flex items-center gap-3 flex-wrap">
<h2 className="text-2xl sm:text-3xl font-bold text-white tracking-tight">{selectedApp.label}</h2>
{selectedApp.application_category?.label && (
<span className="px-3 py-1 bg-primary/10 border border-primary/20 text-primary text-xs font-bold uppercase tracking-widest rounded-full">
{selectedApp.application_category.label}
</span>
)}
</div>
{selectedApp.full_name && selectedApp.full_name !== selectedApp.label && (
<p className="text-slate-400 font-medium">{selectedApp.full_name}</p>
)}
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Description */}
<div className="space-y-4 md:col-span-2">
<div className="flex items-start gap-3 text-slate-300 bg-secondary/30 p-4 rounded-xl border border-border/30">
<Info className="w-5 h-5 text-primary shrink-0 mt-0.5" />
<p className="text-sm leading-relaxed">
{selectedApp.description || "No detailed description provided for this application or service."}
</p>
</div>
</div>
{/* Metadata Info */}
<div className="space-y-3">
<h3 className="text-xs uppercase font-bold text-slate-500 tracking-wider">System Metadata</h3>
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
<div className="flex items-center gap-2 text-slate-400">
<Box className="w-4 h-4" />
<span className="text-sm">App ID</span>
</div>
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.id}</span>
</div>
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
<div className="flex items-center gap-2 text-slate-400">
<Tag className="w-4 h-4" />
<span className="text-sm">System Name</span>
</div>
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.name || 'N/A'}</span>
</div>
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
<div className="flex items-center gap-2 text-slate-400">
<Shield className="w-4 h-4" />
<span className="text-sm">Tag / Alias</span>
</div>
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.tag || 'N/A'}</span>
</div>
</div>
{/* Application Category Detail */}
{selectedApp.application_category && (
<div className="space-y-3">
<h3 className="text-xs uppercase font-bold text-slate-500 tracking-wider">Category Info</h3>
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
<div className="flex items-center gap-2 text-slate-400">
<Server className="w-4 h-4" />
<span className="text-sm">Category ID</span>
</div>
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.application_category.id}</span>
</div>
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
<div className="flex items-center gap-2 text-slate-400">
<Info className="w-4 h-4" />
<span className="text-sm">Category Tag</span>
</div>
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.application_category.tag}</span>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
@@ -11,28 +11,31 @@ import {
} from "@/lib/api-advanced"; } from "@/lib/api-advanced";
import { IpDetails } from "@/components/ui/IpDetails"; import { IpDetails } from "@/components/ui/IpDetails";
import { RemoteIpDetailModal } from "@/components/ui/RemoteIpDetailModal"; import { RemoteIpDetailModal } from "@/components/ui/RemoteIpDetailModal";
import { HelpTrigger } from "@/components/help/HelpTrigger"; import { HelpTrigger } from "@/components/help/HelpTrigger";
export default function NetworkInfrastructurePage() { export default function NetworkInfrastructurePage() {
const [selectedRemoteIp, setSelectedRemoteIp] = useState<string | null>(null); const [selectedRemoteIp, setSelectedRemoteIp] = useState<string | null>(null);
useEffect(() => {
document.title = "Network Topology | BackOne - Deep Package Inspection";
}, []);
// Routing & Geography Tab // Routing & Geography Tab
const { data: regions, isLoading: loadingRegions } = useRegions(20); const { data: regions, isLoading: loadingRegions } = useRegions();
const { data: cities, isLoading: loadingCities } = useCities(20); const { data: cities, isLoading: loadingCities } = useCities();
// Interfaces & VLANs Tab // Interfaces & VLANs Tab
const { data: interfaces, isLoading: loadingInterfaces } = useInterfaces(20); const { data: interfaces, isLoading: loadingInterfaces } = useInterfaces();
const { data: vlans, isLoading: loadingVlans } = useVlans(20); const { data: vlans, isLoading: loadingVlans } = useVlans();
// IP & MAC Tab // IP & MAC Tab
const { data: remoteIps, isLoading: loadingRemoteIps } = useRemoteIps(20); const { data: remoteIps, isLoading: loadingRemoteIps } = useRemoteIps();
const { data: ipVersions, isLoading: loadingIpVersions } = useIpVersions(20); const { data: ipVersions, isLoading: loadingIpVersions } = useIpVersions();
const { data: macBandwidth, isLoading: loadingMacBandwidth } = useMacBandwidth(20); const { data: macBandwidth, isLoading: loadingMacBandwidth } = useMacBandwidth();
// Flow Profiles Tab // Flow Profiles Tab
const { data: flowTypes, isLoading: loadingFlowTypes } = useFlowTypes(20); const { data: flowTypes, isLoading: loadingFlowTypes } = useFlowTypes();
const { data: flowOrigins, isLoading: loadingFlowOrigins } = useFlowOrigins(20); const { data: flowOrigins, isLoading: loadingFlowOrigins } = useFlowOrigins();
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
if (bytes === 0 || !bytes) return "0 B"; if (bytes === 0 || !bytes) return "0 B";
@@ -31,6 +31,7 @@ export default function NetworkIntelligencePage() {
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
document.title = "Traffic Categories | BackOne - Deep Package Inspection";
}, []); }, []);
if (!mounted) return null; if (!mounted) return null;
+3
View File
@@ -7,6 +7,7 @@ import { GlobeMap } from "@/components/ui/GlobeMap";
import { getOriginLocation, getDestinationLocation } from "@/lib/geoUtils"; import { getOriginLocation, getDestinationLocation } from "@/lib/geoUtils";
import { KPICards } from "@/components/dashboard/KPICards"; import { KPICards } from "@/components/dashboard/KPICards";
import { TopWidgets } from "@/components/dashboard/TopWidgets"; import { TopWidgets } from "@/components/dashboard/TopWidgets";
import { HelpTrigger } from "@/components/help/HelpTrigger";
export default function SummaryPage() { export default function SummaryPage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -74,6 +75,7 @@ export default function SummaryPage() {
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
document.title = "Overview Dashboard | BackOne - Deep Package Inspection";
}, []); }, []);
if (!mounted) return null; if (!mounted) return null;
@@ -90,6 +92,7 @@ export default function SummaryPage() {
</h2> </h2>
<p className="text-muted-foreground mt-1">Real-time network traffic and intelligence summary.</p> <p className="text-muted-foreground mt-1">Real-time network traffic and intelligence summary.</p>
</div> </div>
<HelpTrigger pageId="overview" />
</div> </div>
{/* KPI Cards */} {/* KPI Cards */}
+8 -11
View File
@@ -15,9 +15,9 @@ import { HelpTrigger } from "@/components/help/HelpTrigger";
export default function SecurityAuditPage() { export default function SecurityAuditPage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const versions = useTlsVersions(10); const versions = useTlsVersions();
const ciphers = useTlsCiphers(20); const ciphers = useTlsCiphers();
const security = useTlsSecurity(10); const security = useTlsSecurity();
const [secDevices, setSecDevices] = useState<SecurityDevice[]>([]); const [secDevices, setSecDevices] = useState<SecurityDevice[]>([]);
const [secDevicesLoading, setSecDevicesLoading] = useState(true); const [secDevicesLoading, setSecDevicesLoading] = useState(true);
@@ -54,14 +54,11 @@ export default function SecurityAuditPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div>
<div> <h2 className="text-2xl font-bold text-white tracking-tight">Security & Encryption Audit</h2>
<h2 className="text-3xl font-bold text-white tracking-tight">Encryption Audit</h2> <p className="text-sm text-slate-400 mt-1">
<p className="text-sm text-slate-400 mt-1"> Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles.
Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles. </p>
</p>
</div>
<HelpTrigger pageId="security-audit" />
</div> </div>
<UniversalFilters <UniversalFilters
+3 -317
View File
@@ -1,322 +1,8 @@
"use client"; "use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Suspense } from "react";
import { DataTable, Column } from "@/components/ui/DataTable"; import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/Badge"; import ThreatsContent from "@/components/threats/ThreatsContent";
import { useState, useEffect, useMemo, Suspense } from "react";
import { TimestampCell } from "@/components/ui/TimestampCell";
import { ShieldCheck, Loader2 } from "lucide-react";
import { ThreatStat } from "@/lib/api";
import { useThreats } from "@/lib/api-with-context";
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
import { ThreatFilters } from "@/components/threats/ThreatFilters";
import { ThreatRecommendationsDrawer } from "@/components/threats/ThreatRecommendationsDrawer";
import { useSearchParams } from "next/navigation";
import { formatAppLabel } from "@/lib/utils";
import { HelpTrigger } from "@/components/help/HelpTrigger";
// ─── ThreatsContent ───────────────────────────────────────────────────────────
function ThreatsContent() {
const [mounted, setMounted] = useState(false);
const [selectedIp, setSelectedIp] = useState<string | null>(null);
const [selectedMac, setSelectedMac] = useState<string | null>(null);
const [selectedThreat, setSelectedThreat] = useState<ThreatStat | null>(null);
const threats = useThreats();
const searchParams = useSearchParams();
const highlightId = searchParams.get("highlight");
// Column dropdown filters state
const [fType, setFType] = useState("All");
const [fSev, setFSev] = useState("All");
const [fSrcIp, setFSrcIp] = useState("All");
const [fDstIp, setFDstIp] = useState("All");
const [fMac, setFMac] = useState("All");
const [fApp, setFApp] = useState("All");
const [fDomain, setFDomain] = useState("All");
// Date range filter state
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
useEffect(() => {
setMounted(true);
}, []);
const all = threats.data || [];
// Apply all filters
const filtered = useMemo(() => {
return all.filter((row) => {
if (fType !== "All" && (row.threat_type || "") !== fType) return false;
if (fSev !== "All" && (row.severity || "") !== fSev) return false;
if (fSrcIp !== "All" && (row.ip_address || "") !== fSrcIp) return false;
if (fDstIp !== "All" && (row.dst_ip || "") !== fDstIp) return false;
if (fMac !== "All" && (row.mac_address || "") !== fMac) return false;
if (fApp !== "All" && (row.app_label || "") !== fApp) return false;
if (fDomain !== "All" && (row.domain || "") !== fDomain) return false;
if (dateFrom || dateTo) {
const d = row.detected_at
? new Date(row.detected_at).toISOString().slice(0, 10)
: "";
if (dateFrom && d < dateFrom) return false;
if (dateTo && d > dateTo) return false;
}
return true;
});
}, [all, fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain, dateFrom, dateTo]);
// Automatically select a threat if highlightId is passed
useEffect(() => {
if (highlightId && all.length > 0) {
const match = all.find(r => r.id.toString() === highlightId);
if (match) {
setSelectedThreat(match);
}
}
}, [highlightId, all]);
if (!mounted) return null;
const getSeverityBadge = (sev?: string | null) => {
if (!sev) return <Badge variant="green">Unknown</Badge>;
const s = sev.toLowerCase();
if (s.includes("high") || s.includes("critical")) return <Badge variant="red">{sev}</Badge>;
if (s.includes("med")) return <Badge variant="orange">{sev}</Badge>;
if (s.includes("low")) return <Badge variant="yellow">{sev}</Badge>;
return <Badge variant="blue">{sev}</Badge>;
};
const columns: Column<ThreatStat>[] = [
{
header: "Timestamp",
className: "w-[155px] min-w-[155px] max-w-[155px] text-center",
accessor: (row) => <TimestampCell timestamp={row.detected_at} showActiveStatus={false} />
},
{ header: "#", className: "w-[40px] min-w-[40px] max-w-[40px] text-center", accessor: (_row, i) => i + 1 },
{
header: "Type",
className: "w-[170px] min-w-[170px] max-w-[170px]",
accessor: (row) => (
<div
title={row.description || row.threat_type || "Unknown"}
className="truncate w-full block cursor-help text-xs leading-tight"
>
{row.threat_type || "Unknown"}
</div>
),
},
{
header: "Severity",
className: "w-[90px] min-w-[90px] max-w-[90px] text-center",
accessor: (row) => getSeverityBadge(row.severity),
},
{
header: "Source IP",
className: "w-[120px] min-w-[120px] max-w-[120px] text-center",
accessor: (row) =>
row.ip_address ? (
<button
onClick={() => setSelectedIp(row.ip_address)}
className="font-medium text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none w-full text-center"
>
{row.ip_address}
</button>
) : (
<span className="text-muted-foreground text-xs" title="Source IP tidak tersedia dari data event keamanan (hanya MAC Address yang ter-capture)">–</span>
),
},
{
header: "Dest IP",
className: "w-[120px] min-w-[120px] max-w-[120px] text-center",
accessor: (row) =>
row.dst_ip ? (
<button
onClick={() => setSelectedIp(row.dst_ip)}
className="font-mono text-xs text-blue-400 hover:text-blue-300 hover:underline focus:outline-none w-full text-center"
>
{row.dst_ip}
</button>
) : (
<span className="text-muted-foreground text-xs" title="Dest IP tidak tersedia dari data event ini">–</span>
),
},
{
header: "MAC Addr",
className: "w-[140px] min-w-[140px] max-w-[140px] text-center",
accessor: (row) =>
row.mac_address ? (
<button
onClick={() => setSelectedMac(row.mac_address)}
className="text-xs bg-secondary/50 px-1.5 py-0.5 rounded text-muted-foreground hover:text-white hover:bg-secondary transition-colors font-mono focus:outline-none w-full text-center truncate"
title={row.mac_address}
>
{row.mac_address}
</button>
) : "–",
},
{
header: "App",
className: "w-[110px] min-w-[110px] max-w-[110px] text-center",
accessor: (row) => {
const formatted = formatAppLabel(row.app_label);
return formatted ? (
<div title={formatted} className="truncate w-full block text-center cursor-help text-xs">
{formatted}
</div>
) : (
<span className="text-muted-foreground text-xs" title="App tidak tersedia dari event ini">–</span>
);
},
},
{
header: "Domain",
className: "w-[160px] min-w-[160px] max-w-[160px] text-center",
accessor: (row) => row.domain ? (
<div title={row.domain} className="truncate w-full text-center text-xs cursor-help">
{row.domain}
</div>
) : (
<span className="text-muted-foreground text-xs" title="Domain tidak tersedia dari event ini">–</span>
),
},
{
header: "Action",
className: "w-[130px] min-w-[130px] max-w-[130px] text-center",
accessor: (row) => (
<button
onClick={() => setSelectedThreat(row)}
className="px-2 py-1 text-[11px] font-bold text-primary bg-primary/10 border border-primary/20 hover:bg-primary/20 hover:text-white rounded-lg transition-all shadow-sm cursor-pointer whitespace-nowrap focus:outline-none"
>
View Mitigation
</button>
),
},
];
const activeFilterCount =
[fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain].filter((v) => v !== "All").length +
(dateFrom || dateTo ? 1 : 0);
const resetFilters = () => {
setFType("All");
setFSev("All");
setFSrcIp("All");
setFDstIp("All");
setFMac("All");
setFApp("All");
setFDomain("All");
setDateFrom("");
setDateTo("");
};
return (
<div className="space-y-8 animate-fade-in pb-10">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
Detected Threats
{threats.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
</h2>
<p className="text-muted-foreground mt-1">Monitor and respond to network security threats.</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-4 py-2 rounded-lg shadow-[0_0_15px_rgba(16,185,129,0.1)]">
<ShieldCheck className="h-4 w-4" />
<span>Active Monitoring</span>
</div>
<HelpTrigger pageId="threats" />
</div>
</div>
{/* Filters above the table */}
<ThreatFilters
all={all}
fType={fType}
setFType={setFType}
fSev={fSev}
setFSev={setFSev}
fSrcIp={fSrcIp}
setFSrcIp={setFSrcIp}
fDstIp={fDstIp}
setFDstIp={setFDstIp}
fMac={fMac}
setFMac={setFMac}
fApp={fApp}
setFApp={setFApp}
fDomain={fDomain}
setFDomain={setFDomain}
dateFrom={dateFrom}
setDateFrom={setDateFrom}
dateTo={dateTo}
setDateTo={setDateTo}
activeFilterCount={activeFilterCount}
onReset={resetFilters}
/>
{/* Threats Table - Full Width */}
<Card
className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up relative z-10 overflow-visible"
style={{ animationDelay: "100ms" }}
>
<CardHeader className="border-b border-border/50 pb-4 mb-4">
<CardTitle className="text-lg text-white">
Detected Threats
<span className="ml-2 text-muted-foreground font-normal">
({filtered.length}
{filtered.length !== all.length ? ` of ${all.length}` : ""})
</span>
</CardTitle>
</CardHeader>
<CardContent>
<DataTable
data={filtered}
columns={columns}
searchPlaceholder="Search threats by IP, domain, type..."
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (
(row.ip_address || "").toLowerCase().includes(q) ||
(row.domain || "").toLowerCase().includes(q) ||
(row.threat_type || "").toLowerCase().includes(q) ||
(row.app_label || "").toLowerCase().includes(q)
);
}}
getRowClassName={(row) =>
highlightId && row.id.toString() === highlightId
? "bg-red-500/15 border-l-2 border-red-500 animate-pulse font-semibold"
: ""
}
csvExport={{
filename: `threats-${new Date().toISOString().slice(0, 10)}.csv`,
headers: ["Timestamp", "Type", "Severity", "Source IP", "Dest IP", "MAC Address", "App", "Domain"],
rowSerializer: (row) => [
row.detected_at ? new Date(row.detected_at).toISOString() : "",
row.threat_type || "",
row.severity || "",
row.ip_address || "",
row.dst_ip || "",
row.mac_address || "",
row.app_label || "",
row.domain || "",
],
}}
/>
</CardContent>
</Card>
{selectedIp && <DeviceDetailModal ip={selectedIp} onClose={() => setSelectedIp(null)} />}
{selectedMac && <DeviceDetailModal ip="" mac={selectedMac} onClose={() => setSelectedMac(null)} />}
{/* Mitigation recommendations drawer panel */}
<ThreatRecommendationsDrawer
threat={selectedThreat}
onClose={() => setSelectedThreat(null)}
/>
</div>
);
}
export default function ThreatsPage() { export default function ThreatsPage() {
return ( return (
+7 -6
View File
@@ -26,7 +26,7 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
const route = routeArray.join('/'); const route = routeArray.join('/');
const url = new URL(req.url); const url = new URL(req.url);
// Force ALL traffic to go through our own Centralized Proxy Server (Express Backend) // Force ALL traffic to go through our own Centralized Proxy Server (Express Backend)
const localBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3001'; const localBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3001';
const destinationUrl = `${localBase}/api/${route}${url.search}`; const destinationUrl = `${localBase}/api/${route}${url.search}`;
@@ -46,11 +46,12 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
headers.set('cookie', cookie); headers.set('cookie', cookie);
} }
// Forward X-View-As-Agent header for view-as mode data filtering // Forward all custom headers starting with x- (e.g. x-backone-site-uuid, x-view-as-agent)
const viewAsHeader = req.headers.get('x-view-as-agent'); req.headers.forEach((value, name) => {
if (viewAsHeader) { if (name.toLowerCase().startsWith('x-')) {
headers.set('x-view-as-agent', viewAsHeader); headers.set(name, value);
} }
});
const hasBody = !['GET', 'HEAD'].includes(req.method); const hasBody = !['GET', 'HEAD'].includes(req.method);
let body = null; let body = null;
+10 -410
View File
@@ -1,419 +1,21 @@
@import "tailwindcss"; @import "tailwindcss";
@import "./theme.css";
@theme inline { @import "./variables.css";
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-backone: var(--font-backone);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-sidebar: var(--sidebar);
/* OVERRIDE TAILWIND HARDCODED COLORS FOR SEAMLESS LIGHT MODE */
--color-slate-900: var(--slate-900);
--color-slate-800: var(--slate-800);
--color-slate-700: var(--slate-700);
--color-slate-600: var(--slate-600);
--color-slate-500: var(--slate-500);
--color-slate-400: var(--slate-400);
--color-slate-300: var(--slate-300);
--color-slate-200: var(--slate-200);
--color-slate-100: var(--slate-100);
--color-slate-50: var(--slate-50);
--color-gray-900: var(--slate-900);
--color-gray-800: var(--slate-800);
--color-gray-700: var(--slate-700);
--color-gray-600: var(--slate-600);
--color-gray-500: var(--slate-500);
--color-gray-400: var(--slate-400);
--color-gray-300: var(--slate-300);
--color-gray-200: var(--slate-200);
--color-gray-100: var(--slate-100);
--color-gray-50: var(--slate-50);
--color-white: var(--t-white);
--color-black: var(--t-black);
--font-sans: var(--font-inter);
--font-mono: var(--font-inter);
/* Micro-animations */
--animate-fade-in: fade-in 0.5s ease-out forwards;
--animate-slide-up: slide-up 0.5s ease-out forwards;
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes slide-up {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
}
:root {
/* Premium Dark Theme */
--background: #09090b;
/* Very dark zinc */
--foreground: #fafafa;
--card: rgba(24, 24, 27, 0.7);
/* Zinc 900 with transparency for glassmorphism */
--card-foreground: #fafafa;
--border: rgba(255, 255, 255, 0.1);
--primary: #3b82f6;
/* Blue 500 */
--primary-foreground: #ffffff;
--secondary: rgba(39, 39, 42, 0.8);
/* Zinc 800 */
--secondary-foreground: #fafafa;
--muted: rgba(39, 39, 42, 0.5);
--muted-foreground: #a1a1aa;
/* Zinc 400 */
--destructive: #ef4444;
/* Red 500 */
--destructive-foreground: #ffffff;
--sidebar: #09090b;
/* Overrides for hardcoded colors in Dark Mode (Normal) */
--t-white: #ffffff;
--t-black: #000000;
--slate-900: #0f172a;
--slate-800: #1e293b;
--slate-700: #334155;
--slate-600: #475569;
--slate-500: #64748b;
--slate-400: #94a3b8;
--slate-300: #cbd5e1;
--slate-200: #e2e8f0;
--slate-100: #f1f5f9;
--slate-50: #f8fafc;
}
.light {
/* Clean Light Theme */
--background: #f1f5f9;
/* Slate 100 */
--foreground: #0f172a;
--card: rgba(255, 255, 255, 0.9);
--card-foreground: #0f172a;
--border: rgba(0, 0, 0, 0.1);
--primary: #2563eb;
/* Blue 600 */
--primary-foreground: #ffffff;
--secondary: rgba(241, 245, 249, 0.8);
/* Slate 100 */
--secondary-foreground: #0f172a;
--muted: rgba(226, 232, 240, 0.5);
/* Slate 200 */
--muted-foreground: #64748b;
/* Slate 500 */
--sidebar: #ffffff;
/* Overrides for hardcoded colors in Light Mode (Inverted) */
--t-white: #0f172a;
/* Make text-white become very dark slate */
--t-black: #ffffff;
--slate-900: #ffffff;
--slate-800: #f8fafc;
--slate-700: #f1f5f9;
--slate-600: #e2e8f0;
--slate-500: #cbd5e1;
--slate-400: #64748b;
/* Keep 400 slightly dark so it stays readable */
--slate-300: #475569;
--slate-200: #334155;
--slate-100: #1e293b;
--slate-50: #0f172a;
/* Enhance contrast for colored text and backgrounds in Light Mode */
/* RED */
--color-red-300: #b91c1c;
/* red-700 */
--color-red-400: #dc2626;
/* red-600 */
--color-red-500: #b91c1c;
/* red-700 */
--color-red-950: #ef4444;
/* red-500 (so that 950/20 becomes a visible pink bg) */
/* ORANGE */
--color-orange-300: #c2410c;
/* orange-700 */
--color-orange-400: #ea580c;
/* orange-600 */
--color-orange-500: #c2410c;
/* orange-700 */
--color-orange-950: #f97316;
/* orange-500 */
/* YELLOW */
--color-yellow-300: #a16207;
/* yellow-700 */
--color-yellow-400: #ca8a04;
/* yellow-600 */
--color-yellow-500: #a16207;
/* yellow-700 */
--color-yellow-950: #eab308;
/* yellow-500 */
/* GREEN */
--color-green-300: #15803d;
/* green-700 */
--color-green-400: #16a34a;
/* green-600 */
--color-green-500: #15803d;
/* green-700 */
--color-green-950: #22c55e;
/* green-500 */
/* EMERALD */
--color-emerald-300: #047857;
/* emerald-700 */
--color-emerald-400: #059669;
/* emerald-600 */
--color-emerald-500: #047857;
/* emerald-700 */
--color-emerald-950: #10b981;
/* emerald-500 */
/* BLUE */
--color-blue-300: #1d4ed8;
/* blue-700 */
--color-blue-400: #2563eb;
/* blue-600 */
--color-blue-500: #1d4ed8;
/* blue-700 */
--color-blue-950: #3b82f6;
/* blue-500 */
/* PURPLE */
--color-purple-300: #7e22ce;
/* purple-700 */
--color-purple-400: #9333ea;
/* purple-600 */
--color-purple-500: #7e22ce;
/* purple-700 */
--color-purple-950: #a855f7;
/* purple-500 */
}
body { body {
font-family: var(--font-sans), sans-serif; /* Use Georgia font style globally */
font-family: Georgia, serif, var(--font-sans);
color: var(--foreground); color: var(--foreground);
/* Optical sizing + smoothing */
font-optical-sizing: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
} }
/* 1-Second Dual-Layer Smooth Background Crossfade */ /* 1-Second Dual-Layer Smooth Background Crossfade */
:root { /* Dark Mode Background Layer (Active by default, fades out on .light) */
/* Premium Dark Theme */
--background: #09090b;
/* Very dark zinc */
--foreground: #fafafa;
--card: rgba(24, 24, 27, 0.7);
/* Zinc 900 with transparency for glassmorphism */
--card-foreground: #fafafa;
--border: rgba(255, 255, 255, 0.1);
--primary: #3b82f6;
/* Blue 500 */
--primary-foreground: #ffffff;
--secondary: rgba(39, 39, 42, 0.8);
/* Zinc 800 */
--secondary-foreground: #fafafa;
--muted: rgba(39, 39, 42, 0.5);
--muted-foreground: #a1a1aa;
/* Zinc 400 */
--destructive: #ef4444;
/* Red 500 */
--destructive-foreground: #ffffff;
--sidebar: #09090b;
/* Overrides for hardcoded colors in Dark Mode (Normal) */
--t-white: #ffffff;
--t-black: #000000;
--slate-900: #0f172a;
--slate-800: #1e293b;
--slate-700: #334155;
--slate-600: #475569;
--slate-500: #64748b;
--slate-400: #94a3b8;
--slate-300: #cbd5e1;
--slate-200: #e2e8f0;
--slate-100: #f1f5f9;
--slate-50: #f8fafc;
}
.light {
/* Clean Light Theme */
--background: #f1f5f9;
/* Slate 100 */
--foreground: #0f172a;
--card: rgba(255, 255, 255, 0.9);
--card-foreground: #0f172a;
--border: rgba(0, 0, 0, 0.1);
--primary: #2563eb;
/* Blue 600 */
--primary-foreground: #ffffff;
--secondary: rgba(241, 245, 249, 0.8);
/* Slate 100 */
--secondary-foreground: #0f172a;
--muted: rgba(226, 232, 240, 0.5);
/* Slate 200 */
--muted-foreground: #64748b;
/* Slate 500 */
--sidebar: #ffffff;
/* Overrides for hardcoded colors in Light Mode (Inverted) */
--t-white: #0f172a;
/* Make text-white become very dark slate */
--t-black: #ffffff;
--slate-900: #ffffff;
--slate-800: #f8fafc;
--slate-700: #f1f5f9;
--slate-600: #e2e8f0;
--slate-500: #cbd5e1;
--slate-400: #64748b;
/* Keep 400 slightly dark so it stays readable */
--slate-300: #475569;
--slate-200: #334155;
--slate-100: #1e293b;
--slate-50: #0f172a;
/* Enhance contrast for colored text and backgrounds in Light Mode */
/* RED */
--color-red-300: #b91c1c;
/* red-700 */
--color-red-400: #dc2626;
/* red-600 */
--color-red-500: #b91c1c;
/* red-700 */
--color-red-950: #ef4444;
/* red-500 (so that 950/20 becomes a visible pink bg) */
/* ORANGE */
--color-orange-300: #c2410c;
/* orange-700 */
--color-orange-400: #ea580c;
/* orange-600 */
--color-orange-500: #c2410c;
/* orange-700 */
--color-orange-950: #f97316;
/* orange-500 */
/* YELLOW */
--color-yellow-300: #a16207;
/* yellow-700 */
--color-yellow-400: #ca8a04;
/* yellow-600 */
--color-yellow-500: #a16207;
/* yellow-700 */
--color-yellow-950: #eab308;
/* yellow-500 */
/* GREEN */
--color-green-300: #15803d;
/* green-700 */
--color-green-400: #16a34a;
/* green-600 */
--color-green-500: #15803d;
/* green-700 */
--color-green-950: #22c55e;
/* green-500 */
/* EMERALD */
--color-emerald-300: #047857;
/* emerald-700 */
--color-emerald-400: #059669;
/* emerald-600 */
--color-emerald-500: #047857;
/* emerald-700 */
--color-emerald-950: #10b981;
/* emerald-500 */
/* BLUE */
--color-blue-300: #1d4ed8;
/* blue-700 */
--color-blue-400: #2563eb;
/* blue-600 */
--color-blue-500: #1d4ed8;
/* blue-700 */
--color-blue-950: #3b82f6;
/* blue-500 */
/* PURPLE */
--color-purple-300: #7e22ce;
/* purple-700 */
--color-purple-400: #9333ea;
/* purple-600 */
--color-purple-500: #7e22ce;
/* purple-700 */
--color-purple-950: #a855f7;
/* purple-500 */
}
body {
font-family: var(--font-sans), sans-serif;
color: var(--foreground);
}
/* 1-Second Dual-Layer Smooth Background Crossfade */
body::before, body::before,
body::after { body::after {
content: ""; content: "";
@@ -426,7 +28,6 @@ body::after {
background-attachment: fixed; background-attachment: fixed;
} }
/* Dark Mode Background Layer (Active by default, fades out on .light) */
body::before { body::before {
background-color: #09090b; background-color: #09090b;
background-image: background-image:
@@ -570,7 +171,6 @@ input::-ms-clear {
display: none; display: none;
} }
/* Accordion Animation Fix */ /* Accordion Animation Fix */
.accordion-grid { .accordion-grid {
display: grid; display: grid;
+14 -4
View File
@@ -3,10 +3,20 @@ import localFont from "next/font/local";
import { ThemeProvider } from "@/components/ThemeProvider"; import { ThemeProvider } from "@/components/ThemeProvider";
import "./globals.css"; import "./globals.css";
// Bypass google font remote fetch during offline compilation // Inter font — local variable font file, matches demoplace.my.id
const inter = { // demoplace uses: --font-sans: var(--font-inter) which renders as Inter
variable: "font-sans", const inter = localFont({
}; src: [
{
path: "../../public/fonts/Inter-Variable.woff2",
weight: "100 900",
style: "normal",
},
],
variable: "--font-inter",
display: "swap",
adjustFontFallback: false,
});
const backoneFont = localFont({ const backoneFont = localFont({
src: "../../public/fonts/BackOneLogo-Regular.ttf", src: "../../public/fonts/BackOneLogo-Regular.ttf",
+164
View File
@@ -0,0 +1,164 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-backone: var(--font-backone);
/* Set Georgia as the default sans-serif replacement globally */
--font-sans: Georgia, serif, var(--font-inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
/* Mono also uses Inter first — matches demoplace (--default-mono-font-family: var(--font-inter)) */
--font-mono: var(--font-inter, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-sidebar: var(--sidebar);
/* OVERRIDE TAILWIND HARDCODED COLORS FOR SEAMLESS LIGHT MODE */
--color-slate-900: var(--slate-900);
--color-slate-800: var(--slate-800);
--color-slate-700: var(--slate-700);
--color-slate-600: var(--slate-600);
--color-slate-500: var(--slate-500);
--color-slate-400: var(--slate-400);
--color-slate-300: var(--slate-300);
--color-slate-200: var(--slate-200);
--color-slate-100: var(--slate-100);
--color-slate-50: var(--slate-50);
--color-gray-900: var(--slate-900);
--color-gray-800: var(--slate-800);
--color-gray-700: var(--slate-700);
--color-gray-600: var(--slate-600);
--color-gray-500: var(--slate-500);
--color-gray-400: var(--slate-400);
--color-gray-300: var(--slate-300);
--color-gray-200: var(--slate-200);
--color-gray-100: var(--slate-100);
--color-gray-50: var(--slate-50);
--color-white: var(--t-white);
--color-black: var(--t-black);
/* Radix-style color palette — matching demoplace.my.id exactly */
--color-red-50: #fef2f2;
--color-red-100: #ffe2e2;
--color-red-200: #ffcaca;
--color-red-300: #ffa3a3;
--color-red-400: #ff6568;
--color-red-500: #fb2c36;
--color-red-600: #e40014;
--color-red-700: #bf000f;
--color-red-800: #9f0712;
--color-red-900: #82181a;
--color-red-950: #460809;
--color-orange-300: #ffb96d;
--color-orange-400: #ff8b1a;
--color-orange-500: #fe6e00;
--color-orange-900: #7e2a0c;
--color-orange-950: #441306;
--color-amber-100: #fef3c6;
--color-amber-200: #fee685;
--color-amber-300: #ffd236;
--color-amber-400: #fcbb00;
--color-amber-500: #f99c00;
--color-amber-600: #dd7400;
--color-amber-700: #b75000;
--color-amber-800: #953d00;
--color-amber-900: #7b3306;
--color-amber-950: #461901;
--color-yellow-300: #ffe02a;
--color-yellow-400: #fac800;
--color-yellow-500: #edb200;
--color-green-300: #7bf1a8;
--color-green-400: #05df72;
--color-green-500: #00c758;
--color-green-800: #016630;
--color-green-900: #0d542b;
--color-green-950: #032e15;
--color-emerald-100: #d0fae5;
--color-emerald-200: #a4f4cf;
--color-emerald-300: #5ee9b5;
--color-emerald-400: #00d294;
--color-emerald-500: #00bb7f;
--color-emerald-600: #009767;
--color-emerald-700: #007956;
--color-emerald-900: #004e3b;
--color-emerald-950: #002c22;
--color-teal-400: #00d3bd;
--color-teal-500: #00baa7;
--color-cyan-100: #cefafe;
--color-cyan-300: #53eafd;
--color-cyan-400: #00d2ef;
--color-cyan-500: #00b7d7;
--color-cyan-600: #0092b5;
--color-sky-400: #00bcfe;
--color-blue-50: #eff6ff;
--color-blue-200: #bedbff;
--color-blue-300: #90c5ff;
--color-blue-400: #54a2ff;
--color-blue-500: #3080ff;
--color-blue-600: #155dfc;
--color-blue-700: #1447e6;
--color-blue-800: #193cb8;
--color-blue-900: #1c398e;
--color-blue-950: #162456;
--color-indigo-300: #a4b3ff;
--color-indigo-400: #7d87ff;
--color-indigo-500: #625fff;
--color-indigo-600: #4f39f6;
--color-indigo-900: #312c85;
--color-violet-400: #a685ff;
--color-violet-500: #8d54ff;
--color-purple-300: #d9b3ff;
--color-purple-400: #c07eff;
--color-purple-500: #ac4bff;
--color-purple-600: #9810fa;
--color-purple-900: #59168b;
--color-purple-950: #3c0366;
--color-pink-400: #fb64b6;
--color-rose-400: #ff667f;
--color-rose-900: #8b0836;
/* Micro-animations */
--animate-fade-in: fade-in 0.5s ease-out forwards;
--animate-slide-up: slide-up 0.5s ease-out forwards;
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes slide-up {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}
}
+159
View File
@@ -0,0 +1,159 @@
:root {
/* Premium Dark Theme */
--background: #09090b;
/* Very dark zinc */
--foreground: #fafafa;
--card: rgba(24, 24, 27, 0.7);
/* Zinc 900 with transparency for glassmorphism */
--card-foreground: #fafafa;
--border: rgba(255, 255, 255, 0.1);
--primary: #3b82f6;
/* Blue 500 */
--primary-foreground: #ffffff;
--secondary: rgba(39, 39, 42, 0.8);
/* Zinc 800 */
--secondary-foreground: #fafafa;
--muted: rgba(39, 39, 42, 0.5);
--muted-foreground: #a1a1aa;
/* Zinc 400 */
--destructive: #ef4444;
/* Red 500 */
--destructive-foreground: #ffffff;
--sidebar: #09090b;
/* Overrides for hardcoded colors in Dark Mode (Normal) */
--t-white: #ffffff;
--t-black: #000000;
--slate-900: #0f172a;
--slate-800: #1e293b;
--slate-700: #334155;
--slate-600: #475569;
--slate-500: #64748b;
--slate-400: #94a3b8;
--slate-300: #cbd5e1;
--slate-200: #e2e8f0;
--slate-100: #f1f5f9;
--slate-50: #f8fafc;
}
.light {
/* Clean Light Theme */
--background: #f1f5f9;
/* Slate 100 */
--foreground: #0f172a;
--card: rgba(255, 255, 255, 0.9);
--card-foreground: #0f172a;
--border: rgba(0, 0, 0, 0.1);
--primary: #2563eb;
/* Blue 600 */
--primary-foreground: #ffffff;
--secondary: rgba(241, 245, 249, 0.8);
/* Slate 100 */
--secondary-foreground: #0f172a;
--muted: rgba(226, 232, 240, 0.5);
/* Slate 200 */
--muted-foreground: #64748b;
/* Slate 500 */
--sidebar: #ffffff;
/* Overrides for hardcoded colors in Light Mode (Inverted) */
--t-white: #0f172a;
/* Make text-white become very dark slate */
--t-black: #ffffff;
--slate-900: #ffffff;
--slate-800: #f8fafc;
--slate-700: #f1f5f9;
--slate-600: #e2e8f0;
--slate-500: #cbd5e1;
--slate-400: #64748b;
/* Keep 400 slightly dark so it stays readable */
--slate-300: #475569;
--slate-200: #334155;
--slate-100: #1e293b;
--slate-50: #0f172a;
/* Enhance contrast for colored text and backgrounds in Light Mode */
/* RED */
--color-red-300: #b91c1c;
/* red-700 */
--color-red-400: #dc2626;
/* red-600 */
--color-red-500: #b91c1c;
/* red-700 */
--color-red-950: #ef4444;
/* red-500 (so that 950/20 becomes a visible pink bg) */
/* ORANGE */
--color-orange-300: #c2410c;
/* orange-700 */
--color-orange-400: #ea580c;
/* orange-600 */
--color-orange-500: #c2410c;
/* orange-700 */
--color-orange-950: #f97316;
/* orange-500 */
/* YELLOW */
--color-yellow-300: #a16207;
/* yellow-700 */
--color-yellow-400: #ca8a04;
/* yellow-600 */
--color-yellow-500: #a16207;
/* yellow-700 */
--color-yellow-950: #eab308;
/* yellow-500 */
/* GREEN */
--color-green-300: #15803d;
/* green-700 */
--color-green-400: #16a34a;
/* green-600 */
--color-green-500: #15803d;
/* green-700 */
--color-green-950: #22c55e;
/* green-500 */
/* EMERALD */
--color-emerald-300: #047857;
/* emerald-700 */
--color-emerald-400: #059669;
/* emerald-600 */
--color-emerald-500: #047857;
/* emerald-700 */
--color-emerald-950: #10b981;
/* emerald-500 */
/* BLUE */
--color-blue-300: #1d4ed8;
/* blue-700 */
--color-blue-400: #2563eb;
/* blue-600 */
--color-blue-500: #1d4ed8;
/* blue-700 */
--color-blue-950: #3b82f6;
/* blue-500 */
/* PURPLE */
--color-purple-300: #7e22ce;
/* purple-700 */
--color-purple-400: #9333ea;
/* purple-600 */
--color-purple-500: #7e22ce;
/* purple-700 */
--color-purple-950: #a855f7;
/* purple-500 */
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import React from "react";
import { MapPin } from "lucide-react";
export function EmptyMap() {
return (
<div className="flex flex-col items-center justify-center h-72 gap-4">
<div className="w-16 h-16 rounded-full flex items-center justify-center"
style={{ background: "rgba(99,102,241,0.1)", border: "1px solid rgba(99,102,241,0.25)" }}>
<MapPin className="w-7 h-7 text-indigo-400 opacity-60" />
</div>
<div className="text-center font-serif">
<p className="text-sm font-semibold text-slate-300">No agent locations configured</p>
<p className="text-xs text-slate-400 mt-2.5 max-w-sm leading-relaxed flex items-center justify-center gap-1.5 flex-wrap">
<span>Click the</span>
<span className="inline-flex items-center justify-center p-1 rounded bg-indigo-950/20 text-slate-500 border border-slate-700/30" title="Map Pin Icon">
<MapPin className="w-3.5 h-3.5 text-slate-500" />
</span>
<span>icon in the</span>
<span className="text-slate-300 font-semibold">Actions</span>
<span>column to set coordinates.</span>
</p>
</div>
</div>
);
}
+248
View File
@@ -0,0 +1,248 @@
// src/components/admin/AgentLocationMap.tsx
"use client";
import React, { useEffect, useRef } from "react";
import { RotateCcw } from "lucide-react";
import { getViewAsHeaders } from "@/lib/admin-api";
import {
EmptyMap,
MAP_CSS,
drawFlowLines,
drawMarkers,
type AgentLocationPin
} from "./AgentLocationMapHelpers";
interface Props {
pins: AgentLocationPin[];
onEditLocation?: (agentUuid: string, agentLabel: string) => void;
canEdit?: boolean;
}
const AgentLocationMap = React.memo(function AgentLocationMap({
pins,
onEditLocation,
canEdit = false,
}: Props) {
const mapRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<any>(null);
const LRef = useRef<any>(null);
const [flows, setFlows] = React.useState<any[]>([]);
useEffect(() => {
let active = true;
fetch("/api/dashboard/agent-flows", { headers: getViewAsHeaders() })
.then((res) => res.json())
.then((json) => {
if (active && json.ok && json.data) {
setFlows(json.data);
}
})
.catch((err) => console.error("Failed to load agent flows for map:", err));
return () => {
active = false;
};
}, [pins]);
const resetView = () => {
if (!instanceRef.current || !pins.length || !LRef.current) return;
const L = LRef.current;
if (pins.length === 1) {
instanceRef.current.setView([pins[0].latitude, pins[0].longitude], 14, { animate: false });
} else {
instanceRef.current.fitBounds(L.latLngBounds(pins.map((p) => [p.latitude, p.longitude])), {
padding: [70, 70],
maxZoom: 14,
animate: false,
});
}
};
useEffect(() => {
let mounted = true;
if (!mapRef.current) return;
// Inject styles
const styleId = "leaflet-custom-markers-css";
if (!document.getElementById(styleId)) {
const s = document.createElement("style");
s.id = styleId;
s.innerHTML = MAP_CSS;
document.head.appendChild(s);
}
const boot = async () => {
if (!mounted || !mapRef.current) return;
// Inject Leaflet CSS
if (!document.getElementById("lf-css-admin")) {
const lnk = document.createElement("link");
lnk.id = "lf-css-admin";
lnk.rel = "stylesheet";
lnk.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
document.head.appendChild(lnk);
}
const L = (await import("leaflet")).default;
if (!mounted || !mapRef.current) return;
LRef.current = L;
if (instanceRef.current) {
try {
instanceRef.current.remove();
} catch (_) {}
}
const map = L.map(mapRef.current, {
center: pins[0] ? [pins[0].latitude, pins[0].longitude] : [-2.548926, 118.0148634],
zoom: pins.length === 1 ? 14 : 8,
zoomControl: false,
attributionControl: false,
minZoom: 2,
maxZoom: 20,
maxBounds: [[-90, -180], [90, 180]],
maxBoundsViscosity: 1.0,
zoomAnimation: false,
fadeAnimation: false,
markerZoomAnimation: false,
});
instanceRef.current = map;
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
subdomains: "abcd",
maxZoom: 20,
noWrap: true,
}).addTo(map);
if (pins.length > 1) {
map.fitBounds(L.latLngBounds(pins.map((p) => [p.latitude, p.longitude])), {
padding: [70, 70],
maxZoom: 14,
animate: false,
});
}
// ── Get heartbeats for agent uptime from window or fetch ───────────────
const uptimeMap: Record<string, number> = {};
try {
const res = await fetch("/api/dashboard/agent-heartbeats");
if (res.ok) {
const json = await res.json();
if (json.ok && json.data) {
json.data.forEach((item: any) => {
uptimeMap[item.agent_uuid] = item.uptime_percent ?? 100;
});
}
}
} catch (_) {}
if (!mounted) return;
// Draw Flow lines
drawFlowLines(map, L, pins, flows);
// Draw Markers
drawMarkers(map, L, pins, uptimeMap, canEdit, onEditLocation);
};
boot();
return () => {
mounted = false;
if (instanceRef.current) {
try {
instanceRef.current.remove();
} catch (_) {}
instanceRef.current = null;
}
};
}, [pins, flows, canEdit]);
if (!pins.length) return <EmptyMap />;
return (
<div className="relative w-full rounded-xl overflow-hidden" style={{ height: 420 }}>
<div ref={mapRef} style={{ width: "100%", height: "100%", background: "#0a1628" }} />
{/* Controls */}
<div className="absolute top-3 right-3 z-[1000] flex flex-col gap-1.5">
{[
{ t: "Zoom In", l: "+", fn: () => instanceRef.current?.zoomIn() },
{ t: "Zoom Out", l: "−", fn: () => instanceRef.current?.zoomOut() },
].map((b) => (
<button
key={b.t}
onClick={b.fn}
title={b.t}
className="w-9 h-9 flex items-center justify-center rounded-lg font-bold text-xl text-white hover:bg-indigo-600/30 transition-colors"
style={{
background: "rgba(13,21,38,0.92)",
border: "1px solid rgba(99,102,241,0.35)",
backdropFilter: "blur(8px)",
}}
>
{b.l}
</button>
))}
<button
onClick={resetView}
title="Fit all agents"
className="w-9 h-9 flex items-center justify-center rounded-lg text-indigo-400 hover:text-indigo-300 transition-colors"
style={{
background: "rgba(13,21,38,0.92)",
border: "1px solid rgba(99,102,241,0.35)",
backdropFilter: "blur(8px)",
}}
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
{/* Legend */}
<div
className="absolute bottom-3 left-3 z-[1000] flex gap-4 text-xs px-3.5 py-2 rounded-xl"
style={{
background: "rgba(10,17,32,0.88)",
border: "1px solid rgba(99,102,241,0.2)",
backdropFilter: "blur(8px)",
}}
>
{[
["#10b981", "Online", true],
["#ef4444", "Offline", false],
].map(([c, l, glow]: any) => (
<span key={l} className="flex items-center gap-1.5 text-slate-400">
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: c,
boxShadow: glow ? `0 0 7px ${c}` : undefined,
}}
/>
{l}
</span>
))}
{flows.length > 0 && (
<span className="flex items-center gap-1.5 text-slate-500">
<span
style={{
display: "inline-block",
width: 16,
height: 2,
background: "rgba(99,102,241,0.7)",
borderTop: "1px dashed rgba(99,102,241,0.9)",
}}
/>
Traffic flow
</span>
)}
<span className="font-semibold font-mono" style={{ color: "#818cf8" }}>
{pins.length} agent{pins.length !== 1 ? "s" : ""} mapped
</span>
</div>
</div>
);
});
export default AgentLocationMap;
@@ -0,0 +1,182 @@
"use client";
import React from "react";
import { getFlowTooltipContent } from "../dashboard/IndonesiaAgentMapHelpers";
import { EmptyMap } from "./AgentEmptyMap";
import { MAP_CSS } from "./AgentMapCss";
export { EmptyMap, MAP_CSS };
export interface AgentLocationPin {
agent_uuid: string;
label: string;
latitude: number;
longitude: number;
isOnline?: boolean;
uptimePercent?: number;
site_uuid?: string;
}
export function buildMarkerHtml(online: boolean) {
return `
<div class="custom-agent-marker ${online ? 'online' : 'offline'}">
<div class="marker-beacon-ring"></div>
<div class="marker-glass-disc">
<div class="marker-core-dot"></div>
</div>
</div>
`;
}
// ── Draw Flow lines ONLY if they exist in active flows ────────────────
export function drawFlowLines(map: any, L: any, pins: AgentLocationPin[], flows: any[]) {
flows.forEach((flow) => {
const src = pins.find((p) => p.agent_uuid === flow.source);
const dst = pins.find((p) => p.agent_uuid === flow.target);
if (!src || !dst) return;
const bothOn = src.isOnline && dst.isOnline;
const bothOff = !src.isOnline && !dst.isOnline;
const lineColor = bothOn ? "#34d399" : bothOff ? "#f87171" : "#fbbf24";
const weight = Math.min(Math.max(flow.bytes / 1024 / 1024 / 80, 2.0), 6);
// Background polyline for hover trigger area
const baseLine = L.polyline(
[[src.latitude, src.longitude], [dst.latitude, dst.longitude]],
{ color: lineColor, weight: weight + 4, opacity: 0.15 }
).addTo(map);
// Main colored line
const activeLine = L.polyline(
[[src.latitude, src.longitude], [dst.latitude, dst.longitude]],
{ color: lineColor, weight: weight, opacity: 0.5 }
).addTo(map);
// Moving animation line
const animLine = L.polyline(
[[src.latitude, src.longitude], [dst.latitude, dst.longitude]],
{
color: lineColor,
weight: weight * 0.6,
opacity: 0.85,
dashArray: "8 12",
className: "flow-line",
}
).addTo(map);
const tooltipHtml = getFlowTooltipContent(
src.label,
dst.label,
flow.bytes,
flow.flowsCount,
flow.details || []
);
[baseLine, activeLine, animLine].forEach((line) => {
line.bindTooltip(tooltipHtml, { sticky: true, className: "leaflet-custom-popup shadow-2xl" });
});
});
}
// ── Draw Markers with Popups & Tooltips ───────────────────────────────
export function drawMarkers(
map: any,
L: any,
pins: AgentLocationPin[],
uptimeMap: Record<string, number>,
canEdit: boolean,
onEditLocation?: (agentUuid: string, agentLabel: string) => void
) {
pins.forEach((pin) => {
const online = pin.isOnline ?? false;
const color = online ? "#10b981" : "#ef4444";
const colorL = online ? "#34d399" : "#f87171";
const icon = L.divIcon({
html: buildMarkerHtml(online),
className: "",
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -18],
tooltipAnchor: [0, -18],
});
const marker = L.marker([pin.latitude, pin.longitude], { icon });
// ── Permanent label tooltip ──────────────────────────────────────────
const shortLabel = pin.label.length > 22 ? pin.label.slice(0, 20) + "…" : pin.label;
marker.bindTooltip(
`<div style="
padding:4px 10px 4px 8px; border-radius:8px; white-space:nowrap;
background:rgba(10,18,36,0.93); border:1.5px solid ${colorL};
color:${colorL}; font-size:11px; font-weight:700;
font-family:Georgia,serif; letter-spacing:0.02em;
box-shadow:0 0 10px ${color}55, 0 2px 12px rgba(0,0,0,0.7);
display:flex; align-items:center; gap:6px;">
<span style="width:7px;height:7px;border-radius:50%;background:${color};
flex-shrink:0;${online ? `box-shadow:0 0 6px ${color}` : ""};"></span>
${shortLabel}
</div>`,
{ permanent: true, direction: "top", offset: [0, -10], className: "agent-label-tooltip", interactive: false }
);
// ── Click popup ──────────────────────────────────────────────────────
const uptime = uptimeMap[pin.agent_uuid];
const uptimeColor =
(uptime ?? 100) >= 98
? "#34d399"
: (uptime ?? 100) >= 90
? "#fbbf24"
: "#f87171";
const editBtn =
canEdit && onEditLocation
? `<button id="el-${pin.agent_uuid.replace(/\W/g, "")}"
style="margin-top:10px;width:100%;padding:6px;background:rgba(99,102,241,0.15);
border:1px solid rgba(99,102,241,0.35);border-radius:8px;color:#818cf8;
font-size:11px;font-weight:700;cursor:pointer;font-family:Georgia,serif;"
onmouseover="this.style.background='rgba(99,102,241,0.3)'"
onmouseout="this.style.background='rgba(99,102,241,0.15)'">✎ Update Location</button>`
: "";
marker.bindPopup(
`<div style="background:#0d1526;border:1px solid ${colorL}44;border-radius:14px;padding:14px 16px;min-width:200px;font-family:Georgia,serif;box-shadow:0 20px 60px rgba(0,0,0,0.85);">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<span style="width:9px;height:9px;border-radius:50%;background:${color};${
online ? `box-shadow:0 0 8px ${color};` : ""
};flex-shrink:0;"></span>
<span style="font-size:13px;font-weight:800;color:${colorL};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:170px;">${
pin.label
}</span>
</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 12px;font-size:10.5px;font-family:monospace;">
<span style="color:#475569;">UUID</span><span style="color:#93c5fd;">${pin.agent_uuid}</span>
<span style="color:#475569;">Status</span><span style="color:${color};font-weight:700;">${
online ? "● Online" : "○ Offline"
}</span>
<span style="color:#475569;">Lat</span><span style="color:#cbd5e1;">${pin.latitude.toFixed(6)}</span>
<span style="color:#475569;">Lng</span><span style="color:#cbd5e1;">${pin.longitude.toFixed(6)}</span>
${
uptime !== undefined
? `<span style="color:#475569;">Uptime</span><span style="color:${uptimeColor};font-weight:700;">${uptime.toFixed(
1
)}%</span>`
: ""
}
</div>${editBtn}
</div>`,
{ className: "agent-popup", maxWidth: 260 }
);
marker.on("popupopen", () => {
const id = `el-${pin.agent_uuid.replace(/\W/g, "")}`;
setTimeout(() => {
document.getElementById(id)?.addEventListener("click", () => {
onEditLocation?.(pin.agent_uuid, pin.label);
map.closePopup();
});
}, 50);
});
marker.addTo(map);
});
}
+97
View File
@@ -0,0 +1,97 @@
export const MAP_CSS = `
.leaflet-control-attribution { display:none !important; }
.agent-popup .leaflet-popup-content-wrapper { background:#0a1224!important; border:1px solid rgba(99,102,241,0.25)!important; box-shadow:0 12px 36px rgba(0,0,0,0.65)!important; padding:0!important; border-radius:12px!important; }
.agent-popup .leaflet-popup-content { margin:0!important; }
.agent-popup .leaflet-popup-tip-container { display:none!important; }
.agent-popup .leaflet-popup-close-button { color:#94a3b8!important; font-size:18px!important; top:8px!important; right:10px!important; }
.agent-label-tooltip { background:transparent!important; border:none!important; box-shadow:none!important; padding:0!important; }
.agent-label-tooltip::before { display:none!important; }
@keyframes dash-flow { to { stroke-dashoffset:-20; } }
.flow-line { animation: dash-flow 1.4s linear infinite; }
/* Tooltip and popup custom classes */
.leaflet-custom-popup .leaflet-popup-content-wrapper {
background: transparent !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
}
.leaflet-custom-popup .leaflet-popup-tip {
background: #0f172a !important;
border: 1px solid rgba(255,255,255,0.15);
}
.leaflet-custom-popup .leaflet-popup-content {
margin: 0 !important;
padding: 0 !important;
background: transparent !important;
}
/* Premium Map Marker Styles */
.custom-agent-marker {
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: 32px;
height: 32px;
}
.marker-beacon-ring {
position: absolute;
width: 32px;
height: 32px;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.online .marker-beacon-ring {
border: 2px solid #10b981;
animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
}
.offline .marker-beacon-ring {
border: 2px solid #ef4444;
animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
}
@keyframes beacon-ping {
0% { transform: scale(0.4); opacity: 0.85; }
50% { opacity: 0.45; }
100% { transform: scale(1.8); opacity: 0; }
}
.marker-glass-disc {
width: 15px;
height: 15px;
border-radius: 50%;
background: rgba(10, 18, 36, 0.85);
border: 2px solid;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7), inset 0 1px 2px rgba(255, 255, 255, 0.15);
transition: all 0.2s ease-in-out;
}
.online .marker-glass-disc {
border-color: #10b981;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6);
}
.offline .marker-glass-disc {
border-color: #ef4444;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6);
}
.marker-core-dot {
width: 6px;
height: 6px;
border-radius: 50%;
transition: all 0.2s ease-in-out;
}
.online .marker-core-dot {
background: #10b981;
box-shadow: 0 0 6px #10b981;
}
.offline .marker-core-dot {
background: #ef4444;
box-shadow: 0 0 6px #ef4444;
}
.custom-agent-marker:hover .marker-glass-disc {
transform: scale(1.25);
}
`;
+2 -1
View File
@@ -3,7 +3,8 @@
import { useState } from "react"; import { useState } from "react";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { createAgent, deleteAgent, Agent } from "@/lib/actions/agents"; import { createAgent, deleteAgent } from "@/lib/actions/agents";
import { Agent } from "@/lib/actions/agentsCore";
import { AgentDetailModal } from "@/components/ui/AgentDetailModal"; import { AgentDetailModal } from "@/components/ui/AgentDetailModal";
import AgentAccountModal from "@/components/admin/AgentAccountModal"; import AgentAccountModal from "@/components/admin/AgentAccountModal";
import { resolveAgentLabel, type ManagedUser } from "@/lib/admin-api"; import { resolveAgentLabel, type ManagedUser } from "@/lib/admin-api";
+241 -174
View File
@@ -2,8 +2,6 @@
import React, { useEffect, useRef, useState, useMemo } from "react"; import React, { useEffect, useRef, useState, useMemo } from "react";
import { MapPin, AlertTriangle, ChevronDown, ChevronUp, Plus, Info, Loader2 } from "lucide-react"; import { MapPin, AlertTriangle, ChevronDown, ChevronUp, Plus, Info, Loader2 } from "lucide-react";
import * as L from "leaflet";
import "leaflet/dist/leaflet.css";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { getViewAsHeaders } from "@/lib/admin-api"; import { getViewAsHeaders } from "@/lib/admin-api";
import { import {
@@ -12,7 +10,7 @@ import {
MAP_CSS_INJECT, MAP_CSS_INJECT,
type FlowDetail type FlowDetail
} from "./IndonesiaAgentMapHelpers"; } from "./IndonesiaAgentMapHelpers";
import { Agent } from "@/lib/actions/agents"; import { Agent } from "@/lib/actions/agentsCore";
interface LocationData { interface LocationData {
agent_uuid: string; agent_uuid: string;
@@ -109,185 +107,254 @@ export function IndonesiaAgentMap({
// Initialize and Update Map // Initialize and Update Map
useEffect(() => { useEffect(() => {
if (!mapContainerRef.current) return; if (!mapContainerRef.current) return;
let mounted = true;
// Build unique mapping of agent UUIDs → their registered coordinates. const buildMarkerHtml = (online: boolean): string => {
// Only agents with real entries in CustomAgentLocation are rendered on the map. return `
const agentMap: Record<string, { lat: number; lng: number; label: string; agentLabel: string; status: string }> = {}; <div class="custom-agent-marker ${online ? 'online' : 'offline'}">
<div class="marker-beacon-ring"></div>
<div class="marker-glass-disc">
<div class="marker-core-dot"></div>
</div>
</div>
`;
};
agents.forEach((agent) => { const boot = async () => {
const uuid = agent.uuid || agent.serial || ""; if (!mounted) return;
const loc = locations.find((l) => l.agent_uuid === uuid); const L = (await import("leaflet")).default;
if (!mounted || !mapContainerRef.current) return;
// Only map agents that have a real coordinate registered. // Inject CSS once
// Agents without coordinates are intentionally excluded from the map. if (!document.getElementById("lf-css-dpi")) {
if (loc) { const lnk = document.createElement("link");
agentMap[uuid] = { lnk.id = "lf-css-dpi"; lnk.rel = "stylesheet";
lat: loc.latitude, lnk.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
lng: loc.longitude, document.head.appendChild(lnk);
label: loc.label || "",
agentLabel: agent.label || "",
status: agent.activated ? "Online" : "Offline",
};
} }
}); if (!document.getElementById("dpi-map-extra-css")) {
const s = document.createElement("style");
const activeCoords = Object.values(agentMap); s.id = "dpi-map-extra-css";
const centerLat = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lat, 0) / activeCoords.length : -6.2088; s.textContent = `
const centerLng = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lng, 0) / activeCoords.length : 106.8456; @keyframes dpi-beacon { 0%,100%{opacity:.18;transform:scale(1);transform-origin:20px 20px;} 50%{opacity:.05;transform:scale(1.65);transform-origin:20px 20px;} }
.dpi-beacon { animation: dpi-beacon 2.2s ease-in-out infinite; }
// Voyager for light mode: colorful, ocean blue, matches Globe Map aesthetic @keyframes flowDash { to { stroke-dashoffset:-24; } }
const tileUrl = isDark .animated-flow-line { animation: flowDash 1.2s linear infinite; }
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" .agent-label-tooltip { background:transparent!important;border:none!important;box-shadow:none!important;padding:0!important; }
: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"; .agent-label-tooltip::before { display:none!important; }
.dpi-agent-label { display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;border:1px solid;white-space:nowrap;letter-spacing:0.02em; }
if (!mapInstanceRef.current) { .leaflet-control-attribution { display:none!important; }
// Canvas renderer: redraws from geo-coords every frame, no SVG offset bugs .leaflet-custom-popup .leaflet-popup-content-wrapper { background:#0a1224!important; border:1px solid rgba(99,102,241,0.25)!important; box-shadow:0 12px 36px rgba(0,0,0,0.65)!important; padding:0!important; border-radius:12px!important; }
const canvasRenderer = L.canvas({ padding: 0.5 }); .leaflet-custom-popup .leaflet-popup-content { margin:0!important;padding:0!important; }
canvasRendererRef.current = canvasRenderer;
/* Premium Map Marker Styles */
const map = L.map(mapContainerRef.current, { .custom-agent-marker {
zoomControl: false, display: flex;
attributionControl: false, align-items: center;
renderer: canvasRenderer, justify-content: center;
}).setView([centerLat, centerLng], 10); position: relative;
width: 32px;
const tiles = L.tileLayer(tileUrl, { maxZoom: 19 }).addTo(map); height: 32px;
tileLayerRef.current = tiles; }
.marker-beacon-ring {
L.control.zoom({ position: "bottomright" }).addTo(map); position: absolute;
width: 32px;
mapInstanceRef.current = map; height: 32px;
layerGroupRef.current = L.layerGroup().addTo(map); border-radius: 50%;
} else { opacity: 0;
const map = mapInstanceRef.current; pointer-events: none;
if (tileLayerRef.current) { }
tileLayerRef.current.setUrl(tileUrl); .online .marker-beacon-ring {
border: 2px solid #10b981;
animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
}
.offline .marker-beacon-ring {
border: 2px solid #ef4444;
animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
}
@keyframes beacon-ping {
0% { transform: scale(0.4); opacity: 0.85; }
50% { opacity: 0.45; }
100% { transform: scale(1.8); opacity: 0; }
}
.marker-glass-disc {
width: 15px;
height: 15px;
border-radius: 50%;
background: rgba(10, 18, 36, 0.85);
border: 2px solid;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7), inset 0 1px 2px rgba(255, 255, 255, 0.15);
transition: all 0.2s ease-in-out;
}
.online .marker-glass-disc {
border-color: #10b981;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6);
}
.offline .marker-glass-disc {
border-color: #ef4444;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6);
}
.marker-core-dot {
width: 6px;
height: 6px;
border-radius: 50%;
transition: all 0.2s ease-in-out;
}
.online .marker-core-dot {
background: #10b981;
box-shadow: 0 0 6px #10b981;
}
.offline .marker-core-dot {
background: #ef4444;
box-shadow: 0 0 6px #ef4444;
}
.custom-agent-marker:hover .marker-glass-disc {
transform: scale(1.25);
}
`;
document.head.appendChild(s);
} }
if (activeCoords.length > 0) {
map.panTo([centerLat, centerLng]);
}
}
const group = layerGroupRef.current; // Build agent map
group.clearLayers(); const agentMap: Record<string, { lat: number; lng: number; label: string; agentLabel: string; status: string }> = {};
agents.forEach((agent) => {
const renderer = canvasRendererRef.current; const uuid = agent.uuid || agent.serial || "";
const registeredUuids = new Set(locations.map(l => l.agent_uuid)); const loc = locations.find((l) => l.agent_uuid === uuid);
if (loc) {
// ─── A. DRAW FLOW LINES ──────────────────────────────────────────────────── agentMap[uuid] = {
// Drawn BEFORE markers so markers render on top lat: loc.latitude,
flows.forEach((flow) => { lng: loc.longitude,
const src = agentMap[flow.source]; label: loc.label || "",
const dst = agentMap[flow.target]; agentLabel: agent.label || "",
if (!src || !dst || !registeredUuids.has(flow.source) || !registeredUuids.has(flow.target)) return; status: agent.activated ? "Online" : "Offline",
};
const weight = Math.min(Math.max(flow.bytes / 1024 / 1024 / 80, 2.5), 8);
const color = isDark ? "#38bdf8" : "#1d4ed8";
// Glow / shadow underlay
L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
color: isDark ? "#0ea5e9" : "#3b82f6",
weight: weight + 4,
opacity: 0.15,
renderer,
}).addTo(group);
// Solid base line
const baseLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
color,
weight,
opacity: 0.5,
renderer,
}).addTo(group);
// Animated dashed overlay (motion effect via CSS on SVG; canvas uses stroke-dasharray)
const animLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
color,
weight: weight * 0.55,
opacity: 0.95,
dashArray: "10 15",
className: "animated-flow-line",
renderer,
}).addTo(group);
const tooltipHtml = getFlowTooltipContent(
src.agentLabel,
dst.agentLabel,
flow.bytes,
flow.flowsCount,
flow.details || []
);
[baseLine, animLine].forEach(line => {
line.bindTooltip(tooltipHtml, {
sticky: true,
className: "leaflet-custom-popup shadow-2xl",
});
});
});
// ─── B. DRAW AGENT MARKERS (circleMarker = same Canvas, pixel-perfect) ─────
Object.entries(agentMap).forEach(([uuid, info]) => {
const isOnline = info.status === "Online";
const fillColor = isOnline ? "#22c55e" : "#ef4444";
const glowColor = isOnline ? "#4ade80" : "#f87171";
// Outer glow ring
L.circleMarker([info.lat, info.lng], {
radius: 11,
color: glowColor,
weight: 2,
fillColor: glowColor,
fillOpacity: 0.15,
opacity: 0.5,
renderer,
}).addTo(group);
// Inner solid dot
const dot = L.circleMarker([info.lat, info.lng], {
radius: 6,
color: "#ffffff",
weight: 2,
fillColor,
fillOpacity: 1,
opacity: 1,
renderer,
}).addTo(group);
// Agent label — permanent tooltip anchored below the dot
const labelClass = isDark
? "bg-slate-950/90 text-slate-200 border-white/10"
: "bg-white/95 text-slate-800 border-slate-200";
dot.bindTooltip(
`<div class="agent-map-label ${labelClass}">${info.agentLabel}</div>`,
{ permanent: true, direction: "bottom", offset: [0, 8], className: "agent-label-tooltip" }
).openTooltip();
// Detail popup on click
const popupHtml = getAgentTooltipContent(
uuid,
info.agentLabel,
info.status,
isOnline,
info.label,
info.lat,
info.lng
);
dot.bindPopup(popupHtml, {
closeButton: false,
className: "leaflet-custom-popup",
offset: [0, -8],
});
dot.on("popupopen", () => {
const btn = document.getElementById(`btn-edit-${uuid}`);
if (btn) {
btn.addEventListener("click", () => onEditLocation(uuid, info.agentLabel));
} }
}); });
});
const activeCoords = Object.values(agentMap);
const centerLat = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lat, 0) / activeCoords.length : -6.2088;
const centerLng = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lng, 0) / activeCoords.length : 106.8456;
const tileUrl = isDark
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png";
if (!mapInstanceRef.current) {
const map = L.map(mapContainerRef.current, {
zoomControl: false,
attributionControl: false,
zoomAnimation: false, // prevents _leaflet_pos crash on React StrictMode remount
fadeAnimation: false,
markerZoomAnimation: false,
}).setView([centerLat, centerLng], 10, { animate: false });
const tiles = L.tileLayer(tileUrl, { maxZoom: 19 }).addTo(map);
tileLayerRef.current = tiles;
L.control.zoom({ position: "bottomright" }).addTo(map);
mapInstanceRef.current = map;
layerGroupRef.current = L.layerGroup().addTo(map);
} else {
const map = mapInstanceRef.current;
if (tileLayerRef.current) tileLayerRef.current.setUrl(tileUrl);
if (activeCoords.length > 0) map.panTo([centerLat, centerLng]);
}
if (!mounted) return;
const group = layerGroupRef.current;
group.clearLayers();
const registeredUuids = new Set(locations.map(l => l.agent_uuid));
// ── A. FLOW LINES ────────────────────────────────────────────────────────
flows.forEach((flow) => {
const src = agentMap[flow.source];
const dst = agentMap[flow.target];
if (!src || !dst || !registeredUuids.has(flow.source) || !registeredUuids.has(flow.target)) return;
const weight = Math.min(Math.max(flow.bytes / 1024 / 1024 / 80, 2.5), 8);
const color = isDark ? "#38bdf8" : "#1d4ed8";
L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
color: isDark ? "#0ea5e9" : "#3b82f6", weight: weight + 4, opacity: 0.12,
}).addTo(group);
const baseLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
color, weight, opacity: 0.5,
}).addTo(group);
const animLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
color, weight: weight * 0.55, opacity: 0.95, dashArray: "10 15",
className: "animated-flow-line",
}).addTo(group);
const tooltipHtml = getFlowTooltipContent(
src.agentLabel, dst.agentLabel, flow.bytes, flow.flowsCount, flow.details || []
);
[baseLine, animLine].forEach(line => {
line.bindTooltip(tooltipHtml, { sticky: true, className: "leaflet-custom-popup shadow-2xl" });
});
});
// ── B. PREMIUM SVG MARKERS ───────────────────────────────────────────────
Object.entries(agentMap).forEach(([uuid, info]) => {
if (!mounted) return;
const isOnline = info.status === "Online";
const labelColor = isOnline ? "#34d399" : "#f87171";
const borderCol = isOnline ? "#10b981" : "#ef4444";
const icon = L.divIcon({
html: buildMarkerHtml(isOnline),
className: "",
iconSize: [32, 32],
iconAnchor: [16, 16],
tooltipAnchor: [0, -18],
popupAnchor: [0, -18],
});
const marker = L.marker([info.lat, info.lng], { icon });
const shortLabel = info.agentLabel.length > 24 ? info.agentLabel.slice(0, 22) + "…" : info.agentLabel;
marker.bindTooltip(
`<div class="dpi-agent-label" style="background:rgba(10,18,36,0.92);color:${labelColor};border-color:${borderCol};box-shadow:0 0 10px ${borderCol}44,0 2px 8px rgba(0,0,0,0.7);">` +
`<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${borderCol};margin-right:5px;${isOnline?`box-shadow:0 0 6px ${borderCol}`:""}"></span>` +
`${shortLabel}</div>`,
{ permanent: true, direction: "top", offset: [0, -10], className: "agent-label-tooltip", interactive: false }
);
const popupHtml = getAgentTooltipContent(
uuid, info.agentLabel, info.status, isOnline, info.label, info.lat, info.lng
);
marker.bindPopup(popupHtml, { closeButton: false, className: "leaflet-custom-popup", offset: [0, -8] });
marker.on("popupopen", () => {
const btn = document.getElementById(`btn-edit-${uuid}`);
if (btn) btn.addEventListener("click", () => onEditLocation(uuid, info.agentLabel));
});
marker.addTo(group);
});
// Fit bounds
if (activeCoords.length > 1 && mounted) {
const L2 = L as any;
const bounds = L2.latLngBounds(activeCoords.map((c: any) => [c.lat, c.lng]));
mapInstanceRef.current.fitBounds(bounds, { padding: [60, 60], maxZoom: 14, animate: false });
}
};
boot();
return () => {
mounted = false;
// Properly destroy the Leaflet instance so React StrictMode double-invoke
// and HMR remounts don't leave orphaned panes that trigger _leaflet_pos errors.
if (mapInstanceRef.current) {
try { mapInstanceRef.current.remove(); } catch (_) {}
mapInstanceRef.current = null;
layerGroupRef.current = null;
tileLayerRef.current = null;
}
};
}, [agents, locations, flows, isDark]); }, [agents, locations, flows, isDark]);
if (mapError) { if (mapError) {
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff. Show more