diff --git a/backend/db/capacityTracker.js b/backend/db/capacityTracker.js index 0be5123..0627eb5 100644 --- a/backend/db/capacityTracker.js +++ b/backend/db/capacityTracker.js @@ -1,63 +1,77 @@ // backend/db/capacityTracker.js // ───────────────────────────────────────────────────────────────────────────── // MongoDB Capacity & Data Size Breakdown per Network Agent. -// Measures logical document sizes per agent_uuid across all collections. +// Uses $collStats (O(1)) + per-agent document counts (indexed) for speed. +// Results are cached in memory and refreshed on each call. // ───────────────────────────────────────────────────────────────────────────── const mongoose = require('mongoose'); +// In-memory cache — shared with the agents/storage API endpoint +let agentSizesCache = {}; // { agentUuid: sizeMB } +let lastCacheUpdate = null; // Date of last successful update + async function logCapacityStats(prefix = '[MongoDB]') { try { - if (!mongoose.connection || !mongoose.connection.db) { - return; - } + if (!mongoose.connection || !mongoose.connection.db) return; const db = mongoose.connection.db; - // 1. Fetch overall dbStats - const stats = await db.command({ dbStats: 1 }); - const dataSizeMB = (stats.dataSize / (1024 * 1024)).toFixed(2); - const storageSizeMB = (stats.storageSize / (1024 * 1024)).toFixed(2); - console.log(`${prefix} Capacity Used: Data Size = ${dataSizeMB} MB, Storage Size = ${storageSizeMB} MB`); + // 1. Overall database stats (fast — reads WiredTiger metadata) + const stats = await db.command({ dbStats: 1 }); + const dataSizeMB = (stats.dataSize / (1024 * 1024)).toFixed(2); + const storageMB = (stats.storageSize / (1024 * 1024)).toFixed(2); + console.log(`${prefix} Capacity Used: Data Size = ${dataSizeMB} MB, Storage Size = ${storageMB} MB`); - // 2. Fetch breakdown per Agent - const agentSizes = {}; + // 2. Fast per-agent estimate: avgObjSize (from $collStats) × document count per agent + const agentBytes = {}; const collections = await db.listCollections().toArray(); - + for (const colInfo of collections) { const colName = colInfo.name; if (colName.startsWith('system.')) continue; const col = db.collection(colName); - // Check if collection contains at least one document with an agent_uuid field - const sampleDoc = await col.findOne({ agent_uuid: { $ne: null } }); + // Check collection has agent-tagged documents + const sampleDoc = await col.findOne({ agent_uuid: { $ne: null } }, { projection: { _id: 1 } }); if (!sampleDoc) continue; - const pipeline = [ - { $project: { agent_uuid: 1, docSize: { $bsonSize: "$$ROOT" } } }, - { $group: { _id: "$agent_uuid", totalBytes: { $sum: "$docSize" } } } - ]; - - const results = await col.aggregate(pipeline).toArray(); - for (const res of results) { - const agent = res._id || 'Unknown'; - agentSizes[agent] = (agentSizes[agent] || 0) + res.totalBytes; + // $collStats is O(1) — reads storage engine metadata, never scans documents + const collStatsArr = await col.aggregate([{ $collStats: { storageStats: {} } }]).toArray(); + const avgObjSize = collStatsArr[0]?.storageStats?.avgObjSize || 512; // bytes + + // Count documents per agent using the existing agent_uuid index + const countResult = await col.aggregate([ + { $group: { _id: '$agent_uuid', count: { $sum: 1 } } } + ]).toArray(); + + for (const r of countResult) { + const agent = r._id || 'Unknown'; + agentBytes[agent] = (agentBytes[agent] || 0) + (r.count * avgObjSize); } } - // 3. Format and log the breakdown - const sortedAgents = Object.entries(agentSizes) + // 3. Format, log, and update cache + const sorted = Object.entries(agentBytes) .map(([agent, bytes]) => ({ agent, sizeMB: parseFloat((bytes / (1024 * 1024)).toFixed(2)) })) .sort((a, b) => b.sizeMB - a.sizeMB); - if (sortedAgents.length > 0) { + if (sorted.length > 0) { console.log(`${prefix} Data Size Breakdown per Agent:`); - for (const { agent, sizeMB } of sortedAgents) { + for (const { agent, sizeMB } of sorted) { console.log(` - ${agent}: ${sizeMB.toFixed(2)} MB`); } } + + agentSizesCache = {}; + for (const { agent, sizeMB } of sorted) { + agentSizesCache[agent] = sizeMB; + } + lastCacheUpdate = new Date(); + } catch (err) { console.warn(`${prefix} Could not retrieve DB capacity breakdown:`, err.message); } } -module.exports = { logCapacityStats }; +module.exports = { logCapacityStats, agentSizesCache: () => agentSizesCache, lastCacheUpdate: () => lastCacheUpdate }; + diff --git a/backend/db/mongoose.js b/backend/db/mongoose.js index 00a0f70..35991f0 100644 --- a/backend/db/mongoose.js +++ b/backend/db/mongoose.js @@ -24,7 +24,7 @@ async function connectDB() { }); console.log('[MongoDB] ✓ Connected successfully'); const { logCapacityStats } = require('./capacityTracker'); - await logCapacityStats('[MongoDB]'); + logCapacityStats('[MongoDB]').catch(err => console.warn('[MongoDB] Capacity log failed:', err.message)); return; } catch (error) { console.error(`[MongoDB] ✗ Attempt ${attempt} failed: ${error.message}`); diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000..b51659c --- /dev/null +++ b/backend/middleware/auth.js @@ -0,0 +1,72 @@ +const jwt = require('jsonwebtoken'); +const User = require('../models/User'); +const { Summary } = require('../models/Schemas'); + +const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-backone-key'; + +async function requireAuth(req, res, next) { + const token = req.cookies?.token; + if (!token) return res.status(401).json({ error: 'Unauthorized' }); + + try { + req.user = jwt.verify(token, JWT_SECRET); + + // ── VIEW-AS MODE ────────────────────────────────────────────────────────── + const viewAsHeader = req.headers['x-view-as-agent']; + if (viewAsHeader && (req.user.role === 'SUPER_ADMIN' || req.user.role === 'TENANT_ADMIN')) { + try { + const viewDecoded = jwt.verify(viewAsHeader, JWT_SECRET); + if (viewDecoded.type === 'view-as' && viewDecoded.adminId === req.user.id && viewDecoded.viewAs) { + const targetAgentUser = await User.findOne({ agent_uuid: viewDecoded.viewAs, role: 'AGENT_VIEWER' }).lean(); + + let targetSiteUuid = req.user.site_uuid; + if (targetAgentUser && targetAgentUser.site_uuid) { + targetSiteUuid = targetAgentUser.site_uuid; + } else { + const summaryDoc = await Summary.findOne({ agent_uuid: viewDecoded.viewAs }).lean(); + if (summaryDoc && summaryDoc.site_uuid) { + targetSiteUuid = summaryDoc.site_uuid; + } + } + + req.user = { + ...req.user, + role: 'AGENT_VIEWER', + agent_uuid: viewDecoded.viewAs, + agent_label: viewDecoded.viewAsLabel, + site_uuid: targetSiteUuid, + _viewAsMode: true, + _originalRole: req.user.role, + }; + } + } catch (viewErr) { + console.warn('[ViewAs] Invalid view-as token, ignoring:', viewErr.message); + } + } + + next(); + } catch (err) { + res.status(401).json({ error: 'Invalid token' }); + } +} + +function requireAdmin(req, res, next) { + const token = req.cookies?.token; + if (!token) return res.status(401).json({ error: 'Not authenticated' }); + try { + const decoded = jwt.verify(token, JWT_SECRET); + if (decoded.role !== 'SUPER_ADMIN' && decoded.role !== 'TENANT_ADMIN' && decoded.role !== 'SOC_ANALYST') { + return res.status(403).json({ error: 'Forbidden' }); + } + req.adminUser = decoded; + next(); + } catch { + res.status(401).json({ error: 'Token tidak valid' }); + } +} + +module.exports = { + requireAuth, + requireAdmin, + JWT_SECRET +}; diff --git a/backend/models/Schemas.js b/backend/models/Schemas.js index 6649694..1beafbf 100644 --- a/backend/models/Schemas.js +++ b/backend/models/Schemas.js @@ -135,52 +135,6 @@ const EventSchema = new mongoose.Schema({ event_at: Date, }, baseOptions); -// ─── Compound Indexes for common dashboard queries ───────────────────────────── -// ─── TLS Versions (per agent) ────────────────────────────────────────────────── -const TlsVersionStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - tls_version: { type: String, required: true }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - -// ─── TLS Ciphers (per agent) ─────────────────────────────────────────────────── -const TlsCipherStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - tls_cipher: { type: String, required: true }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - -// ─── TLS Security (per agent) ────────────────────────────────────────────────── -const TlsSecurityStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - tls_security: { type: String, required: true }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - -// ─── Country Traffic Stats (per agent) ──────────────────────────────────────── -const CountryStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - country_code: { type: String, required: true }, - country_name: { type: String, default: '' }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - // ─── Compound Indexes for common dashboard queries ───────────────────────────── SummarySchema.index({ agent_uuid: 1, timestamp: -1 }); AppStatSchema.index({ agent_uuid: 1, timestamp: -1, download: -1 }); @@ -193,15 +147,6 @@ FlowSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 }); ThreatSchema.index({ agent_uuid: 1, timestamp: -1 }); AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 }); EventSchema.index({ agent_uuid: 1, timestamp: -1 }); -TlsVersionStatSchema.index({ agent_uuid: 1, timestamp: -1 }); -TlsCipherStatSchema.index({ agent_uuid: 1, timestamp: -1 }); -TlsSecurityStatSchema.index({ agent_uuid: 1, timestamp: -1 }); -CountryStatSchema.index({ agent_uuid: 1, timestamp: -1 }); - -const CustomDeviceLabelSchema = new mongoose.Schema({ - mac_address: { type: String, required: true, unique: true, index: true }, - device_label: { type: String, required: true }, -}, baseOptions); // ── Per-Device Per-Application Stats (synced from proxy) ───────────────── const DeviceAppStatSchema = new mongoose.Schema({ @@ -220,34 +165,8 @@ DeviceAppStatSchema.index({ agent_uuid: 1, ip_address: 1, timestamp: -1 }); DeviceAppStatSchema.index({ ip_address: 1, app_label: 1, timestamp: -1 }); DeviceAppStatSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 }); -// ─── View As Audit Logs ──────────────────────────────────────────────────────── -const ViewAsLogSchema = new mongoose.Schema({ - timestamp: { type: Date, default: Date.now, index: true }, - admin_id: { type: String, required: true }, - admin_username: { type: String, required: true }, - admin_role: String, - agent_uuid: { type: String, required: true }, - agent_label: String, - end_timestamp: Date, - duration: Number, // duration in seconds -}, baseOptions); - -// ─── Lookup App Dictionary ──────────────────────────────────────────────────── -const LookupAppSchema = new mongoose.Schema({ - id: { type: Number, required: true, unique: true, index: true }, - tag: String, - label: { type: String, index: true }, - name: String, - full_name: String, - description: String, - favicon: String, - icon: String, - logo: String, - application_category: Object -}, baseOptions); -LookupAppSchema.index({ label: 1, tag: 1 }); - const telemetrySchemas = require('./SchemasTelemetry'); +const auxSchemas = require('./SchemasAux'); module.exports = { Summary: mongoose.model('Summary', SummarySchema), @@ -257,14 +176,9 @@ module.exports = { DeviceAppStat: mongoose.model('DeviceAppStat', DeviceAppStatSchema), Flow: mongoose.model('Flow', FlowSchema), Threat: mongoose.model('Threat', ThreatSchema), - CustomDeviceLabel: mongoose.model('CustomDeviceLabel', CustomDeviceLabelSchema), AppCategoryStat: mongoose.model('AppCategoryStat', AppCategoryStatSchema), Event: mongoose.model('Event', EventSchema), - TlsVersionStat: mongoose.model('TlsVersionStat', TlsVersionStatSchema), - TlsCipherStat: mongoose.model('TlsCipherStat', TlsCipherStatSchema), - TlsSecurityStat: mongoose.model('TlsSecurityStat', TlsSecurityStatSchema), - CountryStat: mongoose.model('CountryStat', CountryStatSchema), - LookupApp: mongoose.model('LookupApp', LookupAppSchema), - ViewAsLog: mongoose.model('ViewAsLog', ViewAsLogSchema), + ...auxSchemas, ...telemetrySchemas }; + diff --git a/backend/models/SchemasAux.js b/backend/models/SchemasAux.js new file mode 100644 index 0000000..22685c7 --- /dev/null +++ b/backend/models/SchemasAux.js @@ -0,0 +1,132 @@ +// backend/models/SchemasAux.js +// ───────────────────────────────────────────────────────────────────────────── +// Auxiliary MongoDB Schemas to maintain Schemas.js under 256 lines limit. +// ───────────────────────────────────────────────────────────────────────────── + +const mongoose = require('mongoose'); + +const baseOptions = { + timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } +}; + +// ─── TLS Versions (per agent) ────────────────────────────────────────────────── +const TlsVersionStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + tls_version: { type: String, required: true }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +// ─── TLS Ciphers (per agent) ─────────────────────────────────────────────────── +const TlsCipherStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + tls_cipher: { type: String, required: true }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +// ─── TLS Security (per agent) ────────────────────────────────────────────────── +const TlsSecurityStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + tls_security: { type: String, required: true }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +// ─── Country Traffic Stats (per agent) ──────────────────────────────────────── +const CountryStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + country_code: { type: String, required: true }, + country_name: { type: String, default: '' }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +const CustomDeviceLabelSchema = new mongoose.Schema({ + mac_address: { type: String, required: true, unique: true, index: true }, + device_label: { type: String, required: true }, +}, baseOptions); + +// ─── View As Audit Logs ──────────────────────────────────────────────────────── +const ViewAsLogSchema = new mongoose.Schema({ + timestamp: { type: Date, default: Date.now, index: true }, + admin_id: { type: String, required: true }, + admin_username: { type: String, required: true }, + admin_role: String, + agent_uuid: { type: String, required: true }, + agent_label: String, + end_timestamp: Date, + duration: Number, // duration in seconds +}, baseOptions); + +// ─── Lookup App Dictionary ──────────────────────────────────────────────────── +const LookupAppSchema = new mongoose.Schema({ + id: { type: Number, required: true, unique: true, index: true }, + tag: String, + label: { type: String, index: true }, + name: String, + full_name: String, + description: String, + favicon: String, + icon: String, + logo: String, + application_category: Object +}, baseOptions); + +// ─── Tenant Configuration (Dynamic Branding per site_uuid) ───────────────────── +const TenantConfigSchema = new mongoose.Schema({ + site_uuid: { type: String, required: true, unique: true, index: true }, + brand_name: { type: String, required: true }, + brand_logo: { type: String, required: true }, + footer_copyright: { type: String, required: true }, + primary_color: { type: String, default: '#E11D48' } +}, baseOptions); + +const CustomAgentLocationSchema = new mongoose.Schema({ + agent_uuid: { type: String, required: true, unique: true, index: true }, + site_uuid: { type: String, required: true, index: true }, + latitude: { type: Number, required: true }, + longitude: { type: Number, required: true }, + label: { type: String, default: '' }, +}, baseOptions); + +const BlacklistRuleSchema = new mongoose.Schema({ + site_uuid: { type: String, required: true, index: true }, + agent_uuid: { type: String, required: true, index: true }, + type: { type: String, required: true, enum: ['category', 'domain'] }, + value: { type: String, required: true }, + is_active: { type: Boolean, default: true } +}, baseOptions); + +// Set compound indexes +TlsVersionStatSchema.index({ agent_uuid: 1, timestamp: -1 }); +TlsCipherStatSchema.index({ agent_uuid: 1, timestamp: -1 }); +TlsSecurityStatSchema.index({ agent_uuid: 1, timestamp: -1 }); +CountryStatSchema.index({ agent_uuid: 1, timestamp: -1 }); +LookupAppSchema.index({ label: 1, tag: 1 }); +BlacklistRuleSchema.index({ site_uuid: 1, agent_uuid: 1, type: 1, value: 1 }, { unique: true }); + +module.exports = { + TlsVersionStat: mongoose.model('TlsVersionStat', TlsVersionStatSchema), + TlsCipherStat: mongoose.model('TlsCipherStat', TlsCipherStatSchema), + TlsSecurityStat: mongoose.model('TlsSecurityStat',TlsSecurityStatSchema), + CountryStat: mongoose.model('CountryStat', CountryStatSchema), + CustomDeviceLabel:mongoose.model('CustomDeviceLabel',CustomDeviceLabelSchema), + ViewAsLog: mongoose.model('ViewAsLog', ViewAsLogSchema), + LookupApp: mongoose.model('LookupApp', LookupAppSchema), + TenantConfig: mongoose.model('TenantConfig', TenantConfigSchema), + CustomAgentLocation: mongoose.model('CustomAgentLocation', CustomAgentLocationSchema), + BlacklistRule: mongoose.model('BlacklistRule', BlacklistRuleSchema), +}; diff --git a/backend/models/User.js b/backend/models/User.js index 9ad78b0..5fbf12a 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -17,6 +17,7 @@ const UserSchema = new mongoose.Schema({ role: { type: String, enum: ['SUPER_ADMIN', 'TENANT_ADMIN', 'SOC_ANALYST', 'ENGINEER', 'AGENT_VIEWER'], default: 'AGENT_VIEWER' }, site_uuid: { type: String, default: null, index: true }, agent_uuid: { type: String, default: null }, + created_by: { type: String, default: null, index: true }, is_active: { type: Boolean, default: true }, }, { timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } diff --git a/backend/routes/agentDetailsHandler.js b/backend/routes/agentDetailsHandler.js index debfe35..c6f6403 100644 --- a/backend/routes/agentDetailsHandler.js +++ b/backend/routes/agentDetailsHandler.js @@ -13,7 +13,10 @@ module.exports = async function agentDetailsHandler(req, res, helpers) { getCustomLabelsMap } = helpers; - const uuid = String(req.query.uuid ?? ''); + let uuid = String(req.query.uuid ?? ''); + if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) { + uuid = req.user.agent_uuid; + } if (!uuid) return res.status(400).json({ ok: false, message: 'uuid required' }); const timeFilter = getTimeFilter(req); @@ -268,8 +271,14 @@ module.exports = async function agentDetailsHandler(req, res, helpers) { // 9. Server Discovery const server_discovery = []; - // Find the user object of this agent to get its name/label - const agentUser = await User.findOne({ agent_uuid: uuid, role: 'AGENT_VIEWER' }); + const userQuery = { agent_uuid: uuid, role: 'AGENT_VIEWER' }; + const isGlobalUser = req.user?.role === 'SUPER_ADMIN' || + ((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)); + if (!isGlobalUser && req.user?.site_uuid) { + userQuery.site_uuid = req.user.site_uuid; + } + + const agentUser = await User.findOne(userQuery); const agent_label = agentUser?.account_name || uuid; const devicesDl = devices.reduce((sum, d) => sum + d.download, 0); diff --git a/backend/routes/appDetailsDpiHelper.js b/backend/routes/appDetailsDpiHelper.js new file mode 100644 index 0000000..aa176b1 --- /dev/null +++ b/backend/routes/appDetailsDpiHelper.js @@ -0,0 +1,170 @@ +const axios = require('axios'); + +let appLookupCache = null; +let agentMapCache = null; +let agentCachePopulating = false; + +function timeRangeToMinutes(timeRange) { + const mapping = { + '5m': 5, '10m': 10, '30m': 30, '1h': 60, + '1d': 1440, '7d': 10080, '30d': 43200, 'all': 43200 + }; + return mapping[timeRange] ?? 60; +} + +// Resolve agent UUID → DPI numeric agent ID +async function populateAgentCache(BASE_URL, token, siteUuid) { + if (agentMapCache !== null || agentCachePopulating) return; + agentCachePopulating = true; + try { + const headers = { 'x-api-key': token, 'Accept': 'application/json' }; + if (siteUuid) headers['x-net-site'] = siteUuid; + const res = await axios.get(`${BASE_URL}/data/stats/top/agent/download`, { + headers, params: { filter_interval: 43200, settings_limit: 100 }, timeout: 4000 + }); + agentMapCache = {}; + if (res.data && Array.isArray(res.data.data)) { + res.data.data.forEach(r => { + if (r.agent?.uuid && r.agent?.id) agentMapCache[r.agent.uuid] = r.agent.id; + }); + } + console.log(`[AppDetailsDpiHelper] Agent cache: ${Object.keys(agentMapCache).length} agents`); + } catch (e) { + agentMapCache = {}; + console.warn('[AppDetailsDpiHelper] Agent cache failed:', e.message); + } finally { + agentCachePopulating = false; + } +} + +// Resolve app label → DPI application ID +async function populateAppCache(BASE_URL, token, siteUuid) { + if (appLookupCache !== null) return; + try { + const headers = { 'x-api-key': token, 'Accept': 'application/json' }; + if (siteUuid) headers['x-net-site'] = siteUuid; + const res = await axios.get(`${BASE_URL}/lookup/applications`, { + headers, params: { settings_limit: 2000 }, timeout: 8000 + }); + appLookupCache = {}; + if (res.data && Array.isArray(res.data.data)) { + res.data.data.forEach(a => { + if (!a.label || !a.id) return; + let domain = null; + if (a.home_page?.url) { + domain = a.home_page.url.replace(/^https?:\/\/(www\.)?/, '').split('/')[0]; + } else if (a.domain_list?.length > 0) { + domain = a.domain_list[0].label; + } else { + domain = a.label.toLowerCase(); + } + appLookupCache[a.label.toLowerCase()] = { id: a.id, label: a.label, domain }; + }); + } + console.log(`[AppDetailsDpiHelper] App cache: ${Object.keys(appLookupCache).length} apps`); + } catch (e) { + appLookupCache = {}; + console.warn('[AppDetailsDpiHelper] App cache failed:', e.message); + } +} + +// Core DPI fetch for app-details +async function fetchFromDpiApi(label, agentUuid, timeRange, token, siteUuid) { + const BASE_URL = process.env.NETIFY_INFORMATICS_BASE_URL || 'https://informatics.netify.ai/api/v1'; + const headers = { 'x-api-key': token, 'Accept': 'application/json', 'x-net-site': siteUuid }; + + const TIMEOUT_MS = 12000; + const deadline = new Promise((_, reject) => + setTimeout(() => reject(new Error(`AppDetailsDpiHelper: ${TIMEOUT_MS}ms timeout`)), TIMEOUT_MS) + ); + + async function doFetch() { + await Promise.all([ + populateAgentCache(BASE_URL, token, siteUuid), + populateAppCache(BASE_URL, token, siteUuid) + ]); + + const appInfo = appLookupCache?.[label.toLowerCase()]; + if (!appInfo) { + console.warn(`[AppDetailsDpiHelper] App "${label}" not found in lookup cache`); + return null; + } + + const params = { + filter_interval: timeRangeToMinutes(timeRange), + filter_applications: `["${appInfo.id}"]`, + settings_limit: 10000 + }; + + if (agentUuid && agentMapCache?.[agentUuid]) { + params.filter_agents = `[${agentMapCache[agentUuid]}]`; + } + + const [dlRes, ulRes] = await Promise.all([ + axios.get(`${BASE_URL}/data/stats/top/local_ip/download`, { headers, params, timeout: 10000 }), + axios.get(`${BASE_URL}/data/stats/top/local_ip/upload`, { headers, params, timeout: 10000 }), + ]); + + const ipsMap = {}; + (dlRes.data?.data || []).forEach(item => { + const ip = item.local_ip?.address; + if (!ip) return; + if (!ipsMap[ip]) { + ipsMap[ip] = { + ip_address: ip, + download: item.download || 0, + upload: 0, + first_seen: item.last_seen_at?.date || new Date().toISOString(), + last_seen: item.last_seen_at?.date || new Date().toISOString(), + domain: appInfo.domain, + protocol: 'HTTPS / TLS' + }; + } else { + ipsMap[ip].download = item.download || 0; + } + }); + + (ulRes.data?.data || []).forEach(item => { + const ip = item.local_ip?.address; + if (!ip) return; + if (!ipsMap[ip]) { + ipsMap[ip] = { + ip_address: ip, + download: 0, + upload: item.upload || 0, + first_seen: item.last_seen_at?.date || new Date().toISOString(), + last_seen: item.last_seen_at?.date || new Date().toISOString(), + domain: appInfo.domain, + protocol: 'HTTPS / TLS' + }; + } else { + ipsMap[ip].upload = item.upload || 0; + if (item.last_seen_at?.date) { + const d = new Date(item.last_seen_at.date); + if (d > new Date(ipsMap[ip].last_seen)) ipsMap[ip].last_seen = item.last_seen_at.date; + if (d < new Date(ipsMap[ip].first_seen)) ipsMap[ip].first_seen = item.last_seen_at.date; + } + } + }); + + const top_ips = Object.values(ipsMap).sort((a, b) => (b.download + b.upload) - (a.download + a.upload)); + const totalDl = top_ips.reduce((s, x) => s + x.download, 0); + const totalUl = top_ips.reduce((s, x) => s + x.upload, 0); + + console.log(`[AppDetailsDpiHelper] DPI API: label=${label} agent=${agentUuid} top_ips=${top_ips.length} dl=${(totalDl/1e9).toFixed(2)}GB`); + return { top_ips, totalDl, totalUl }; + } + + try { + return await Promise.race([doFetch(), deadline]); + } catch (err) { + console.warn('[AppDetailsDpiHelper] DPI API timeout/error:', err.message); + return null; + } +} + +module.exports = { + getAppLookupCache: () => appLookupCache, + populateAppCache, + fetchFromDpiApi +}; diff --git a/backend/routes/appDetailsHandler.js b/backend/routes/appDetailsHandler.js index cfb9845..2f545d2 100644 --- a/backend/routes/appDetailsHandler.js +++ b/backend/routes/appDetailsHandler.js @@ -10,169 +10,7 @@ const axios = require('axios'); const { AppStat, DeviceAppStat, Flow } = require('../models/Schemas'); -// ─── Shared in-memory caches (for DPI API fallback only) ───────────────────── -let appLookupCache = null; -let agentMapCache = null; -let agentCachePopulating = false; - -function timeRangeToMinutes(timeRange) { - const mapping = { - '5m': 5, '10m': 10, '30m': 30, '1h': 60, - '1d': 1440, '7d': 10080, '30d': 43200, 'all': 43200 - }; - return mapping[timeRange] ?? 60; -} - -// Resolve agent UUID → DPI numeric agent ID (for filter_agents param) -async function populateAgentCache(BASE_URL, token, siteUuid) { - if (agentMapCache !== null || agentCachePopulating) return; - agentCachePopulating = true; - try { - const headers = { 'x-api-key': token, 'Accept': 'application/json' }; - if (siteUuid) headers['x-net-site'] = siteUuid; - const res = await axios.get(`${BASE_URL}/data/stats/top/agent/download`, { - headers, params: { filter_interval: 43200, settings_limit: 100 }, timeout: 4000 - }); - agentMapCache = {}; - if (res.data && Array.isArray(res.data.data)) { - res.data.data.forEach(r => { - if (r.agent?.uuid && r.agent?.id) agentMapCache[r.agent.uuid] = r.agent.id; - }); - } - console.log(`[AppDetailsHandler] Agent cache: ${Object.keys(agentMapCache).length} agents`); - } catch (e) { - agentMapCache = {}; - console.warn('[AppDetailsHandler] Agent cache failed:', e.message); - } finally { - agentCachePopulating = false; - } -} - -// Resolve app label → DPI application ID -async function populateAppCache(BASE_URL, token, siteUuid) { - if (appLookupCache !== null) return; - try { - const headers = { 'x-api-key': token, 'Accept': 'application/json' }; - if (siteUuid) headers['x-net-site'] = siteUuid; - const res = await axios.get(`${BASE_URL}/lookup/applications`, { - headers, params: { settings_limit: 2000 }, timeout: 8000 - }); - appLookupCache = {}; - if (res.data && Array.isArray(res.data.data)) { - res.data.data.forEach(a => { - if (!a.label || !a.id) return; - let domain = null; - if (a.home_page?.url) { - domain = a.home_page.url.replace(/^https?:\/\/(www\.)?/, '').split('/')[0]; - } else if (a.domain_list?.length > 0) { - domain = a.domain_list[0].label; - } else { - domain = a.label.toLowerCase(); - } - appLookupCache[a.label.toLowerCase()] = { id: a.id, label: a.label, domain }; - }); - } - console.log(`[AppDetailsHandler] App cache: ${Object.keys(appLookupCache).length} apps`); - } catch (e) { - appLookupCache = {}; - console.warn('[AppDetailsHandler] App cache failed:', e.message); - } -} - -// Core DPI fetch for app-details — only used when MongoDB has no data -async function fetchFromDpiApi(label, agentUuid, timeRange, token, siteUuid) { - const BASE_URL = process.env.NETIFY_INFORMATICS_BASE_URL || 'https://informatics.netify.ai/api/v1'; - const headers = { 'x-api-key': token, 'Accept': 'application/json', 'x-net-site': siteUuid }; - - const TIMEOUT_MS = 12000; - const deadline = new Promise((_, reject) => - setTimeout(() => reject(new Error(`AppDetailsHandler: ${TIMEOUT_MS}ms timeout`)), TIMEOUT_MS) - ); - - async function doFetch() { - await Promise.all([ - populateAgentCache(BASE_URL, token, siteUuid), - populateAppCache(BASE_URL, token, siteUuid) - ]); - - const appInfo = appLookupCache?.[label.toLowerCase()]; - if (!appInfo) { - console.warn(`[AppDetailsHandler] App "${label}" not found in lookup cache`); - return null; - } - - const params = { - filter_interval: timeRangeToMinutes(timeRange), - filter_applications: `["${appInfo.id}"]`, - settings_limit: 10000 - }; - - if (agentUuid && agentMapCache?.[agentUuid]) { - params.filter_agents = `[${agentMapCache[agentUuid]}]`; - } - - const [dlRes, ulRes] = await Promise.all([ - axios.get(`${BASE_URL}/data/stats/top/local_ip/download`, { headers, params, timeout: 10000 }), - axios.get(`${BASE_URL}/data/stats/top/local_ip/upload`, { headers, params, timeout: 10000 }), - ]); - - const ipsMap = {}; - (dlRes.data?.data || []).forEach(item => { - const ip = item.local_ip?.address; - if (!ip) return; - if (!ipsMap[ip]) { - ipsMap[ip] = { - ip_address: ip, - download: item.download || 0, - upload: 0, - first_seen: item.last_seen_at?.date || new Date().toISOString(), - last_seen: item.last_seen_at?.date || new Date().toISOString(), - domain: appInfo.domain, - protocol: 'HTTPS / TLS' - }; - } else { - ipsMap[ip].download = item.download || 0; - } - }); - - (ulRes.data?.data || []).forEach(item => { - const ip = item.local_ip?.address; - if (!ip) return; - if (!ipsMap[ip]) { - ipsMap[ip] = { - ip_address: ip, - download: 0, - upload: item.upload || 0, - first_seen: item.last_seen_at?.date || new Date().toISOString(), - last_seen: item.last_seen_at?.date || new Date().toISOString(), - domain: appInfo.domain, - protocol: 'HTTPS / TLS' - }; - } else { - ipsMap[ip].upload = item.upload || 0; - if (item.last_seen_at?.date) { - const d = new Date(item.last_seen_at.date); - if (d > new Date(ipsMap[ip].last_seen)) ipsMap[ip].last_seen = item.last_seen_at.date; - if (d < new Date(ipsMap[ip].first_seen)) ipsMap[ip].first_seen = item.last_seen_at.date; - } - } - }); - - const top_ips = Object.values(ipsMap).sort((a, b) => (b.download + b.upload) - (a.download + a.upload)); - const totalDl = top_ips.reduce((s, x) => s + x.download, 0); - const totalUl = top_ips.reduce((s, x) => s + x.upload, 0); - - console.log(`[AppDetailsHandler] DPI API: label=${label} agent=${agentUuid} top_ips=${top_ips.length} dl=${(totalDl/1e9).toFixed(2)}GB`); - return { top_ips, totalDl, totalUl }; - } - - try { - return await Promise.race([doFetch(), deadline]); - } catch (err) { - console.warn('[AppDetailsHandler] DPI API timeout/error:', err.message); - return null; - } -} +const { getAppLookupCache, populateAppCache, fetchFromDpiApi } = require('./appDetailsDpiHelper'); // ─── Main Handler ───────────────────────────────────────────────────────────── module.exports = async function appDetailsHandler(req, res, helpers) { @@ -189,7 +27,10 @@ module.exports = async function appDetailsHandler(req, res, helpers) { const timeFilter = getTimeFilter(req); const baseFilter = getBaseFilter(req, timeFilter); - const agentUuid = String(req.query.agent_uuid ?? '') || req.user?.agent_uuid || null; + let agentUuid = req.user?.agent_uuid || null; + if (req.user?.role !== 'AGENT_VIEWER') { + agentUuid = String(req.query.agent_uuid ?? '') || agentUuid; + } if (agentUuid) baseFilter.agent_uuid = agentUuid; // ── Step 1: Query DeviceAppStat (Primary source for per-device bandwidth per-app) ── @@ -202,7 +43,7 @@ module.exports = async function appDetailsHandler(req, res, helpers) { if (token && SITE_UUID) { await populateAppCache(BASE_URL, token, SITE_UUID).catch(e => console.warn('[AppDetails] Cache error:', e.message)); } - const appMeta = appLookupCache?.[label.toLowerCase()]; + const appMeta = getAppLookupCache()?.[label.toLowerCase()]; const ipsMap = {}; deviceApps.forEach(da => { diff --git a/backend/routes/auth/core.js b/backend/routes/auth/core.js index 090cefa..669620f 100644 --- a/backend/routes/auth/core.js +++ b/backend/routes/auth/core.js @@ -5,9 +5,11 @@ const jwt = require('jsonwebtoken'); const User = require('../../models/User'); const { makeToken, setCookieToken, requireAuth, JWT_SECRET } = require('./helpers'); +const { TenantConfig, CustomAgentLocation } = require('../../models/Schemas'); + const router = express.Router(); -// ─── Auto-seed SUPER_ADMIN dan SOC_ANALYST jika belum ada ─────────────────────── +// ─── Auto-seed SUPER_ADMIN, SOC_ANALYST, dan TENANT_ADMIN jika belum ada ───────── (async () => { try { const count = await User.countDocuments({ role: 'SUPER_ADMIN' }); @@ -25,19 +27,122 @@ const router = express.Router(); console.log('[Auth] ⚠ GANTI PASSWORD INI SEGERA DI PRODUCTION!'); } - const analystCount = await User.countDocuments({ role: 'SOC_ANALYST' }); - if (analystCount === 0) { - const hash = bcrypt.hashSync('analyst', 10); + const siabCount = await User.countDocuments({ username: 'siab' }); + if (siabCount === 0) { + const hash = bcrypt.hashSync('siab', 10); await User.create({ - username: 'analyst', + username: 'siab', password_hash: hash, - account_name: 'BackOne SOC Analyst', - role: 'SOC_ANALYST', - site_uuid: process.env.NETIFY_SITE_UUID || null, + account_name: 'SIAB Administrator', + role: 'TENANT_ADMIN', + site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e', agent_uuid: null, }); - console.log('[Auth] ✓ Default SOC_ANALYST created: analyst / analyst'); + console.log('[Auth] ✓ Default SIAB Tenant created: siab / siab'); } + + const nexusCount = await User.countDocuments({ username: 'nexus' }); + if (nexusCount === 0) { + const hash = bcrypt.hashSync('nexus', 10); + await User.create({ + username: 'nexus', + password_hash: hash, + account_name: 'Nexus Administrator', + role: 'TENANT_ADMIN', + site_uuid: 'd7902405_0dc2_458b_8584_ed4d24b64f24', + agent_uuid: null, + }); + console.log('[Auth] ✓ Default Nexus Tenant created: nexus / nexus'); + } + + // Repair/Migration: Ensure legacy users have appropriate created_by values + try { + const missingCreatedBy = await User.find({ $or: [{ created_by: { $exists: false } }, { created_by: null }] }); + if (missingCreatedBy.length > 0) { + console.log(`[Auth] Migrating ${missingCreatedBy.length} legacy users to set created_by...`); + for (const u of missingCreatedBy) { + if (u.username === 'admin') { + u.created_by = 'admin'; + } else if (u.site_uuid === '6681452d_9cae_4ff4_8ae8_0d504774265e') { + u.created_by = 'siab'; + } else if (u.site_uuid === 'd7902405_0dc2_458b_8584_ed4d24b64f24') { + u.created_by = 'nexus'; + } else { + u.created_by = 'admin'; + } + await u.save(); + } + console.log(`[Auth] Migration complete.`); + } + } catch (migrateErr) { + console.error('[Auth] Migration failed:', migrateErr.message); + } + + const defaultConfigs = [ + { + site_uuid: 'default', + brand_name: 'BackOne', + brand_logo: '/backone-logo.png', + footer_copyright: 'PT. Data Bisnis Solusi', + primary_color: '#E11D48', + }, + { + site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e', + brand_name: 'SIAB', + brand_logo: '/siab-logo.png', + footer_copyright: 'PT. SIAB Indonesia', + primary_color: '#3B82F6', + }, + { + site_uuid: 'd7902405_0dc2_458b_8584_ed4d24b64f24', + brand_name: 'Nexus', + brand_logo: '/nexus-logo.png', + footer_copyright: 'PT. Nexus Solusi', + primary_color: '#8B5CF6', + } + ]; + + for (const config of defaultConfigs) { + const existing = await TenantConfig.findOne({ site_uuid: config.site_uuid }); + if (!existing) { + await TenantConfig.create(config); + console.log(`[Auth] ✓ Seeded TenantConfig for: ${config.brand_name}`); + } + } + + // Seed default agent locations + const defaultLocations = [ + { + agent_uuid: 'F6-2V-DT-8A', + site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e', + latitude: -6.2263304, + longitude: 106.4247322, + label: 'CPI Balaraja Agent Office' + }, + { + agent_uuid: '2F-TF-1D-GK', + site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e', + latitude: -6.3763318, + longitude: 106.8983017, + label: 'JRP Cibubur Agent Office' + }, + { + agent_uuid: '8A-V3-PB-85', + site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e', + latitude: -6.2253265, + longitude: 106.8061484, + label: 'IFG LT.18 Agent HQ' + } + ]; + + for (const loc of defaultLocations) { + const existing = await CustomAgentLocation.findOne({ agent_uuid: loc.agent_uuid }); + if (!existing) { + await CustomAgentLocation.create(loc); + console.log(`[Auth] ✓ Seeded CustomAgentLocation for: ${loc.agent_uuid}`); + } + } + } catch (err) { console.warn('[Auth] Seed skipped (MongoDB not ready yet):', err.message); } diff --git a/backend/routes/auth/helpers.js b/backend/routes/auth/helpers.js index 0fc580b..4cc8f8a 100644 --- a/backend/routes/auth/helpers.js +++ b/backend/routes/auth/helpers.js @@ -4,7 +4,7 @@ const multer = require('multer'); const path = require('path'); const fs = require('fs'); -const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-backone-key'; +const { requireAuth, requireAdmin, JWT_SECRET } = require('../../middleware/auth'); function makeToken(user) { return jwt.sign( @@ -31,52 +31,6 @@ function setCookieToken(res, token) { }); } -function requireAuth(req, res, next) { - const token = req.cookies?.token; - if (!token) return res.status(401).json({ error: 'Not authenticated' }); - try { - req.user = jwt.verify(token, JWT_SECRET); - - // ── VIEW-AS MODE ────────────────────────────────────────────────────────── - const viewAsHeader = req.headers['x-view-as-agent']; - if (viewAsHeader && req.user.role === 'SUPER_ADMIN') { - try { - const viewDecoded = jwt.verify(viewAsHeader, JWT_SECRET); - if (viewDecoded.type === 'view-as' && viewDecoded.adminId === req.user.id && viewDecoded.viewAs) { - req.user = { - ...req.user, - role: 'AGENT_VIEWER', - agent_uuid: viewDecoded.viewAs, - agent_label: viewDecoded.viewAsLabel, - _viewAsMode: true, - _originalRole: 'SUPER_ADMIN', - }; - } - } catch (viewErr) { - console.warn('[ViewAs] Invalid view-as token, ignoring:', viewErr.message); - } - } - next(); - } catch { - res.status(401).json({ error: 'Token tidak valid' }); - } -} - -function requireAdmin(req, res, next) { - const token = req.cookies?.token; - if (!token) return res.status(401).json({ error: 'Not authenticated' }); - try { - const decoded = jwt.verify(token, JWT_SECRET); - if (decoded.role !== 'SUPER_ADMIN' && decoded.role !== 'TENANT_ADMIN' && decoded.role !== 'SOC_ANALYST') { - return res.status(403).json({ error: 'Role Anda tidak memiliki izin untuk melakukan aksi ini (Hanya Administrator / Analyst)' }); - } - req.adminUser = decoded; - next(); - } catch { - res.status(401).json({ error: 'Token tidak valid' }); - } -} - function getUploadsDir() { if (fs.existsSync('/home/adminbackend/web/demoplace.my.id/public_html')) { return '/home/adminbackend/web/demoplace.my.id/public_html/api/uploads'; diff --git a/backend/routes/auth/users.js b/backend/routes/auth/users.js index 0fc31cd..0773d79 100644 --- a/backend/routes/auth/users.js +++ b/backend/routes/auth/users.js @@ -17,7 +17,17 @@ function blockAnalyst(req, res, next) { // GET /api/auth/admin/users — daftar semua users (admin & analyst) router.get('/admin/users', requireAdmin, async (req, res) => { try { - const users = await User.find({}, '-password_hash').sort({ created_at: 1 }); + let query = {}; + if (req.adminUser.role === 'TENANT_ADMIN') { + query = { + $or: [ + { role: 'AGENT_VIEWER', site_uuid: req.adminUser.site_uuid }, + { created_by: req.adminUser.username } + ] + }; + } + query.username = { $ne: req.adminUser.username }; + const users = await User.find(query, '-password_hash').sort({ created_at: 1 }); const data = users.map(u => ({ id: u._id.toString(), username: u.username, @@ -42,7 +52,21 @@ router.post('/admin/create-agent-user', requireAdmin, blockAnalyst, async (req, return res.status(400).json({ ok: false, error: 'Username dan password wajib diisi' }); } const passwordHash = bcrypt.hashSync(password, 10); - const siteUuid = process.env.BACKONE_SITE_UUID || process.env.NETIFY_SITE_UUID || null; + + let siteUuid = null; + if (agent_uuid) { + const { Summary } = require('../../models/Schemas'); + const summaryDoc = await Summary.findOne({ agent_uuid: agent_uuid.trim() }); + if (summaryDoc) { + siteUuid = summaryDoc.site_uuid; + } + } + + if (!siteUuid) { + siteUuid = req.adminUser.role === 'SUPER_ADMIN' + ? (req.body.site_uuid || process.env.BACKONE_SITE_UUID || process.env.NETIFY_SITE_UUID || null) + : req.adminUser.site_uuid; + } const newUser = await User.create({ username: username.trim(), @@ -51,6 +75,7 @@ router.post('/admin/create-agent-user', requireAdmin, blockAnalyst, async (req, agent_uuid: agent_uuid?.trim() || null, role: 'AGENT_VIEWER', site_uuid: siteUuid, + created_by: req.adminUser.username, }); res.json({ ok: true, message: 'Akun Network Agent berhasil dibuat', userId: newUser._id.toString() }); @@ -69,6 +94,10 @@ router.post('/admin/update-agent-user', requireAdmin, blockAnalyst, upload.singl const target = await User.findById(user_id).select('+password_hash'); if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' }); if (target.role === 'SUPER_ADMIN') return res.status(403).json({ ok: false, error: 'Tidak bisa mengubah akun SUPER_ADMIN dari sini' }); + + if (req.adminUser.role !== 'SUPER_ADMIN' && target.site_uuid !== req.adminUser.site_uuid) { + return res.status(403).json({ ok: false, error: 'Unauthorized: This account does not belong to your tenant.' }); + } if (username?.trim()) { const existing = await User.findOne({ username: username.trim(), _id: { $ne: user_id } }); @@ -77,7 +106,16 @@ router.post('/admin/update-agent-user', requireAdmin, blockAnalyst, upload.singl } if (password) target.password_hash = bcrypt.hashSync(password, 10); if (account_name != null) target.account_name = account_name?.trim() || null; - if (agent_uuid != null) target.agent_uuid = agent_uuid?.trim() || null; + if (agent_uuid != null) { + target.agent_uuid = agent_uuid?.trim() || null; + if (agent_uuid.trim()) { + const { Summary } = require('../../models/Schemas'); + const summaryDoc = await Summary.findOne({ agent_uuid: agent_uuid.trim() }); + if (summaryDoc) { + target.site_uuid = summaryDoc.site_uuid; + } + } + } if (req.file) target.profile_picture = req.file.filename; await target.save(); @@ -94,6 +132,10 @@ router.delete('/admin/delete-agent-user/:id', requireAdmin, blockAnalyst, async const target = await User.findById(req.params.id); if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' }); if (target.role === 'SUPER_ADMIN') return res.status(403).json({ ok: false, error: 'Tidak bisa menghapus SUPER_ADMIN' }); + + if (req.adminUser.role !== 'SUPER_ADMIN' && target.site_uuid !== req.adminUser.site_uuid) { + return res.status(403).json({ ok: false, error: 'Unauthorized: This account does not belong to your tenant.' }); + } await User.findByIdAndDelete(req.params.id); res.json({ ok: true, message: 'Akun berhasil dihapus' }); } catch (err) { @@ -107,6 +149,10 @@ router.post('/admin/upload-agent-picture/:id', requireAdmin, blockAnalyst, uploa if (!req.file) return res.status(400).json({ ok: false, error: 'File gambar wajib diupload' }); const target = await User.findById(req.params.id); if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' }); + + if (req.adminUser.role !== 'SUPER_ADMIN' && target.site_uuid !== req.adminUser.site_uuid) { + return res.status(403).json({ ok: false, error: 'Unauthorized: This account does not belong to your tenant.' }); + } target.profile_picture = req.file.filename; await target.save(); res.json({ ok: true, message: 'Foto profil berhasil diperbarui', filename: req.file.filename }); @@ -135,7 +181,13 @@ router.post('/admin/create-external-user', requireAdmin, blockAnalyst, upload.si } const passwordHash = bcrypt.hashSync(password, 10); - const siteUuid = process.env.BACKONE_SITE_UUID || process.env.NETIFY_SITE_UUID || null; + const siteUuid = req.adminUser.role === 'SUPER_ADMIN' + ? (req.body.site_uuid || process.env.BACKONE_SITE_UUID || process.env.NETIFY_SITE_UUID || null) + : req.adminUser.site_uuid; + + const createdBy = req.adminUser.role === 'SUPER_ADMIN' + ? (req.body.created_by || req.adminUser.username) + : req.adminUser.username; const newUser = await User.create({ username: username.trim(), @@ -143,6 +195,7 @@ router.post('/admin/create-external-user', requireAdmin, blockAnalyst, upload.si account_name: account_name?.trim() || null, role: role, site_uuid: siteUuid, + created_by: createdBy, profile_picture: req.file ? req.file.filename : null }); diff --git a/backend/routes/dashboard.js b/backend/routes/dashboard.js index 31df8d3..47a9793 100644 --- a/backend/routes/dashboard.js +++ b/backend/routes/dashboard.js @@ -10,20 +10,55 @@ const axios = require('axios'); const PROXY_URL = process.env.PROXY_URL || 'http://localhost:4000'; +// ─── Rebranding Helper (Memory Safe & Fast) ────────────────────────────────── +function rebrandString(str) { + if (typeof str !== 'string') return str; + return str + .replace(/netify\.unclassified/gi, 'backone.unclassified') + .replace(/netify\.(?!ai)/gi, 'backone.') + .replace(/Netify's/g, "BackOne's") + .replace(/netify's/g, "backone's") + .replace(/Netify(?!(\.ai))/g, 'BackOne') + .replace(/netify(?!(\.ai))/g, 'backone'); +} + +function rebrandObj(obj) { + if (obj === null || obj === undefined) return obj; + + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + obj[i] = rebrandObj(obj[i]); + } + return obj; + } + + if (typeof obj === 'object') { + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + if (typeof obj[key] === 'string') { + obj[key] = rebrandString(obj[key]); + } else if (typeof obj[key] === 'object') { + obj[key] = rebrandObj(obj[key]); + } + } + } + return obj; + } + + if (typeof obj === 'string') { + return rebrandString(obj); + } + + return obj; +} + // ─── Rebranding Middleware ──────────────────────────────────────────────────── router.use((req, res, next) => { const originalJson = res.json.bind(res); res.json = function (body) { if (body) { try { - const sanitized = JSON.stringify(body) - .replace(/netify\.unclassified/gi, 'backone.unclassified') - .replace(/netify\.(?!ai)/gi, 'backone.') - .replace(/Netify's/g, "BackOne's") - .replace(/netify's/g, "backone's") - .replace(/Netify(?!(\.ai))/g, 'BackOne') - .replace(/netify(?!(\.ai))/g, 'backone'); - body = JSON.parse(sanitized); + body = rebrandObj(body); } catch (err) { console.error('[Dashboard] Rebrand error:', err.message); } @@ -56,5 +91,8 @@ router.use(require('./dashboard/tls')); router.use(require('./dashboard/telemetry')); router.use(require('./dashboard/events')); router.use(require('./dashboard/sslSan')); +router.use(require('./dashboard/tenantConfig')); +router.use(require('./dashboard/agentLocations')); +router.use(require('./dashboard/blacklist')); module.exports = router; diff --git a/backend/routes/dashboard/agentLocations.js b/backend/routes/dashboard/agentLocations.js new file mode 100644 index 0000000..bbcc5b9 --- /dev/null +++ b/backend/routes/dashboard/agentLocations.js @@ -0,0 +1,188 @@ +const express = require('express'); +const router = express.Router(); +const { CustomAgentLocation, Summary, DeviceStat, Flow } = require('../../models/Schemas'); +const { getTimeFilter } = require('./helpers'); + +// ─── 1. GET /api/dashboard/agent-locations ────────────────────────────────────── +router.get('/agent-locations', async (req, res) => { + try { + const isGlobalUser = req.user?.role === 'SUPER_ADMIN' || + ((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)); + + let query = {}; + if (!isGlobalUser && req.user?.site_uuid) { + query.site_uuid = req.user.site_uuid; + } + if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) { + query.agent_uuid = req.user.agent_uuid; + } + + const locations = await CustomAgentLocation.find(query).lean(); + res.json({ ok: true, data: locations }); + } catch (err) { + console.error('[GET /agent-locations]', err.message); + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// ─── 2. POST /api/dashboard/agent-locations ───────────────────────────────────── +router.post('/agent-locations', async (req, res) => { + try { + if (req.user?.role !== 'SUPER_ADMIN' && req.user?.role !== 'TENANT_ADMIN') { + return res.status(403).json({ ok: false, error: 'Only administrators can configure agent geolocations.' }); + } + const { agent_uuid, latitude, longitude, label } = req.body; + if (!agent_uuid || latitude === undefined || longitude === undefined) { + return res.status(400).json({ ok: false, error: 'agent_uuid, latitude, and longitude are required' }); + } + + // Determine site_uuid + let siteUuid = null; + const isGlobalUser = req.user?.role === 'SUPER_ADMIN' || + ((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)); + + if (!isGlobalUser && req.user?.site_uuid) { + const agentBelongs = await Summary.findOne({ agent_uuid, site_uuid: req.user.site_uuid }); + if (!agentBelongs) { + return res.status(403).json({ ok: false, error: 'Unauthorized: This agent does not belong to your tenant.' }); + } + siteUuid = req.user.site_uuid; + } else { + // Find the site_uuid from Summary collection for this agent + const summaryDoc = await Summary.findOne({ agent_uuid }); + if (summaryDoc) { + siteUuid = summaryDoc.site_uuid; + } else { + // Fallback or use standard env site_uuid + siteUuid = process.env.NETIFY_SITE_UUID || '6681452d_9cae_4ff4_8ae8_0d504774265e'; + } + } + + const findQuery = { agent_uuid }; + if (!isGlobalUser && req.user?.site_uuid) { + findQuery.site_uuid = req.user.site_uuid; + } + + const upserted = await CustomAgentLocation.findOneAndUpdate( + findQuery, + { + agent_uuid, + site_uuid: siteUuid, + latitude: parseFloat(latitude), + longitude: parseFloat(longitude), + label: label || '' + }, + { new: true, upsert: true } + ); + + res.json({ ok: true, data: upserted }); + } catch (err) { + console.error('[POST /agent-locations]', err.message); + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// ─── 3. DELETE /api/dashboard/agent-locations/:agent_uuid ──────────────────────── +router.delete('/agent-locations/:agent_uuid', async (req, res) => { + try { + if (req.user?.role !== 'SUPER_ADMIN' && req.user?.role !== 'TENANT_ADMIN') { + return res.status(403).json({ ok: false, error: 'Only administrators can delete agent geolocations.' }); + } + const { agent_uuid } = req.params; + + let query = { agent_uuid }; + const isGlobalUser = req.user?.role === 'SUPER_ADMIN' || + ((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)); + + if (!isGlobalUser && req.user?.site_uuid) { + query.site_uuid = req.user.site_uuid; + } + + const resDelete = await CustomAgentLocation.deleteOne(query); + res.json({ ok: true, deleted: resDelete.deletedCount > 0 }); + } catch (err) { + console.error('[DELETE /agent-locations]', err.message); + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// ─── 4. GET /api/dashboard/agent-flows ────────────────────────────────────────── +router.get('/agent-flows', async (req, res) => { + try { + const requestedSiteUuid = req.headers['x-backone-site-uuid']; + const isGlobalUser = req.user?.role === 'SUPER_ADMIN' || + ((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)); + + const siteUuid = (isGlobalUser && requestedSiteUuid) + ? requestedSiteUuid + : (req.user?.site_uuid || '6681452d_9cae_4ff4_8ae8_0d504774265e'); + + const timeFilter = getTimeFilter(req); + + // Build IP-to-Agent mapping from DeviceStat + const deviceQuery = { site_uuid: siteUuid }; + if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) { + deviceQuery.agent_uuid = req.user.agent_uuid; + } + const devices = await DeviceStat.find(deviceQuery).select('ip_address agent_uuid').lean(); + const deviceIpToAgent = {}; + for (const dev of devices) { + if (dev.ip_address && dev.agent_uuid) { + deviceIpToAgent[dev.ip_address] = dev.agent_uuid; + } + } + + // Query flows + const flowsQuery = { site_uuid: siteUuid }; + if (timeFilter) flowsQuery.timestamp = timeFilter; + if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) { + flowsQuery.agent_uuid = req.user.agent_uuid; + } + + const flows = await Flow.find(flowsQuery) + .select('agent_uuid src_ip dst_ip download upload app_label') + .sort({ timestamp: -1 }) + .limit(5000) + .lean(); + + const flowMap = {}; + for (const flow of flows) { + const srcAgent = flow.agent_uuid; + const dstAgent = deviceIpToAgent[flow.dst_ip]; + + if (srcAgent && dstAgent && srcAgent !== dstAgent) { + const key = `${srcAgent}->${dstAgent}`; + if (!flowMap[key]) { + flowMap[key] = { + source: srcAgent, + target: dstAgent, + bytes: 0, + flowsCount: 0, + details: [] + }; + } + const bytes = ((flow.download || 0) + (flow.upload || 0)); + flowMap[key].bytes += bytes; + flowMap[key].flowsCount += 1; + flowMap[key].details.push({ + src_ip: flow.src_ip, + dst_ip: flow.dst_ip, + app: flow.app_label || 'Unclassified', + bytes: bytes + }); + } + } + + const result = Object.values(flowMap); + for (const f of result) { + f.details.sort((a, b) => b.bytes - a.bytes); + f.details = f.details.slice(0, 5); // top 5 sub-flows + } + res.json({ ok: true, data: result }); + } catch (err) { + console.error('[GET /agent-flows]', err.message); + res.status(500).json({ ok: false, error: err.message }); + } +}); + +module.exports = router; diff --git a/backend/routes/dashboard/agents.js b/backend/routes/dashboard/agents.js index 87f8930..4f68095 100644 --- a/backend/routes/dashboard/agents.js +++ b/backend/routes/dashboard/agents.js @@ -53,10 +53,20 @@ router.get('/agents/uptime', async (req, res) => { // GET /api/dashboard/agents router.get('/agents', async (req, res) => { try { - if (req.user?.role !== 'SUPER_ADMIN' && req.user?._originalRole !== 'SUPER_ADMIN') { - return res.status(403).json({ ok: false, error: 'Forbidden: SUPER_ADMIN only' }); + const isAuthorized = req.user?.role === 'SUPER_ADMIN' || + req.user?.role === 'TENANT_ADMIN' || + req.user?._originalRole === 'SUPER_ADMIN' || + req.user?._originalRole === 'TENANT_ADMIN'; + + if (!isAuthorized) { + return res.status(403).json({ ok: false, error: 'Forbidden: Admin access only' }); } - const agents = await Summary.distinct('agent_uuid'); + const query = {}; + const effectiveRole = req.user?._originalRole || req.user?.role; + if (effectiveRole === 'TENANT_ADMIN') { + query.site_uuid = req.user.site_uuid; + } + const agents = await Summary.distinct('agent_uuid', query); res.json({ ok: true, count: agents.length, agents }); } catch (err) { res.status(500).json({ ok: false, error: err.message }); @@ -64,49 +74,29 @@ router.get('/agents', async (req, res) => { }); // GET /api/dashboard/agents/storage +// Returns per-agent total data size from in-memory cache (capacityTracker). +// Cache is computed once at startup and refreshed every 5-minute collection cycle. +// Values represent total MongoDB storage footprint per agent (across 7-day retention window). router.get('/agents/storage', async (req, res) => { try { - if (req.user?.role !== 'SUPER_ADMIN' && req.user?._originalRole !== 'SUPER_ADMIN') { - return res.status(403).json({ ok: false, error: 'Forbidden: SUPER_ADMIN only' }); + const isAuthorized = req.user?.role === 'SUPER_ADMIN' || + req.user?.role === 'TENANT_ADMIN' || + req.user?._originalRole === 'SUPER_ADMIN' || + req.user?._originalRole === 'TENANT_ADMIN'; + + if (!isAuthorized) { + return res.status(403).json({ ok: false, error: 'Forbidden: Admin access only' }); } - const db = mongoose.connection.db; - if (!db) { - return res.json({ ok: true, storage: {} }); - } + const { agentSizesCache, lastCacheUpdate } = require('../../db/capacityTracker'); + const storage = agentSizesCache(); + const cachedAt = lastCacheUpdate(); - const agentSizes = {}; - const collections = await db.listCollections().toArray(); - - for (const colInfo of collections) { - const colName = colInfo.name; - if (colName.startsWith('system.')) continue; - const col = db.collection(colName); - - const sampleDoc = await col.findOne({ agent_uuid: { $ne: null } }); - if (!sampleDoc) continue; - - const pipeline = [ - { $project: { agent_uuid: 1, docSize: { $bsonSize: "$$ROOT" } } }, - { $group: { _id: "$agent_uuid", totalBytes: { $sum: "$docSize" } } } - ]; - - const results = await col.aggregate(pipeline).toArray(); - for (const res of results) { - const agent = res._id || 'Unknown'; - agentSizes[agent] = (agentSizes[agent] || 0) + res.totalBytes; - } - } - - const storageMap = {}; - for (const [agent, bytes] of Object.entries(agentSizes)) { - storageMap[agent] = parseFloat((bytes / (1024 * 1024)).toFixed(2)); - } - - res.json({ ok: true, storage: storageMap }); + res.json({ ok: true, storage, cached_at: cachedAt }); } catch (err) { res.status(500).json({ ok: false, error: err.message }); } }); + module.exports = router; diff --git a/backend/routes/dashboard/blacklist.js b/backend/routes/dashboard/blacklist.js new file mode 100644 index 0000000..18ef3c5 --- /dev/null +++ b/backend/routes/dashboard/blacklist.js @@ -0,0 +1,99 @@ +const express = require('express'); +const router = express.Router(); +const { BlacklistRule } = require('../../models/Schemas'); +const { getBaseFilter } = require('./helpers'); + +// GET /api/dashboard/blacklist +router.get('/blacklist', async (req, res) => { + try { + const filter = getBaseFilter(req); + const site_uuid = filter.site_uuid; + if (!site_uuid) { + return res.status(400).json({ error: 'Site UUID is required' }); + } + + const query = { site_uuid }; + if (filter.agent_uuid) { + query.agent_uuid = filter.agent_uuid; + } + + const rules = await BlacklistRule.find(query).sort({ created_at: -1 }).lean(); + return res.json({ ok: true, data: rules }); + } catch (err) { + console.error('[Blacklist GET] Error:', err.message); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +// POST /api/dashboard/blacklist +router.post('/blacklist', async (req, res) => { + try { + if (req.user?.role !== 'AGENT_VIEWER') { + return res.status(403).json({ error: 'Only Network Agents (or Admins in View As mode) can modify blacklist rules.' }); + } + const filter = getBaseFilter(req); + const site_uuid = filter.site_uuid; + const agent_uuid = filter.agent_uuid; + if (!site_uuid) { + return res.status(400).json({ error: 'Site UUID is required' }); + } + if (!agent_uuid) { + return res.status(400).json({ error: 'Agent UUID is required' }); + } + + const { type, value } = req.body; + if (!type || !value) { + return res.status(400).json({ error: 'Type and value are required' }); + } + + if (!['category', 'domain'].includes(type)) { + return res.status(400).json({ error: 'Invalid blacklist type' }); + } + + // Upsert or create rule isolated per agent + const rule = await BlacklistRule.findOneAndUpdate( + { site_uuid, agent_uuid, type, value: value.trim() }, + { site_uuid, agent_uuid, type, value: value.trim(), is_active: true }, + { upsert: true, new: true } + ); + + return res.json({ ok: true, data: rule }); + } catch (err) { + console.error('[Blacklist POST] Error:', err.message); + if (err.code === 11000) { + return res.status(400).json({ error: 'Rule already exists' }); + } + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +// DELETE /api/dashboard/blacklist/:id +router.delete('/blacklist/:id', async (req, res) => { + try { + if (req.user?.role !== 'AGENT_VIEWER') { + return res.status(403).json({ error: 'Only Network Agents (or Admins in View As mode) can modify blacklist rules.' }); + } + const filter = getBaseFilter(req); + const site_uuid = filter.site_uuid; + const agent_uuid = filter.agent_uuid; + if (!site_uuid) { + return res.status(400).json({ error: 'Site UUID is required' }); + } + if (!agent_uuid) { + return res.status(400).json({ error: 'Agent UUID is required' }); + } + + const ruleId = req.params.id; + const result = await BlacklistRule.deleteOne({ _id: ruleId, site_uuid, agent_uuid }); + if (result.deletedCount === 0) { + return res.status(404).json({ error: 'Blacklist rule not found' }); + } + + return res.json({ ok: true, message: 'Blacklist rule deleted' }); + } catch (err) { + console.error('[Blacklist DELETE] Error:', err.message); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/backend/routes/dashboard/devices.js b/backend/routes/dashboard/devices.js index 41510b5..d642235 100644 --- a/backend/routes/dashboard/devices.js +++ b/backend/routes/dashboard/devices.js @@ -61,10 +61,33 @@ router.get('/devices', async (req, res) => { // POST /api/dashboard/devices/update-label router.post('/devices/update-label', async (req, res) => { try { + const isAuthorized = req.user?.role === 'SUPER_ADMIN' || + req.user?.role === 'TENANT_ADMIN' || + req.user?._originalRole === 'SUPER_ADMIN' || + req.user?._originalRole === 'TENANT_ADMIN'; + + if (!isAuthorized) { + return res.status(403).json({ ok: false, error: 'Only administrators can update device labels.' }); + } + const { mac_address, device_label } = req.body; if (!mac_address) return res.status(400).json({ ok: false, error: 'mac_address required' }); if (device_label === undefined) return res.status(400).json({ ok: false, error: 'device_label required' }); + const isGlobalUser = req.user?.role === 'SUPER_ADMIN' || + ((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)) || + req.user?._originalRole === 'SUPER_ADMIN'; + + if (!isGlobalUser && req.user?.site_uuid) { + const deviceExists = await DeviceStat.findOne({ + mac_address, + site_uuid: req.user.site_uuid + }); + if (!deviceExists) { + return res.status(403).json({ ok: false, error: 'Unauthorized: This device does not belong to your tenant.' }); + } + } + await CustomDeviceLabel.findOneAndUpdate( { mac_address }, { device_label }, diff --git a/backend/routes/dashboard/events.js b/backend/routes/dashboard/events.js index ef7e9c9..29f13db 100644 --- a/backend/routes/dashboard/events.js +++ b/backend/routes/dashboard/events.js @@ -6,11 +6,9 @@ const { getTimeFilter, getBaseFilter } = require('./helpers'); // GET /api/dashboard/events router.get('/events', async (req, res) => { try { - console.log('[/events] Request received. Query:', req.query); const limit = parseInt(req.query.limit ?? 0); const timeFilter = getTimeFilter(req); const base = getBaseFilter(req, timeFilter); - console.log('[/events] Event base filter:', base); let query = Event.find(base).sort({ timestamp: -1 }); if (limit > 0) { @@ -26,13 +24,10 @@ router.get('/events', async (req, res) => { let macToIpMap = {}; if (missingIpMacs.length > 0) { const baseFilterNull = getBaseFilter(req, null); - console.log('[/events] getBaseFilter(req, null) returned:', baseFilterNull); - const filterForDevices = { mac_address: { $in: missingIpMacs }, ...baseFilterNull }; - console.log('[/events] DEBUG filterForDevices:', filterForDevices); const devices = await DeviceStat.find(filterForDevices).lean(); for (const d of devices) { macToIpMap[d.mac_address] = d.ip_address; diff --git a/backend/routes/dashboard/flows.js b/backend/routes/dashboard/flows.js index 35a5dbe..dc5be00 100644 --- a/backend/routes/dashboard/flows.js +++ b/backend/routes/dashboard/flows.js @@ -6,18 +6,36 @@ const { getTimeFilter, getBaseFilter, topFlowField } = require('./helpers'); // GET /api/dashboard/flows router.get('/flows', async (req, res) => { try { - const limit = parseInt(req.query.limit ?? 50); + const rawLimit = parseInt(req.query.limit ?? 50); const skip = parseInt(req.query.skip ?? 0); + // Guard: limit=0 means "count only" from frontend — return empty data with total. + // 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 query = getBaseFilter(req, timeFilter); + if (limit === 0) { + // Frontend is requesting total count only (for pagination), not actual rows + const total = await Flow.countDocuments(query); + return res.json({ ok: true, data: [], total }); + } + + // When an explicit calendar date range is active, sort OLDEST FIRST so + // historical data (e.g., July 13) appears before more recent data (July 14). + // Without the date filter (sidebar time range only), keep NEWEST FIRST + // for real-time monitoring of the most recent flows. + const hasExplicitDateRange = !!(req.query.date_from || req.query.date_to); + const sortOrder = hasExplicitDateRange ? 1 : -1; + const raw = await Flow .find(query) - .sort({ timestamp: -1 }) + .sort({ timestamp: sortOrder }) .skip(skip) .limit(limit) .lean(); + + const data = raw.map(f => { const port = f.dst_port ?? 0; const proto = f.protocol || 'TCP'; diff --git a/backend/routes/dashboard/helpers.js b/backend/routes/dashboard/helpers.js index 347c5d6..41d994c 100644 --- a/backend/routes/dashboard/helpers.js +++ b/backend/routes/dashboard/helpers.js @@ -1,13 +1,26 @@ const { CustomDeviceLabel, Flow } = require('../../models/Schemas'); function getTimeFilter(req) { + // Explicit calendar date range (from the per-page date picker) takes priority + // over the global sidebar time range. Both dates are interpreted as WIB (UTC+7) + // to match the dashboard's display timezone (Rule 20). + const dateFrom = req.query.date_from; + const dateTo = req.query.date_to; + if (dateFrom || dateTo) { + const filter = {}; + if (dateFrom) filter.$gte = new Date(`${dateFrom}T00:00:00.000+07:00`); + if (dateTo) filter.$lte = new Date(`${dateTo}T23:59:59.999+07:00`); + return filter; + } + + // Fall back to sidebar global time range const range = req.query.timeRange || '1d'; if (range === 'all') return null; const now = new Date(); const ms = { '5m': 5 * 60000, '30m': 30 * 60000, - '1h': 60 * 60000, + '1h': 60 * 3600000, '1d': 24 * 3600000, '7d': 7 * 24 * 3600000, }; @@ -15,16 +28,17 @@ function getTimeFilter(req) { return { $gte: new Date(now.getTime() - delta) }; } + function getBaseFilter(req, timeFilter = null) { const filter = {}; if (timeFilter) filter.timestamp = timeFilter; const requestedSiteUuid = req.headers['x-backone-site-uuid']; - console.log('[DEBUG] getBaseFilter headers:', Object.keys(req.headers), 'x-backone-site-uuid:', requestedSiteUuid, 'role:', req.user?.role); - const hasSwitcherRole = ['SUPER_ADMIN', 'SOC_ANALYST', 'ENGINEER'].includes(req.user?.role); + const isGlobalUser = req.user?.role === 'SUPER_ADMIN' || + ((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)); - if (hasSwitcherRole && requestedSiteUuid) { + if (isGlobalUser && requestedSiteUuid) { filter.site_uuid = requestedSiteUuid; } else if (req.user?.site_uuid) { filter.site_uuid = req.user.site_uuid; diff --git a/backend/routes/dashboard/summary.js b/backend/routes/dashboard/summary.js index 9e91a95..716a48c 100644 --- a/backend/routes/dashboard/summary.js +++ b/backend/routes/dashboard/summary.js @@ -11,77 +11,94 @@ router.get('/summary', async (req, res) => { const base = getBaseFilter(req, timeFilter); const baseWithoutTime = getBaseFilter(req, null); - const latestDoc = await Summary.findOne(baseWithoutTime).sort({ timestamp: -1 }); - - let latestTime = null; let bandwidthDown = 0; let bandwidthUp = 0; - let totalDevicesCount = 0; let activeFlowsCount = 0; + let downloadSpeed = 0; + let uploadSpeed = 0; + let latestTime = null; - if (latestDoc) { - latestTime = latestDoc.timestamp; - const summaries = await Summary.find({ ...baseWithoutTime, timestamp: latestTime }).lean(); - - bandwidthDown = summaries.reduce((s, r) => s + (r.bandwidth_down ?? 0), 0); - bandwidthUp = summaries.reduce((s, r) => s + (r.bandwidth_up ?? 0), 0); - totalDevicesCount = summaries.reduce((s, r) => s + (r.total_devices ?? 0), 0); - activeFlowsCount = summaries.reduce((s, r) => s + (r.active_flows ?? 0), 0); + if (base.agent_uuid) { + // ── Agent-Level Summary (View As Agent mode) ───────────────────────────── + // The proxy saves per-agent summaries with agent_uuid = . + // Use the latest one for the scoped agent instead of site aggregates. + const latestAgentSummary = await Summary + .findOne(baseWithoutTime) + .sort({ timestamp: -1 }) + .lean(); + + if (latestAgentSummary) { + bandwidthDown = latestAgentSummary.bandwidth_down || 0; + bandwidthUp = latestAgentSummary.bandwidth_up || 0; + activeFlowsCount = latestAgentSummary.active_flows || 0; + downloadSpeed = latestAgentSummary.download_speed || 0; + uploadSpeed = latestAgentSummary.upload_speed || 0; + latestTime = latestAgentSummary.timestamp; + } + } else { + // ── 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 + ? [baseWithoutTime.site_uuid] + : await Summary.distinct('site_uuid', { agent_uuid: null }); + + for (const siteId of siteIds) { + const latestSiteSummary = await Summary + .findOne({ agent_uuid: null, site_uuid: siteId }) + .sort({ timestamp: -1 }) + .lean(); + + 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; + downloadSpeed += latestSiteSummary.download_speed || 0; + uploadSpeed += latestSiteSummary.upload_speed || 0; + if (!latestTime || latestSiteSummary.timestamp > latestTime) { + latestTime = latestSiteSummary.timestamp; + } + } + } + + // Fallback: if no site-level summaries exist yet, aggregate from per-agent summaries + if (bandwidthDown === 0 && bandwidthUp === 0) { + const latestAgentDoc = await Summary.findOne(baseWithoutTime).sort({ timestamp: -1 }); + if (latestAgentDoc) { + latestTime = latestAgentDoc.timestamp; + 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); + downloadSpeed = agentSummaries.reduce((s, r) => s + (r.download_speed ?? 0), 0); + uploadSpeed = agentSummaries.reduce((s, r) => s + (r.upload_speed ?? 0), 0); + } + } } - const [fallbackDevices, fallbackFlows, fallbackFlowBandwidth, realThreatsCount, realEventsCount, fallbackThreatsCount] = await Promise.all([ + // Device count, Threats, Events — always use the scoped base filter + // (already contains agent_uuid when in AGENT_VIEWER mode) + const [uniqueDevices, realThreatsCount, realEventsCount] = await Promise.all([ DeviceStat.distinct('ip_address', base).then(r => r.length), - Flow.countDocuments(base), - Flow.aggregate([ - { $match: base }, - { $group: { _id: null, down: { $sum: '$download' }, up: { $sum: '$upload' } } } - ]), Threat.countDocuments(base), Event.countDocuments(base), - Event.countDocuments({ - ...base, - $or: [ - { severity: { $in: ['Critical', 'High'] } }, - { category_label: 'Cybersecurity' } - ] - }) ]); - const flowDown = fallbackFlowBandwidth[0]?.down || 0; - const flowUp = fallbackFlowBandwidth[0]?.up || 0; - - let finalDown = bandwidthDown > 0 ? bandwidthDown : flowDown; - let finalUp = bandwidthUp > 0 ? bandwidthUp : flowUp; - let finalDevices = fallbackDevices; - let finalActiveFlows = activeFlowsCount > 0 ? activeFlowsCount : fallbackFlows; - - const range = req.query.timeRange || '1d'; - if (range !== 'all' && range !== '1d') { - const scaleMap = { - '5m': 1 / (24 * 12), - '10m': 1 / (24 * 6), - '30m': 1 / 48, - '1h': 1 / 24, - '7d': 7, - }; - const multiplier = scaleMap[range] ?? 1; - finalDown = Math.round(finalDown * multiplier); - finalUp = Math.round(finalUp * multiplier); - finalActiveFlows = Math.round(finalActiveFlows * multiplier); - } - res.json({ ok: true, data: { - total_devices: finalDevices, - total_threats: realThreatsCount > 0 ? realThreatsCount : fallbackThreatsCount, + total_devices: uniqueDevices, + total_threats: realThreatsCount, total_events: realEventsCount, last_fetch: latestTime || new Date(), - bandwidth_down: finalDown, - bandwidth_up: finalUp, - active_flows: finalActiveFlows, - download_speed: latestDoc?.download_speed ?? 0, - upload_speed: latestDoc?.upload_speed ?? 0, + bandwidth_down: bandwidthDown, + bandwidth_up: bandwidthUp, + active_flows: activeFlowsCount, + download_speed: downloadSpeed, + upload_speed: uploadSpeed, flow_speed: 0, } }); @@ -91,6 +108,9 @@ router.get('/summary', async (req, res) => { } }); + + + // GET /api/dashboard/timeline router.get('/timeline', async (req, res) => { try { diff --git a/backend/routes/dashboard/tenantConfig.js b/backend/routes/dashboard/tenantConfig.js new file mode 100644 index 0000000..e6114b7 --- /dev/null +++ b/backend/routes/dashboard/tenantConfig.js @@ -0,0 +1,38 @@ +const express = require('express'); +const router = express.Router(); +const { TenantConfig } = require('../../models/Schemas'); + +router.get('/tenant-config', async (req, res) => { + try { + let siteUuid = 'default'; + + // If Super Admin has a selected site (passed in x-backone-site-uuid header), + // we want them to see the branding of that selected site. + // Otherwise they see BackOne (default) branding. + const isGlobalUser = req.user?.role === 'SUPER_ADMIN' || + ((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)); + + if (isGlobalUser) { + const requestedSiteUuid = req.headers['x-backone-site-uuid']; + if (requestedSiteUuid) { + siteUuid = requestedSiteUuid; + } + } else if (req.user?.site_uuid) { + // For TENANT_ADMIN or other isolated roles, they only see their own site branding + siteUuid = req.user.site_uuid; + } + + let config = await TenantConfig.findOne({ site_uuid: siteUuid }); + if (!config) { + // Fallback to default branding if config is not found + config = await TenantConfig.findOne({ site_uuid: 'default' }); + } + + res.json({ ok: true, data: config }); + } catch (err) { + console.error('[/tenant-config]', err.message); + res.status(500).json({ ok: false, error: err.message }); + } +}); + +module.exports = router; diff --git a/backend/routes/dashboard/threats.js b/backend/routes/dashboard/threats.js index 92caed7..421a64a 100644 --- a/backend/routes/dashboard/threats.js +++ b/backend/routes/dashboard/threats.js @@ -281,7 +281,10 @@ router.get('/intelligence/server-discovery', async (req, res) => { // Resolve IPs using DeviceStat const macs = events.map(e => e.mac_address).filter(Boolean); - const devices = await DeviceStat.find({ mac_address: { $in: macs } }).lean(); + const agentFilter = {}; + if (query.agent_uuid) agentFilter.agent_uuid = query.agent_uuid; + if (query.site_uuid) agentFilter.site_uuid = query.site_uuid; + const devices = await DeviceStat.find({ mac_address: { $in: macs }, ...agentFilter }).lean(); const macMap = {}; devices.forEach(d => { macMap[d.mac_address] = d; diff --git a/backend/routes/deviceDetailsHandler.js b/backend/routes/deviceDetailsHandler.js index f7c40c5..8c0f084 100644 --- a/backend/routes/deviceDetailsHandler.js +++ b/backend/routes/deviceDetailsHandler.js @@ -48,30 +48,34 @@ module.exports = async function deviceDetailsHandler(req, res, helpers) { const t0 = Date.now(); try { const { - getTimeFilter, generateMacFromIp, + getTimeFilter, getBaseFilter, generateMacFromIp, resolveDeviceTypeFromIp, resolveOSFromIp, resolveVendorFromIp, generateAutoLabel } = helpers; + const baseFilter = getBaseFilter(req); + let ip = String(req.query.ip ?? ''); const mac = String(req.query.mac ?? ''); if (!ip && mac) { - const dev = await DeviceStat.findOne({ mac_address: mac }).sort({ timestamp: -1 }).lean(); + const dev = await DeviceStat.findOne({ mac_address: mac, ...baseFilter }).sort({ timestamp: -1 }).lean(); if (dev) { ip = dev.ip_address; } else { - const flow = await Flow.findOne({ src_mac: mac }).sort({ timestamp: -1 }).lean(); + const flow = await Flow.findOne({ src_mac: mac, ...baseFilter }).sort({ timestamp: -1 }).lean(); if (flow) ip = flow.src_ip; } } - if (!ip) return res.status(400).json({ ok: false, message: 'ip or mac required' }); + if (!ip && !mac) return res.status(400).json({ ok: false, message: 'ip or mac required' }); - const agentUuidParam = String(req.query.agent_uuid ?? ''); - const metaFilter = {}; - if (req.user?.site_uuid) metaFilter.site_uuid = req.user.site_uuid; + // Find device stats by ip if set, else by mac + const deviceQuery = ip ? { ip_address: ip } : { mac_address: mac }; + const device = await DeviceStat.findOne({ ...deviceQuery, ...baseFilter }).sort({ timestamp: -1 }).lean(); + if (!ip && device?.ip_address) { + ip = device.ip_address; + } - const device = await DeviceStat.findOne({ ip_address: ip, ...metaFilter }).sort({ timestamp: -1 }).lean(); - const agentUuid = agentUuidParam || device?.agent_uuid || req.user?.agent_uuid || null; + const agentUuid = baseFilter.agent_uuid || device?.agent_uuid || null; // ── PRIMARY bandwidth source ───────────────────────────────────────────── const totalDownload = device?.download || 0; @@ -89,15 +93,35 @@ module.exports = async function deviceDetailsHandler(req, res, helpers) { } // ── Parallel queries ───────────────────────────────────────────────────── + const flowQueryConditions = []; + if (ip) { + flowQueryConditions.push({ src_ip: ip }, { dst_ip: ip }); + } + if (mac) { + flowQueryConditions.push({ src_mac: mac }, { dst_mac: mac }); + } + + const threatQuery = { + ...(agentUuid ? { agent_uuid: agentUuid } : {}) + }; + if (ip && mac) { + threatQuery.$or = [{ ip_address: ip }, { mac_address: mac }, { src_mac: mac }]; + } else if (ip) { + threatQuery.ip_address = ip; + } else if (mac) { + threatQuery.$or = [{ mac_address: mac }, { src_mac: mac }]; + } + const appFilter = agentUuid ? { agent_uuid: agentUuid, ip_address: ip } : { ip_address: ip }; if (req.user?.site_uuid) appFilter.site_uuid = req.user.site_uuid; const [deviceAppStats, flowsQuery, rawThreats] = await Promise.all([ - // PRIMARY: per-device per-app from DPI API (stored by proxy Step 3b) - DeviceAppStat.find(appFilter).sort({ timestamp: -1 }).lean(), - Flow.find({ ...flowFilter, $or: [{ src_ip: ip }, { dst_ip: ip }] }).sort({ timestamp: -1 }).limit(2000).lean(), - Threat.find({ ...(agentUuid ? { agent_uuid: agentUuid } : {}), ip_address: ip }) - .sort({ detected_at: -1 }).lean(), + // Only query DeviceAppStat if we have an IP + ip ? DeviceAppStat.find(appFilter).sort({ timestamp: -1 }).lean() : [], + flowQueryConditions.length > 0 + ? Flow.find({ ...flowFilter, $or: flowQueryConditions }).sort({ timestamp: -1 }).limit(2000).lean() + : [], + Threat.find(threatQuery).sort({ detected_at: -1 }).lean(), ]); // ── Apps tab — use DeviceAppStat (real DPI per-IP per-app data) ────────── @@ -132,7 +156,8 @@ module.exports = async function deviceDetailsHandler(req, res, helpers) { }; for (const f of flowsQuery) { - if (f.src_ip !== ip) continue; // outbound only + const isOutbound = ip ? (f.src_ip === ip) : (mac ? (f.src_mac === mac) : false); + if (!isOutbound) continue; // outbound only const down = f.download || 0; const up = f.upload || 0; const ls = f.last_seen || (f.timestamp ? new Date(f.timestamp).toISOString() : new Date().toISOString()); @@ -178,7 +203,7 @@ module.exports = async function deviceDetailsHandler(req, res, helpers) { })); const flows = flowsQuery - .filter(f => f.src_ip === ip) + .filter(f => ip ? (f.src_ip === ip) : (mac ? (f.src_mac === mac) : false)) .map(f => ({ flow_id: f.flow_id || f._id.toString(), src_ip: f.src_ip, diff --git a/backend/routes/remoteIpDetailsHandler.js b/backend/routes/remoteIpDetailsHandler.js index 3b6fbe5..759ca32 100644 --- a/backend/routes/remoteIpDetailsHandler.js +++ b/backend/routes/remoteIpDetailsHandler.js @@ -10,9 +10,11 @@ module.exports = async function remoteIpDetailsHandler(req, res, helpers) { const flowFilter = {}; if (req.user?.site_uuid) flowFilter.site_uuid = req.user.site_uuid; - // Agent scope if viewer + // Agent scope if viewer or query param if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) { flowFilter.agent_uuid = req.user.agent_uuid; + } else if (req.query?.agent_uuid) { + flowFilter.agent_uuid = req.query.agent_uuid; } const rawTimeRange = String(req.query.timeRange ?? 'all'); diff --git a/backend/server.js b/backend/server.js index f218b52..98b1224 100644 --- a/backend/server.js +++ b/backend/server.js @@ -55,43 +55,15 @@ app.use('/api/auth', authRoutes); app.use('/api/uploads', express.static(getUploadsDir())); // ─── Auth Middleware ────────────────────────────────────────────────────────── -const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-backone-key'; - -function requireAuth(req, res, next) { - const token = req.cookies?.token; - if (!token) return res.status(401).json({ error: 'Unauthorized' }); - - try { - req.user = jwt.verify(token, JWT_SECRET); - - // ── VIEW-AS MODE ────────────────────────────────────────────────────────── - // Jika SUPER_ADMIN sedang dalam mode "View As Agent", frontend mengirim - // header X-View-As-Agent berisi JWT token yang berisi agent_uuid yang dipilih. - const viewAsHeader = req.headers['x-view-as-agent']; - if (viewAsHeader && req.user.role === 'SUPER_ADMIN') { - try { - const viewDecoded = jwt.verify(viewAsHeader, JWT_SECRET); - if (viewDecoded.type === 'view-as' && viewDecoded.adminId === req.user.id && viewDecoded.viewAs) { - req.user = { - ...req.user, - role: 'AGENT_VIEWER', - agent_uuid: viewDecoded.viewAs, - agent_label: viewDecoded.viewAsLabel, - _viewAsMode: true, - _originalRole: 'SUPER_ADMIN', - }; - } - } catch (viewErr) { - console.warn('[ViewAs] Invalid view-as token, ignoring:', viewErr.message); - } - } - // ───────────────────────────────────────────────────────────────────────── - - next(); - } catch (err) { - res.status(401).json({ error: 'Invalid token' }); - } -} +const { requireAuth } = require('./middleware/auth'); +const { getTimeFilter, getBaseFilter } = require('./routes/dashboard/helpers'); +const { + generateMacFromIp, + resolveVendorFromIp, + resolveDeviceTypeFromIp, + resolveOSFromIp, + generateAutoLabel +} = require('./deviceResolver'); // ─── Protected Dashboard Routes ─────────────────────────────────────────────── const dashboardRoutes = require('./routes/dashboard'); @@ -99,109 +71,16 @@ const dashboardRoutes = require('./routes/dashboard'); // Override /api/dashboard/app-details to show real-time device mapping per application app.get('/api/dashboard/app-details', requireAuth, (req, res) => { require('./routes/appDetailsHandler')(req, res, { - getTimeFilter: (req) => { - const range = req.query.timeRange || 'all'; - if (range === 'all') return null; - const now = new Date(); - const ms = { - '5m': 5 * 60000, - '10m': 10 * 60000, - '30m': 30 * 60000, - '1h': 60 * 60000, - '1d': 24 * 3600000, - '7d': 7 * 24 * 3600000, - }; - const delta = ms[range] ?? ms['1h']; - return { $gte: new Date(now.getTime() - delta) }; - }, - getBaseFilter: (req, timeFilter = null) => { - const filter = {}; - if (timeFilter) filter.timestamp = timeFilter; - if (req.user?.site_uuid) filter.site_uuid = req.user.site_uuid; - - // Agent-based isolation (RBAC / Multi-Tenant) - if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) { - filter.agent_uuid = req.user.agent_uuid; - } - return filter; - } + getTimeFilter, + getBaseFilter }); }); // Override /api/dashboard/device-details to map real-time classifications (Facebook, YouTube, etc.) app.get('/api/dashboard/device-details', requireAuth, (req, res) => { - const generateMacFromIp = (ip) => { - if (!ip) return '00:16:3e:00:11:22'; - let hash = 0; - for (let i = 0; i < ip.length; i++) { - hash = (hash << 5) - hash + ip.charCodeAt(i); - hash |= 0; - } - const hex = Math.abs(hash).toString(16).padEnd(8, 'a'); - return `00:16:3e:${hex.substring(0,2)}:${hex.substring(2,4)}:${hex.substring(4,6)}`; - }; - - const resolveVendorFromIp = (ip) => { - if (!ip) return 'Intel Corporation'; - if (ip.startsWith('10.6.30.') || ip.startsWith('10.250.')) return 'Supermicro / Dell Inc.'; - if (ip.startsWith('10.6.10.') || ip.startsWith('10.6.11.')) return 'Cisco Systems, Inc.'; - if (ip.startsWith('192.168.')) return 'TP-Link Corporation'; - let hash = 0; - for (let i = 0; i < ip.length; i++) hash = (hash << 5) - hash + ip.charCodeAt(i); - const vendors = ['Intel Corporation', 'Asustek Computer Inc.', 'Apple Inc.', 'Hewlett Packard', 'Samsung Electronics']; - return vendors[Math.abs(hash) % vendors.length]; - }; - - const resolveDeviceTypeFromIp = (ip) => { - if (!ip) return 'Workstation'; - if (ip.endsWith('.1') || ip.endsWith('.254')) return 'Gateway / Router'; - if (ip.startsWith('10.6.30.')) return 'Database Server'; - if (ip.startsWith('10.250.')) return 'Core Network Node'; - if (ip.startsWith('10.6.12.')) return 'Finance Workstation'; - return 'Workstation / Laptop'; - }; - - const resolveOSFromIp = (ip) => { - if (!ip) return 'Windows 11'; - if (ip.startsWith('10.6.30.') || ip.startsWith('10.250.')) return 'Linux (Ubuntu Server 24.04)'; - if (ip.startsWith('10.6.12.')) return 'Windows 11 Enterprise'; - if (ip.startsWith('192.168.')) return 'iOS / Android'; - return 'Windows 11 Pro'; - }; - - const generateAutoLabel = (ip, mac, manufacturer, deviceType) => { - const brand = manufacturer && manufacturer !== '-' && manufacturer !== 'Unknown' ? manufacturer.split(' ')[0] : ''; - const type = deviceType && deviceType !== '-' && deviceType !== 'Unknown' ? deviceType : 'Device'; - const suffix = ip ? ip.split('.').slice(-2).join('.') : (mac ? mac.split(':').slice(-2).join(':') : 'Node'); - return brand ? `${brand} ${type} (${suffix})` : `${type} (${suffix})`; - }; - require('./routes/deviceDetailsHandler')(req, res, { - // Device detail: default timeRange is 'all' so ALL historical data shows - // Only respect explicit time filters if user deliberately passes one - getTimeFilter: (req) => { - const range = req.query.timeRange || 'all'; - if (range === 'all') return null; - const now = new Date(); - const ms = { - '5m': 5 * 60000, - '30m': 30 * 60000, - '1h': 60 * 60000, - '1d': 24 * 3600000, - '7d': 7 * 24 * 3600000, - }; - const delta = ms[range] ?? ms['1h']; - return { $gte: new Date(now.getTime() - delta) }; - }, - getBaseFilter: (req, timeFilter = null) => { - const filter = {}; - if (timeFilter) filter.timestamp = timeFilter; - if (req.user?.site_uuid) filter.site_uuid = req.user.site_uuid; - if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) { - filter.agent_uuid = req.user.agent_uuid; - } - return filter; - }, + getTimeFilter, + getBaseFilter, generateMacFromIp, resolveDeviceTypeFromIp, resolveOSFromIp, @@ -212,20 +91,7 @@ app.get('/api/dashboard/device-details', requireAuth, (req, res) => { app.get('/api/dashboard/remote-ip-details', requireAuth, async (req, res) => { require('./routes/remoteIpDetailsHandler')(req, res, { - getTimeFilter: (req) => { - const range = req.query.timeRange || 'all'; - if (range === 'all') return null; - const now = new Date(); - const ms = { - '5m': 5 * 60000, - '30m': 30 * 60000, - '1h': 60 * 60000, - '1d': 24 * 3600000, - '7d': 7 * 24 * 3600000, - }; - const delta = ms[range] ?? ms['1h']; - return { $gte: new Date(now.getTime() - delta) }; - } + getTimeFilter }); }); diff --git a/ecosystem.config.js b/ecosystem.config.js index 20ab979..f0395e6 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -1,27 +1,21 @@ module.exports = { apps: [ { - name: "backone-frontend", - script: "./.next/standalone/server.js", - env: { - NODE_ENV: "production", - PORT: 8009 - } + name: "backone-proxy", + script: "./proxy/index.js", + env_file: ".env.production" }, { name: "backone-backend", script: "./backend/server.js", - env: { - NODE_ENV: "production", - PORT: 3001 - } + env_file: ".env.production" }, { - name: "backone-proxy", - script: "./proxy/index.js", + name: "backone-frontend", + script: "server.js", + env_file: ".env.production", env: { - NODE_ENV: "production", - PORT: 4000 + PORT: 8009 } } ] diff --git a/next.config.ts b/next.config.ts index 6dc15c9..43935a7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", + serverExternalPackages: ["mongoose"], experimental: { serverActions: { allowedOrigins: ["demoplace.my.id", "www.demoplace.my.id"], diff --git a/package-lock.json b/package-lock.json index 0a6b822..8c2ce9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "netify-app", "version": "0.1.0", "dependencies": { + "@types/leaflet": "^1.9.21", "axios": "^1.18.1", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.11.1", @@ -21,6 +22,7 @@ "express": "^5.2.1", "framer-motion": "^12.42.2", "jsonwebtoken": "^9.0.3", + "leaflet": "^1.9.4", "lucide-react": "^1.21.0", "mongodb-memory-server": "^11.2.0", "mongoose": "^9.7.4", @@ -1860,6 +1862,15 @@ "@types/node": "*" } }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -6933,6 +6944,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/package.json b/package.json index 99f4f52..b2ae3b7 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev:next": "next dev", "dev:backend": "node backend/server.js", "dev:proxy": "node proxy/index.js", + "kill:ports": "powershell -Command \"@(3000,3001,4000) | ForEach-Object { $port = $_; $pids = netstat -ano | Select-String \\\":$port\\s+.+LISTENING\\\" | ForEach-Object { ($_ -split '\\s+')[-1] }; $pids | Where-Object { $_ -match '^\\d+$' } | ForEach-Object { taskkill /PID $_ /F 2>$null } }; Write-Host 'Ports 3000/3001/4000 cleared.'\"", "build": "next build", "start": "next start", "start:prod": "concurrently \"next start\" \"node backend/server.js\" \"node proxy/index.js\"", @@ -17,6 +18,7 @@ "install:all": "npm install && cd backend && npm install && cd ../proxy && npm install && cd .." }, "dependencies": { + "@types/leaflet": "^1.9.21", "axios": "^1.18.1", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.11.1", @@ -30,6 +32,7 @@ "express": "^5.2.1", "framer-motion": "^12.42.2", "jsonwebtoken": "^9.0.3", + "leaflet": "^1.9.4", "lucide-react": "^1.21.0", "mongodb-memory-server": "^11.2.0", "mongoose": "^9.7.4", diff --git a/proxy/check_deviceappstats.js b/proxy/check_deviceappstats.js new file mode 100644 index 0000000..82c71fb --- /dev/null +++ b/proxy/check_deviceappstats.js @@ -0,0 +1,27 @@ +const { MongoClient } = require('mongodb'); +const client = new MongoClient('mongodb://127.0.0.1:27017'); + +client.connect().then(async () => { + const db = client.db('backone_dpi'); + const col = db.collection('deviceappstats'); + const CIBUBUR = '2F-TF-1D-GK'; + const BALARAJA = 'F6-2V-DT-8A'; + + const countCibubur = await col.countDocuments({ agent_uuid: CIBUBUR, app_label: 'YouTube' }); + const countBalaraja = await col.countDocuments({ agent_uuid: BALARAJA, app_label: 'YouTube' }); + + const sampleCibubur = await col.find({ agent_uuid: CIBUBUR, app_label: 'YouTube' }).sort({ timestamp: -1 }).limit(10).toArray(); + const sampleBalaraja = await col.find({ agent_uuid: BALARAJA, app_label: 'YouTube' }).sort({ timestamp: -1 }).limit(5).toArray(); + + console.log(`DeviceAppStat count YouTube:`); + console.log(`- JRP Cibubur (${CIBUBUR}): ${countCibubur}`); + console.log(`- CPI Balaraja (${BALARAJA}): ${countBalaraja}`); + + console.log(`\nSample JRP Cibubur DeviceAppStat for YouTube:`); + sampleCibubur.forEach(r => { + console.log(` IP: ${r.ip_address} | DL: ${(r.download/1e6).toFixed(2)} MB | UL: ${(r.upload/1e6).toFixed(2)} MB | Time: ${r.timestamp.toISOString()}`); + }); + + await client.close(); + process.exit(0); +}).catch(e => { console.error(e.message); process.exit(1); }); diff --git a/proxy/check_flows_dates.js b/proxy/check_flows_dates.js new file mode 100644 index 0000000..d0abfbf --- /dev/null +++ b/proxy/check_flows_dates.js @@ -0,0 +1,34 @@ +const { MongoClient } = require('mongodb'); +const client = new MongoClient('mongodb://127.0.0.1:27017'); + +client.connect().then(async () => { + const db = client.db('backone_dpi'); + const col = db.collection('flows'); + const SIAB = '6681452d_9cae_4ff4_8ae8_0d504774265e'; + + const july13start = new Date('2026-07-13T00:00:00.000+07:00'); + const july13end = new Date('2026-07-13T23:59:59.999+07:00'); + const july14start = new Date('2026-07-14T00:00:00.000+07:00'); + + const count13 = await col.countDocuments({ site_uuid: SIAB, timestamp: { $gte: july13start, $lte: july13end } }); + const count14 = await col.countDocuments({ site_uuid: SIAB, timestamp: { $gte: july14start } }); + const total = await col.countDocuments({ site_uuid: SIAB }); + + const oldest = await col.findOne({ site_uuid: SIAB }, { sort: { timestamp: 1 }, projection: { timestamp: 1, first_seen: 1, last_seen: 1 } }); + const newest = await col.findOne({ site_uuid: SIAB }, { sort: { timestamp: -1 }, projection: { timestamp: 1, first_seen: 1, last_seen: 1 } }); + + const sample13 = await col.findOne( + { site_uuid: SIAB, timestamp: { $gte: july13start, $lte: july13end } }, + { projection: { timestamp: 1, first_seen: 1, last_seen: 1, flow_id: 1, agent_uuid: 1 } } + ); + + console.log('Total SIAB flows:', total); + console.log('SIAB flows with timestamp on July 13:', count13); + console.log('SIAB flows with timestamp on July 14+:', count14); + console.log('Oldest flow:', JSON.stringify(oldest, null, 2)); + console.log('Newest flow:', JSON.stringify(newest, null, 2)); + console.log('Sample July 13 flow:', JSON.stringify(sample13, null, 2)); + + await client.close(); + process.exit(0); +}).catch(e => { console.error(e.message); process.exit(1); }); diff --git a/proxy/clean_and_recollect.js b/proxy/clean_and_recollect.js new file mode 100644 index 0000000..ea5e773 --- /dev/null +++ b/proxy/clean_and_recollect.js @@ -0,0 +1,38 @@ +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') }); +const mongoose = require('mongoose'); +const { collectAllAgents } = require('./collector'); + +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi'; + +async function run() { + console.log('Connecting to MongoDB...'); + await mongoose.connect(MONGODB_URI); + console.log('Connected.'); + + const db = mongoose.connection.db; + const collections = ['devicestats', 'deviceappstats', 'appstats']; + + console.log('Clearing old contaminated collections...'); + for (const colName of collections) { + await db.collection(colName).deleteMany({}); + console.log(` ✓ Cleared ${colName}`); + } + + console.log('\nRunning initial data collection cycle for ALL agents...'); + const result = await collectAllAgents(); + console.log('Collection completed:', JSON.stringify(result, null, 2)); + + // Log new clean sizes + const { logCapacityStats } = require('./db/capacityTracker'); + await logCapacityStats('[MongoDB] Capacity after clean run:'); + + await mongoose.disconnect(); + console.log('Done.'); + process.exit(0); +} + +run().catch(e => { + console.error('Fatal error during clean and recollect:', e); + process.exit(1); +}); diff --git a/proxy/cleanup_duplicate_agents.js b/proxy/cleanup_duplicate_agents.js new file mode 100644 index 0000000..ae5d73b --- /dev/null +++ b/proxy/cleanup_duplicate_agents.js @@ -0,0 +1,58 @@ +// cleanup_duplicate_agents.js +// One-time cleanup: remove agent data stored under the wrong site_uuid. +// SIAB agents (from current collector run) = definitive source of truth. +// Any SIAB agent found under NEXUS → delete from NEXUS. +// Any non-SIAB agent found under SIAB → delete from SIAB. + +const mongoose = require('mongoose'); +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '../../../..', '.env.local') }); + +const SIAB_UUID = '6681452d_9cae_4ff4_8ae8_0d504774265e'; +const NEXUS_UUID = 'd7902405_0dc2_458b_8584_ed4d24b64f24'; + +// Definitive SIAB agent list (from most recent collector run) +const SIAB_AGENTS = ['F6-2V-DT-8A', 'YW-6I-61-LL', '2F-TF-1D-GK', '1R-79-J9-YE', '8A-V3-PB-85']; + +async function cleanup() { + await mongoose.connect(process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi'); + const db = mongoose.connection.db; + + const collections = (await db.listCollections().toArray()) + .map(c => c.name) + .filter(n => !n.startsWith('system.')); + + let totalDeleted = 0; + + for (const colName of collections) { + const col = db.collection(colName); + + // 1. Delete SIAB agents that are stored under NEXUS site_uuid + const r1 = await col.deleteMany({ + site_uuid: NEXUS_UUID, + agent_uuid: { $in: SIAB_AGENTS } + }); + if (r1.deletedCount > 0) { + console.log(`[${colName}] Removed ${r1.deletedCount} docs (SIAB agents from NEXUS)`); + totalDeleted += r1.deletedCount; + } + + // 2. Delete non-SIAB agents that are stored under SIAB site_uuid + const r2 = await col.deleteMany({ + site_uuid: SIAB_UUID, + agent_uuid: { $nin: [...SIAB_AGENTS, null] } // keep null = site-level summaries + }); + if (r2.deletedCount > 0) { + console.log(`[${colName}] Removed ${r2.deletedCount} docs (non-SIAB agents from SIAB)`); + totalDeleted += r2.deletedCount; + } + } + + console.log(`\n✅ Cleanup complete. Total documents removed: ${totalDeleted}`); + await mongoose.disconnect(); +} + +cleanup().catch(err => { + console.error('❌ Cleanup failed:', err.message); + process.exit(1); +}); diff --git a/proxy/collector.js b/proxy/collector.js index 409f3ab..5017b69 100644 --- a/proxy/collector.js +++ b/proxy/collector.js @@ -124,6 +124,11 @@ async function collectAllAgents() { return { success: false, mode: 'all', message: 'No sites configured', results: [] }; } + // Track agent UUIDs already assigned to a site to prevent cross-site duplication. + // The Netify /data/stats/top/agent/download endpoint is org-level and can return + // the same agent for multiple site queries. Each agent must belong to exactly one site. + const processedAgentUuids = new Set(); + for (const siteUuid of SITE_UUIDS) { console.log(`[Collector] Fetching agents for Site: ${siteUuid}`); const rawAgents = await netify.fetchAgents(siteUuid); @@ -132,16 +137,78 @@ async function collectAllAgents() { continue; } - let agents = rawAgents; + // Deduplicate: only keep agents not yet seen in a previous site this cycle + const agents = rawAgents.filter(a => { + if (processedAgentUuids.has(a.uuid)) { + console.log(`[Collector] Skipping agent ${a.uuid} — already assigned to another site.`); + return false; + } + return true; + }); if (agents.length === 0) { - console.warn(`[Collector] No mapped agents matched for site ${siteUuid}. Skipping.`); + console.warn(`[Collector] No unique agents for site ${siteUuid} (all were already assigned). Skipping.`); continue; } + // Register these agents as belonging to this site + for (const agent of agents) processedAgentUuids.add(agent.uuid); + totalAgents += agents.length; console.log(`[Collector] Processing ${agents.length} agents for site ${siteUuid}: ${agents.map(a => a.uuid).join(', ')}`); - + + // ── Site-Level Summary (Pilihan A) ───────────────────────────────────── + // Collect bandwidth at site level (no agentUuid filter) so numbers match + // Netify portal exactly and avoid double-counting across agents. + try { + console.log(`[Collector] → Fetching site-level summary for site: ${siteUuid}`); + const siteSummary = await netify.fetchBandwidthSummary(1440, null, siteUuid); + if (siteSummary) { + let download_speed = 0; + let upload_speed = 0; + + try { + const prev = await Summary.findOne({ agent_uuid: null, site_uuid: siteUuid }).sort({ timestamp: -1 }).lean(); + if (prev && prev.timestamp) { + const timeDiffSec = (timestamp.getTime() - new Date(prev.timestamp).getTime()) / 1000; + if (timeDiffSec > 0) { + const bytesDiffDown = Math.max(0, siteSummary.bandwidth_down - (prev.bandwidth_down || 0)); + const bytesDiffUp = Math.max(0, siteSummary.bandwidth_up - (prev.bandwidth_up || 0)); + download_speed = bytesDiffDown / timeDiffSec; + upload_speed = bytesDiffUp / timeDiffSec; + } + } + } catch (err) { + console.error('[Collector] Error calculating site summary speeds:', err.message); + } + + const activeFlows = siteSummary.active_flows || 0; + const totalBandwidth = (siteSummary.bandwidth_down || 0) + (siteSummary.bandwidth_up || 0); + const packet_drops = Math.floor(activeFlows * 0.015); + const peak_flow_rate = Math.floor(activeFlows * 1.18); + const cpu_usage = Math.min(98, Math.max(1.2, parseFloat((2.5 + (activeFlows * 0.04) + (totalBandwidth / 10000000)).toFixed(2)))); + const memory_usage = Math.min(99, Math.max(10.5, parseFloat((15.4 + (activeFlows * 0.02) + (totalBandwidth / 25000000)).toFixed(2)))); + const queue_depth = Math.max(0, Math.floor((activeFlows * 0.15) + (totalBandwidth / 5000000))); + + await new Summary({ + timestamp, + agent_uuid: null, // null = site-level aggregate (bukan per-agent) + site_uuid: siteUuid, + ...siteSummary, + download_speed, + upload_speed, + packet_drops, + peak_flow_rate, + cpu_usage, + memory_usage, + queue_depth + }).save(); + console.log(`[Collector] ✓ Site-level summary saved for site: ${siteUuid} | Down: ${(siteSummary.bandwidth_down / 1e9).toFixed(2)} GB | Up: ${(siteSummary.bandwidth_up / 1e9).toFixed(2)} GB | Flows: ${siteSummary.active_flows?.toLocaleString()}`); + } + } catch (err) { + console.error(`[Collector] ✗ Failed to save site-level summary for ${siteUuid}:`, err.message); + } + for (const agent of agents) { const result = await collectForAgent(agent.uuid, timestamp, siteUuid); results.push({ ...result, agent_label: agent.label, site_uuid: siteUuid }); diff --git a/proxy/collectorHelperDpi2.js b/proxy/collectorHelperDpi2.js index 9b007e3..408330f 100644 --- a/proxy/collectorHelperDpi2.js +++ b/proxy/collectorHelperDpi2.js @@ -4,7 +4,7 @@ // Split from collector.js to satisfy the 256-line file size limit. // ───────────────────────────────────────────────────────────────────────────── -const { DeviceStat, DeviceAppStat, Flow, Threat, Event } = require('./models/Schemas'); +const { DeviceStat, DeviceAppStat, Flow, Threat, Event, BlacklistRule, LookupApp } = require('./models/Schemas'); const { generateMacFromIp, resolveVendorFromIp, @@ -108,6 +108,62 @@ async function collectFlows(agentUuid, timestamp, SITE_UUID, netify, label, ipTo await Flow.bulkWrite(operations); console.log(`[Collector] ✓ ${flowDocs.length} flows upserted for ${label}`); + // Blacklist Detection + try { + const blacklistRules = await BlacklistRule.find({ site_uuid: SITE_UUID, agent_uuid: agentUuid, is_active: true }).lean(); + if (blacklistRules.length > 0) { + const blacklistedCategories = new Set(blacklistRules.filter(r => r.type === 'category').map(r => r.value.toLowerCase())); + const blacklistedDomains = new Set(blacklistRules.filter(r => r.type === 'domain').map(r => r.value.toLowerCase())); + + const threatDocs = []; + for (const f of flowDocs) { + let isViolation = false; + let categoryLabel = ""; + + // Check if domain is blacklisted + if (f.domain && blacklistedDomains.has(f.domain.toLowerCase())) { + isViolation = true; + } else if (f.app_label && blacklistedDomains.has(f.app_label.toLowerCase())) { + isViolation = true; + } + + // Look up app details to check category + if (!isViolation && f.app_label) { + const appDef = await LookupApp.findOne({ label: f.app_label }).lean(); + if (appDef && appDef.application_category?.label) { + categoryLabel = appDef.application_category.label; + if (blacklistedCategories.has(categoryLabel.toLowerCase())) { + isViolation = true; + } + } + } + + if (isViolation) { + threatDocs.push({ + timestamp, + agent_uuid: f.agent_uuid, + site_uuid: f.site_uuid, + threat_type: "Blacklist Policy Violation", + severity: "High", + src_ip: f.src_ip, + dst_ip: f.dst_ip, + dst_port: f.dst_port, + protocol: f.protocol, + description: `Access to blacklisted app/domain: ${f.app_label} (${f.domain || 'N/A'})${categoryLabel ? ' - Category: ' + categoryLabel : ''}`, + event_at: new Date().toISOString() + }); + } + } + + if (threatDocs.length > 0) { + await Threat.insertMany(threatDocs); + console.log(`[Collector] ✓ ${threatDocs.length} blacklist policy violation threats recorded for ${label}`); + } + } + } catch (err) { + console.error('[Collector] Blacklist detection failed:', err.message); + } + // Removed 1-hour pruning to comply with Rule 19 (7-day global retention) } } diff --git a/proxy/db/capacityTracker.js b/proxy/db/capacityTracker.js new file mode 100644 index 0000000..7f385a2 --- /dev/null +++ b/proxy/db/capacityTracker.js @@ -0,0 +1,59 @@ +// proxy/db/capacityTracker.js +// ───────────────────────────────────────────────────────────────────────────── +// MongoDB Capacity & Data Size Breakdown per Network Agent. +// Measures logical document sizes per agent_uuid across all collections. +// ───────────────────────────────────────────────────────────────────────────── + +const mongoose = require('mongoose'); + +async function logCapacityStats(prefix = '[MongoDB]') { + try { + if (!mongoose.connection || !mongoose.connection.db) { + return; + } + const db = mongoose.connection.db; + + const stats = await db.command({ dbStats: 1 }); + const dataSizeMB = (stats.dataSize / (1024 * 1024)).toFixed(2); + const storageSizeMB = (stats.storageSize / (1024 * 1024)).toFixed(2); + console.log(`${prefix} Capacity Used: Data Size = ${dataSizeMB} MB, Storage Size = ${storageSizeMB} MB`); + + const agentSizes = {}; + const collections = await db.listCollections().toArray(); + + for (const colInfo of collections) { + const colName = colInfo.name; + if (colName.startsWith('system.')) continue; + const col = db.collection(colName); + + const sampleDoc = await col.findOne({ agent_uuid: { $ne: null } }); + if (!sampleDoc) continue; + + const pipeline = [ + { $project: { agent_uuid: 1, docSize: { $bsonSize: "$$ROOT" } } }, + { $group: { _id: "$agent_uuid", totalBytes: { $sum: "$docSize" } } } + ]; + + const results = await col.aggregate(pipeline).toArray(); + for (const res of results) { + const agent = res._id || 'Unknown'; + agentSizes[agent] = (agentSizes[agent] || 0) + res.totalBytes; + } + } + + const sortedAgents = Object.entries(agentSizes) + .map(([agent, bytes]) => ({ agent, sizeMB: parseFloat((bytes / (1024 * 1024)).toFixed(2)) })) + .sort((a, b) => b.sizeMB - a.sizeMB); + + if (sortedAgents.length > 0) { + console.log(`${prefix} Data Size Breakdown per Agent:`); + for (const { agent, sizeMB } of sortedAgents) { + console.log(` - ${agent}: ${sizeMB.toFixed(2)} MB`); + } + } + } catch (err) { + console.warn(`${prefix} Could not retrieve DB capacity breakdown:`, err.message); + } +} + +module.exports = { logCapacityStats }; diff --git a/proxy/index.js b/proxy/index.js index a143e29..b9773de 100644 --- a/proxy/index.js +++ b/proxy/index.js @@ -1,21 +1,6 @@ // proxy/index.js // ───────────────────────────────────────────────────────────────────────────── // BackOne Proxy Server - Entry Point -// -// Responsibilities: -// 1. Connect to MongoDB (dengan retry otomatis) -// 2. Start DPI data collection scheduler (setiap 5 menit) -// 3. Expose REST API untuk manual trigger dan health check -// 4. Lakukan immediate collect saat startup (tidak perlu tunggu cron pertama) -// -// Environment Variables: -// MONGODB_URI - MongoDB connection string (default: localhost:27017) -// PROXY_COLLECT_MODE - 'all' (default) | 'agent' | 'agents' -// PROXY_AGENT_UUID - Required if PROXY_COLLECT_MODE=agent -// PROXY_AGENT_UUIDS - Comma-separated list of agent UUIDs, required if PROXY_COLLECT_MODE=agents -// PROXY_AGENT_DELAY_MS - Delay between agent collections in ms (default: 5000) -// PROXY_CRON_SCHEDULE - Default: '*/5 * * * *' (setiap 5 menit) -// PROXY_PORT - Port untuk REST API (default: 4000) // ───────────────────────────────────────────────────────────────────────────── const path = require('path'); @@ -25,11 +10,12 @@ const express = require('express'); const cors = require('cors'); const mongoose = require('mongoose'); const scheduler = require('./scheduler'); +const routes = require('./routes'); const PORT = parseInt(process.env.PROXY_PORT || '4000'); const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi'; -// ─── Connect to MongoDB (retry-based, tidak exit prematurely) ────────────────── +// Connect to MongoDB with automated retries async function connectDB() { const MAX_RETRIES = 10; const RETRY_DELAYS = [2000, 3000, 5000, 5000, 10000, 10000, 10000, 15000, 15000, 30000]; @@ -43,8 +29,8 @@ async function connectDB() { socketTimeoutMS: 30000, }); console.log('[MongoDB] ✓ Connected successfully to', MONGODB_URI); - const { logCapacityStats } = require('../backend/db/capacityTracker'); - await logCapacityStats('[MongoDB]'); + const { logCapacityStats } = require('./db/capacityTracker'); + logCapacityStats('[MongoDB]').catch(err => console.warn('[MongoDB] Capacity log failed:', err.message)); return true; } catch (err) { console.error(`[MongoDB] ✗ Attempt ${attempt} failed: ${err.message}`); @@ -57,244 +43,37 @@ async function connectDB() { } console.error('[MongoDB] All connection attempts failed. Check if MongoDB is running on', MONGODB_URI); - console.error('[MongoDB] Proxy REST API will still run. Fix MongoDB and restart.'); return false; } -// ─── REST API ────────────────────────────────────────────────────────────────── const app = express(); app.use(express.json()); -app.use(cors({ origin: '*' })); // Proxy hanya dikonsumsi oleh backend, open CORS ok +app.use(cors({ origin: '*' })); +app.use('/', routes); // Mount extracted routes -/** - * GET /health - * Liveness check - */ -app.get('/health', (req, res) => { - const dbState = mongoose.connection.readyState; - const dbLabel = ['disconnected', 'connected', 'connecting', 'disconnecting'][dbState] || 'unknown'; - res.json({ - ok: dbState === 1, - service: 'BackOne Proxy Server', - db: dbLabel, - mode: process.env.PROXY_COLLECT_MODE || 'all', - time: new Date().toISOString(), - }); -}); - -/** - * GET /status - * Full scheduler + DB status - */ -app.get('/status', (req, res) => { - const dbState = mongoose.connection.readyState; - res.json({ - ok: true, - db_state: ['disconnected', 'connected', 'connecting', 'disconnecting'][dbState] || 'unknown', - ...scheduler.getStatus(), - time: new Date().toISOString(), - }); -}); - -/** - * POST /collect/all - * Manual trigger — collect ALL agents sekarang - */ -app.post('/collect/all', async (req, res) => { - if (mongoose.connection.readyState !== 1) { - return res.status(503).json({ ok: false, error: 'MongoDB not connected. Cannot collect.' }); - } - const { collectAllAgents } = require('./collector'); - console.log('[Proxy API] Manual trigger: collect ALL agents'); - try { - const result = await collectAllAgents(); - res.json({ ok: result.success, ...result }); - } catch (err) { - res.status(500).json({ ok: false, error: err.message }); - } -}); - -/** - * POST /collect/:agentUuid - * Manual trigger — collect ONE specific agent - */ -app.post('/collect/:agentUuid', async (req, res) => { - if (mongoose.connection.readyState !== 1) { - return res.status(503).json({ ok: false, error: 'MongoDB not connected. Cannot collect.' }); - } - const { agentUuid } = req.params; - const { collectSpecificAgent } = require('./collector'); - console.log(`[Proxy API] Manual trigger: collect agent ${agentUuid}`); - try { - const result = await collectSpecificAgent(agentUuid); - res.json({ ok: result.success, ...result }); - } catch (err) { - res.status(500).json({ ok: false, error: err.message }); - } -}); - -/** - * POST /collect/agents - * Manual trigger — collect multiple specific agents - * Body: { "uuids": ["uuid1", "uuid2", ...], "delay_ms": 5000 } - */ -app.post('/collect/agents', async (req, res) => { - if (mongoose.connection.readyState !== 1) { - return res.status(503).json({ ok: false, error: 'MongoDB not connected. Cannot collect.' }); - } - const { uuids, delay_ms } = req.body; - if (!uuids || !Array.isArray(uuids) || uuids.length === 0) { - return res.status(400).json({ ok: false, error: 'Body must include "uuids" as a non-empty array of agent UUIDs.' }); - } - const { collectSpecificAgents } = require('./collector'); - console.log(`[Proxy API] Manual trigger: collect agents ${uuids.join(', ')}`); - try { - const result = await collectSpecificAgents(uuids, delay_ms || 5000); - res.json({ ok: result.success, ...result }); - } catch (err) { - res.status(500).json({ ok: false, error: err.message }); - } -}); - -/** - * GET /agents - * List all agent UUIDs yang sudah tersimpan di MongoDB - */ -app.get('/agents', async (req, res) => { - try { - if (mongoose.connection.readyState !== 1) { - return res.status(503).json({ ok: false, error: 'MongoDB not connected.' }); - } - const { DashboardSummary } = require('../backend/models/Schemas'); - const agents = await DashboardSummary.distinct('agent_uuid'); - res.json({ ok: true, data: agents }); - } catch (err) { - res.status(500).json({ ok: false, error: err.message }); - } -}); - -/** - * GET /domain-details - * Fetch live IP/MAC details for a specific domain from Netify API - * Fetch live IP/MAC details for a specific domain from MongoDB - */ -app.get('/domain-details', async (req, res) => { - const { domain, agentUuid } = req.query; - if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' }); - - try { - const { Flow, DeviceStat } = require('../backend/models/Schemas'); - - const filter = { domain: domain }; - if (agentUuid) { - filter.agent_uuid = agentUuid; - } - - const raw = await Flow.find(filter).sort({ timestamp: -1 }).limit(100).lean(); - if (!raw || !Array.isArray(raw)) { - return res.json({ ok: true, data: [] }); - } - - const results = []; - const seen = new Set(); - - for (const r of raw) { - const ip = r.src_ip; - const mac = r.src_mac; - if (!ip || !mac) continue; - - const key = `${ip}-${mac}`; - if (seen.has(key)) continue; - seen.add(key); - - let deviceName = 'Unknown Device'; - try { - const dev = await DeviceStat.findOne({ mac_address: mac }).sort({ timestamp: -1 }); - if (dev && dev.name && dev.name !== 'Unknown Device') { - deviceName = dev.name; - } - } catch (e) { - // ignore timeout errors - } - - results.push({ - ip, - mac, - deviceName, - lastSeen: r.timestamp || r.last_seen || r.first_seen, - }); - } - - res.json({ ok: true, data: results }); - } catch (err) { - res.status(500).json({ ok: false, error: err.message }); - } -}); - -/** - * GET /latest - * Tampilkan data terbaru dari semua collection (untuk debug) - */ -app.get('/latest', async (req, res) => { - try { - const { Summary, AppStat, DeviceStat, Flow, Threat, Event, AppCategoryStat } = require('./models/Schemas'); - const [summary, apps, devices, flows, threats, events, categories] = await Promise.all([ - Summary.findOne().sort({ timestamp: -1 }).lean(), - AppStat.find().sort({ timestamp: -1 }).limit(5).lean(), - DeviceStat.find().sort({ timestamp: -1 }).limit(5).lean(), - Flow.find().sort({ timestamp: -1 }).limit(5).lean(), - Threat.find().sort({ timestamp: -1 }).limit(5).lean(), - Event.find().sort({ timestamp: -1 }).limit(5).lean(), - AppCategoryStat.find().sort({ timestamp: -1 }).limit(5).lean(), - ]); - res.json({ ok: true, data: { summary, apps, devices, flows, threats, events, categories } }); - } catch (err) { - res.status(500).json({ ok: false, error: err.message }); - } -}); - -// ─── Boot Sequence ───────────────────────────────────────────────────────────── async function main() { console.log('\n╔════════════════════════════════════════════════╗'); console.log('║ BackOne Proxy Server - Starting ║'); console.log('╚════════════════════════════════════════════════╝\n'); console.log(`[Proxy] Mode: ${process.env.PROXY_COLLECT_MODE || 'all'}`); - if (process.env.PROXY_COLLECT_MODE === 'agent') { - console.log(`[Proxy] Agent UUID: ${process.env.PROXY_AGENT_UUID || '(not set!)'}`); - } - if (process.env.PROXY_COLLECT_MODE === 'agents') { - const agents = (process.env.PROXY_AGENT_UUIDS || '').split(',').map(s => s.trim()).filter(Boolean); - console.log(`[Proxy] Agent UUIDs: ${agents.length > 0 ? agents.join(', ') : '(not set!)'}`); - console.log(`[Proxy] Agent delay: ${parseInt(process.env.PROXY_AGENT_DELAY_MS || '5000')}ms`); - } - - // 1. Start REST API server FIRST (so /health is always available) + + // Start REST API server first so liveness probes remain active app.listen(PORT, '0.0.0.0', () => { console.log(`\n🚀 Proxy REST API running at http://0.0.0.0:${PORT}`); console.log(` GET /health → liveness check`); - console.log(` GET /status → scheduler + DB status`); - console.log(` GET /agents → list agent UUIDs in MongoDB`); - console.log(` POST /collect/all → trigger manual collect all`); - console.log(` POST /collect/:agentUuid → trigger manual collect specific`); - console.log(` POST /collect/agents → trigger manual collect multiple\n`); + console.log(` GET /status → scheduler + DB status\n`); }); - // 2. Connect to MongoDB (retry in background) const connected = await connectDB(); - // 3. Start scheduler only if DB connected if (connected) { scheduler.startScheduler(); - console.log('\n[Proxy] ✓ Scheduler started. Data collection is active.'); - console.log('[Proxy] ✓ First collection will run in 2 seconds after MongoDB is ready.\n'); + console.log('\n[Proxy] ✓ Scheduler started. Data collection is active.\n'); } else { - console.error('\n[Proxy] ✗ Could not connect to MongoDB. Scheduler NOT started.'); - console.error('[Proxy] ✗ To fix: ensure MongoDB is running at', MONGODB_URI); - console.error('[Proxy] ✗ Then restart the proxy server.\n'); + console.error('\n[Proxy] ✗ Could not connect to MongoDB. Scheduler NOT started.\n'); } } main().catch(err => { console.error('[Proxy] Fatal startup error:', err); - // Don't exit — let REST API still serve /health }); diff --git a/proxy/models/Schemas.js b/proxy/models/Schemas.js index 7ce60ca..1af95ca 100644 --- a/proxy/models/Schemas.js +++ b/proxy/models/Schemas.js @@ -78,7 +78,7 @@ const FlowSchema = new mongoose.Schema({ site_uuid: { type: String, index: true }, flow_id: String, src_ip: { type: String, index: true }, - src_mac: String, + src_mac: { type: String, index: true }, // indexed for MAC-to-IP resolution in events dst_ip: { type: String, index: true }, dst_port: Number, protocol: String, @@ -131,52 +131,6 @@ const EventSchema = new mongoose.Schema({ event_at: Date, }, baseOptions); -// ─── Compound indexes for common dashboard queries ───────────────────────────── -// ─── TLS Versions (per agent) ────────────────────────────────────────────────── -const TlsVersionStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - tls_version: { type: String, required: true }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - -// ─── TLS Ciphers (per agent) ─────────────────────────────────────────────────── -const TlsCipherStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - tls_cipher: { type: String, required: true }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - -// ─── TLS Security (per agent) ────────────────────────────────────────────────── -const TlsSecurityStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - tls_security: { type: String, required: true }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - -// ─── Country Traffic Stats (per agent) ──────────────────────────────────────── -const CountryStatSchema = new mongoose.Schema({ - timestamp: { type: Date, required: true, index: true, expires: '7d' }, - agent_uuid: { type: String, index: true }, - site_uuid: { type: String, index: true }, - country_code: { type: String, required: true }, - country_name: { type: String, default: '' }, - download: Number, - upload: Number, - flows: Number, -}, baseOptions); - // ─── Compound indexes for common dashboard queries ───────────────────────────── SummarySchema.index({ agent_uuid: 1, timestamp: -1 }); AppStatSchema.index({ agent_uuid: 1, timestamp: -1, download: -1 }); @@ -187,15 +141,7 @@ FlowSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 }); ThreatSchema.index({ agent_uuid: 1, timestamp: -1 }); AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 }); EventSchema.index({ agent_uuid: 1, timestamp: -1 }); -TlsVersionStatSchema.index({ agent_uuid: 1, timestamp: -1 }); -TlsCipherStatSchema.index({ agent_uuid: 1, timestamp: -1 }); -TlsSecurityStatSchema.index({ agent_uuid: 1, timestamp: -1 }); -CountryStatSchema.index({ agent_uuid: 1, timestamp: -1 }); - -const CustomDeviceLabelSchema = new mongoose.Schema({ - mac_address: { type: String, required: true, unique: true, index: true }, - device_label: { type: String, required: true }, -}, baseOptions); +FlowSchema.index({ site_uuid: 1, src_mac: 1, timestamp: -1 }); // for MAC-to-IP resolution // ── Per-Device Per-Application Stats ──────────────────────────────────────── // Collected from DPI API: /data/stats/top/application/download with filter_local_ips @@ -216,25 +162,8 @@ DeviceAppStatSchema.index({ agent_uuid: 1, ip_address: 1, timestamp: -1 }); DeviceAppStatSchema.index({ ip_address: 1, app_label: 1, timestamp: -1 }); DeviceAppStatSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 }); -// ─── Lookup App Dictionary ──────────────────────────────────────────────────── -const LookupAppSchema = new mongoose.Schema({ - id: { type: Number, required: true, unique: true, index: true }, - tag: String, - label: { type: String, index: true }, - name: String, - full_name: String, - description: String, - favicon: String, - icon: String, - logo: String, - application_category: Object -}, baseOptions); -LookupAppSchema.index({ label: 1, tag: 1 }); - - -// ─── DPI Telemetry Property Schemas (SNI, SSL, QUIC, SSH, mDNS, DHCP, UA, BT) -// Split into SchemasTelemetry.js to keep this file under 256 lines. const telemetrySchemas = require('./SchemasTelemetry'); +const auxSchemas = require('./SchemasAux'); module.exports = { Summary: mongoose.model('Summary', SummarySchema), @@ -244,14 +173,10 @@ module.exports = { DeviceAppStat: mongoose.model('DeviceAppStat', DeviceAppStatSchema), Flow: mongoose.model('Flow', FlowSchema), Threat: mongoose.model('Threat', ThreatSchema), - CustomDeviceLabel: mongoose.model('CustomDeviceLabel', CustomDeviceLabelSchema), AppCategoryStat: mongoose.model('AppCategoryStat', AppCategoryStatSchema), Event: mongoose.model('Event', EventSchema), - TlsVersionStat: mongoose.model('TlsVersionStat', TlsVersionStatSchema), - TlsCipherStat: mongoose.model('TlsCipherStat', TlsCipherStatSchema), - TlsSecurityStat: mongoose.model('TlsSecurityStat', TlsSecurityStatSchema), - CountryStat: mongoose.model('CountryStat', CountryStatSchema), - LookupApp: mongoose.model('LookupApp', LookupAppSchema), + ...auxSchemas, ...telemetrySchemas, }; + diff --git a/proxy/models/SchemasAux.js b/proxy/models/SchemasAux.js new file mode 100644 index 0000000..31b0533 --- /dev/null +++ b/proxy/models/SchemasAux.js @@ -0,0 +1,132 @@ +// proxy/models/SchemasAux.js +// ───────────────────────────────────────────────────────────────────────────── +// Auxiliary MongoDB Schemas to maintain Schemas.js under 256 lines limit. +// ───────────────────────────────────────────────────────────────────────────── + +const mongoose = require('mongoose'); + +const baseOptions = { + timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } +}; + +// ─── TLS Versions (per agent) ────────────────────────────────────────────────── +const TlsVersionStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + tls_version: { type: String, required: true }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +// ─── TLS Ciphers (per agent) ─────────────────────────────────────────────────── +const TlsCipherStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + tls_cipher: { type: String, required: true }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +// ─── TLS Security (per agent) ────────────────────────────────────────────────── +const TlsSecurityStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + tls_security: { type: String, required: true }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +// ─── Country Traffic Stats (per agent) ──────────────────────────────────────── +const CountryStatSchema = new mongoose.Schema({ + timestamp: { type: Date, required: true, index: true, expires: '7d' }, + agent_uuid: { type: String, index: true }, + site_uuid: { type: String, index: true }, + country_code: { type: String, required: true }, + country_name: { type: String, default: '' }, + download: Number, + upload: Number, + flows: Number, +}, baseOptions); + +const CustomDeviceLabelSchema = new mongoose.Schema({ + mac_address: { type: String, required: true, unique: true, index: true }, + device_label: { type: String, required: true }, +}, baseOptions); + +// ─── View As Audit Logs ──────────────────────────────────────────────────────── +const ViewAsLogSchema = new mongoose.Schema({ + timestamp: { type: Date, default: Date.now, index: true }, + admin_id: { type: String, required: true }, + admin_username: { type: String, required: true }, + admin_role: String, + agent_uuid: { type: String, required: true }, + agent_label: String, + end_timestamp: Date, + duration: Number, // duration in seconds +}, baseOptions); + +// ─── Lookup App Dictionary ──────────────────────────────────────────────────── +const LookupAppSchema = new mongoose.Schema({ + id: { type: Number, required: true, unique: true, index: true }, + tag: String, + label: { type: String, index: true }, + name: String, + full_name: String, + description: String, + favicon: String, + icon: String, + logo: String, + application_category: Object +}, baseOptions); + +// ─── Tenant Configuration (Dynamic Branding per site_uuid) ───────────────────── +const TenantConfigSchema = new mongoose.Schema({ + site_uuid: { type: String, required: true, unique: true, index: true }, + brand_name: { type: String, required: true }, + brand_logo: { type: String, required: true }, + footer_copyright: { type: String, required: true }, + primary_color: { type: String, default: '#E11D48' } +}, baseOptions); + +const CustomAgentLocationSchema = new mongoose.Schema({ + agent_uuid: { type: String, required: true, unique: true, index: true }, + site_uuid: { type: String, required: true, index: true }, + latitude: { type: Number, required: true }, + longitude: { type: Number, required: true }, + label: { type: String, default: '' }, +}, baseOptions); + +const BlacklistRuleSchema = new mongoose.Schema({ + site_uuid: { type: String, required: true, index: true }, + agent_uuid: { type: String, required: true, index: true }, + type: { type: String, required: true, enum: ['category', 'domain'] }, + value: { type: String, required: true }, + is_active: { type: Boolean, default: true } +}, baseOptions); + +// Set compound indexes +TlsVersionStatSchema.index({ agent_uuid: 1, timestamp: -1 }); +TlsCipherStatSchema.index({ agent_uuid: 1, timestamp: -1 }); +TlsSecurityStatSchema.index({ agent_uuid: 1, timestamp: -1 }); +CountryStatSchema.index({ agent_uuid: 1, timestamp: -1 }); +LookupAppSchema.index({ label: 1, tag: 1 }); +BlacklistRuleSchema.index({ site_uuid: 1, agent_uuid: 1, type: 1, value: 1 }, { unique: true }); + +module.exports = { + TlsVersionStat: mongoose.model('TlsVersionStat', TlsVersionStatSchema), + TlsCipherStat: mongoose.model('TlsCipherStat', TlsCipherStatSchema), + TlsSecurityStat: mongoose.model('TlsSecurityStat',TlsSecurityStatSchema), + CountryStat: mongoose.model('CountryStat', CountryStatSchema), + CustomDeviceLabel:mongoose.model('CustomDeviceLabel',CustomDeviceLabelSchema), + ViewAsLog: mongoose.model('ViewAsLog', ViewAsLogSchema), + LookupApp: mongoose.model('LookupApp', LookupAppSchema), + TenantConfig: mongoose.model('TenantConfig', TenantConfigSchema), + CustomAgentLocation: mongoose.model('CustomAgentLocation', CustomAgentLocationSchema), + BlacklistRule: mongoose.model('BlacklistRule', BlacklistRuleSchema), +}; diff --git a/proxy/netifyClient.js b/proxy/netifyClient.js index a8ed62a..89e9866 100644 --- a/proxy/netifyClient.js +++ b/proxy/netifyClient.js @@ -1,10 +1,9 @@ // proxy/netifyClient.js // ───────────────────────────────────────────────────────────────────────────── -// Clean DPI API wrapper for the BackOne Proxy Server -// Re-exports modules split into logical units to satisfy the 256-line limit. +// DPI API wrapper for the BackOne Proxy Server targeting original Netify API. // ───────────────────────────────────────────────────────────────────────────── -const { netifyFetch, agentMap } = require('./netifyClientCore'); +const { netifyFetch, BASE_URL, agentMap } = require('./netifyClientCore'); const telemetry = require('./netifyTelemetry'); const PORT_SERVICE_MAP = { @@ -21,19 +20,48 @@ const PORT_SERVICE_MAP = { }; async function fetchAgents(siteUuid = null) { - const data = await netifyFetch('/data/stats/top/agent/download', { - filter_interval: 43200, settings_limit: 100 - }, null, siteUuid); + 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); + 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), @@ -237,5 +265,7 @@ module.exports = { fetchFlows, fetchCyberThreats, fetchEvents, + BASE_URL, + PORT_SERVICE_MAP, ...telemetry, }; diff --git a/proxy/netifyClientCore.js b/proxy/netifyClientCore.js index b17456a..14a4641 100644 --- a/proxy/netifyClientCore.js +++ b/proxy/netifyClientCore.js @@ -1,7 +1,6 @@ // proxy/netifyClientCore.js // ───────────────────────────────────────────────────────────────────────────── // Core fetch and authentication helpers for Netify DPI API. -// Split from netifyClient.js to keep file sizes under 256 lines. // ───────────────────────────────────────────────────────────────────────────── const path = require('path'); @@ -10,7 +9,6 @@ const axios = require('axios'); const BASE_URL = process.env.NETIFY_INFORMATICS_BASE_URL || 'https://informatics.netify.ai/api/v1'; const JWT_TOKEN = process.env.NETIFY_TOKEN || process.env.NETIFY_JWT_TOKEN; -// In-memory map: agent_uuid -> numeric agent ID const agentMap = {}; function getHeaders(siteUuid) { @@ -39,13 +37,16 @@ function fixDates(obj) { async function netifyFetch(endpoint, params = {}, agentUuid = null, siteUuid = null) { if (agentUuid) { const agentId = agentMap[agentUuid]; - if (agentId) params.filter_agents = `[${agentId}]`; - else params.settings_agent = agentUuid; + if (agentId) { + params.filter_agents = `[${agentId}]`; + } else { + params.settings_agent = agentUuid; + } } const token = process.env.NETIFY_API_KEY || JWT_TOKEN; - if (!token || !siteUuid) { - console.error(`[DpiClient] Missing DPI_API_KEY or siteUuid for endpoint ${endpoint}`); + if (!token) { + console.error(`[DpiClient] Missing DPI_API_KEY for endpoint ${endpoint}`); return null; } @@ -54,9 +55,8 @@ async function netifyFetch(endpoint, params = {}, agentUuid = null, siteUuid = n headers: getHeaders(siteUuid), params, timeout: 30000, }); const json = res.data; + if (json?.status_code !== 0) { - // Netify returns 200 when there is simply no data in the requested timeframe - if (json?.status_code === 200) return []; console.error(`[DpiClient] API Error ${json?.status_code} on ${endpoint}: ${json?.status_message || 'No message'}`); return null; } diff --git a/proxy/routes.js b/proxy/routes.js new file mode 100644 index 0000000..4eaf94f --- /dev/null +++ b/proxy/routes.js @@ -0,0 +1,194 @@ +// proxy/routes.js +// ─── REST API Routes for BackOne Proxy Server ─────────────────────────────── +const express = require('express'); +const router = express.Router(); +const mongoose = require('mongoose'); +const scheduler = require('./scheduler'); + +/** + * GET /health + * Liveness check + */ +router.get('/health', (req, res) => { + const dbState = mongoose.connection.readyState; + const dbLabel = ['disconnected', 'connected', 'connecting', 'disconnecting'][dbState] || 'unknown'; + res.json({ + ok: dbState === 1, + service: 'BackOne Proxy Server', + db: dbLabel, + mode: process.env.PROXY_COLLECT_MODE || 'all', + time: new Date().toISOString(), + }); +}); + +/** + * GET /status + * Full scheduler + DB status + */ +router.get('/status', (req, res) => { + const dbState = mongoose.connection.readyState; + res.json({ + ok: true, + db_state: ['disconnected', 'connected', 'connecting', 'disconnecting'][dbState] || 'unknown', + ...scheduler.getStatus(), + time: new Date().toISOString(), + }); +}); + +/** + * POST /collect/all + * Manual trigger — collect ALL agents sekarang + */ +router.post('/collect/all', async (req, res) => { + if (mongoose.connection.readyState !== 1) { + return res.status(503).json({ ok: false, error: 'MongoDB not connected. Cannot collect.' }); + } + const { collectAllAgents } = require('./collector'); + console.log('[Proxy API] Manual trigger: collect ALL agents'); + try { + const result = await collectAllAgents(); + res.json({ ok: result.success, ...result }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +/** + * POST /collect/:agentUuid + * Manual trigger — collect ONE specific agent + */ +router.post('/collect/:agentUuid', async (req, res) => { + if (mongoose.connection.readyState !== 1) { + return res.status(503).json({ ok: false, error: 'MongoDB not connected. Cannot collect.' }); + } + const { agentUuid } = req.params; + const { collectSpecificAgent } = require('./collector'); + console.log(`[Proxy API] Manual trigger: collect agent ${agentUuid}`); + try { + const result = await collectSpecificAgent(agentUuid); + res.json({ ok: result.success, ...result }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +/** + * POST /collect/agents + * Manual trigger — collect multiple specific agents + * Body: { "uuids": ["uuid1", "uuid2", ...], "delay_ms": 5000 } + */ +router.post('/collect/agents', async (req, res) => { + if (mongoose.connection.readyState !== 1) { + return res.status(503).json({ ok: false, error: 'MongoDB not connected. Cannot collect.' }); + } + const { uuids, delay_ms } = req.body; + if (!uuids || !Array.isArray(uuids) || uuids.length === 0) { + return res.status(400).json({ ok: false, error: 'Body must include "uuids" as a non-empty array of agent UUIDs.' }); + } + const { collectSpecificAgents } = require('./collector'); + console.log(`[Proxy API] Manual trigger: collect agents ${uuids.join(', ')}`); + try { + const result = await collectSpecificAgents(uuids, delay_ms || 5000); + res.json({ ok: result.success, ...result }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +/** + * GET /agents + * List all agent UUIDs yang sudah tersimpan di MongoDB + */ +router.get('/agents', async (req, res) => { + try { + if (mongoose.connection.readyState !== 1) { + return res.status(503).json({ ok: false, error: 'MongoDB not connected.' }); + } + const { Summary } = require('./models/Schemas'); + const agents = await Summary.distinct('agent_uuid'); + res.json({ ok: true, data: agents }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +/** + * GET /domain-details + * Fetch live IP/MAC details for a specific domain from MongoDB + */ +router.get('/domain-details', async (req, res) => { + const { domain, agentUuid } = req.query; + if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' }); + + try { + const { Flow, DeviceStat } = require('./models/Schemas'); + + const filter = { domain: domain }; + if (agentUuid) { + filter.agent_uuid = agentUuid; + } + + const raw = await Flow.find(filter).sort({ timestamp: -1 }).limit(100).lean(); + if (!raw || !Array.isArray(raw)) { + return res.json({ ok: true, data: [] }); + } + + const results = []; + const seen = new Set(); + + for (const r of raw) { + const ip = r.src_ip; + const mac = r.src_mac; + if (!ip || !mac) continue; + + const key = `${ip}-${mac}`; + if (seen.has(key)) continue; + seen.add(key); + + let deviceName = 'Unknown Device'; + try { + const dev = await DeviceStat.findOne({ mac_address: mac }).sort({ timestamp: -1 }); + if (dev && dev.name && dev.name !== 'Unknown Device') { + deviceName = dev.name; + } + } catch (e) { + // ignore timeout errors + } + + results.push({ + ip, + mac, + deviceName, + lastSeen: r.timestamp || r.last_seen || r.first_seen, + }); + } + + res.json({ ok: true, data: results }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +/** + * GET /latest + * Tampilkan data terbaru dari semua collection (untuk debug) + */ +router.get('/latest', async (req, res) => { + try { + const { Summary, AppStat, DeviceStat, Flow, Threat, Event, AppCategoryStat } = require('./models/Schemas'); + const [summary, apps, devices, flows, threats, events, categories] = await Promise.all([ + Summary.findOne().sort({ timestamp: -1 }).lean(), + AppStat.find().sort({ timestamp: -1 }).limit(5).lean(), + DeviceStat.find().sort({ timestamp: -1 }).limit(5).lean(), + Flow.find().sort({ timestamp: -1 }).limit(5).lean(), + Threat.find().sort({ timestamp: -1 }).limit(5).lean(), + Event.find().sort({ timestamp: -1 }).limit(5).lean(), + AppCategoryStat.find().sort({ timestamp: -1 }).limit(5).lean(), + ]); + res.json({ ok: true, data: { summary, apps, devices, flows, threats, events, categories } }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +module.exports = router; diff --git a/proxy/scheduler.js b/proxy/scheduler.js index dc6da44..e1f14b1 100644 --- a/proxy/scheduler.js +++ b/proxy/scheduler.js @@ -55,7 +55,7 @@ async function runCollection() { const now = Date.now(); if (now - lastCapacityLogAt >= CAPACITY_LOG_INTERVAL_MS) { lastCapacityLogAt = now; - const { logCapacityStats } = require('../backend/db/capacityTracker'); + const { logCapacityStats } = require('./db/capacityTracker'); await logCapacityStats(`[PROXY] [MongoDB] Capacity Used after Run #${runCount}:`); } diff --git a/public/siab-logo.png b/public/siab-logo.png new file mode 100644 index 0000000..89f5b7f Binary files /dev/null and b/public/siab-logo.png differ diff --git a/scripts/deploy_production.js b/scripts/deploy_production.js index a6da216..cc043ee 100644 --- a/scripts/deploy_production.js +++ b/scripts/deploy_production.js @@ -11,7 +11,7 @@ const config = { password: 'htEo7x6LsBQiEHHH' }; -const DEPLOY_DIR = 'backone-production'; +const DEPLOY_DIR = 'web/demoplace.my.id/public_html'; async function createZip() { console.log("Packaging application..."); @@ -30,11 +30,15 @@ async function createZip() { // Add static assets (Next.js standalone requires these copied over) archive.directory(path.join(__dirname, '../.next/static'), '.next/static'); archive.directory(path.join(__dirname, '../public'), 'public'); + archive.directory(path.join(__dirname, '../public'), false); // Add backend and proxy archive.directory(path.join(__dirname, '../backend'), 'backend'); archive.directory(path.join(__dirname, '../proxy'), 'proxy'); + // Add PM2 ecosystem config + archive.file(path.join(__dirname, '../ecosystem.config.js'), { name: 'ecosystem.config.js' }); + // Add production env archive.file(path.join(__dirname, '../.env.production'), { name: '.env.production' }); @@ -81,26 +85,24 @@ async function deploy() { await sftp.put(zipPath, `${targetDir}/deploy.zip`); console.log("Extracting package on server..."); - await runSSH(`cd ${targetDir} && unzip -o deploy.zip && rm deploy.zip`); + await runSSH(`cd ${targetDir} && rm -rf .next && unzip -o deploy.zip && rm deploy.zip`); - console.log("Installing production dependencies for backend & proxy..."); - await runSSH(`cd ${targetDir}/backend && npm install --production`); - await runSSH(`cd ${targetDir}/proxy && npm install --production`); + console.log("Creating symlink for static files..."); + await runSSH(`cd ${targetDir} && rm -rf _next && ln -s .next _next`); + + console.log("Installing production dependencies for root, backend & proxy..."); + await runSSH(`. ~/.nvm/nvm.sh && nvm use 20 && cd ${targetDir} && npm install --production`); + await runSSH(`. ~/.nvm/nvm.sh && nvm use 20 && cd ${targetDir}/backend && npm install --production`); + await runSSH(`. ~/.nvm/nvm.sh && nvm use 20 && cd ${targetDir}/proxy && npm install --production`); console.log("Configuring PM2..."); - // Create ecosystem file for PM2 - const ecosystem = ` -module.exports = { - apps: [ - { name: 'backone-proxy', script: './proxy/index.js', env_file: '.env.production' }, - { name: 'backone-backend', script: './backend/server.js', env_file: '.env.production' }, - { name: 'backone-frontend', script: 'server.js', env_file: '.env.production' } - ] -};`; - await runSSH(`cd ${targetDir} && echo "${ecosystem.replace(/\n/g, '\\n')}" > ecosystem.config.js`); + // PM2 ecosystem is now packaged in deploy.zip directly, no need to generate via bash echo. + console.log("Restarting PM2 processes..."); - const pm2Result = await runSSH(`cd ${targetDir} && pm2 start ecosystem.config.js || pm2 restart ecosystem.config.js`); + // Clean delete old PM2 processes if they exist to apply new paths, then start fresh + await runSSH(`. ~/.nvm/nvm.sh && nvm use 20 && npx pm2 delete backone-frontend backone-backend backone-proxy || true`); + const pm2Result = await runSSH(`. ~/.nvm/nvm.sh && nvm use 20 && cd ${targetDir} && npx pm2 start ecosystem.config.js`); console.log(pm2Result); console.log("Deployment completed successfully!"); diff --git a/scripts/migrate_production_config.js b/scripts/migrate_production_config.js new file mode 100644 index 0000000..85f2005 --- /dev/null +++ b/scripts/migrate_production_config.js @@ -0,0 +1,123 @@ +// scripts/migrate_production_config.js +const { MongoClient } = require('mongodb'); +const path = require('path'); + +const LOCAL_URI = 'mongodb://127.0.0.1:27017'; +const REMOTE_URI = 'mongodb://backone_user:SusuKudaLiar@mongodb.prod.proit.id:27017/backone_dpi?authSource=backone_dpi'; +const DB_NAME = 'backone_dpi'; + +async function migrate() { + console.log("============================================================="); + console.log(" BackOne DPI - Selective Configuration Migration to Prod "); + console.log("=============================================================\n"); + + let localClient, remoteClient; + try { + console.log("Connecting to local MongoDB..."); + localClient = new MongoClient(LOCAL_URI, { serverSelectionTimeoutMS: 3000 }); + await localClient.connect(); + const localDb = localClient.db(DB_NAME); + console.log("✓ Connected to local MongoDB."); + + console.log("Connecting to production MongoDB..."); + remoteClient = new MongoClient(REMOTE_URI, { serverSelectionTimeoutMS: 5000 }); + await remoteClient.connect(); + const remoteDb = remoteClient.db(DB_NAME); + console.log("✓ Connected to production MongoDB.\n"); + + // 1. Sync users (Upsert based on username to preserve existing user credentials if updated) + console.log("--- Syncing 'users' collection ---"); + const localUsersColl = localDb.collection('users'); + const remoteUsersColl = remoteDb.collection('users'); + + const localUsers = await localUsersColl.find({}).toArray(); + console.log(`Found ${localUsers.length} users in local database.`); + + let userUpsertCount = 0; + for (const user of localUsers) { + // Remove _id to avoid duplicate key errors or mismatched ObjectId + const { _id, ...userData } = user; + const res = await remoteUsersColl.updateOne( + { username: user.username }, + { $set: userData }, + { upsert: true } + ); + if (res.upsertedCount > 0 || res.modifiedCount > 0) { + userUpsertCount++; + } + } + console.log(`✓ Synchronized ${userUpsertCount} users to production MongoDB.`); + + // 2. Sync tenantconfigs (Clear & Replace) + console.log("\n--- Syncing 'tenantconfigs' collection ---"); + const localConfigColl = localDb.collection('tenantconfigs'); + const remoteConfigColl = remoteDb.collection('tenantconfigs'); + + const localConfigs = await localConfigColl.find({}).toArray(); + console.log(`Found ${localConfigs.length} tenant configurations locally.`); + + if (localConfigs.length > 0) { + await remoteConfigColl.deleteMany({}); + const cleanedConfigs = localConfigs.map(c => { + const { _id, ...rest } = c; + return rest; + }); + await remoteConfigColl.insertMany(cleanedConfigs); + console.log(`✓ Synchronized ${localConfigs.length} configurations to production.`); + } + + // 3. Sync customagentlocations (Clear & Replace) + console.log("\n--- Syncing 'customagentlocations' collection ---"); + const localLocColl = localDb.collection('customagentlocations'); + const remoteLocColl = remoteDb.collection('customagentlocations'); + + const localLocs = await localLocColl.find({}).toArray(); + console.log(`Found ${localLocs.length} agent locations locally.`); + + if (localLocs.length > 0) { + await remoteLocColl.deleteMany({}); + const cleanedLocs = localLocs.map(l => { + const { _id, ...rest } = l; + return rest; + }); + await remoteLocColl.insertMany(cleanedLocs); + console.log(`✓ Synchronized ${localLocs.length} agent locations to production.`); + } + + // 4. Sync blacklistrules (Clear & Replace, if exists) + console.log("\n--- Syncing 'blacklistrules' collection ---"); + const localRulesColl = localDb.collection('blacklistrules'); + const remoteRulesColl = remoteDb.collection('blacklistrules'); + + // Check if collection exists + const localColls = await localDb.listCollections({ name: 'blacklistrules' }).toArray(); + if (localColls.length > 0) { + const localRules = await localRulesColl.find({}).toArray(); + console.log(`Found ${localRules.length} blacklist rules locally.`); + if (localRules.length > 0) { + await remoteRulesColl.deleteMany({}); + const cleanedRules = localRules.map(r => { + const { _id, ...rest } = r; + return rest; + }); + await remoteRulesColl.insertMany(cleanedRules); + console.log(`✓ Synchronized ${localRules.length} blacklist rules to production.`); + } + } else { + console.log("No blacklist rules found locally."); + } + + console.log("\n============================================================="); + console.log("✓ Database configuration migration completed successfully!"); + console.log("============================================================="); + + } catch (err) { + console.error("\n✗ Migration failed:", err.message); + process.exit(1); + } finally { + if (localClient) await localClient.close(); + if (remoteClient) await remoteClient.close(); + } +} + +migrate(); diff --git a/src/app/(dashboard)/agents/AgentsPageModals.tsx b/src/app/(dashboard)/agents/AgentsPageModals.tsx new file mode 100644 index 0000000..ad0ad11 --- /dev/null +++ b/src/app/(dashboard)/agents/AgentsPageModals.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { AgentModals } from "@/components/admin/AgentModals"; +import ExternalAccountModal from "@/components/admin/ExternalAccountModal"; +import AgentLocationModal from "@/components/admin/AgentLocationModal"; +import { Agent } from "@/lib/actions/agents"; +import { type ManagedUser } from "@/lib/admin-api"; + +interface AgentsPageModalsProps { + isCreateOpen: boolean; + setIsCreateOpen: (open: boolean) => void; + isDeleteOpen: boolean; + setIsDeleteOpen: (open: boolean) => void; + selectedAgent: Agent | null; + loadAgents: () => Promise; + detailAgent: Agent | null; + setDetailAgent: (agent: Agent | null) => void; + accountModalOpen: boolean; + setAccountModalOpen: (open: boolean) => void; + accountModalAgent: { uuid: string; label: string } | null; + setAccountModalAgent: (agent: { uuid: string; label: string } | null) => void; + managedUsers: ManagedUser[]; + externalModalOpen: boolean; + setExternalModalOpen: (open: boolean) => void; + externalModalUser: ManagedUser | null; + setExternalModalUser: (user: ManagedUser | null) => void; + targetSiteUuid: string | null; + setTargetSiteUuid: (uuid: string | null) => void; + targetCreatedBy: string | null; + setTargetCreatedBy: (createdBy: string | null) => void; + isLocModalOpen: boolean; + setIsLocModalOpen: (open: boolean) => void; + locModalAgentUuid: string; + setLocModalAgentUuid: (uuid: string) => void; + locModalAgentLabel: string; + setLocModalAgentLabel: (label: string) => void; + locModalCurrentLoc: any | null; + setLocModalCurrentLoc: (loc: any | null) => void; + loadLocations: () => Promise; +} + +export default function AgentsPageModals({ + isCreateOpen, + setIsCreateOpen, + isDeleteOpen, + setIsDeleteOpen, + selectedAgent, + loadAgents, + detailAgent, + setDetailAgent, + accountModalOpen, + setAccountModalOpen, + accountModalAgent, + setAccountModalAgent, + managedUsers, + externalModalOpen, + setExternalModalOpen, + externalModalUser, + setExternalModalUser, + targetSiteUuid, + setTargetSiteUuid, + targetCreatedBy, + setTargetCreatedBy, + isLocModalOpen, + setIsLocModalOpen, + locModalAgentUuid, + setLocModalAgentUuid, + locModalAgentLabel, + setLocModalAgentLabel, + locModalCurrentLoc, + setLocModalCurrentLoc, + loadLocations, +}: AgentsPageModalsProps) { + return ( + <> + + + { + setExternalModalOpen(false); + setExternalModalUser(null); + setTargetSiteUuid(null); + setTargetCreatedBy(null); + }} + onSuccess={loadAgents} + user={externalModalUser} + targetSiteUuid={targetSiteUuid} + targetCreatedBy={targetCreatedBy} + /> + + { + setIsLocModalOpen(false); + setLocModalAgentUuid(""); + setLocModalAgentLabel(""); + setLocModalCurrentLoc(null); + }} + onSuccess={() => { + loadLocations(); + }} + agentUuid={locModalAgentUuid} + agentLabel={locModalAgentLabel} + currentLocation={locModalCurrentLoc} + /> + + ); +} diff --git a/src/app/(dashboard)/agents/AgentsTableSection.tsx b/src/app/(dashboard)/agents/AgentsTableSection.tsx new file mode 100644 index 0000000..36238e6 --- /dev/null +++ b/src/app/(dashboard)/agents/AgentsTableSection.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { DataTable, Column } from "@/components/ui/DataTable"; +import { Loader2 } from "lucide-react"; +import { Agent } from "@/lib/actions/agents"; + +interface AgentsTableSectionProps { + agents: Agent[]; + isLoading: boolean; + columns: Column[]; +} + +export default function AgentsTableSection({ + agents, + isLoading, + columns, +}: AgentsTableSectionProps) { + return ( + + + Provisioned Network Agents ({agents.length}) + + + {isLoading ? ( +
+ +
+ ) : ( + { + const q = query.toLowerCase(); + return ( + (row.uuid || "").toLowerCase().includes(q) || + (row.serial || "").toLowerCase().includes(q) || + (row.label || "").toLowerCase().includes(q) + ); + }} + /> + )} +
+
+ ); +} diff --git a/src/app/(dashboard)/agents/ExternalAccountsSection.tsx b/src/app/(dashboard)/agents/ExternalAccountsSection.tsx new file mode 100644 index 0000000..ce1f0ea --- /dev/null +++ b/src/app/(dashboard)/agents/ExternalAccountsSection.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { DataTable, Column } from "@/components/ui/DataTable"; +import { Plus, Loader2 } from "lucide-react"; +import { type ManagedUser } from "@/lib/admin-api"; + +interface ExternalAccountsSectionProps { + role: string | null; + isLoading: boolean; + externalUsers: ManagedUser[]; + superadminUsers: ManagedUser[]; + siabUsers: ManagedUser[]; + nexusUsers: ManagedUser[]; + externalColumns: Column[]; + setExternalModalUser: (user: ManagedUser | null) => void; + setTargetSiteUuid: (uuid: string | null) => void; + setTargetCreatedBy: (createdBy: string | null) => void; + setExternalModalOpen: (open: boolean) => void; +} + +export default function ExternalAccountsSection({ + role, + isLoading, + externalUsers, + superadminUsers, + siabUsers, + nexusUsers, + externalColumns, + setExternalModalUser, + setTargetSiteUuid, + setTargetCreatedBy, + setExternalModalOpen, +}: ExternalAccountsSectionProps) { + if (role === 'SUPER_ADMIN') { + return ( +
+ {/* Table 1: Superadmin (BackOne) Accounts */} + + + + Superadmin (BackOne) External Accounts ({superadminUsers.length}) + + + + + {isLoading ? ( +
+ +
+ ) : ( + { + const q = query.toLowerCase(); + return ( + (row.username || "").toLowerCase().includes(q) || + (row.account_name || "").toLowerCase().includes(q) || + (row.role || "").toLowerCase().includes(q) + ); + }} + /> + )} +
+
+ + {/* Table 2: SIAB Accounts */} + + + SIAB Tenant External Accounts ({siabUsers.length}) + + + + {isLoading ? ( +
+ +
+ ) : ( + { + const q = query.toLowerCase(); + return ( + (row.username || "").toLowerCase().includes(q) || + (row.account_name || "").toLowerCase().includes(q) || + (row.role || "").toLowerCase().includes(q) + ); + }} + /> + )} +
+
+ + {/* Table 3: Nexus Accounts */} + + + Nexus Tenant External Accounts ({nexusUsers.length}) + + + + {isLoading ? ( +
+ +
+ ) : ( + { + const q = query.toLowerCase(); + return ( + (row.username || "").toLowerCase().includes(q) || + (row.account_name || "").toLowerCase().includes(q) || + (row.role || "").toLowerCase().includes(q) + ); + }} + /> + )} +
+
+
+ ); + } + + if (role === 'TENANT_ADMIN') { + return ( + + + External Accounts Inventory ({externalUsers.length}) + + + + {isLoading ? ( +
+ +
+ ) : ( + { + const q = query.toLowerCase(); + return ( + (row.username || "").toLowerCase().includes(q) || + (row.account_name || "").toLowerCase().includes(q) || + (row.role || "").toLowerCase().includes(q) + ); + }} + /> + )} +
+
+ ); + } + + return null; +} diff --git a/src/app/(dashboard)/agents/UptimeStatsCards.tsx b/src/app/(dashboard)/agents/UptimeStatsCards.tsx new file mode 100644 index 0000000..ab44b11 --- /dev/null +++ b/src/app/(dashboard)/agents/UptimeStatsCards.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Server, CheckCircle2, XCircle, Activity } from "lucide-react"; + +interface UptimeStatsCardsProps { + totalAgents: number; + onlineAgents: number; + offlineAgents: number; + avgUptime: number; +} + +export default function UptimeStatsCards({ + totalAgents, + onlineAgents, + offlineAgents, + avgUptime, +}: UptimeStatsCardsProps) { + return ( +
+
+
+ +
+ Total Agents + {totalAgents} +
+
+
+ +
+ Online Agents + {onlineAgents} +
+
+
+ +
+ Offline Agents + {offlineAgents} +
+
+
+ +
+ Avg. Historical Uptime + {avgUptime.toFixed(2)}% +
+
+ ); +} diff --git a/src/app/(dashboard)/agents/columns.tsx b/src/app/(dashboard)/agents/columns.tsx index e2eca91..2b3b6e3 100644 --- a/src/app/(dashboard)/agents/columns.tsx +++ b/src/app/(dashboard)/agents/columns.tsx @@ -4,7 +4,7 @@ import { Column } from "@/components/ui/DataTable"; import { Agent } from "@/lib/actions/agents"; import { Badge } from "@/components/ui/Badge"; 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 { role: string | null; @@ -17,6 +17,8 @@ interface GetColumnsProps { setAccountModalAgent: (agent: { uuid: string; label: string } | null) => void; handleViewAs: (uuid: string, label: string) => void; openDelete: (agent: Agent) => void; + onEditLocation?: (agentUuid: string, agentLabel: string) => void; + locations?: { agent_uuid: string; latitude: number; longitude: number; label?: string }[]; } export function getAgentColumns({ @@ -30,11 +32,14 @@ export function getAgentColumns({ setAccountModalAgent, handleViewAs, openDelete, + onEditLocation, + locations = [], }: GetColumnsProps): Column[] { + const isSuperAdmin = role === 'SUPER_ADMIN'; const cols: Column[] = [ { header: "UUID / Serial", - className: "w-[16%] text-center", + className: isSuperAdmin ? "w-[12%] text-center" : "w-[14%] text-center", accessor: (row) => ( {(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && ( <> + {onEditLocation && (() => { + const hasCoords = locations.some(l => l.agent_uuid === uuid); + return ( + + ); + })()} - ) - } - ]; +function formatStorageSize(sizeMB: number): string { + if (sizeMB === 0) return "0.00 MB"; + if (sizeMB >= 1024 * 1024) { + return `${(sizeMB / (1024 * 1024)).toFixed(2)} TB`; + } + if (sizeMB >= 1024) { + return `${(sizeMB / 1024).toFixed(2)} GB`; + } + if (sizeMB < 1) { + return `${(sizeMB * 1024).toFixed(2)} KB`; + } + return `${sizeMB.toFixed(2)} MB`; } + diff --git a/src/app/(dashboard)/agents/externalColumns.tsx b/src/app/(dashboard)/agents/externalColumns.tsx new file mode 100644 index 0000000..e2417b6 --- /dev/null +++ b/src/app/(dashboard)/agents/externalColumns.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Column } from "@/components/ui/DataTable"; +import { Badge } from "@/components/ui/Badge"; +import { type ManagedUser } from "@/lib/admin-api"; +import { UserCog, CheckCircle2, XCircle } from "lucide-react"; + +export function getExternalAccountColumns( + onManageAccount: (user: ManagedUser) => void +): Column[] { + return [ + { + header: "Account Name", + className: "w-[25%] text-left", + accessor: (row) => ( +
+
+ {row.profile_picture ? ( + {row.account_name + ) : ( + {(row.account_name || row.username).substring(0, 2).toUpperCase()} + )} +
+ {row.account_name || "-"} +
+ ) + }, + { + header: "Username", + className: "w-[25%] text-left", + accessor: (row) => {row.username} + }, + { + 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 ( + + {row.role.replace('_', ' ')} + + ); + } + }, + { + header: "Status", + className: "w-[15%] text-center", + accessor: (row) => ( +
+ {(row as any).is_active !== false ? ( + Active + ) : ( + Inactive + )} +
+ ) + }, + { + header: "Actions", + className: "w-[15%] text-center", + accessor: (row) => ( + + ) + } + ]; +} diff --git a/src/app/(dashboard)/agents/page.tsx b/src/app/(dashboard)/agents/page.tsx index 32255cc..d3e5199 100644 --- a/src/app/(dashboard)/agents/page.tsx +++ b/src/app/(dashboard)/agents/page.tsx @@ -1,151 +1,75 @@ "use client"; -import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { getAgents, Agent } from "@/lib/actions/agents"; +import { Loader2, Plus, Server } from "lucide-react"; +import { getAgentColumns } from "./columns"; +import { getExternalAccountColumns } from "./externalColumns"; +import ExternalAccountsSection from "./ExternalAccountsSection"; +import UptimeStatsCards from "./UptimeStatsCards"; +import AgentsTableSection from "./AgentsTableSection"; +import AgentsPageModals from "./AgentsPageModals"; +import { useAgentsData } from "./useAgentsData"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; +import dynamic from "next/dynamic"; -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"; +const IndonesiaAgentMap = dynamic( + () => import("@/components/dashboard/IndonesiaAgentMap").then(m => ({ default: m.IndonesiaAgentMap })), + { + ssr: false, + loading: () => ( +
+
+ + Loading Agent Location Map... +
+
+ ), + } +); export default function AgentsPage() { - const router = useRouter(); - const [role, setRole] = useState(null); - const [agents, setAgents] = useState([]); - const [uptimeMap, setUptimeMap] = useState>({}); - const [storageMap, setStorageMap] = useState>({}); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(""); - - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [isDeleteOpen, setIsDeleteOpen] = useState(false); - const [detailAgent, setDetailAgent] = useState(null); - const [managedUsers, setManagedUsers] = useState([]); - const [accountModalOpen, setAccountModalOpen] = useState(false); - const [accountModalAgent, setAccountModalAgent] = useState<{ uuid: string; label: string } | null>(null); - - const [externalModalOpen, setExternalModalOpen] = useState(false); - const [externalModalUser, setExternalModalUser] = useState(null); - - const [viewAsLoading, setViewAsLoading] = useState(null); - const [selectedAgent, setSelectedAgent] = useState(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(() => { - fetch('/api/auth/me', { headers: getViewAsHeaders() }) - .then(res => res.json()) - .then(data => { - if (data.user) { - setRole(data.user.role || null); - if (data.user.role === 'AGENT_VIEWER') { - router.push('/'); - } else { - loadAgents(); - } - } else { - setIsLoading(false); - } - }) - .catch(err => { - console.error("Auth check failed:", err); - setIsLoading(false); - }); - }, [router]); - - useEffect(() => { - if (role && role !== 'AGENT_VIEWER') loadUptime(); - }, [timeRange]); - - useEffect(() => { - if (role === 'SUPER_ADMIN') { - loadStorage(); - } - }, [role]); - - useEffect(() => { - if (role && role !== 'AGENT_VIEWER' && refreshKey > 0) { - const siteUuid = localStorage.getItem("backone_site_uuid") || undefined; - getAgents(siteUuid) - .then(data => setAgents(data)) - .catch(err => console.warn("Failed to auto-refresh agents:", err)); - loadUptime(); - if (role === 'SUPER_ADMIN') { - loadStorage(); - } - } - }, [refreshKey, role]); - - const openDelete = (agent: Agent) => { - setSelectedAgent(agent); - setIsDeleteOpen(true); - }; + const { + role, + agents, + uptimeMap, + storageMap, + isLoading, + error, + isCreateOpen, + setIsCreateOpen, + isDeleteOpen, + setIsDeleteOpen, + detailAgent, + setDetailAgent, + managedUsers, + accountModalOpen, + setAccountModalOpen, + accountModalAgent, + setAccountModalAgent, + externalModalOpen, + setExternalModalOpen, + externalModalUser, + setExternalModalUser, + targetSiteUuid, + setTargetSiteUuid, + targetCreatedBy, + setTargetCreatedBy, + locations, + isLocModalOpen, + setIsLocModalOpen, + locModalAgentUuid, + setLocModalAgentUuid, + locModalAgentLabel, + setLocModalAgentLabel, + locModalCurrentLoc, + setLocModalCurrentLoc, + viewAsLoading, + selectedAgent, + loadAgents, + loadLocations, + handleViewAs, + openDelete, + handleEditLocation, + } = useAgentsData(); const columns = getAgentColumns({ role, @@ -158,6 +82,8 @@ export default function AgentsPage() { setAccountModalAgent, handleViewAs, openDelete, + onEditLocation: handleEditLocation, + locations, }); const totalAgents = agents.length; @@ -167,6 +93,10 @@ export default function AgentsPage() { const avgUptime = uptimeValues.length > 0 ? uptimeValues.reduce((s, v) => s + v, 0) / uptimeValues.length : 100; const externalUsers = managedUsers.filter(u => u.role !== 'AGENT_VIEWER'); + const superadminUsers = externalUsers.filter(u => u.role === 'SUPER_ADMIN' || !u.site_uuid || u.site_uuid === 'default' || (u.site_uuid !== '6681452d_9cae_4ff4_8ae8_0d504774265e' && u.site_uuid !== 'd7902405_0dc2_458b_8584_ed4d24b64f24')); + const siabUsers = externalUsers.filter(u => u.role !== 'SUPER_ADMIN' && u.site_uuid === '6681452d_9cae_4ff4_8ae8_0d504774265e'); + const nexusUsers = externalUsers.filter(u => u.role !== 'SUPER_ADMIN' && u.site_uuid === 'd7902405_0dc2_458b_8584_ed4d24b64f24'); + const externalColumns = getExternalAccountColumns((user) => { setExternalModalUser(user); setExternalModalOpen(true); @@ -174,21 +104,24 @@ export default function AgentsPage() { return (
-
+

Agents Inventory {isLoading && }

- {(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && ( - - )} +
+ + {(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && ( + + )} +
{error && ( @@ -197,111 +130,50 @@ export default function AgentsPage() {
)} - {/* Historical Uptime Status Cards Grid */} -
-
-
- -
- Total Agents - {totalAgents} -
-
-
- -
- Online Agents - {onlineAgents} -
-
-
- -
- Offline Agents - {offlineAgents} -
-
-
- -
- Avg. Historical Uptime - {avgUptime.toFixed(2)}% -
-
+ - - - Provisioned Network Agents ({agents.length}) - - - {isLoading ? ( -
- -
- ) : ( - { - const q = query.toLowerCase(); - return ( - (row.uuid || "").toLowerCase().includes(q) || - (row.serial || "").toLowerCase().includes(q) || - (row.label || "").toLowerCase().includes(q) - ); - }} - /> - )} -
-
- - {(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && ( - - - External Accounts Inventory ({externalUsers.length}) - - - - {isLoading ? ( -
- -
- ) : ( - { - const q = query.toLowerCase(); - return ( - (row.username || "").toLowerCase().includes(q) || - (row.account_name || "").toLowerCase().includes(q) || - (row.role || "").toLowerCase().includes(q) - ); - }} - /> - )} -
-
+ {/* Indonesia Agent Geolocation & Flow Map */} + {!isLoading && ( + )} - + + + + - - { setExternalModalOpen(false); setExternalModalUser(null); }} - onSuccess={loadAgents} - user={externalModalUser} + externalModalOpen={externalModalOpen} + setExternalModalOpen={setExternalModalOpen} + externalModalUser={externalModalUser} + setExternalModalUser={setExternalModalUser} + targetSiteUuid={targetSiteUuid} + setTargetSiteUuid={setTargetSiteUuid} + targetCreatedBy={targetCreatedBy} + setTargetCreatedBy={setTargetCreatedBy} + isLocModalOpen={isLocModalOpen} + setIsLocModalOpen={setIsLocModalOpen} + locModalAgentUuid={locModalAgentUuid} + setLocModalAgentUuid={setLocModalAgentUuid} + locModalAgentLabel={locModalAgentLabel} + setLocModalAgentLabel={setLocModalAgentLabel} + locModalCurrentLoc={locModalCurrentLoc} + setLocModalCurrentLoc={setLocModalCurrentLoc} + loadLocations={loadLocations} />
); diff --git a/src/app/(dashboard)/agents/useAgentsData.ts b/src/app/(dashboard)/agents/useAgentsData.ts new file mode 100644 index 0000000..34991aa --- /dev/null +++ b/src/app/(dashboard)/agents/useAgentsData.ts @@ -0,0 +1,229 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { getAgents, Agent } from "@/lib/actions/agents"; +import { getAdminUsers, startViewAs, getViewAsHeaders, type ManagedUser } from "@/lib/admin-api"; +import { useTimeFilter } from "@/contexts/TimeFilterContext"; +import { usePollingKey } from "@/lib/usePolling"; + +export function useAgentsData() { + const router = useRouter(); + const [role, setRole] = useState(null); + const [agents, setAgents] = useState([]); + const [uptimeMap, setUptimeMap] = useState>({}); + const [storageMap, setStorageMap] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [detailAgent, setDetailAgent] = useState(null); + const [managedUsers, setManagedUsers] = useState([]); + const [accountModalOpen, setAccountModalOpen] = useState(false); + const [accountModalAgent, setAccountModalAgent] = useState<{ uuid: string; label: string } | null>(null); + + const [externalModalOpen, setExternalModalOpen] = useState(false); + const [externalModalUser, setExternalModalUser] = useState(null); + const [targetSiteUuid, setTargetSiteUuid] = useState(null); + const [targetCreatedBy, setTargetCreatedBy] = useState(null); + const [selectedSite, setSelectedSite] = useState("6681452d_9cae_4ff4_8ae8_0d504774265e"); + + const [locations, setLocations] = useState([]); + const [isLocModalOpen, setIsLocModalOpen] = useState(false); + const [locModalAgentUuid, setLocModalAgentUuid] = useState(""); + const [locModalAgentLabel, setLocModalAgentLabel] = useState(""); + const [locModalCurrentLoc, setLocModalCurrentLoc] = useState(null); + + useEffect(() => { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem('backone_site_uuid'); + if (stored) setSelectedSite(stored); + } + }, []); + + const [viewAsLoading, setViewAsLoading] = useState(null); + const [selectedAgent, setSelectedAgent] = useState(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 loadLocations = async () => { + try { + const res = await fetch("/api/dashboard/agent-locations", { + headers: getViewAsHeaders(), + }); + if (res.ok) { + const json = await res.json(); + if (json.ok && json.data) setLocations(json.data); + } + } catch (err) { + console.error("Failed to load locations:", 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(); + await loadLocations(); + } 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(() => { + fetch('/api/auth/me', { headers: getViewAsHeaders() }) + .then(res => res.json()) + .then(data => { + if (data.user) { + const userRole = data.user.role || null; + setRole(userRole); + if (userRole !== 'SUPER_ADMIN' && userRole !== 'TENANT_ADMIN') { + router.push('/'); + } else { + loadAgents(); + } + } else { + setIsLoading(false); + } + }) + .catch(err => { + console.error("Auth check failed:", err); + setIsLoading(false); + }); + }, [router]); + + useEffect(() => { + if (role && role !== 'AGENT_VIEWER') loadUptime(); + }, [timeRange]); + + useEffect(() => { + if (role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') { + loadStorage(); + } + if (role && role !== 'AGENT_VIEWER') { + loadLocations(); + } + }, [role]); + + useEffect(() => { + if (role && role !== 'AGENT_VIEWER' && refreshKey > 0) { + const siteUuid = localStorage.getItem("backone_site_uuid") || undefined; + getAgents(siteUuid) + .then(data => setAgents(data)) + .catch(err => console.warn("Failed to auto-refresh agents:", err)); + loadUptime(); + loadLocations(); + if (role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') { + loadStorage(); + } + } + }, [refreshKey, role]); + + const openDelete = (agent: Agent) => { + setSelectedAgent(agent); + setIsDeleteOpen(true); + }; + + const handleEditLocation = (agentUuid: string, agentLabel: string) => { + const loc = locations.find(l => l.agent_uuid === agentUuid) || null; + setLocModalAgentUuid(agentUuid); + setLocModalAgentLabel(agentLabel); + setLocModalCurrentLoc(loc); + setIsLocModalOpen(true); + }; + + return { + role, + agents, + uptimeMap, + storageMap, + isLoading, + error, + isCreateOpen, + setIsCreateOpen, + isDeleteOpen, + setIsDeleteOpen, + detailAgent, + setDetailAgent, + managedUsers, + accountModalOpen, + setAccountModalOpen, + accountModalAgent, + setAccountModalAgent, + externalModalOpen, + setExternalModalOpen, + externalModalUser, + setExternalModalUser, + targetSiteUuid, + setTargetSiteUuid, + targetCreatedBy, + setTargetCreatedBy, + locations, + isLocModalOpen, + setIsLocModalOpen, + locModalAgentUuid, + setLocModalAgentUuid, + locModalAgentLabel, + setLocModalAgentLabel, + locModalCurrentLoc, + setLocModalCurrentLoc, + viewAsLoading, + selectedAgent, + loadAgents, + loadLocations, + handleViewAs, + openDelete, + handleEditLocation, + }; +} diff --git a/src/app/(dashboard)/apps/page.tsx b/src/app/(dashboard)/apps/page.tsx index 5ae35d3..94c064b 100644 --- a/src/app/(dashboard)/apps/page.tsx +++ b/src/app/(dashboard)/apps/page.tsx @@ -11,12 +11,13 @@ import { Loader2, ChevronRight, Globe } from "lucide-react"; import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters"; import { fmtBytes, getAppFavicon, formatAppLabel } from "@/lib/utils"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; +import { getViewAsStatusSync } from "@/lib/admin-api"; export default function AppsPage() { const [mounted, setMounted] = useState(false); const [page, setPage] = useState(0); const pageSize = 20; - const { data, isLoading } = useTopApps(pageSize); const [selectedApp, setSelectedApp] = useState(null); const { @@ -25,6 +26,10 @@ export default function AppsPage() { handleReset, activeCount } = useUniversalFilterState(); + // Pass dateFrom/dateTo so the backend filters by the exact calendar date range + const { data, isLoading } = useTopApps(pageSize, dateFrom, dateTo); + + useEffect(() => { setMounted(true); }, []); @@ -49,15 +54,13 @@ export default function AppsPage() { const filteredData = all.filter((row: any) => { if (filters.app_label && filters.app_label !== "All" && row.app_label !== filters.app_label) return false; if (filters.category && filters.category !== "All" && row.category !== filters.category) return false; - - 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; - } + // Date filtering is handled server-side via date_from/date_to params. + // AppStat records do not have a last_seen field, so client-side date + // filtering is intentionally omitted here. return true; }); + let processedData = [...filteredData]; const sortUpload = filters.sort_upload || "All"; const sortDownload = filters.sort_download || "All"; @@ -71,14 +74,10 @@ export default function AppsPage() { processedData.sort((a, b) => sortDownload === "Descending" ? (b.download ?? 0) - (a.download ?? 0) : (a.download ?? 0) - (b.download ?? 0)); } - const minTime = all.reduce((min: number, r: any) => { - if (r.last_seen) { - const time = new Date(r.last_seen).getTime(); - if (isFinite(time) && time < min) return time; - } - return min; - }, Infinity); - const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01"; + // Use the data retention window (7 days) as the minimum selectable date. + // Dynamic computation from loaded data causes a circular dependency where + // the sidebar time filter (e.g., Last 24h) restricts the date picker range. + const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); const top5 = processedData.slice(0, 5); @@ -128,7 +127,7 @@ export default function AppsPage() { return (
-
+

Applications @@ -138,6 +137,7 @@ export default function AppsPage() { Click any application to see which agents and devices are using it.

+
{ - const gb = value / 1073741824; - const mb = value / 1048576; - return gb >= 1 ? `${gb.toFixed(1)}GB` : `${mb.toFixed(0)}MB`; - }} + tickFormatter={(value) => fmtBytes(value)} /> setSelectedApp(null)} /> )} diff --git a/src/app/(dashboard)/devices/page.tsx b/src/app/(dashboard)/devices/page.tsx index d0c0c78..89139f6 100644 --- a/src/app/(dashboard)/devices/page.tsx +++ b/src/app/(dashboard)/devices/page.tsx @@ -11,10 +11,10 @@ import { fmtBytes } from "@/lib/utils"; import { Loader2, ChevronRight, ChevronDown } from "lucide-react"; import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; export default function DevicesPage() { const [mounted, setMounted] = useState(false); - const devices = useDevices(5000); const [selectedDevice, setSelectedDevice] = useState(null); const { @@ -23,6 +23,9 @@ export default function DevicesPage() { handleReset, activeCount } = useUniversalFilterState(); + // Pass dateFrom/dateTo so the backend filters by the exact calendar date range + const devices = useDevices(5000, dateFrom, dateTo); + useEffect(() => { setMounted(true); }, []); @@ -130,15 +133,13 @@ export default function DevicesPage() { if (filters.device_type && filters.device_type !== "All" && row.device_type !== filters.device_type) return false; if (filters.os_label && filters.os_label !== "All" && row.os_label !== filters.os_label) return false; if (filters.manufacturer && filters.manufacturer !== "All" && row.manufacturer !== filters.manufacturer) return false; - - if (dateFrom || dateTo) { - const d = row.last_seen ? new Date(row.last_seen).toISOString().slice(0, 10) : ""; - if (dateFrom && d < dateFrom) return false; - if (dateTo && d > dateTo) return false; - } + // Date filtering is handled server-side via date_from/date_to on timestamp. + // last_seen (DPI network activity time) != timestamp (collection cycle time), + // so client-side date filtering would incorrectly exclude valid records. return true; }); + let processedData = [...filteredData]; const sortUpload = filters.sort_upload || "All"; const sortDownload = filters.sort_download || "All"; @@ -149,18 +150,15 @@ export default function DevicesPage() { processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download); } - const minTime = all.reduce((min: number, r: any) => { - if (r.last_seen) { - const time = new Date(r.last_seen).getTime(); - if (isFinite(time) && time < min) return time; - } - return min; - }, Infinity); - const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01"; + // Use the data retention window (7 days) as the minimum selectable date. + // Do NOT compute minDate from loaded data — the loaded data is already filtered + // by the sidebar time range (e.g., last 24h), which would incorrectly restrict + // the calendar to only dates within the current filter window. + const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); return (
-
+

Devices @@ -171,6 +169,7 @@ export default function DevicesPage() { Click an IP to see app usage & flows.

+
-
+

DNS Intelligence {isLoading && }

+
-
-

BackOne Metadata

-

- Deep Packet Inspection metadata and fingerprinting.{" "} - Click any row to view device-level detail. -

+
+
+

DPI MetaData

+

+ Deep Packet Inspection metadata and fingerprinting.{" "} + Click any row to view device-level detail. +

+
+
diff --git a/src/app/(dashboard)/events/page.tsx b/src/app/(dashboard)/events/page.tsx index 0507755..e24b37a 100644 --- a/src/app/(dashboard)/events/page.tsx +++ b/src/app/(dashboard)/events/page.tsx @@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Loader2, AlertCircle, Info, AlertTriangle } from "lucide-react"; import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal"; import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; import { useSearchParams } from "next/navigation"; import { Suspense } from "react"; @@ -18,7 +19,6 @@ function EventsContent() { const [mounted, setMounted] = useState(false); const [selectedIp, setSelectedIp] = useState(null); const [selectedMac, setSelectedMac] = useState(null); - const { data, isLoading } = useEvents(); const searchParams = useSearchParams(); const highlightId = searchParams.get("highlight"); @@ -28,6 +28,9 @@ function EventsContent() { handleReset, activeCount } = useUniversalFilterState(); + // Pass dateFrom/dateTo so the backend filters by the exact calendar date range + const { data, isLoading } = useEvents(0, dateFrom, dateTo); + useEffect(() => { setMounted(true); }, []); @@ -62,14 +65,10 @@ function EventsContent() { return true; }); - const minTime = all.reduce((min: number, r: any) => { - if (r.timestamp) { - const time = new Date(r.timestamp).getTime(); - if (isFinite(time) && time < min) return time; - } - return min; - }, Infinity); - const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01"; + // Use the data retention window (7 days) as the minimum selectable date. + // Dynamic computation from loaded data causes a circular dependency where + // the sidebar time filter (e.g., Last 24h) restricts the date picker range. + const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); const getSeverityIcon = (severity: string) => { switch (severity.toLowerCase()) { @@ -157,11 +156,12 @@ function EventsContent() { return (
-
+

Network Events {isLoading && }

+
); } -export { EventsContent }; diff --git a/src/app/(dashboard)/flows/page.tsx b/src/app/(dashboard)/flows/page.tsx index 2826991..a9f107d 100644 --- a/src/app/(dashboard)/flows/page.tsx +++ b/src/app/(dashboard)/flows/page.tsx @@ -10,6 +10,7 @@ import { fmtBytes, formatAppLabel } from "@/lib/utils"; import { IpDetails } from "@/components/ui/IpDetails"; import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal"; import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) { const label = appLabel || domain || ""; @@ -61,14 +62,16 @@ function explainAppOrPort(appLabel: string | null, domain: string | null, port: export default function FlowsPage() { const [mounted, setMounted] = useState(false); const [selectedIp, setSelectedIp] = useState(null); - const { data, isLoading } = useFlows(); - + const { filters, dateFrom, dateTo, handleFilterChange, setDateFrom, setDateTo, handleReset, activeCount } = useUniversalFilterState(); + // Pass dateFrom/dateTo so the backend filters by the exact calendar date range + const { data, isLoading } = useFlows(5000, dateFrom, dateTo); + useEffect(() => { setMounted(true); }, []); @@ -77,6 +80,7 @@ export default function FlowsPage() { const all = data || []; + // Extract unique options const opts = (key: string) => { const raw = Array.from(new Set(all.map((r: any) => String(r[key] || "")).filter(Boolean))); @@ -101,16 +105,13 @@ export default function FlowsPage() { 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; - } - + // Date filtering is handled server-side via date_from/date_to on timestamp. + // last_seen != timestamp (a flow collected July 13 can have last_seen=July 14) + // so client-side filtering by last_seen would incorrectly exclude valid records. return true; }); + let processedData = [...filteredData]; const sortUpload = filters.sort_upload || "All"; const sortDownload = filters.sort_download || "All"; @@ -127,14 +128,10 @@ export default function FlowsPage() { }); } - const minTime = all.reduce((min: number, r: any) => { - if (r.last_seen) { - const time = new Date(r.last_seen).getTime(); - if (isFinite(time) && time < min) return time; - } - return min; - }, Infinity); - const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01"; + // Use the data retention window (7 days) as the minimum selectable date. + // Dynamic computation from loaded data causes a circular dependency where + // the sidebar time filter (e.g., Last 24h) restricts the date picker range. + const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); const columns: Column[] = [ { @@ -207,11 +204,12 @@ export default function FlowsPage() { return (
-
+

Active Flows {isLoading && }

+
-
+

Geographic Traffic @@ -110,6 +111,7 @@ export default function GeographyPage() {

Visualize data flow and activity across the globe.

+
-
-

- Threat Intelligence Feeds - {loadingStats && } -

+
+

+ Threat Intelligence Feeds + {loadingStats && } +

+
diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 83239e6..5b61e60 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,5 +1,6 @@ import { DashboardLayout } from "@/components/layout/DashboardLayout"; import { TimeFilterProvider } from "@/contexts/TimeFilterContext"; +import { TenantConfigProvider } from "@/contexts/TenantConfigContext"; export default function AppLayout({ children, @@ -8,7 +9,9 @@ export default function AppLayout({ }>) { return ( - {children} + + {children} + ); } diff --git a/src/app/(dashboard)/lookup/page.tsx b/src/app/(dashboard)/lookup/page.tsx index 4ad2d8b..21f5c9b 100644 --- a/src/app/(dashboard)/lookup/page.tsx +++ b/src/app/(dashboard)/lookup/page.tsx @@ -1,359 +1,44 @@ "use client"; import { useState, useEffect } from "react"; -import { useLookupApplications, useLookupCategories, LookupApp } from "@/lib/api"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; -import { DropdownFilterSelect } from "@/components/ui/DropdownFilterSelect"; -import { Loader2, Search, AppWindow, HelpCircle, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, X, Info, Tag, Box, Shield, Server } from "lucide-react"; +import { Tabs, Tab } from "@/components/ui/Tabs"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; +import { ApplicationCatalog } from "@/components/lookup/ApplicationCatalog"; +import { BlacklistConfiguration } from "@/components/lookup/BlacklistConfiguration"; export default function LookupPage() { 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(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(() => { setMounted(true); }, []); - // 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; - const applications = data?.applications || []; - const pagination = data?.pagination || { total_records: 0, total_pages: 1, current_page: 1 }; - return (
-
-

- Application Database - {isLoading && } -

-

- Search BackOne's extensive classification database of over 2,500+ apps, protocols, and services. -

+ {/* Page Header */} +
+
+

+ Application Database +

+

+ Search application lookup catalog and configure traffic blacklist policy rules. +

+
+
- {/* Search & Filter Bar */} -
-
- - setSearchQuery(e.target.value)} - /> -
- -
- { - setSelectedCategory(val === "All" ? "" : val); - setPage(1); // Reset page on category change - }} - options={categories || []} - placeholder="All Categories" - /> -
-
- - {/* Cards Grid */} - {error ? ( -
-

Failed to load application lookup database

-

{error.message}

-
- ) : applications.length === 0 && !isLoading ? ( -
- -

No applications found

-

Try refining your search keyword or clearing the input.

-
- ) : ( -
-
- {applications.map((app: LookupApp) => ( - 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" - > -
- {/* Top: Icon + Title */} -
-
-
- {app.favicon || app.logo ? ( - <> - {app.label} { - // 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'; - } - }} - /> - - - ) : ( - - )} -
- - {app.application_category?.label || "Unclassified"} - -
- -
-

- {app.label} -

-

{app.tag}

-
- -

- {app.description || "No description provided for this application definition."} -

-
- - {/* Bottom: ID and system information */} -
- ID: {app.id} - {app.name || "N/A"} -
-
-
- ))} -
- - {/* Pagination Controls */} - {pagination.total_pages > 1 && ( -
-
- Showing {((page - 1) * limit) + 1} to{" "} - - {Math.min(page * limit, pagination.total_records)} - {" "} - of {pagination.total_records} entries -
- -
- {/* First Page */} - - - {/* Prev Page */} - - - {/* Page indicators */} - - {page} / {pagination.total_pages} - - - {/* Next Page */} - - - {/* Last Page */} - -
-
- )} -
- )} - - {/* Application Detail Modal */} - {selectedApp && ( -
-
-
e.stopPropagation()} - > - - {/* Header Background */} -
- -
- - {/* Content */} -
- - {/* Icon/Logo */} -
- {selectedApp.logo || selectedApp.favicon ? ( - {selectedApp.label} { - const img = e.target as HTMLElement; - img.style.display = 'none'; - const sibling = img.nextSibling as HTMLElement; - if (sibling) sibling.style.display = 'block'; - }} - /> - ) : null} - -
- - {/* Title & Category */} -
-
-

{selectedApp.label}

- {selectedApp.application_category?.label && ( - - {selectedApp.application_category.label} - - )} -
- {selectedApp.full_name && selectedApp.full_name !== selectedApp.label && ( -

{selectedApp.full_name}

- )} -
- -
- - {/* Description */} -
-
- -

- {selectedApp.description || "No detailed description provided for this application or service."} -

-
-
- - {/* Metadata Info */} -
-

System Metadata

- -
-
- - App ID -
- {selectedApp.id} -
- -
-
- - System Name -
- {selectedApp.name || 'N/A'} -
- -
-
- - Tag / Alias -
- {selectedApp.tag || 'N/A'} -
-
- - {/* Application Category Detail */} - {selectedApp.application_category && ( -
-

Category Info

- -
-
- - Category ID -
- {selectedApp.application_category.id} -
- -
-
- - Category Tag -
- {selectedApp.application_category.tag} -
-
- )} - -
- -
-
-
- )} + {/* Tabs Container */} + + + + + + + +
); } diff --git a/src/app/(dashboard)/network-infrastructure/page.tsx b/src/app/(dashboard)/network-infrastructure/page.tsx index b2cabf9..3154e72 100644 --- a/src/app/(dashboard)/network-infrastructure/page.tsx +++ b/src/app/(dashboard)/network-infrastructure/page.tsx @@ -11,6 +11,7 @@ import { } from "@/lib/api-advanced"; import { IpDetails } from "@/components/ui/IpDetails"; import { RemoteIpDetailModal } from "@/components/ui/RemoteIpDetailModal"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; export default function NetworkInfrastructurePage() { const [selectedRemoteIp, setSelectedRemoteIp] = useState(null); @@ -42,9 +43,12 @@ export default function NetworkInfrastructurePage() { return (
-
-

Network Infrastructure

-

Physical, logical, and geographic network topologies.

+
+
+

Network Infrastructure

+

Physical, logical, and geographic network topologies.

+
+
diff --git a/src/app/(dashboard)/network-intelligence/page.tsx b/src/app/(dashboard)/network-intelligence/page.tsx index 9cba53b..377ba3b 100644 --- a/src/app/(dashboard)/network-intelligence/page.tsx +++ b/src/app/(dashboard)/network-intelligence/page.tsx @@ -13,6 +13,7 @@ import { useAppCategories, useContinents } from "@/lib/api-with-context"; import { fmtBytes } from "@/lib/utils"; import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters"; import { CategoryDetailPanel } from "@/components/network/CategoryDetailPanel"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; const COLORS = ['#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#f85149']; @@ -82,7 +83,7 @@ export default function NetworkIntelligencePage() { return (
-
+

Traffic Categories @@ -90,6 +91,7 @@ export default function NetworkIntelligencePage() {

Deep packet inspection analytics and geography.

+
-
+

Summary Overview @@ -90,6 +91,7 @@ export default function SummaryPage() {

Real-time network traffic and intelligence summary.

+
{/* KPI Cards */} diff --git a/src/app/(dashboard)/security-audit/page.tsx b/src/app/(dashboard)/security-audit/page.tsx index 4e21b3c..4d68f9f 100644 --- a/src/app/(dashboard)/security-audit/page.tsx +++ b/src/app/(dashboard)/security-audit/page.tsx @@ -11,6 +11,7 @@ import TlsVersionsChart from "@/components/security-audit/TlsVersionsChart"; import TlsTables from "@/components/security-audit/TlsTables"; import SslSanTable from "@/components/security-audit/SslSanTable"; import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters"; +import { HelpTrigger } from "@/components/help/HelpTrigger"; export default function SecurityAuditPage() { const [mounted, setMounted] = useState(false); @@ -53,11 +54,14 @@ export default function SecurityAuditPage() { return (
-
-

Security & Encryption Audit

-

- Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles. -

+
+
+

Security & Encryption Audit

+

+ Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles. +

+
+
-
+

Threat Intelligence @@ -220,9 +221,12 @@ function ThreatsContent() {

Monitor and respond to network security threats.

-
- - Active Monitoring +
+ +
+ + Active Monitoring +
diff --git a/src/app/help/page.tsx b/src/app/help/page.tsx new file mode 100644 index 0000000..582fdbd --- /dev/null +++ b/src/app/help/page.tsx @@ -0,0 +1,10 @@ +import HelpPageContent from "@/components/help/HelpPageContent"; + +export const metadata = { + title: "User Guide — Deep Package Inspection", + description: "Panduan lengkap cara membaca data di setiap halaman dashboard DPI.", +}; + +export default function HelpRoute() { + return ; +} diff --git a/src/components/admin/AgentLocationModal.tsx b/src/components/admin/AgentLocationModal.tsx new file mode 100644 index 0000000..b69e79c --- /dev/null +++ b/src/components/admin/AgentLocationModal.tsx @@ -0,0 +1,274 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { X, MapPin, Loader2, CheckCircle2, AlertCircle, RefreshCw } from "lucide-react"; +import { getViewAsHeaders } from "@/lib/admin-api"; + +interface AgentLocationModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + agentUuid: string; + agentLabel: string; + currentLocation: { latitude: number; longitude: number; label: string } | null; +} + +export default function AgentLocationModal({ + isOpen, + onClose, + onSuccess, + agentUuid, + agentLabel, + currentLocation, +}: AgentLocationModalProps) { + const [latitude, setLatitude] = useState(""); + const [longitude, setLongitude] = useState(""); + const [label, setLabel] = useState(""); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [isResetting, setIsResetting] = useState(false); + const [success, setSuccess] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + if (!isOpen) return; + setError(""); + setSuccess(""); + if (currentLocation) { + setLatitude(String(currentLocation.latitude)); + setLongitude(String(currentLocation.longitude)); + setLabel(currentLocation.label || ""); + } else { + setLatitude(""); + setLongitude(""); + setLabel(""); + } + }, [isOpen, currentLocation]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!latitude || !longitude) { + setError("Latitude and Longitude are required."); + return; + } + + const latVal = parseFloat(latitude); + const lngVal = parseFloat(longitude); + + if (isNaN(latVal) || latVal < -90 || latVal > 90) { + setError("Latitude must be a valid number between -90 and 90."); + return; + } + if (isNaN(lngVal) || lngVal < -180 || lngVal > 180) { + setError("Longitude must be a valid number between -180 and 180."); + return; + } + + setIsSubmitting(true); + setError(""); + setSuccess(""); + + try { + const res = await fetch("/api/dashboard/agent-locations", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getViewAsHeaders() + }, + body: JSON.stringify({ + agent_uuid: agentUuid, + latitude: latVal, + longitude: lngVal, + label: label.trim(), + }), + }); + + const data = await res.json(); + if (!res.ok || !data.ok) { + throw new Error(data.error || "Failed to update location."); + } + + setSuccess("Location coordinates successfully updated!"); + setTimeout(() => { + onSuccess(); + onClose(); + }, 1500); + } catch (err: any) { + setError(err.message || "An error occurred."); + } finally { + setIsSubmitting(false); + } + }; + + const handleReset = async () => { + if (!confirm("Are you sure you want to reset this agent coordinates to default (Jakarta)?")) return; + + setIsResetting(true); + setError(""); + setSuccess(""); + + try { + const res = await fetch(`/api/dashboard/agent-locations/${agentUuid}`, { + method: "DELETE", + headers: getViewAsHeaders(), + }); + + const data = await res.json(); + if (!res.ok || !data.ok) { + throw new Error(data.error || "Failed to reset location."); + } + + setSuccess("Location coordinates successfully reset to default."); + setTimeout(() => { + onSuccess(); + onClose(); + }, 1500); + } catch (err: any) { + setError(err.message || "An error occurred."); + } finally { + setIsResetting(false); + } + }; + + return ( + + {isOpen && ( +
+ {/* Backdrop */} + + + {/* Modal Container */} + + {/* Close Button */} + + + {/* Header */} +
+
+ +
+
+

Agent Geolocation

+

Configure coordinates for {agentLabel}

+
+
+ + {/* Error / Success Alerts */} + {error && ( +
+ + {error} +
+ )} + {success && ( +
+ + {success} +
+ )} + + {/* Form */} +
+
+ +
+ setLatitude(e.target.value)} + placeholder="e.g. -6.2263304" + className="w-full rounded-xl border border-white/10 bg-slate-950/40 py-2.5 px-3.5 text-sm text-white placeholder-slate-500 outline-none transition-all focus:border-primary/50 focus:bg-slate-950/60 focus:ring-1 focus:ring-primary/50" + /> +
+
+ +
+ +
+ setLongitude(e.target.value)} + placeholder="e.g. 106.4247322" + className="w-full rounded-xl border border-white/10 bg-slate-950/40 py-2.5 px-3.5 text-sm text-white placeholder-slate-500 outline-none transition-all focus:border-primary/50 focus:bg-slate-950/60 focus:ring-1 focus:ring-primary/50" + /> +
+
+ +
+ +
+ setLabel(e.target.value)} + placeholder="e.g. Balaraja Office HQ" + className="w-full rounded-xl border border-white/10 bg-slate-950/40 py-2.5 px-3.5 text-sm text-white placeholder-slate-500 outline-none transition-all focus:border-primary/50 focus:bg-slate-950/60 focus:ring-1 focus:ring-primary/50" + /> +
+
+ + {/* Action Buttons */} +
+ {currentLocation ? ( + + ) : ( +
+ )} + +
+ + +
+
+ + +
+ )} + + ); +} diff --git a/src/components/admin/ExternalAccountModal.tsx b/src/components/admin/ExternalAccountModal.tsx index 01b32b1..275b114 100644 --- a/src/components/admin/ExternalAccountModal.tsx +++ b/src/components/admin/ExternalAccountModal.tsx @@ -11,6 +11,8 @@ interface ExternalAccountModalProps { onClose: () => void; onSuccess: () => void; user?: ManagedUser | null; + targetSiteUuid?: string | null; + targetCreatedBy?: string | null; } export default function ExternalAccountModal({ @@ -18,6 +20,8 @@ export default function ExternalAccountModal({ onClose, onSuccess, user, + targetSiteUuid, + targetCreatedBy, }: ExternalAccountModalProps) { const fileInputRef = useRef(null); @@ -84,6 +88,8 @@ export default function ExternalAccountModal({ setSuccess(password ? "Password successfully reset & Account updated!" : "Account successfully updated!"); } else { fd.append("role", role); + if (targetSiteUuid) fd.append("site_uuid", targetSiteUuid); + if (targetCreatedBy) fd.append("created_by", targetCreatedBy); await createAdminExternalUser(fd); setSuccess("External account successfully created!"); } diff --git a/src/components/dashboard/IndonesiaAgentMap.tsx b/src/components/dashboard/IndonesiaAgentMap.tsx new file mode 100644 index 0000000..4646173 --- /dev/null +++ b/src/components/dashboard/IndonesiaAgentMap.tsx @@ -0,0 +1,417 @@ +"use client"; + +import React, { useEffect, useRef, useState, useMemo } from "react"; +import { MapPin, AlertTriangle, ChevronDown, ChevronUp, Plus, Info, Loader2 } from "lucide-react"; +import * as L from "leaflet"; +import "leaflet/dist/leaflet.css"; +import { useTheme } from "next-themes"; +import { getViewAsHeaders } from "@/lib/admin-api"; +import { + getFlowTooltipContent, + getAgentTooltipContent, + MAP_CSS_INJECT, + type FlowDetail +} from "./IndonesiaAgentMapHelpers"; +import { Agent } from "@/lib/actions/agents"; + +interface LocationData { + agent_uuid: string; + latitude: number; + longitude: number; + label?: string; +} + +interface FlowData { + source: string; + target: string; + bytes: number; + flowsCount: number; + details: FlowDetail[]; +} + +interface IndonesiaAgentMapProps { + agents: Agent[]; + locations: LocationData[]; + onEditLocation: (agentUuid: string, label: string) => void; + role?: string | null; +} + +export function IndonesiaAgentMap({ + agents, + locations, + onEditLocation, + role, +}: IndonesiaAgentMapProps) { + const mapContainerRef = useRef(null); + const [loadingFlows, setLoadingFlows] = useState(true); + const [flows, setFlows] = useState([]); + const [mapError, setMapError] = useState(false); + + const mapInstanceRef = useRef(null); + const layerGroupRef = useRef(null); + const tileLayerRef = useRef(null); + const canvasRendererRef = useRef(null); + + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme !== "light"; + + // Agents that have no registered coordinate yet + const unregisteredAgents = useMemo(() => { + const registeredUuids = new Set(locations.map(l => l.agent_uuid)); + return agents.filter(a => { + const uuid = a.uuid || a.serial || ""; + return uuid && !registeredUuids.has(uuid); + }); + }, [agents, locations]); + + const [panelOpen, setPanelOpen] = useState(true); + + // Fetch Inter-Agent Flows + useEffect(() => { + setLoadingFlows(true); + fetch("/api/dashboard/agent-flows", { headers: getViewAsHeaders() }) + .then((res) => res.json()) + .then((data) => { + if (data.ok && data.data) { + setFlows(data.data); + } + }) + .catch((e) => console.error("[Map Flows Fetch]", e.message)) + .finally(() => setLoadingFlows(false)); + }, [locations]); + + // Handle Map Resize & Alignment + useEffect(() => { + if (!mapInstanceRef.current) return; + + const triggerInvalidate = () => { + if (mapInstanceRef.current) { + mapInstanceRef.current.invalidateSize(); + } + }; + + const timeout = setTimeout(triggerInvalidate, 150); + window.addEventListener("resize", triggerInvalidate); + + let observer: ResizeObserver | null = null; + if (mapContainerRef.current) { + observer = new ResizeObserver(() => triggerInvalidate()); + observer.observe(mapContainerRef.current); + } + + return () => { + clearTimeout(timeout); + window.removeEventListener("resize", triggerInvalidate); + if (observer) observer.disconnect(); + }; + }, [agents, locations, flows]); + + // Initialize and Update Map + useEffect(() => { + if (!mapContainerRef.current) return; + + // Build unique mapping of agent UUIDs → their registered coordinates. + // Only agents with real entries in CustomAgentLocation are rendered on the map. + const agentMap: Record = {}; + + agents.forEach((agent) => { + const uuid = agent.uuid || agent.serial || ""; + const loc = locations.find((l) => l.agent_uuid === uuid); + + // Only map agents that have a real coordinate registered. + // Agents without coordinates are intentionally excluded from the map. + if (loc) { + agentMap[uuid] = { + lat: loc.latitude, + lng: loc.longitude, + label: loc.label || "", + agentLabel: agent.label || "", + status: agent.activated ? "Online" : "Offline", + }; + } + }); + + const activeCoords = Object.values(agentMap); + const centerLat = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lat, 0) / activeCoords.length : -6.2088; + const centerLng = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lng, 0) / activeCoords.length : 106.8456; + + // Voyager for light mode: colorful, ocean blue, matches Globe Map aesthetic + const tileUrl = isDark + ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" + : "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"; + + if (!mapInstanceRef.current) { + // Canvas renderer: redraws from geo-coords every frame, no SVG offset bugs + const canvasRenderer = L.canvas({ padding: 0.5 }); + canvasRendererRef.current = canvasRenderer; + + const map = L.map(mapContainerRef.current, { + zoomControl: false, + attributionControl: false, + renderer: canvasRenderer, + }).setView([centerLat, centerLng], 10); + + const tiles = L.tileLayer(tileUrl, { maxZoom: 19 }).addTo(map); + tileLayerRef.current = tiles; + + L.control.zoom({ position: "bottomright" }).addTo(map); + + mapInstanceRef.current = map; + layerGroupRef.current = L.layerGroup().addTo(map); + } else { + const map = mapInstanceRef.current; + if (tileLayerRef.current) { + tileLayerRef.current.setUrl(tileUrl); + } + if (activeCoords.length > 0) { + map.panTo([centerLat, centerLng]); + } + } + + const group = layerGroupRef.current; + group.clearLayers(); + + const renderer = canvasRendererRef.current; + const registeredUuids = new Set(locations.map(l => l.agent_uuid)); + + // ─── A. DRAW FLOW LINES ──────────────────────────────────────────────────── + // Drawn BEFORE markers so markers render on top + flows.forEach((flow) => { + const src = agentMap[flow.source]; + const dst = agentMap[flow.target]; + if (!src || !dst || !registeredUuids.has(flow.source) || !registeredUuids.has(flow.target)) return; + + const weight = Math.min(Math.max(flow.bytes / 1024 / 1024 / 80, 2.5), 8); + const color = isDark ? "#38bdf8" : "#1d4ed8"; + + // Glow / shadow underlay + L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], { + color: isDark ? "#0ea5e9" : "#3b82f6", + weight: weight + 4, + opacity: 0.15, + renderer, + }).addTo(group); + + // Solid base line + const baseLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], { + color, + weight, + opacity: 0.5, + renderer, + }).addTo(group); + + // Animated dashed overlay (motion effect via CSS on SVG; canvas uses stroke-dasharray) + const animLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], { + color, + weight: weight * 0.55, + opacity: 0.95, + dashArray: "10 15", + className: "animated-flow-line", + renderer, + }).addTo(group); + + const tooltipHtml = getFlowTooltipContent( + src.agentLabel, + dst.agentLabel, + flow.bytes, + flow.flowsCount, + flow.details || [] + ); + + [baseLine, animLine].forEach(line => { + line.bindTooltip(tooltipHtml, { + sticky: true, + className: "leaflet-custom-popup shadow-2xl", + }); + }); + }); + + // ─── B. DRAW AGENT MARKERS (circleMarker = same Canvas, pixel-perfect) ───── + Object.entries(agentMap).forEach(([uuid, info]) => { + const isOnline = info.status === "Online"; + const fillColor = isOnline ? "#22c55e" : "#ef4444"; + const glowColor = isOnline ? "#4ade80" : "#f87171"; + + // Outer glow ring + L.circleMarker([info.lat, info.lng], { + radius: 11, + color: glowColor, + weight: 2, + fillColor: glowColor, + fillOpacity: 0.15, + opacity: 0.5, + renderer, + }).addTo(group); + + // Inner solid dot + const dot = L.circleMarker([info.lat, info.lng], { + radius: 6, + color: "#ffffff", + weight: 2, + fillColor, + fillOpacity: 1, + opacity: 1, + renderer, + }).addTo(group); + + // Agent label — permanent tooltip anchored below the dot + const labelClass = isDark + ? "bg-slate-950/90 text-slate-200 border-white/10" + : "bg-white/95 text-slate-800 border-slate-200"; + + dot.bindTooltip( + `
${info.agentLabel}
`, + { 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)); + } + }); + }); + }, [agents, locations, flows, isDark]); + + if (mapError) { + return ( +
+ +

Map failed to initialize.

+

Please reload the page.

+
+ ); + } + + return ( +
+